Every pricing plan needs to list of features. To allow users to type all related data in one form we want to implement something that is called “Nested form” and is well known from other Web Frameworks - Ruby on Rails, thanks to a dedicated gem that handles it out of the box. Some community members considered this as an anti-pattern, however from
the user experience perspective it saves a lot of time when adding data to the tables that have one-to-many relationships.

There are a few ways to achieve it with Elixir, after analyzing all of them the most efficient and the most Phonix-way in my opinion is the one that uses LiveView, one of the flag features of Web Framework that is getting more traction.

To achieve my goal with Phoenix it took me a bit of time, so I decided to describe the process of adding this feature to my SaaS app.

Setup of Phoenix LiveView

Surprisingly to me, not every Phoenix app is ready to play with LiveView. You can add a flag during project generation or setup it manually. I used the second approach and the whole process is not complicated. You can find an installation guide here: https://hexdocs.pm/phoenix_live_view/installation.html

After setting up a live view let’s add our features to the schema of a pricing plan:

1
2
3
4
5
6
7
8
9
10
11
  defmodule Kickstart.Accounts.PricingPlan do
schema "pricing_plans" do
field :description, :string
field :name, :string
field :position, :integer
field :is_visible, :boolean

+ embeds_many :features, Feature, on_replace: :delete # add this line
timestamps()
end
end

Modify pricing plan changeset in the same file, by adding cast_embed function that does the job:

1
2
3
4
5
6
7
8
  def changeset(pricing_plan, attrs) do
pricing_plan
|> cast(attrs, [:name, :price, :period, :description, :position, :is_visible])
|> validate_required([:name, :price, :period, :description, :position])
|> validate_length(:description, min: 5)
|> validate_number(:price, greater_than: 0)
+ |> cast_embed(:features, with: &Kickstart.Accounts.Feature.changeset/2, required: true)
end

and add Feature model:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
defmodule Kickstart.Accounts.Feature do
use Ecto.Schema
import Ecto.Changeset

embedded_schema do
field :title
field :temp_id, :string, virtual: true
field :delete, :boolean, virtual: true
end

def changeset(feature, attrs) do
feature
|> Map.put(:temp_id, (feature.temp_id || attrs["temp_id"])) # So its persisted
|> cast(attrs, [:title, :delete]) # Add delete here
|> validate_required([:title])
|> maybe_mark_for_deletion()
end

defp maybe_mark_for_deletion(%{data: %{id: nil}} = changeset), do: changeset
defp maybe_mark_for_deletion(changeset) do
if get_change(changeset, :delete) do
%{changeset | action: :delete}
else
changeset
end
end
end

We also need to add a small module that will be used later in our live form:

1
2
3
4
5
6
7
defmodule Kickstart.PricingPlans do
alias Kickstart.Accounts.Feature

def change_feature(%Feature{} = feature) do
Feature.changeset(feature, %{})
end
end

And finally, implement our live form:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
defmodule KickstartWeb.PricingPlanFormLive do
use Phoenix.LiveView

alias Kickstart.Accounts
alias Kickstart.PricingPlans
alias Kickstart.Accounts.PricingPlan
alias Kickstart.Accounts.Feature

def mount(_params, %{"action" => action, "csrf_token" => csrf_token} = session, socket) do
pricing_plan = get_pricing_plan(session)
changeset =
Accounts.change_pricing_plan(pricing_plan)
|> Ecto.Changeset.put_embed(:features, pricing_plan.features)

assigns = [
conn: socket,
action: action,
csrf_token: csrf_token,
changeset: changeset,
pricing_plan: pricing_plan
]

{:ok, assign(socket, assigns)}
end

def render(assigns) do
KickstartWeb.Admin.PricingPlanView.render("form.html", assigns)
end

def handle_event("add-feature", _, socket) do
vars = Map.get(socket.assigns.changeset.changes, :features, socket.assigns.pricing_plan.features)

features =
vars
|> Enum.concat([
PricingPlans.change_feature(%Feature{temp_id: get_temp_id()})
])

changeset =
socket.assigns.changeset
|> Ecto.Changeset.put_embed(:features, features)

{:noreply, assign(socket, changeset: changeset)}
end

def handle_event("remove-feature", %{"remove" => remove_id}, socket) do
features =
socket.assigns.changeset.changes.features
|> Enum.reject(fn %{data: feature} ->
feature.temp_id == remove_id
end)

changeset =
socket.assigns.changeset
|> Ecto.Changeset.put_embed(:features, features)

{:noreply, assign(socket, changeset: changeset)}
end

def get_pricing_plan(%{"id" => id} = _pricing_plan_params), do: Accounts.get_pricing_plan!(id)
def get_pricing_plan(_pricing_plan_params), do: %PricingPlan{features: []}

defp get_temp_id, do: :crypto.strong_rand_bytes(5) |> Base.url_encode64 |> binary_part(0, 5)
end

Update our edit and new templates (new will be similar - just remove an id from the session):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<section id="torch-header-and-content">
<div class="torch-container">
<div class="header">
<h3>Edit Pricing plan</h3>
</div>
<%= live_render @conn, KickstartWeb.PricingPlanFormLive,
session: %{
"id" => @pricing_plan.id,
"action" => Routes.admin_pricing_plan_path(@conn, :update, @pricing_plan),
"csrf_token" => Plug.CSRFProtection.get_csrf_token()
}
%>
</div>
</section>

And this is our form with nested inputs (please remember to change the extension to .leex):j

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<%= form_for @changeset, @action, [multipart: true, csrf_token: @csrf_token], fn f -> %>

(... list of inputs ... )

<legend>
<span>Features</span>
</legend>

<%= inputs_for f, :features, fn v -> %>
<div class="flex flex-wrap -mx-1 overflow-hidden">
<div class="torch-form-group">
<%= label v, :title%>
<%= text_input v, :title, class: "form-control" %>
<%= error_tag v, :title %>
</div>

<div class="torch-form-group">
<%= label v, :delete %><br>
<%= if is_nil(v.data.temp_id) do %>
<%= checkbox v, :delete %>
<% else %>
<%= hidden_input v, :temp_id %>
<a href="#" phx-click="remove-feature" phx-value-remove="<%= v.data.temp_id %>">&times</a>
<% end %>
</div>
</div>
<% end %>

<a href="#" phx-click="add-feature">Add feature</a>

(... submit button ... )

<% end %>

Summary

I got stuck for a while when working on this feature because my initial implementation used mount/2 instead of mount/3. Apart from this implementing this with the live view was quite enjoyable and I’m very happy with the final result.
Here you can see how it works: