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 :)