Add row and bulk actions

v0.0.1

A Gervaz table acts on one row with row actions and on a selection with bulk actions. This guide adds an edit button, a soft delete, a restore and a permanent delete to each row, lays the buttons out inline or in a dropdown, and adds bulk versions. The examples come from the redirects and tags tables of Mishka CMS.

Before you begin

  • Have a table with an identity and columns. See Build an admin table.
  • Declare the actions the buttons run in source.actions, on the resource or the domain. Any bulk action, a selectable row, and a row action of type :destroy, :update, :unarchive, :permanent_destroy or :accordion need get. A :destroy action also needs destroy.
  • For archive and restore, add AshArchival.Resource to the resource and declare the archive actions, usually on the domain. See How domain defaults work.

Add row actions

Each row action has a name and a required type. The type decides what the button does. These four are the built-in edit, delete, restore and permanent delete:

elixir
row_actions do
  action :edit do
    type :edit
    js fn _record -> MishkaCmsCore.CoreComponents.show_modal("redirect-form-modal") end

    ui do
      label fn -> dgettext("mishka_gervaz", "Edit") end
      icon "hero-pencil-square"
    end
  end

  action :delete do
    type :destroy
    confirm fn _record -> dgettext("mishka_gervaz", "Archive this redirect?") end

    ui do
      label fn -> dgettext("mishka_gervaz", "Delete") end
      icon "hero-trash"
    end
  end

  action :unarchive do
    type :unarchive
    confirm fn _record -> dgettext("mishka_gervaz", "Restore this redirect?") end

    ui do
      label fn -> dgettext("mishka_gervaz", "Restore") end
      icon "hero-arrow-path"
    end
  end

  action :permanent_destroy do
    type :permanent_destroy

    confirm fn _record ->
      dgettext("mishka_gervaz", "Permanently delete this redirect? This cannot be undone.")
    end

    ui do
      label fn -> dgettext("mishka_gervaz", "Delete permanently") end
      icon "hero-trash"
    end
  end
end

The types behave like this:

  • :edit sends the record to the form component, with no message to your LiveView. A js function runs first, so it can open the modal that holds the form.
  • :destroy soft-deletes with source.actions.destroy.
  • :unarchive restores with the archive restore action, and :permanent_destroy deletes an archived record with the archive destroy action.
  • :update runs the Ash action named in action, an atom or a {master_action, tenant_action} tuple.
  • :event sends {:row_action, event, payload} to the parent LiveView. :link navigates to path, a string used as written or a function of the record. Both require their option.
  • :accordion declares that the row expands, and draws nothing unless the :expand feature is on.
  • :row_click draws nothing. The built-in templates do not call its click handler, so a row does not become clickable from it.

confirm takes a string or a function of the record, and Gervaz shows the text as written. Gervaz does not replace placeholders in it. A :destroy action with no confirm asks "Are you sure?". The label is ui.label when you set it. Without one, :destroy draws "Delete", :unarchive "Restore" and :permanent_destroy "Delete Permanently", and the other types draw the humanized action name.

Control when an action shows

A row action shows for active records by default. visible changes that: :active, :archived, true, false, or a function of the record and the table state. The function has two arguments here. The visible of the realtime block also has two, the record and the user. Every other predicate of the table takes the state alone.

elixir
visible fn _record, state ->
  state.archive_status == :active and Redirects.writes?(state.current_user)
end

Two shortcuts cover most cases: visible :archived for restore and permanent delete, and restricted true for an action only masters should see.

Lay the actions out

Name the actions that draw inline and the ones that fold into a dropdown with actions_layout. A dropdown holds actions and separators, and it needs a ui block with a label. The tags table puts three buttons inline and the rest in a menu:

elixir
row_actions do
  actions_layout do
    position :end
    sticky true
    inline [:table_edit, :table_archive, :table_unarchive]
    dropdown [:tag_actions]
  end

  action :table_edit do
    type :edit
    js fn _record -> MishkaCmsCore.CoreComponents.show_modal("blog-tag-form-modal") end

    ui do
      label fn -> dgettext("mishka_gervaz", "Edit") end
      icon "hero-pencil"
    end
  end

  action :table_archive do
    type :destroy

    ui do
      label fn -> dgettext("mishka_gervaz", "Delete") end
      icon "hero-trash"
    end
  end

  action :table_unarchive do
    type :unarchive

    ui do
      label fn -> dgettext("mishka_gervaz", "Restore") end
      icon "hero-arrow-uturn-left"
    end
  end

  dropdown :tag_actions do
    ui do
      label fn -> dgettext("mishka_gervaz", "Actions") end
      icon "hero-ellipsis-vertical"
    end

    action :versions do
      type :event
      event "show_versions"
      visible :active

      ui do
        label fn -> dgettext("mishka_gervaz", "Versions") end
        icon "hero-clock"
      end
    end

    separator label: dgettext_noop("mishka_gervaz", "Permanent")

    action :permanent_destroy do
      type :permanent_destroy
      visible :archived
      confirm dgettext_noop(
                "mishka_gervaz",
                "Are you sure you want to PERMANENTLY delete this tag? This action cannot be undone!"
              )

      ui do
        label fn -> dgettext("mishka_gervaz", "Delete forever") end
        icon "hero-x-circle"
      end
    end
  end
end

Gervaz does not check the names in inline and dropdown when it compiles, and a name that matches no action draws nothing. Once either list is set, an action or dropdown that neither list names is not drawn. The auto_collapse_after option is accepted but no built-in template reads it. sticky true pins the Actions column to the right edge while the table scrolls sideways.

The :versions action is an :event. The tags page answers it in its LiveView:

elixir
def handle_info({:row_action, "show_versions", %{"id" => tag_id}}, socket) do
  ...
end

Add bulk actions

A bulk action works on the selected rows. A built-in type resolves the handler for you. Use the inline form for short ones:

elixir
bulk_actions do
  action :archive,
    type: :destroy,
    confirm: dgettext_noop("mishka_gervaz", "Archive selected tags?")

  action :unarchive,
    type: :unarchive,
    confirm: dgettext_noop("mishka_gervaz", "Restore selected tags?"),
    visible: :archived

  action :permanent_destroy,
    type: :permanent_destroy,
    confirm: dgettext_noop("mishka_gervaz", "Permanently delete selected tags? This cannot be undone."),
    visible: :archived
end

confirm is the text of the browser's confirmation dialog. {count} in it becomes the number of rows the action runs on. visible is :active or :archived, or a function of the state alone. The redirects table uses the function form, with handler: :destroy instead of type, which acts the same way:

elixir
action :archive,
  handler: :destroy,
  confirm: dgettext_noop("mishka_gervaz", "Archive selected redirects?"),
  visible: fn state ->
    state.archive_status == :active and Redirects.writes?(state.current_user)
  end

Without a type, a bulk action's handler is :parent. The table sends {:bulk_action, name, selected_ids} to the parent LiveView, and you run the work there. A handler can also be a function fn selected_ids, state -> {:ok, state} end, an Ash action name, or a {master_action, tenant_action} tuple. The ids arrive as a list, as :all, or as {:all_except, ids}, so select-all does not list the whole table.

Show the checkboxes

The built-in templates draw checkboxes when the :select feature is on and at least one bulk action is visible in the current archive scope. Add row do selectable true end as well. It is what makes source.actions.get required, but it does not draw the checkboxes by itself.

the redirects table with two rows selected, the bulk action bar above the table with the Archive action, and the checkboxes on each row

Selecting rows shows the bulk action bar.

Archive and restore

With an archive block on the domain or the resource, and AshArchival.Resource on the resource, the table gains an archive toggle in its filter area. In the archived view the :archived actions show. The two flags below remove the manual step of switching back:

elixir
hooks do
  switch_to_active_on_empty_archive true
  switch_to_archive_on_empty_active true
end

The first switches to the active list after an unarchive or a permanent delete leaves the archive empty. The second switches to the archive after a delete leaves the active list empty. See React to table and form events with hooks for them and for the hooks that run around an action.

Next steps