defmodule Corex.DataTable do @moduledoc ~S''' Phoenix table component for tabular data with column slots, optional row actions, sorting, and row selection. Supports in-memory lists, LiveView stream rows (`phx-update="stream"`), and Ecto-backed pages paired with [`Corex.Pagination`](Corex.Pagination.html). See [`data_table/1`](#data_table/1) for anatomy and patterns (basic, actions, streaming, sortable, selectable, with database). Helpers: [`Corex.DataTable.Sort`](Corex.DataTable.Sort.html), [`Corex.DataTable.Selection`](Corex.DataTable.Selection.html). ''' @doc type: :component use Phoenix.Component alias Corex.DataTable.Translation @doc ~S''' Renders a table with data. ## Anatomy ### Basic ```heex <.data_table id="basic-table" class="data-table" rows={@list_rows}> <:col :let={row} label="ID">{row.id} <:col :let={row} label="Name">{row.name} <:col :let={row} label="Role">{row.role} <:col :let={row} label="Email">{row.email} ``` ### Actions Use the `:action` slot to add actions for each row, like Edit and Delete buttons. ```heex <.data_table id="basic-table" class="data-table" rows={@list_rows}> <:col :let={row} label="ID">{row.id} <:col :let={row} label="Name">{row.name} <:col :let={row} label="Role">{row.role} <:col :let={row} label="Email">{row.email} <:action :let={row}> <.action phx-click="edit" phx-value-id={row.id}>Edit <.action phx-click="delete" phx-value-id={row.id}>Delete ``` ### Streaming Pass the stream to `rows`. Column slot receives `{id, item}`. Items need an `:id` field (or use `stream_configure/3` with `:dom_id`). Add rows with `stream_insert/3`. ```elixir # mount socket |> stream(:items, []) |> assign(:next_id, 1) ``` ```heex <.data_table id="my-table" class="data-table" rows={@streams.items}> <:col :let={{_id, item}} label="Name">{item.name} ``` Add a row: `stream_insert(socket, :items, %{id: id, name: "New"})` from `handle_event` or `handle_info`. With the `:empty` slot, the empty row stays in the DOM and is hidden by the `data-table` stylesheet whenever the tbody has data rows (same idea as [stream empty state siblings](https://elixirforum.com/t/stream-empty-state-is-there-a-way-to-check-when-a-stream-is-empty/57219/20); avoids counting stream items on the server). ### Row click Pass `row_click` to handle clicks on data cells (not the action column). Use `JS.push/2` to update LiveView state without navigating. ```heex

Row clicked: {@row_clicked}

<.data_table id="users-table" class="data-table" rows={@users} row_click={fn user -> JS.push("row_click", value: %{id: user.id, name: user.name}) end} > <:col :let={user} label="Name">{user.name} <:action :let={user}> <.action class="button button--sm">Edit ``` ```elixir def handle_event("row_click", %{"id" => id, "name" => name}, socket) do {:noreply, assign(socket, :row_clicked, "#{name} (##{id})")} end ``` ### Sortable Set `sort_by`, `sort_order`, `on_sort`; give each sortable column a `name`. Delegate sorting to [`Corex.DataTable.Sort`](Corex.DataTable.Sort.html). LiveView minimum: ```elixir # mount socket |> assign(:users, users) |> Corex.DataTable.Sort.assign_for_sort(:users, default_sort_by: :id, default_sort_order: :asc) # handle_event("sort", params, socket) {:noreply, Corex.DataTable.Sort.handle_sort(socket, params, :users)} ``` ```heex <.data_table id="users-sortable" class="data-table" rows={@users} sort_by={@sort_by} sort_order={@sort_order} on_sort="sort"> <:col :let={user} label="ID" name={:id}>{user.id} <:col :let={user} label="Name" name={:name}>{user.name} <:sort_icon :let={%{direction: direction}}> <.heroicon name={%{asc: "hero-chevron-up", desc: "hero-chevron-down", none: "hero-chevron-up-down"}[direction]} /> ``` ### Selectable Set `selectable`, `selected`, `on_select`, `on_select_all`, and `row_id`. Delegate selection to [`Corex.DataTable.Selection`](Corex.DataTable.Selection.html). LiveView minimum: ```elixir # mount socket |> assign(:users, users) |> Corex.DataTable.Selection.assign_for_selection(:users, table_id: "users-table", row_id: &"user-#{&1.id}") def handle_event("select", params, socket) do {:noreply, Corex.DataTable.Selection.handle_select(socket, params, :users)} end def handle_event("select_all", params, socket) do {:noreply, Corex.DataTable.Selection.handle_select_all(socket, params, :users)} end ``` ```heex <.data_table id="users-table" class="data-table" rows={@users} row_id={&"user-#{&1.id}"} selectable={true} selected={@selected} on_select="select" on_select_all="select_all" checkbox_class="checkbox" > <:checkbox_indicator> <.heroicon name="hero-check" /> <:col :let={user} label="ID" name={:id}>{user.id} <:col :let={user} label="Name" name={:name}>{user.name} <:col :let={user} label="Email" name={:email}>{user.email} ``` ### With database Sort and paginate in your context (`order_by`, `limit`, `offset`), then pass each page to `<.data_table>` and `<.pagination>`. Re-fetch on `on_sort` and `on_page_change`. ```elixir # mount {rows, total} = MyApp.list_cities(page: 1, page_size: 10, order_by: :name, order_dir: :asc) {:ok, socket |> assign(:cities, rows) |> assign(:page, 1) |> assign(:page_size, 10) |> assign(:sort_by, :name) |> assign(:sort_order, :asc) |> assign(:total, total)} ``` ```heex <.data_table id="cities-table" class="data-table" rows={@cities} sort_by={@sort_by} sort_order={@sort_order} on_sort="sort" > <:col :let={city} label="Name" name={:name}>{city.name} <.pagination id="cities-pagination" class="pagination" count={@total} page={@page} page_size={@page_size} controlled on_page_change="page" /> ``` ```elixir def handle_event("sort", %{"sort_by" => sort_by}, socket) do sort_by = String.to_existing_atom(sort_by) order = if socket.assigns.sort_by == sort_by do if socket.assigns.sort_order == :asc, do: :desc, else: :asc else :asc end {rows, total} = MyApp.list_cities( page: 1, page_size: socket.assigns.page_size, order_by: sort_by, order_dir: order ) {:noreply, socket |> assign(:cities, rows) |> assign(:page, 1) |> assign(:sort_by, sort_by) |> assign(:sort_order, order) |> assign(:total, total)} end def handle_event("page", %{"page" => page}, socket) do page = String.to_integer(page) {rows, total} = MyApp.list_cities( page: page, page_size: socket.assigns.page_size, order_by: socket.assigns.sort_by, order_dir: socket.assigns.sort_order ) {:noreply, socket |> assign(:cities, rows) |> assign(:page, page) |> assign(:total, total)} end ``` ## Style Use data attributes to target elements: ```css [data-scope="data-table"][data-part="root"] {} [data-scope="data-table"][data-part="thead"] {} [data-scope="data-table"][data-part="tbody"] {} [data-scope="data-table"][data-part="row"] {} [data-scope="data-table"][data-part="cell"] {} [data-scope="data-table"][data-part="grow-cell"] {} [data-scope="data-table"][data-part="col-grow"] {} [data-scope="data-table"][data-part="sort-header"] {} [data-scope="data-table"][data-part="sort-text"] {} [data-scope="data-table"][data-part="sort-icon-container"] {} [data-scope="data-table"][data-part="sort-trigger"] {} [data-scope="data-table"][data-part="selection-header"] {} [data-scope="data-table"][data-part="selection-cell"] {} [data-scope="data-table"][data-part="action-header"] {} [data-scope="data-table"][data-part="action-cell"] {} [data-scope="data-table"][data-part="actions"] {} [data-scope="data-table"][data-part="empty-row"] {} [data-scope="data-table"][data-part="empty-cell"] {} [data-scope="data-table"][data-part="empty"] {} ``` With the `data-table` class, the stylesheet hides `[data-part="empty-row"]` when it is not the only row in the tbody so list and stream tables can use `<:empty>` without server-side row counts. If you wish to use the default Corex styling, use the class `data-table` on the component. Modifier classes on the root: - `data-table--sm|md|lg|xl` — font size on header and body cells; cell padding - `data-table--accent|brand|alert|success|info` — header ink (`--color-ink-*`) on column titles only Optional `dir="ltr"` or `dir="rtl"` on the component root for text direction. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/data-table.css"; ``` ''' attr(:id, :string, required: true, doc: "The id of the table, used for LiveStream updates") attr(:rows, :list, required: true, doc: "The list of row data to render") attr(:row_id, :any, default: nil, doc: "the function for generating the row id") attr(:row_click, :any, default: nil, doc: "the function for handling phx-click on each row") attr(:row_item, :any, default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" ) attr(:translation, Corex.DataTable.Translation, default: nil, doc: "Override translatable strings" ) attr(:sort_by, :atom, default: nil, doc: "The currently sorted column name") attr(:sort_order, :atom, default: :asc, values: [:asc, :desc], doc: "The current sort direction" ) attr(:on_sort, :any, default: nil, doc: "The event to trigger when a sortable header is clicked" ) attr(:selectable, :boolean, default: false, doc: "Whether the rows are selectable") attr(:selected, :list, default: [], doc: "The list of currently selected row IDs") attr(:on_select, :any, default: nil, doc: "The event to trigger when a single row is selected") attr(:on_select_all, :any, default: nil, doc: "The event to trigger when the select all checkbox is toggled" ) attr(:checkbox_class, :string, default: nil, doc: "The class applied to the internal checkboxes" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Text direction" ) attr(:rest, :global) slot :col, required: true do attr(:label, :string) attr(:class, :string, required: false) attr(:name, :atom, required: false, doc: "The field name used for sorting") end slot :sort_icon, doc: "the slot for showing the sort icon" do attr(:direction, :atom, doc: "the current sort direction (:asc or :desc)") end slot :action, doc: "the slot for showing user actions in the last table column" do attr(:class, :string, required: false) end slot(:checkbox_indicator, doc: "the slot for showing the checkbox indicator icon") slot(:empty, doc: "Optional slot shown when the table has no rows") def data_table(assigns) do assigns = assigns |> assign( :translation, Translation.resolve(Map.get(assigns, :translation)) ) |> resolve_row_id() col_count = length(assigns.col) + if(assigns.selectable, do: 1, else: 0) + if assigns.action != [], do: 1, else: 0 assigns = assign(assigns, :empty_col_count, col_count) ~H"""
<:indicator :if={@checkbox_indicator != []}> {render_slot(@checkbox_indicator)}
{col[:label]}
{col[:label]}
{@translation.actions}
{render_slot(@empty)}
<:indicator :if={@checkbox_indicator != []}> {render_slot(@checkbox_indicator)} {render_slot(col, @row_item.(row))}
{render_slot(@action, @row_item.(row))}
""" end defp resolve_row_id(%{rows: %Phoenix.LiveView.LiveStream{}} = assigns) do assign(assigns, :row_id, assigns.row_id || fn {id, _item} -> id end) end defp resolve_row_id(assigns), do: assigns defp col_data_part(index, count) when index == count - 1, do: "col-grow" defp col_data_part(_, _), do: "col" defp cell_data_part(index, count) when index == count - 1, do: "grow-cell" defp cell_data_part(_, _), do: "cell" end