Dynamic fields from conf to UI in Elixir and Phoenix
Learning Elixir is quite a joy. Yet some simple things take a looong time to figure out. Here's one written out to help others like me.
Our task is basically to convert a conf line like info_fields: %{custom_field: :string}
to a UI in Phoenix that users can edit.
Create a conf file
I created my file under config/custom.exs
use Mix.Config
config :ceiba, Custom,
event_types: %{
excursion: %{
info_fields: %{
kohtumise_paik: :string,
telefoni_number: :integer,
kestvus: :integer,
dünaamiline_kõik: :string
}
}
}
And then imported this file in the main config config/config.exs
like so:
# Load custom configuration
import_config "custom.exs"
It's one of the last lines in the file.
Setup main DB table schema
The way I set up my tables is that there are normal columns like :string
type and then is the :map
type where I save the custom fields using an embedded schema. Notice the embeds_one
in this schema. I suggest this post for more details.
# lib/ceiba/core/slot.ex
defmodule Ceiba.Core.Slot do
use Ecto.Schema
import Ecto.Changeset
schema "slots" do
field :description, :string
field :location, :string
field :title, :string
embeds_one :info, Ceiba.Core.SlotInfo # embed another schema
timestamps()
end
@doc false
def changeset(slot, attrs) do
slot
|> cast(attrs, [
:location,
:title,
:description,
])
|> cast_embed(:info) # Not entirely sure if this was needed
|> validate_required([
:location,
:title,
:description,
])
end
end
Setup embedded schema
Now that there's an embedded schema reference, let's create it. Remember, the purpose for this file is to fill the :map
cell in the DB. Whatever you defined here will get saved as jsonb
in Postgres.
#lib/ceiba/core/slot_info.ex
defmodule Ceiba.Core.SlotInfo do
use Ecto.Schema
import Ecto.Changeset
@dynamic_fields Application.get_env(:ceiba, Custom)[:event_types][:excursion][:info_fields] # This is how I access my conf file
embedded_schema do
for {field_name, type} <- @dynamic_fields do
field(field_name, type) # Dynamically add fields to schema
end
end
def changeset(info, attrs) do
info
|> cast(attrs, Map.keys(@dynamic_fields)) # Allow all changes
end
end
Notice how to use Application
to access the config and add the fields dynamically and also that instead of schema
we're using embedded_schema
so that this module will only be loaded into memory and not put into the database as a separate table.
Database table setup
Obviously we still need to take care of migrations, otherwise there's nowhere to save this data!
# priv/repo/migrations/20190117135643_create_slots.exs
defmodule Ceiba.Repo.Migrations.CreateSlots do
use Ecto.Migration
def change do
create table(:slots) do
add :location, :string
add :title, :string
add :description, :string
add :info, :map # This is where the embedded data is saved
timestamps()
end
end
end
On to the UI and Phoenix
I've used mix phx.gen.html
pretty extensively so my project setup is pretty standard.
In the larger picture we now just have to edit the index.html.eex
and other such html.eex
files so they show our dynamic fields and let the user edit them also.
Helper function
I've put a helper function that fetches the schemas and figures out what the dynamic fields are and returns them like so:
%{
dünaamiline_kõik: :string,
id: :binary_id,
kestvus: :integer,
kohtumise_paik: :string,
telefoni_number: :integer
}
This is going to be useful in displaying the info soon. This algorithm should work also if you have multiple embedded schemas (not so sure if you have none at all).
# lib/ceiba_web/views/slot_view.ex
defmodule CeibaWeb.SlotView do
use CeibaWeb, :view
def extra_fields do
for embed_field <- Ceiba.Core.Slot.__schema__(:embeds),
{:embed, embedded} = Ceiba.Core.Slot.__schema__(:type, embed_field),
field <- embedded.related.__schema__(:fields),
into: %{},
do: {field, embedded.related.__schema__(:type, field)}
end
end
Display all Slots in a table
I'm not going to show you all of the file as it's long and boring, but here's how to write the headings:
<thead>
<tr>
<th>Location</th>
<th>Title</th>
<th>Description</th>
<%= for {key, value} <- extra_fields, key != :id do %>
<th><%= Phoenix.Naming.humanize key %></th>
<%= end %>
<th></th>
</tr>
</thead>
And here's how to write the body.
<tbody>
<%= for slot <- @slots do %>
<tr>
<td><p><%= slot.location %></p></td>
<td><p><%= slot.title %></p></td>
<td><p><%= slot.description %></p></td>
<%= for {key, value} <- Map.from_struct(slot.info || Ceiba.Core.SlotInfo), key != :id do %>
<td><p><%= value %></p></td>
<%= end %>
<td><p>
<%= link "Show", to: Routes.slot_path(@conn, :show, slot) %>
<%= link "Edit", to: Routes.slot_path(@conn, :edit, slot) %>
<%= link "Delete", to: Routes.slot_path(@conn, :delete, slot), method: :delete, data: [confirm: "Are you sure?"] %>
</p></td>
</tr>
<% end %>
</tbody>
Since the fields are dynamic and some rows may have empty cells we still try to have as many <td>
as needed to keep the table in tact with slot.info || Ceiba.Core.SlotInfo
. So if slot.info
is nil
we take the default values from the module. Also we ignore the :id
field as it's useless.
lib/ceiba_web/templates/slot/form.html.eex
Both the new.html.eex
and edit.html.eex
reference the form.html.eex
file we only need to make the change there:
<%= inputs_for f, :info, fn i -> %>
<%= for {field, type} <- extra_fields, field != :id do %>
<%= label i, field %>
<%= case type do %>
<% :string -> %>
<%= text_input i, field %>
<% :integer -> %>
<%= number_input i, field %>
<% end %>
<% end %>
<% end %>
This will only work if you've already defined extra_fields
in the View file (in my case lib/ceiba_web/views/slot_view.ex
).
Conclusion
Okay I think that's everything. Took like 3 days to figure all of this out with the help from Elixirs Slack community - specifically @jayjun, @lostkobrakai and @hauleth. I quite possibly would have abandoned Elixir by now if it weren't for these guys. I just hope I get to be as helpful to others some day - going to stick around that slack for sure :)