React to table and form events with hooks
v0.0.1
A hook is a function in the DSL that Gervaz calls at a fixed point of a table's or a form's life. This guide chooses the right kind of hook, scopes a table's query, reacts to row and bulk actions, controls the flash messages, replaces a built-in action, and hooks into a form's validation, save and client-side JS. Most examples come from the layouts, drafts, design tokens, snippets and tags resources of Mishka CMS. The observer signatures and the js block show the syntax from the Gervaz documentation, and Mishka CMS does not use them as written.
Before you begin
- Have a table or a form with the action you want to hook. See Add row and bulk actions.
- Know that a hook runs inside a LiveComponent, so
self()is the parent LiveView process. Usesend/2to hand work back to the page.
Choose the weakest layer that works
A table has three kinds of hook, in increasing order of force:
- Global lifecycle hooks fire for every event of one kind, such as every load or every sort.
- Per-action observers fire beside the built-in handler of one named action. They cannot replace it.
- Per-action overrides replace the built-in handler of one named action.
Besides the hooks, built-in transition flags cover common behavior after an action.
The lifecycle hooks do not all return the same shape. on_load must return {:cont, query}, and Gervaz ignores a bare query. on_realtime, on_expand and on_event accept a plain socket, {:cont, socket} or {:halt, socket}, and {:halt, socket} stops the built-in behavior. on_filter, on_sort and on_select accept a plain socket or {:cont, socket}. Do not return {:halt, socket} from those three, because Gervaz does not unwrap it.
Scope the query with on_load
on_load runs before data loads and returns {:cont, query}. Use it to scope the list by a route param. The drafts table lists only the drafts of one source layout and shows nothing without it:
hooks do
on_load fn
query, %{path_params: %{source_layout_id: id}} when not is_nil(id) ->
{:cont, Ash.Query.filter(query, is_nil(applied_at) and source_layout_id == ^id)}
query, _unscoped ->
{:cont, Ash.Query.filter(query, false)}
end
endon_load also lets a table list something that is not in a database. The design tokens table of Mishka CMS has no data layer. Its hook hands rows to Ash.DataLayer.Simple, which then applies the filter, sort and paging that Gervaz built onto the query:
on_load fn query, state ->
{:cont, Ash.DataLayer.Simple.set_data(query, rows(state))}
endThe other global hooks are before_delete, after_delete, on_realtime, on_expand, on_filter, on_event, on_select and on_sort. Their signatures are in Hooks DSL. on_event runs for an event the table does not handle itself, such as a custom row event with no override_row_action. Handle one named row event with override_row_action. The drafts table of Mishka Document uses on_expand to ask its LiveView for the content of an expanded row:
on_expand fn id, socket ->
send(self(), {:expand_row, id})
{:halt, socket}
endAn on_realtime hook that returns {:halt, socket} skips the table's own update. If it adds or removes rows itself, pass its socket through MishkaGervaz.Table.Web.DataLoader.refresh_total/1 so the total, the page count and the empty state follow.
Observe a row or bulk action
The per-action entities take an action name, or a list of names, and a function. They fire beside the built-in handler.
before_row_action :unarchive, fn record, state -> :ok end
after_row_action :delete_draft, fn result, state -> {:ok, state} end
on_row_action_success :unarchive, fn result, state, socket -> socket end
on_row_action_error :publish, fn reason, state, socket -> socket end
before_bulk_action :archive, fn ids, state -> :ok end
on_bulk_action_success :unarchive, fn summary, state, socket -> socket end
before_row_action [:unarchive, :restore], fn record, state -> :ok endOnly on_*_success and on_*_error hooks accept arity 2 or 3. The other per-action hooks take two arguments. Take the socket (arity 3) when you need put_flash/3, push_navigate/2 or a reload. A before_* hook refuses by returning {:halt, {:error, message}}. An after_* hook is for side effects, and cannot change the outcome.
The drafts tables tell their LiveView about a delete, because the deleted draft may be the one being edited:
after_row_action :delete_draft, fn result, state ->
send(self(), {:draft_row_action, "delete_draft", record_id(result)})
{:ok, state}
endafter_bulk_action and the on_bulk_action_* hooks receive a summary struct, MishkaGervaz.Table.Web.Events.BulkActionResult. A before_bulk_action hook receives the selected ids. The struct has the fields :action_name, :action_type, :status (:success, :partial_success or :error), :succeeded_count, :failed_count, :skipped_count, :requested_count, :succeeded_records, :failed_errors, :skipped_record_ids and :ash_result.
Control the flash messages
An arity-3 bulk hook decides whether the core handler's default flash also fires. A plain socket lets it fire. BulkActionHooks.silence/1 returns {:halt, socket} and leaves all messaging to the hook. Use BulkActionHooks.put_flash/3, not Phoenix.LiveView.put_flash/3: the hook runs inside a component, whose own flash the parent layout does not read, and the helper sends {:put_flash, kind, message} to the LiveView instead. The page needs the matching handle_info/2 clause.
on_bulk_action_success :unarchive, fn summary, _state, socket ->
msg =
dgettext(
"mishka_gervaz",
"Restored %{restored} layout(s) as inactive. Activate them to go live.",
restored: summary.succeeded_count
)
socket
|> MishkaGervaz.Table.Web.Events.BulkActionHooks.put_flash(:info, msg)
|> MishkaGervaz.Table.Web.Events.BulkActionHooks.silence()
endAn arity-3 on_row_action_error hook that returns {:halt, socket} puts no error flash. One that returns the plain socket lets the default flash fire as well. A failed row or bulk action flashes <action> failed: <reason>, where the action is its ui label or its humanized name. Each error in the reason is translated in the errors Gettext domain of the backend that Gervaz is configured with.
Replace a built-in action
override_row_action and override_bulk_action replace the built-in handler for a named action. The layouts drafts table hands the work of forking back to its editor:
override_row_action :fork, fn payload, state ->
send(self(), {:draft_row_action, "fork_draft", payload["id"]})
{:ok, state}
endUse the built-in transitions
These flags replace the hooks people otherwise write to move between lists:
| Flag | Default | Does |
|---|---|---|
switch_to_active_on_empty_archive | false | Shows the active list after the archive empties. |
switch_to_archive_on_empty_active | false | Shows the archive after the active list empties. |
clear_selection_after_bulk | true | Clears the selection after a successful bulk action. |
reset_page_on_empty_current_page | false | Reloads page 1 after a load that leaves a page past the first with a total of zero. |
redirect_on_empty | none | A path, or a function of the state, to go to when the total is zero. |
Hook into a form
A form has server hooks and client hooks. The server hooks that run are on_init, on_validate, on_change, before_save and on_cancel. The DSL also accepts after_save and on_error as server hooks, but Gervaz never calls them. Use the client hooks below, or the parent's {:form_saved, ...} message, for work after a save. on_validate runs on every validation event, so keep it cheap. The tags form makes the slug follow the name while a tag is added:
hooks do
on_validate fn params, state ->
MishkaCmsCore.Runtime.Helpers.follow_slug(params, state, :name)
end
on_cancel fn state -> state end
endbefore_save is where a refusal belongs. Return {:cont, params} to save with those params, or {:halt, state} to cancel the save before it reaches the action. Any other return, a bare params map included, saves the original params. The snippet form of Mishka CMS keeps its own wording for the two author mistakes it cares about:
hooks do
before_save fn params, state ->
case refuse(params) do
nil -> {:cont, apply_visibility(params, state)}
message -> {:halt, tell_editor(message)}
end
end
endThe hooks transform_params and transform_errors are accepted, but Gervaz does not call them. To change params, use before_save, or override transform_params/2 on the submit handler as Override a builder, handler or loader shows.
Run JavaScript from a form hook
The nested js block holds functions that return a %Phoenix.LiveView.JS{}. Gervaz pushes it to the browser as a "gervaz:exec-js" event aimed at the element with the id <component-id>-form-wrapper.
hooks do
js do
on_init fn -> JS.focus(to: "#title") end
after_save fn record_id -> JS.exec("data-hide", to: "#post-form-modal") end
on_cancel fn record_id -> JS.exec("data-hide", to: "#post-form-modal") end
on_error fn record_id -> JS.dispatch("shake", to: "#post-form") end
end
endon_init takes no arguments and runs as phx-mounted on the form container. on_cancel runs when the reader clicks Cancel. Gervaz pushes after_save and on_error as a "gervaz:exec-js" event. after_save receives the saved record id. on_cancel and on_error receive the record id, or nil in create mode. The page needs a listener for the event, and a custom template must keep the id on its root element. Both are shown in Mount a table and a form. Close a modal with the js hooks or with the parent's {:form_saved, ...} clause, and not with both.
Next steps
- Hooks DSL: every hook and its signature.
- Override a builder, handler or loader: replace a mechanism when a hook is too small.
- Write a custom type: the Fork action type that pairs with the override above.