Mount a table and a form in a LiveView

v0.0.1

A Gervaz table and a Gervaz form are two Phoenix LiveComponents. The DSL on your resource describes everything they show. The parent LiveView gives each one the signed-in user, decodes the URL, and answers a few messages. This guide mounts both on one page, as the Mishka Blog tags page does, and covers the assigns, the messages and the modal.

Before you begin

  • Have a resource with the MishkaGervaz.Resource extension and a table or a form section. See Get Started.
  • Have a LiveView that knows the signed-in user as @current_user. Gervaz uses it as the actor, and it decides whether the user is a master. See Master and tenant users.

Mount the table

Draw the table with MishkaGervaz.Table.Web.Live.

heex
<.live_component
  module={MishkaGervaz.Table.Web.Live}
  id="tags-table"
  resource={MishkaBlog.Tag}
  current_user={@current_user}
  url_state={@url_state}
/>
AssignRequiredMeaning
idYesThe component id. Every send_update/2 call must use the same id.
resourceYesAn Ash resource with the Gervaz extension.
current_userYesThe actor.
url_stateNoThe result of UrlSync.decode/3. With nil, no URL state is read for this mount.
templateNoThe template this mount starts with: a module, or the name/0 of a template in switchable_templates.
switchable_templatesNoThe templates the switcher offers on this mount. An empty list or a single entry turns the switcher off here.
url_syncNofalse stops this mount from changing the address bar. Its url_state and path_params still apply.

Gervaz applies template, switchable_templates and url_sync only when the component starts. A later render of the parent does not undo a choice the reader has made since.

Decode the URL

If the resource has a url_sync block, decode the URL in handle_params/3 and pass the result as url_state. Gervaz reads the prefix, the allowed params and the maximum filter length from the resource, so you repeat nothing.

elixir
alias MishkaGervaz.Table.Web.UrlSync

@impl true
def handle_params(params, uri, socket) do
  url_state = UrlSync.decode(params, uri, MishkaBlog.Tag)
  {:noreply, assign(socket, :url_state, url_state)}
end

UrlSync.decode/3 returns nil when the resource has URL sync turned off. decode/4 takes overrides: allowed_params, allowed_filters, max_filter_length and prefix.

Mount the form

Draw the form with MishkaGervaz.Form.Web.Live. Use the id that Gervaz derives from the form identity, so the id cannot drift from the DSL.

heex
<.live_component
  module={MishkaGervaz.Form.Web.Live}
  id={MishkaGervaz.Resource.Info.Form.component_id(MishkaBlog.Tag)}
  resource={MishkaBlog.Tag}
  current_user={@current_user}
/>
AssignRequiredMeaning
idYesUse MishkaGervaz.Resource.Info.Form.component_id/1.
resourceYesAn Ash resource with the Gervaz extension.
current_userYesThe actor.
record_idNonil opens create mode. An id opens edit mode.
defaultsNoA map such as %{site_id: @site_id} that fills create mode and any param a hidden field would have carried.
hidden_fieldsNoA list of fields this mount does not draw.
submitNofalse draws no submit row in create mode. An edit form still draws it. The save event is still accepted.
submit_alternativesNoOther ways to create, offered from a caret beside the submit button. Create mode only.

A change to record_id or defaults starts the form over. The other three assigns apply only when the component starts. An update that leaves defaults out, such as the one a table's Edit button sends, keeps the defaults the form has. Pass defaults: nil to drop them.

A hidden field's value still reaches the save, but only if something supplies it. Pair every hidden field that an action requires with a defaults entry. The media upload of the Mishka CMS page builder hides the site field whenever the page being edited has a site, and supplies that site as the default:

heex
<.live_component
  module={MishkaGervaz.Form.Web.Live}
  id={MishkaGervaz.Resource.Info.Form.component_id(Media)}
  resource={Media}
  current_user={@current_user}
  hidden_fields={(@editor.site_id && [:site_id]) || []}
  submit={false}
  defaults={%{site_id: @editor.site_id, media_category_id: @assets_category}}
/>

Each entry of submit_alternatives is a map with :id, :label and an optional :description, plus either :navigate or :name and :value. A :navigate path makes the item a link that leaves without submitting. A :name and :value pair makes the item a submit button of the same form, and the pair reaches before_save as an ordinary param.

Answer the form's messages

The form component sends these messages to the parent LiveView:

elixir
def handle_info({:form_saved, :create, _result}, socket), do: ...
def handle_info({:form_saved, :update, _result}, socket), do: ...
def handle_info({:form_cancelled, _resource}, socket), do: ...
def handle_info({:form_event, _event, _params}, socket), do: ...

:form_event carries any form event Gervaz does not recognize. {:form_cancelled, resource} arrives only when the form declares an on_cancel hook, as the tags and redirects forms do. After a save, a create returns the form to an empty create form. An update keeps the form on the saved record when the form was mounted with a record_id assign, even a nil one. Otherwise it also returns to the empty create form.

Answer the table's messages

elixir
def handle_info({:row_action, event_name, payload}, socket), do: ...
def handle_info({:bulk_action, action_name, selected_ids}, socket), do: ...
def handle_info({:expand_row, id}, socket), do: ...
def handle_info({:show_modal, id}, socket), do: ...
def handle_info({:edit_modal, id}, socket), do: ...
def handle_info({:show_versions, id}, socket), do: ...
def handle_info({:table_event, event, params}, socket), do: ...

def handle_info({:put_flash, kind, message}, socket) do
  {:noreply, put_flash(socket, kind, message)}
end

Handle :row_action for each :event row action, and :bulk_action for each bulk action whose handler is :parent or whose type is :event. The payload of a row action is the params map with string keys, such as %{"id" => id}. The :put_flash clause lets the table and your hooks show flash messages, because the parent layout does not read a component's own flash.

A :edit row action needs no clause. The table sends the record to the form component itself, with send_update/2 and reset: true.

Forward realtime and refresh

The table subscribes to PubSub itself when the resource has a realtime block, but the broadcasts arrive at the LiveView. Forward them. The Mishka Blog tags page does it like this:

elixir
def handle_info(
      %Phoenix.Socket.Broadcast{
        topic: "tag" <> _,
        payload: %Ash.Notifier.Notification{} = notification
      },
      socket
    ) do
  send_update(MishkaGervaz.Table.Web.Live,
    id: "tags-table",
    pubsub_notification: notification
  )

  {:noreply, socket}
end

With a refresh block, the table sends :gervaz_refresh to the parent on a timer. Forward it, or nothing reloads:

elixir
def handle_info(:gervaz_refresh, socket) do
  send_update(MishkaGervaz.Table.Web.Live, id: "tags-table", gervaz_refresh: true)
  {:noreply, socket}
end

For an expandable row, answer {:expand_row, id} with send_update(MishkaGervaz.Table.Web.Live, id: "tags-table", expanded_html: html), or with expanded_error: reason on failure.

Add content around the table

The table component takes three slots. :filter_actions draws in the filter bar, :before_table draws above the table, and :rail draws beside the records. The Redirects page of Mishka CMS puts an activity feed in the filter bar:

heex
<.live_component
  module={MishkaGervaz.Table.Web.Live}
  id={@table_id}
  resource={MishkaCmsCore.Runtime.Redirect}
  current_user={@current_user}
  url_state={@url_state}
>
  <:filter_actions>
    <ActivityFeed.feed setup={@activity_feed} />
  </:filter_actions>
</.live_component>

Open and close the form in a modal

A form in a modal keeps what was typed through a close that sends it nothing, such as the modal's close icon. Whatever opens it again starts it over. To open an empty create form, send the form a reset:

elixir
send_update(MishkaGervaz.Form.Web.Live,
  id: MishkaGervaz.Resource.Info.Form.component_id(Redirect),
  record_id: nil,
  reset: true
)

A button that opens the form without asking the server can use MishkaGervaz.Form.Web.Live.reset/2, which returns a JS command that resets the form with the given component id.

To close the modal after a save, use one of two mechanisms and not both. The first is the form's js hooks, which push a "gervaz:exec-js" event to the browser. The second is your own handling of {:form_saved, ...} and {:form_cancelled, ...}, which pushes the same event. Either way the page needs a listener. Mishka CMS registers this one in its boot.js:

javascript
window.addEventListener("phx:gervaz:exec-js", (e) => {
  const el = document.getElementById(e.detail.target);
  if (el) liveSocket.execJS(el, e.detail.js);
});

The Mishka Blog tags page closes its modal from the parent:

elixir
defp close_modal(socket, id),
  do: exec_modal_js(socket, id, MishkaCmsCore.CoreComponents.hide_modal(id))

defp exec_modal_js(socket, id, js) do
  push_event(socket, "gervaz:exec-js", %{target: id, js: Jason.encode!(js.ops)})
end

def handle_info({:form_saved, :create, _result}, socket) do
  {:noreply,
   socket
   |> close_modal("blog-tag-form-modal")
   |> put_flash(:info, gettext("Tag created successfully"))}
end

Keep the modal rendered instead of wrapping it in :if. A modal that leaves the DOM cannot run its exit transition.

Next steps