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.Resourceextension and atableor aformsection. 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.
<.live_component
module={MishkaGervaz.Table.Web.Live}
id="tags-table"
resource={MishkaBlog.Tag}
current_user={@current_user}
url_state={@url_state}
/>| Assign | Required | Meaning |
|---|---|---|
id | Yes | The component id. Every send_update/2 call must use the same id. |
resource | Yes | An Ash resource with the Gervaz extension. |
current_user | Yes | The actor. |
url_state | No | The result of UrlSync.decode/3. With nil, no URL state is read for this mount. |
template | No | The template this mount starts with: a module, or the name/0 of a template in switchable_templates. |
switchable_templates | No | The templates the switcher offers on this mount. An empty list or a single entry turns the switcher off here. |
url_sync | No | false 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.
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)}
endUrlSync.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.
<.live_component
module={MishkaGervaz.Form.Web.Live}
id={MishkaGervaz.Resource.Info.Form.component_id(MishkaBlog.Tag)}
resource={MishkaBlog.Tag}
current_user={@current_user}
/>| Assign | Required | Meaning |
|---|---|---|
id | Yes | Use MishkaGervaz.Resource.Info.Form.component_id/1. |
resource | Yes | An Ash resource with the Gervaz extension. |
current_user | Yes | The actor. |
record_id | No | nil opens create mode. An id opens edit mode. |
defaults | No | A map such as %{site_id: @site_id} that fills create mode and any param a hidden field would have carried. |
hidden_fields | No | A list of fields this mount does not draw. |
submit | No | false draws no submit row in create mode. An edit form still draws it. The save event is still accepted. |
submit_alternatives | No | Other 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:
<.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:
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
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)}
endHandle :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:
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}
endWith a refresh block, the table sends :gervaz_refresh to the parent on a timer. Forward it, or nothing reloads:
def handle_info(:gervaz_refresh, socket) do
send_update(MishkaGervaz.Table.Web.Live, id: "tags-table", gervaz_refresh: true)
{:noreply, socket}
endFor 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:
<.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:
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:
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:
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"))}
endKeep the modal rendered instead of wrapping it in :if. A modal that leaves the DOM cannot run its exit transition.
Next steps
- Share table state and update it live: the URL sync, realtime and refresh blocks behind these messages.
- React to table and form events with hooks: the
jshooks and flash control. - Troubleshooting: symptoms of a missing message clause.