This guide covers everything beyond a first collection. For that, see Getting Started.
Table of Contents
- Layout Options
- URL State Management
- Relationship Fields
- Embedded Resources
- Pagination
- Loading, Empty & Error States
- Initial Data Loading
- Refreshing and Updating
- Loading Less Data
- Query Access
- Selection & Bulk Actions
- Telemetry & Tracing
Layout Options
Grid Columns
Set a grid's number of columns with grid_columns, as a number or by breakpoint (xs, sm,
md, lg, xl, 2xl):
<Cinder.collection resource={MyApp.Product} layout={:grid} grid_columns={4}>
...
</Cinder.collection>
<Cinder.collection resource={MyApp.Product} layout={:grid} grid_columns={[xs: 1, sm: 2, md: 3, lg: 4]}>
...
</Cinder.collection>Or replace the classes on the grid (or list) container entirely with container_class:
<Cinder.collection resource={MyApp.Product} layout={:grid} container_class="grid grid-cols-2 lg:grid-cols-4 gap-8">
...
</Cinder.collection>Sort Buttons
Lists and grids have no column headings to click, so they sort with a row of buttons above the
items. Change its label with sort_label, or hide it with show_sort={false}:
<Cinder.collection resource={MyApp.User} layout={:list} sort_label="Order by:">
...
</Cinder.collection>Clickable Rows and Items
click takes a function from the record to a Phoenix.LiveView.JS command, run when its row or
item is clicked. Clickable rows and items get a pointer cursor:
<Cinder.collection resource={MyApp.User} click={fn user -> JS.navigate(~p"/users/#{user.id}") end}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>Commands can be chained:
click={fn user ->
JS.push("select_user", value: %{id: user.id})
|> JS.add_class("selected", to: "#user-#{user.id}")
end}Row and Item Classes
item_class adds classes to each row or item, as a string or a function of the record:
<Cinder.collection resource={MyApp.User} item_class={fn user -> if user.archived, do: "opacity-50" end}>
...
</Cinder.collection>URL State Management
URL sync keeps a collection's filters, sorting, search and page in the URL, so they can be bookmarked, shared, and moved through with the browser's back button.
Setup
Add use Cinder.UrlSync to the LiveView, after use MyAppWeb, :live_view, and pass the
collection url_state={@url_state}:
defmodule MyAppWeb.UsersLive do
use MyAppWeb, :live_view
use Cinder.UrlSync
def render(assigns) do
~H"""
<Cinder.collection id="users" resource={MyApp.User} actor={@current_user} url_state={@url_state}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<:col :let={user} field="email" filter>{user.email}</:col>
<:col :let={user} field="is_active" filter>{user.is_active}</:col>
</Cinder.collection>
"""
end
endYour LiveView can still have its own handle_params/3: @url_state is assigned before it runs.
Only one collection on a page can sync to the URL.
The URL Format
Each filter is a parameter named after its field. Values with two parts (ranges) or several (multi-select) are separated by commas, and either end of a range can be left empty:
/users?name=john a text filter
/users?is_active=true a boolean filter
/users?tags=admin,editor a multi-select filter
/users?inserted_at=2024-01-01,2024-12-31 a date range
/users?age=18, a number range with only a minimum
/users?department.name=Sales a relationship field
/users?profile__country=NZ an embedded field
/users?search=smith the search box
/users?sort=-inserted_at,name sorted by inserted_at descending, then name
/users?page=3&page_size=50 the page, and a page size other than the default
/users?after=g3QAAAABZAAC... keyset pagination's positionParameters that don't apply to the collection, like a filter on a field that isn't filterable or a sort on a field that isn't sortable, are ignored.
Relationship Fields
A column's field can reach through relationships with dots, to show, filter and sort by a
related record's field:
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<:col :let={user} field="department.name" filter sort>{user.department.name}</:col>
<:col :let={user} field="office.building.address" filter>{user.office.building.address}</:col>
</Cinder.collection>Cinder loads what each column's field needs to show it: the relationships in its path, and any
calculation or aggregate it names. Load anything else your slots show with
query_opts={[load: ...]} (Loading Less Data). A <:filter> slot's field
isn't loaded, since nothing shows it.
A filter on a relationship field picks its type from the related field. To give it options,
configure it as on any other column: filter={[type: :select, options: @department_names]}.
Embedded Resources
Reach into an embedded resource (or a map attribute) with a double underscore:
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="profile__country" filter sort>{user.profile.country}</:col>
<:col :let={user} field="settings__address__city" filter>{user.settings.address.city}</:col>
</Cinder.collection>Embedded fields filter and sort like any other: an embedded enum gets a select filter with its values. To sort by one by default, see Sorting.
Pagination
Collections show 25 records a page. Change it with page_size, or let users choose from a list:
<Cinder.collection resource={MyApp.User} page_size={50}>
...
</Cinder.collection>
<Cinder.collection resource={MyApp.User} page_size={[default: 25, options: [10, 25, 50, 100]]}>
...
</Cinder.collection>Set the default for every collection, in either form, in your config:
config :cinder, default_page_size: [default: 25, options: [10, 25, 50, 100]]Counting Records
Each page read also counts the matching records, for the "Page 2 of 10" and "showing 11-20 of
100" footer. On large tables that count can cost more than the page itself, so change it with
pagination's count option:
count: true(the default): counted with each page.count: :async: counted in the background after the page loads. The last total stays on screen until the new one arrives.count: false: never counted. Users can only move to the previous and next pages.
<Cinder.collection resource={MyApp.Event} pagination={[count: :async]}>
...
</Cinder.collection>Keyset Pagination
By default, pages are numbered, and users can jump to any page. That gets slower the further in the page is, since the data layer reads past every record before it. Keyset pagination only moves to the previous and next pages, but each is as fast as the first:
<Cinder.collection resource={MyApp.Event} pagination={:keyset}>
...
</Cinder.collection>
<Cinder.collection resource={MyApp.Event} pagination={[mode: :keyset, count: false]}>
...
</Cinder.collection>Paginating the Action
Give the read action pagination, so pages are read from the data layer. Without it, the whole
collection is loaded at once, with no pages (and a warning if you've set a page_size). With the
default count: true, it must be countable:
actions do
read :read do
pagination offset?: true, keyset?: true, countable: true, default_limit: 25
end
endLoading, Empty & Error States
Change the text shown while loading, when nothing matches, and when the read fails:
<Cinder.collection
resource={MyApp.User}
loading_message="Fetching users..."
empty_message="No users yet"
error_message="Users couldn't be loaded"
>
<:col :let={user} field="name">{user.name}</:col>
</Cinder.collection>Or replace them with your own content, using the <:loading>, <:empty> and <:error> slots,
which take precedence over the messages:
<Cinder.collection id="users" resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
<:loading>
<div class="flex justify-center p-8">Fetching users...</div>
</:loading>
<:empty :let={context}>
<p :if={context.filtered?}>No users match your filters.</p>
<p :if={!context.filtered?}>No users yet. <.link navigate={~p"/users/new"}>Add one</.link></p>
</:empty>
<:error>
<p>Users couldn't be loaded.</p>
<button type="button" phx-click="retry">Try again</button>
</:error>
</Cinder.collection>def handle_event("retry", _params, socket) do
{:noreply, Cinder.refresh_table(socket, "users")}
endThe <:empty> slot receives:
filtered?: whether a filter or search is in use, to tell "nothing matches" from "nothing yet"filters: the filters in use, by fieldsearch_term: the search box's text
While loading, the current rows stay on screen under the loading state. An error replaces them.
Initial Data Loading
A collection reads its data after the page renders, so the first HTML has the table but not its
rows. With initial_load={:sync}, it reads its first page before rendering instead, so the rows
are in the server-rendered HTML, for pages that need to be crawlable or readable without
JavaScript:
<Cinder.collection resource={MyApp.Article} initial_load={:sync}>
<:col :let={article} field="title">{article.title}</:col>
</Cinder.collection>Only the first load changes: filtering, sorting, paging and refreshing stay asynchronous. Any state in the URL applies to the first render.
The cost is waiting for the query twice, once before the page is sent and again before it connects, so a slow query delays the page appearing and then becoming interactive. You can change the default for every collection, but it's usually better to use it only where it's needed:
config :cinder, default_initial_load: :syncRefreshing and Updating
Refreshing
After changing records, re-read a collection with refresh_table/2. It keeps the current
filters, sorting and page:
def handle_event("delete_user", %{"id" => id}, socket) do
MyApp.User
|> Ash.get!(id, actor: socket.assigns.current_user)
|> Ash.destroy!(actor: socket.assigns.current_user)
{:noreply, Cinder.refresh_table(socket, "users")}
endRefresh several at once with refresh_tables(socket, ["users", "audit-log"]).
Normally a refresh shows the loading state. With silent: true, the current rows stay as they
are until the new ones arrive, with no loading indicator, and if the refresh fails, the current
rows stay and the error is only logged. That suits refreshing whenever a PubSub notification
arrives:
def handle_info(%Ash.Notifier.Notification{}, socket) do
{:noreply, Cinder.refresh_table(socket, "users", silent: true)}
endUpdating Rows in Place
When you already have the changed data, like from a PubSub message, change the rows on screen without re-reading the collection:
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(MyApp.PubSub, "users")
{:ok, socket}
end
def handle_info({:user_status_changed, user_id, new_status}, socket) do
{:noreply, Cinder.update_item(socket, "users", user_id, &%{&1 | status: new_status})}
end
def handle_info({:users_deactivated, user_ids}, socket) do
{:noreply, Cinder.update_items(socket, "users", user_ids, &%{&1 | active: false})}
endWhen the message carries bare records, missing the relationships or calculations the collection
shows, use update_if_visible/4 or update_items_if_visible/4. Your function is only called for
records on the current page, so you only load what's shown:
def handle_info({:user_updated, user}, socket) do
{:noreply,
Cinder.update_if_visible(socket, "users", user, fn user ->
Ash.load!(user, [:department, :manager])
end)}
endThe new record replaces the one on screen, so it needs everything the columns show loaded, as the collection would have.
Removing Rows
remove_item/3 and remove_items/3 take rows off the page, like after a delete. They also leave
the selection, and the total count goes down by the number removed:
def handle_info({:users_deleted, user_ids}, socket) do
{:noreply, Cinder.remove_items(socket, "users", user_ids)}
endRows from the next page don't move up to fill the gap. If the page ends up empty, the collection reads it again (or the page before, if it was the last).
What Updates Don't Do
Updating and removing rows only changes what's on screen. To get any of these, use
refresh_table/2 instead:
- Calculations and aggregates aren't recalculated.
- Rows aren't re-sorted, so a row whose sorted field changes stays where it is.
- Filters and search aren't re-applied, so a row that no longer matches stays.
- Rows can't be added: new records only appear on a refresh.
Updates for records that aren't on the current page are ignored.
Loading Less Data
Columns load what their fields need. To load anything else your slots show, or to read only some
attributes, use query_opts:
<Cinder.collection
resource={MyApp.User}
query_opts={[load: [:avatar_url], select: [:id, :name, :email, :inserted_at]]}
>
...
</Cinder.collection>select must list every attribute your columns and slots show, and the primary key: attributes
left out are nil.
Give slow queries more time with query_opts={[timeout: 30_000]}.
Query Access
on_query_change sends your LiveView the collection's query after each load, with its filters,
search and sorting but no pagination, for exporting the same records, say:
<Cinder.collection id="users" resource={MyApp.User} actor={@current_user} on_query_change={:users_query}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
</Cinder.collection>def handle_info({:users_query, %{query: query, count: count, id: "users"}}, socket) do
{:noreply, assign(socket, users_query: query, users_count: count)}
end
def handle_event("export", _params, socket) do
users = Ash.read!(socket.assigns.users_query, actor: socket.assigns.current_user)
# ...turn them into a CSV and send it to the user
{:noreply, socket}
endRead it with the same actor, tenant or scope you gave the collection. count is the total
that the footer shows, so showing it elsewhere costs no extra query:
<h1>Users <span :if={@users_count}>({@users_count})</span></h1>It's nil when nothing is counted. With count: :async, the message is sent twice: once when the
page loads, with a nil count, and again when the count arrives.
Selection & Bulk Actions
Selecting Records
selectable adds a checkbox to each row or item, and a "select all" for the page:
<Cinder.collection resource={MyApp.User} actor={@current_user} selectable>
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>Without a click handler, clicking a row or item also selects it.
To make only some records selectable, pass a function instead of true. The others get a
disabled checkbox, and "select all" skips them. This is checked on the server too:
<Cinder.collection resource={MyApp.User} selectable={&(&1.status == :active)}>
...
</Cinder.collection>Cinder keeps track of the selection itself. To know about it as well, use
on_selection_change:
<Cinder.collection resource={MyApp.User} selectable on_selection_change={:selection_changed}>
...
</Cinder.collection>def handle_info({:selection_changed, %{selected_ids: ids, selected_count: count}}, socket) do
{:noreply, assign(socket, :selected_count, count)}
endThe message also has the collection's component_id, and an action: :select, :deselect,
:select_all, :clear or :remove.
Bulk Actions
A <:bulk_action> slot is a button that runs an action on the selected records. With label and
variant (:primary, :secondary or :danger), it's styled by the theme:
<Cinder.collection resource={MyApp.User} actor={@current_user} selectable>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<:bulk_action action={:archive} label="Archive ({count})" />
<:bulk_action action={:destroy} label="Delete" variant={:danger} confirm="Delete {count} users?" />
</Cinder.collection>{count} in the label or confirmation is the number selected. Buttons are disabled when nothing
is selected. confirm asks in the browser before running: it's a prompt for the user, not a
security check.
For your own button, put it in the slot instead of a label. The slot receives the
selected_count and selected_ids:
<:bulk_action :let={selection} action={:archive}>
<button class="btn" disabled={selection.selected_count == 0}>Archive selected</button>
</:bulk_action>What Actions Run
An atom is an action on the resource, run with Ash.bulk_update or Ash.bulk_destroy, depending
on its type. Pass more options to it with action_opts:
<:bulk_action action={:archive} label="Archive" action_opts={[notify?: true]} />A function is called with a query for the selected records, and options with the collection's
actor, tenant and scope, like a code interface function:
<:bulk_action action={&MyApp.Accounts.archive_users/2} label="Archive" />Either way, the action only reaches selected records that are still in the collection's query,
with its filters, tenant and read action. Changing the collection's query, actor, tenant or
scope clears the selection. Whether the actor may run the action is up to your Ash policies:
Cinder adds no authorization of its own.
After an Action
When an action succeeds, the selection is cleared and the collection re-read. To act on the result, name messages to send your LiveView:
<:bulk_action action={:archive} label="Archive" on_success={:users_archived} on_error={:archive_failed} />def handle_info({:users_archived, %{count: count}}, socket) do
{:noreply, put_flash(socket, :info, "Archived #{count} users")}
end
def handle_info({:archive_failed, %{reason: reason}}, socket) do
{:noreply, put_flash(socket, :error, "Archiving failed: #{inspect(reason)}")}
endon_success gets the component_id, action, count and Ash's result. on_error gets the
component_id, action, reason, and partial. If some records were changed and some failed,
on_error is sent with partial: true and the errors as reason, and the selection is cleared,
since the changed records are no longer what was selected. For atom actions, Cinder asks Ash for
the errors (return_errors?: true); pass action_opts={[return_errors?: false]} to skip that
for very large selections.
Telemetry & Tracing
Each time a collection loads its data, Cinder emits a :telemetry span, so you can see which collection is slow, and with what filters:
[:cinder, :query, :start | :stop | :exception]around reading a page of data[:cinder, :count, :start | :stop | :exception]around the separate count query ofpagination={[count: :async]}
Measurements are the standard span ones: system_time and monotonic_time on start, and duration (in native time units) on stop.
Metadata:
id- the collection'sidresource,action- what was readfilters- the applied filters, by fieldsearch_term- the search box's valuesort_by,page_size,current_page,pagination_mode- on:queryevents onlyresult- on stop,:okor:error(a failed read is a stop, not an exception)
Ash's own [:ash, ...] events still fire for each read inside these spans, so you can attach to whichever you need. For example, to log slow loads:
defmodule MyApp.SlowCollections do
require Logger
# Call from your application's start/2
def attach do
:telemetry.attach("slow-collections", [:cinder, :query, :stop], &__MODULE__.handle_event/4, nil)
end
def handle_event(_event, %{duration: duration}, metadata, _config) do
ms = System.convert_time_unit(duration, :native, :millisecond)
if ms > 500 do
Logger.warning("#{metadata.id} took #{ms}ms with filters #{inspect(metadata.filters)}")
end
end
endSensitive fields
Filter values and search terms are things your users typed. As Ash does for errors, a filter on a field marked sensitive?: true (an attribute, calculation or aggregate, including through relationships) has its value replaced with "**redacted**", and so does the search term when any searchable column is sensitive. Set config :ash, :show_sensitive?, true to see them, in development for example.
Tracing
Cinder also opens an Ash.Tracer span around each load, named cinder:<id>:query (or :count), using the same tracers as Ash: config :ash, :tracer, and any tracer: in query_opts. With a tracer like opentelemetry_ash, Ash's spans for the read appear nested inside Cinder's, so a trace shows which collection a query belongs to.