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
end

On 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: an Ecto.Query with 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).

  • navigate and patch, functions of the row that give a path, make the item a link: LiveView follows it (push_navigate or push_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 elsenothing 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_actiona function of the context, in the LiveView's process
eventthe LiveView gets {Masu.Action, name, context} in handle_info/2, to change its assigns
navigate, patch(:row_action) a link, of the row
confirma question first: "Delete {count} orders?" ({count} is the rows selected)
dangerit 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
:asyncit 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"})
end

The 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)}
end

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