Skip to content

Instantly share code, notes, and snippets.

@Wigny
Last active May 7, 2025 02:53
Show Gist options
  • Save Wigny/53c433a89456f3bfbe3df14b4b2c07be to your computer and use it in GitHub Desktop.
Save Wigny/53c433a89456f3bfbe3df14b4b2c07be to your computer and use it in GitHub Desktop.
Mix.install([
{:bandit, "~> 1.6"},
{:phoenix, "~> 1.7.0"},
{:phoenix_live_view, "~> 1.0.10"},
{:phoenix_ecto, "~> 4.6"},
{:ecto_sql, "~> 3.12"},
{:postgrex, ">= 0.0.0"},
{:testcontainers, "~> 1.12"}
])
defmodule Example.Migration0 do
use Ecto.Migration
def change do
create table(:email_notifications) do
add(:name, :string)
add(:email, :string)
end
create(unique_index(:email_notifications, [:email]))
end
end
defmodule Example.Repo do
use Ecto.Repo,
otp_app: :example,
adapter: Ecto.Adapters.Postgres
end
defmodule Example.EmailNotification do
use Ecto.Schema
import Ecto.Changeset
schema "email_notifications" do
field :email, :string
field :name, :string
field :delete, :boolean, default: false, virtual: true
end
def changeset(email_notification, attrs) do
changeset =
email_notification
|> cast(attrs, [:email, :name, :delete])
|> validate_required([:email, :name])
|> unique_constraint(:email)
if get_change(changeset, :delete) do
%{changeset | action: :delete}
else
changeset
end
end
end
defmodule Example.ErrorHTML do
def render(template, _assigns), do: Phoenix.Controller.status_message_from_template(template)
end
defmodule Example.Layouts do
use Phoenix.Component
def render("root.html", assigns) do
~H"""
<!DOCTYPE html>
<html>
<head>
<script
src={"https://unpkg.com/phoenix@#{Application.spec(:phoenix, :vsn)}/priv/static/phoenix.min.js"}
>
</script>
<script
src={"https://unpkg.com/phoenix_live_view@#{Application.spec(:phoenix_live_view, :vsn)}/priv/static/phoenix_live_view.min.js"}
>
</script>
<script type="module">
let liveSocket = new LiveView.LiveSocket("/live", Phoenix.Socket)
liveSocket.connect()
</script>
</head>
<body>{@inner_content}</body>
</html>
"""
end
def render("app.html", assigns) do
~H"""
<main>{@inner_content}</main>
"""
end
end
defmodule Example.HomeLive do
use Phoenix.LiveView, layout: {Example.Layouts, :app}
alias Phoenix.LiveView.JS
defmodule MailingList do
use Ecto.Schema
import Ecto.Changeset
alias Example.EmailNotification
@primary_key false
embedded_schema do
embeds_many :emails, EmailNotification
end
def get do
emails = Example.Repo.all(EmailNotification)
new(emails)
end
def new(emails) do
%__MODULE__{emails: emails}
end
def changeset(mailing_list, attrs \\ %{}) do
mailing_list
|> cast(attrs, [])
|> cast_embed(:emails)
end
def delete_email_at(changeset, index) do
existing = get_embed(changeset, :emails)
{email, emails} = List.pop_at(existing, index)
emails =
if email.data.id do
delete = get_field(email, :delete)
change = EmailNotification.changeset(email, %{delete: not delete})
List.replace_at(existing, index, change)
else
emails
end
put_embed(changeset, :emails, emails)
end
def append_email(changeset, email) do
existing = get_embed(changeset, :emails)
put_embed(changeset, :emails, existing ++ [email])
end
def save(changeset) do
changes = get_embed(changeset, :emails)
case apply_changesets(changes) do
{:ok, emails} -> {:ok, new(Enum.reject(emails, &is_nil/1))}
{:error, changes} -> {:error, put_embed(changeset, :emails, changes)}
end
end
defp apply_changesets(changesets) do
multi =
changesets
|> Enum.with_index()
|> Enum.reduce(Ecto.Multi.new(), &add_changeset_operation/2)
case Example.Repo.transaction(multi) do
{:ok, changes} ->
{:ok, Map.values(changes)}
{:error, {:email, index}, changeset, _changes} ->
{:error, List.replace_at(changesets, index, changeset)}
end
end
defp add_changeset_operation({%{action: :delete} = changeset, index}, multi) do
Ecto.Multi.run(multi, {:email, index}, fn repo, _changes ->
with {:ok, _entry} <- repo.delete(changeset), do: {:ok, nil}
end)
end
defp add_changeset_operation({changeset, index}, multi) do
Ecto.Multi.insert_or_update(multi, {:email, index}, changeset)
end
end
def render(assigns) do
~H"""
<style>
.wrapper[data-delete="true"] {
border: 1px solid red;
}
</style>
<.form :let={f} for={@form} phx-change="validate" phx-submit="save" class="w-2/3">
<.inputs_for :let={ef} field={f[:emails]}>
<div class="wrapper" data-delete={to_string(ef[:delete].value)}>
<input type="hidden" name={ef[:delete].name} value={to_string(ef[:delete].value)} />
<div>
<label>Email</label>
<input
type="email"
id={ef[:email].id}
name={ef[:email].name}
value={ef[:email].value}
placeholder="email"
/>
<span :for={{msg, _opts} <- ef[:email].errors} :if={used_input?(ef[:email])}>
{msg}
</span>
</div>
<div>
<label>Name</label>
<input
type="text"
id={ef[:name].id}
name={ef[:name].name}
value={ef[:name].value}
placeholder="name"
/>
<span :for={{msg, _opts} <- ef[:name].errors} :if={used_input?(ef[:name])}>
{msg}
</span>
</div>
<button type="button" phx-click={JS.push("delete-email", value: %{index: ef.index})}>
delete
</button>
</div>
</.inputs_for>
<button type="button" phx-click="add-email">
add more
</button>
<button type="submit">
save
</button>
</.form>
"""
end
def mount(_params, _session, socket) do
mailing_list = MailingList.get()
changeset = MailingList.changeset(mailing_list)
{:ok,
socket
|> assign(:mailing_list, mailing_list)
|> assign(:form, to_form(changeset))}
end
def handle_event("validate", %{"mailing_list" => params}, socket) do
changeset = MailingList.changeset(socket.assigns.mailing_list, params)
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
def handle_event("add-email", _params, socket) do
add_email = fn form ->
changeset = MailingList.append_email(form.source, %{})
to_form(changeset, action: :add_email)
end
{:noreply, update(socket, :form, add_email)}
end
def handle_event("delete-email", %{"index" => index}, socket) do
delete_email = fn form ->
changeset = MailingList.delete_email_at(form.source, index)
to_form(changeset, action: :delete_email)
end
{:noreply, update(socket, :form, delete_email)}
end
def handle_event("save", %{"mailing_list" => params}, socket) do
changeset = MailingList.changeset(socket.assigns.mailing_list, params)
case MailingList.save(changeset) do
{:ok, mailing_list} ->
{:noreply,
socket
|> assign(:mailing_list, mailing_list)
|> assign(:form, to_form(MailingList.changeset(mailing_list)))}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset, action: :save))}
end
end
end
defmodule Example.Router do
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :put_root_layout, {Example.Layouts, :root}
end
scope "/", Example do
pipe_through :browser
live "/", HomeLive, :index
end
end
defmodule Example.Endpoint do
use Phoenix.Endpoint, otp_app: :example
socket "/live", Phoenix.LiveView.Socket
plug Example.Router
end
{:ok, _testcontainer} = Testcontainers.start_link()
{:ok, container} = Testcontainers.start_container(Testcontainers.PostgresContainer.new())
Application.put_env(
:example,
Example.Repo,
Testcontainers.PostgresContainer.connection_parameters(container)
)
Application.put_env(:example, Example.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 5001],
server: true,
adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: Example.ErrorHTML], layout: false],
live_view: [signing_salt: "aaaaaaaa"],
secret_key_base: String.duplicate("a", 64)
)
Application.put_env(:phoenix, :json_library, JSON)
{:ok, _} =
Supervisor.start_link(
[
{Ecto.Migrator,
migrator: &Ecto.Migrator.run(&1, [{0, Example.Migration0}], &2, &3),
repos: [Example.Repo],
log_migrations_sql: :debug},
Example.Repo,
Example.Endpoint
],
strategy: :one_for_one
)
Process.sleep(:infinity)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment