Every app has its own things to do with rows: archive them, assign them, send an invoice again, open one in another page. A grid offers them where the person works, rather than in buttons built around it: on the rows selected, and on each row.
<.data_grid id="orders" source={Order} repo={Repo} selectable scope={@current_scope}>
<:col field={:number} />
<:col field={:customer} />
<:col field={:status} type={:enum} />
<:action label="Archive" on_action={&OrdersGrid.archive/1} />
<:action :if={@admin?} label="Delete" on_action={&OrdersGrid.delete/1}
confirm="Delete {count} orders?" danger />
<:row_action label="Open" navigate={&~p"/orders/#{&1}"} />
<:row_action label="Send again" on_action={&OrdersGrid.resend/1}
disabled={&(&1.status == "draft")} />
</.data_grid>defmodule MyAppWeb.OrdersGrid do
alias MyApp.Orders
def archive(%{selected: selected, scope: scope}) do
{count, _} = Orders.archive(scope, selected)
{:ok, "#{count} orders archived"}
end
def resend(%{row: order, scope: scope}) do
case Orders.resend_invoice(scope, order) do
{:ok, _} -> {:ok, "Invoice sent again"}
{:error, reason} -> {:error, "Not sent: #{reason}"}
end
end
endOn the rows selected: :action
With selectable, the bar above the grid shows the actions while rows
are selected: "4 selected · Archive · Delete · Clear".
The action's context has selected, the rows as Masu.Live.selected/1
gives them, and count:
{:ids, ids}- the rows the person ticked;{:query, query}- every row of the query (the box in the header), for an Ecto source: anEcto.Querywith the person's filters and search, less the rows they took out of it. An action on a million rows is one statement:
def archive(%{selected: {:query, query}}), do: Repo.update_all(query, set: [archived: true])
def archive(%{selected: {:ids, ids}}), do: Repo.update_all(where(Order, [o], o.id in ^ids), set: [archived: true])When the action is done, the grid reads its rows again (they may have
changed) and clears the selection; keep_selection keeps it. A failed
action keeps it, so the person can try again.
On a row: :row_action
Each row has a button at its end (a column of its own, stuck to the end
as a pinned one) that opens the menu of its actions. A right click on a
row, and the context-menu key or Shift+F10 on its active cell, open them
too, after the cell's own items (cell_menu, :cell_action). The menu
is an ARIA menu: the arrows move, Enter runs, Escape closes.
The action's context has id and row, the row as the source reads it
now (not the browser's copy).
navigateandpatch, functions of the row that give a path, make the item a link: LiveView follows it (push_navigateorpush_patch), a middle click opens it in a tab, and the browser copies it as any link.show, a function of the row, says whether the row has the item.disabled, a function of the row (or a boolean), shows it unavailable: in the menu, not clickable.
The grid reads show, disabled and the link of each row as it sends
the row; the server reads them again, with the row from the source, when
the person runs the action.
Other people on the row
In an Issho room (issho), the context's editors are the people whose
editor is open on a cell of the row: an action may wait, or say so,
rather than change the row under them.
def resend(%{editors: [person | _]}), do: {:error, "#{person.name} is editing this order."}
def resend(%{row: order}), do: ...An action that changes rows of a grid with live broadcasts them, as
any change of your app does (Masu.Live.broadcast_upsert/3): the others
see them, with a flash.
Opening a row: on_row_activate
A list whose rows open (a panel beside the grid, the row's own page) opens a row on a click on it, or Enter, rather than on a link in one of its cells:
<.data_grid id="inbox" rows={@items} on_row_activate={&{:patch, ~p"/inbox?item=#{&1.id}"}}>
<:col field={:title} />
<:col field={:author} />
</.data_grid>The function gets id and row, the row as the source reads it now,
with grid and scope, in the LiveView's process. What it gives:
{:patch, path} | the grid patches the LiveView's URL (push_patch): handle_params/3 opens the row |
{:navigate, path} | the grid goes to another LiveView (push_navigate) |
| anything else | nothing more: a function that tells the LiveView, &send(self(), {:open, &1.id}), opens it in handle_info/2 |
A row opens on:
- a click on one of its cells, with no key held: not on a link, a button or an input in the cell, nor on a box, the arrow of a tree's row or of a detail; not the click that ends an editor, nor the second of a double click; not a drag, which selects a range;
- Enter on its active cell, the keyboard's way to the same row.
A cell that edits is the editor's: a click there makes it active, and Enter edits it, as in any grid that edits. The cells that open their row show a pointer. A group's row, and a new row not saved yet, do not open.
What an action does
| Attribute | |
|---|---|
on_action | a function of the context, in the LiveView's process |
event | the LiveView gets {Masu.Action, name, context} in handle_info/2, to change its assigns |
navigate, patch | (:row_action) a link, of the row |
confirm | a question first: "Delete {count} orders?" ({count} is the rows selected) |
danger | it destroys: the button, and its question's answer, say so |
keep_selection | (:action) the selection stays when it is done |
show, disabled | (:row_action) of the row |
:if leaves an action out: :if={@admin?}. The server runs only the
actions the grid offers: one left out by :if, hidden or disabled for
the row, or with no rows selected, is refused, whatever the browser
sends.
Results
on_action gives:
:ok (or any other value) | done |
{:ok, message} | done, and the grid says message, above it and to a screen reader |
{:error, message} | failed: the grid says so |
:async | it goes on in another process, which replies |
Later: jobs and tasks
An action on 50,000 rows, or one that calls a slow service, goes on in
a job. Its context's ref reaches the grid from any process, on any
node of the cluster: the job says how far it is, and how it went.
def export(context) do
%{ref: context.ref, ids: ids(context.selected)}
|> MyApp.Workers.Export.new()
|> Oban.insert!()
:async
end
# In the job:
def perform(%Oban.Job{args: %{"ref" => ref, "ids" => ids}}) do
ids
|> Enum.chunk_every(500)
|> Enum.with_index(1)
|> Enum.each(fn {chunk, i} ->
MyApp.Export.run(chunk)
Masu.progress(ref, min(i * 500, length(ids)), length(ids))
end)
Masu.reply(ref, {:ok, "#{length(ids)} orders exported"})
endThe grid shows the action running ("Export: running…", then "Export:
1,500 of 4,210"), and its reply: :ok, {:ok, message} or
{:error, message}. The rows are read again on :ok. If the person
left, the reply goes nowhere.
An action of the LiveView's own, which changes its assigns as it goes, runs in a task of the LiveView:
<:action label="Archive" on_action={&(send(self(), {:archive, &1}) && :async)} />
def handle_info({:archive, context}, socket) do
{:noreply,
start_async(socket, :archive, fn ->
Orders.archive(context.scope, context.selected)
Masu.reply(context.ref, {:ok, "#{context.count} archived"})
end)}
endThe scope
scope on the grid (your app's @current_scope, or the current user)
is in every action's context, and in every Masu.Write (see
Editing): the grid is not where your app learns who acts.
Testing
select(view, "orders", [1, 2])
assert actions(view, "orders", :selection) == ["Archive"]
assert action(view, "orders", "Archive") == {:ok, "2 orders archived"}
assert actions(view, "orders", :row, 1) == ["Open", "Send again"]
assert row_action(view, "orders", "Open", 1) == {:navigate, "/orders/1"}
assert row_action(view, "orders", "Send again", 3) == {:error, :refused}
assert activate_row(view, "inbox", 7) == {:patch, "/inbox?item=7"}action/3, row_action/4 and activate_row/3 send what the browser
sends, and the server checks it as it does for a person: an action the
grid does not offer, or that is disabled for the row, is {:error, :refused}.