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. Use send/2 to hand work back to the page.

Choose the weakest layer that works

A table has three kinds of hook, in increasing order of force:

  1. Global lifecycle hooks fire for every event of one kind, such as every load or every sort.
  2. Per-action observers fire beside the built-in handler of one named action. They cannot replace it.
  3. 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:

elixir
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
end

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

elixir
on_load fn query, state ->
  {:cont, Ash.DataLayer.Simple.set_data(query, rows(state))}
end

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

elixir
on_expand fn id, socket ->
  send(self(), {:expand_row, id})
  {:halt, socket}
end

An 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.

elixir
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 end

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

elixir
after_row_action :delete_draft, fn result, state ->
  send(self(), {:draft_row_action, "delete_draft", record_id(result)})
  {:ok, state}
end

after_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.

elixir
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()
end

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

elixir
override_row_action :fork, fn payload, state ->
  send(self(), {:draft_row_action, "fork_draft", payload["id"]})
  {:ok, state}
end

Use the built-in transitions

These flags replace the hooks people otherwise write to move between lists:

FlagDefaultDoes
switch_to_active_on_empty_archivefalseShows the active list after the archive empties.
switch_to_archive_on_empty_activefalseShows the archive after the active list empties.
clear_selection_after_bulktrueClears the selection after a successful bulk action.
reset_page_on_empty_current_pagefalseReloads page 1 after a load that leaves a page past the first with a total of zero.
redirect_on_emptynoneA 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:

elixir
hooks do
  on_validate fn params, state ->
    MishkaCmsCore.Runtime.Helpers.follow_slug(params, state, :name)
  end

  on_cancel fn state -> state end
end

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

elixir
hooks do
  before_save fn params, state ->
    case refuse(params) do
      nil -> {:cont, apply_visibility(params, state)}
      message -> {:halt, tell_editor(message)}
    end
  end
end

The 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.

elixir
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
end

on_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