Current section
Files
Jump to
Current section
Files
usage-rules.md
<!--
SPDX-FileCopyrightText: 2025 Cinder contributors <https://github.com/sevenseacat/cinder/graphs/contributors>
SPDX-License-Identifier: MIT
-->
# Cinder Usage Rules
Cinder is a data collection component for Phoenix LiveView with Ash Framework integration. It supports table, list, and grid layouts with shared filtering, sorting, search, and pagination.
## Basic Usage
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
<:col :let={user} field="email" filter>{user.email}</:col>
<:col :let={user} field="created_at" sort>{user.created_at}</:col>
</Cinder.collection>
```
## Layouts
```heex
<!-- Table (default) -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>
<!-- List -->
<Cinder.collection resource={MyApp.User} actor={@current_user} layout={:list}>
<:col field="name" filter sort />
<:item :let={user}>
<div class="p-4">{user.name}</div>
</:item>
</Cinder.collection>
<!-- Grid -->
<Cinder.collection resource={MyApp.Product} actor={@current_user} layout={:grid} grid_columns={[xs: 1, md: 2, lg: 3]}>
<:col field="name" filter sort />
<:item :let={product}>
<div class="p-4 border rounded">{product.name}</div>
</:item>
</Cinder.collection>
```
## Data Sources
```heex
<!-- Resource -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<!-- Pre-configured query -->
<Cinder.collection query={MyApp.User |> Ash.Query.filter(active: true)} actor={@current_user}>
<!-- Custom read action -->
<Cinder.collection query={Ash.Query.for_read(MyApp.User, :active_users)} actor={@current_user}>
```
## Field Notation
- **Direct fields**: `field="name"`
- **Relationships**: `field="department.name"` (dot notation)
- **Embedded resources**: `field="settings__country"` (double underscore)
## Column Configuration
### Data Columns
- `field` - required for data columns
- `filter` - enables filtering (auto-detects type from Ash attribute)
- `sort` - enables sorting
- `search` - includes field in global search
- `label="Custom"` - override column header
- `class="css-class"` - CSS class for table cells
### Filter Configuration
```heex
<!-- Auto-detected from Ash attribute type -->
<:col field="status" filter />
<!-- Specify type -->
<:col field="status" filter={:select} />
<!-- Full configuration -->
<:col field="status" filter={[type: :select, prompt: "All Statuses", options: @statuses]} />
<:col field="price" filter={[type: :number_range, min: 0, max: 1000]} />
<:col field="tags" filter={[type: :multi_select, match_mode: :any]} />
<:col field="active" filter={[type: :boolean, labels: %{true: "Active", false: "Inactive"}]} />
<!-- Custom filter function -->
<:col field="name" filter={[type: :text, fn: &custom_name_filter/2]} />
```
### Sorting Configuration
```heex
<!-- Basic sorting (cycle: nil → asc → desc → nil) -->
<:col field="name" sort />
<!-- Custom sort cycles -->
<:col field="priority" sort={[cycle: [:desc, :asc]]} />
<:col field="created_at" sort={[cycle: [:desc, :asc, nil]]} />
```
### Sort Mode
```heex
<!-- Additive (default): clicking B while sorted by A gives "A then B" -->
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<!-- Exclusive: clicking a column replaces existing sorts -->
<Cinder.collection resource={MyApp.User} actor={@current_user} sort_mode="exclusive">
```
### Action Columns
```heex
<:col :let={user} label="Actions">
<.link patch={~p"/users/#{user.id}/edit"}>Edit</.link>
<button phx-click="delete" phx-value-id={user.id}>Delete</button>
</:col>
```
## Filter-Only Slots
Filter on fields without displaying them as columns:
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<!-- Filter-only fields -->
<:filter field="department.name" type="select" options={@departments} />
<:filter field="active" type="boolean" />
<:filter field="created_at" type="date_range" />
</Cinder.collection>
```
## Collection Configuration
### Required
- `resource={Resource}` or `query={query}` - data source
- `actor={@current_user}` - required for Ash authorization
### Key Options
- `layout={:table | :list | :grid}` - layout type (default: `:table`)
- `grid_columns={4}` or `grid_columns={[xs: 1, md: 2, lg: 3]}` - grid column count
- `theme="modern"` - built-in themes: default, modern, retro, futuristic, dark, daisy_ui, flowbite, compact
- `page_size={25}` - fixed page size
- `page_size={[default: 25, options: [10, 25, 50, 100]]}` - configurable with dropdown
- `url_state={@url_state}` - enable URL synchronization
- `click={fn item -> JS.navigate(~p"/path/#{item.id}") end}` - row/item click handler
- `item_class={fn item -> if item.urgent, do: "bg-red-50" end}` - per-row/item class (string or function), appended to the theme class
- `query_opts={[timeout: 30_000, load: [:association]]}` - Ash query options
- `tenant={@tenant}` - multi-tenancy support
- `scope={@scope}` - Ash scope for authorization context
### Search Configuration
```heex
<!-- Auto-enabled when columns have search attribute -->
<:col :let={user} field="name" search filter>{user.name}</:col>
<!-- Custom search configuration -->
<Cinder.collection search={[label: "Search users", placeholder: "Enter name or email"]}>
<!-- Custom search function -->
<Cinder.collection search={[fn: &MyApp.CustomSearch.search/3]}>
<!-- Disable search -->
<Cinder.collection search={false}>
```
### Collapsible Filters
```heex
<!-- Collapsed by default -->
<Cinder.collection show_filters={:toggle}>
<!-- Expanded by default with toggle button -->
<Cinder.collection show_filters={:toggle_open}>
<!-- Always show / always hide -->
<Cinder.collection show_filters={true}>
<Cinder.collection show_filters={false}>
```
Global default: `config :cinder, show_filters: :toggle`
### Display Options
- `empty_message="No records found"` - custom empty state text
- `loading_message="Loading..."` - custom loading state text
- `error_message="Failed to load"` - custom error state text
- `filters_label="Filters"` - customize filter section label
- `sort_label="Sort by:"` - label for sort controls (list/grid layouts)
## Built-in Filter Types
Auto-detected from Ash resource attributes:
| Ash Type | Filter Type | UI |
|----------|-------------|-----|
| `:string` | `:text` | Text input with contains search |
| `:boolean` | `:boolean` | Radio buttons (Yes/No) |
| `:date`, `:datetime` | `:date_range` | From/To date pickers |
| `:integer`, `:decimal` | `:number_range` | Min/Max inputs |
| `Ash.Type.Enum` | `:select` | Dropdown with enum values |
| `{:array, _}` | `:multi_select` | Multi-select dropdown |
Additional filter types:
- `:radio_group` - Radio buttons for arbitrary options (not just boolean)
- `:multi_checkboxes` - Checkbox list for multi-value selection
- `:checkbox` - Single checkbox for "show only X" filtering
- `:autocomplete` - Searchable dropdown for large option lists
### Filter Type Options
- **Text**: `operator`, `case_sensitive`, `placeholder`
- **Select**: `options`, `prompt`
- **Boolean**: `labels` map with `true`/`false` keys
- **Date Range**: `include_time`
- **Number Range**: `min`, `max`, `step`
- **Multi-Select**: `options`, `prompt`, `match_mode` (`:any`/`:all`)
- **Multi-Checkboxes**: `options`, `match_mode` (`:any`/`:all`)
- **Checkbox**: `value`, `label`
- **Radio Group**: `options`
- **Autocomplete**: `options`, `placeholder`, `max_results`
## Custom Controls Layout
The `<:controls>` slot replaces the default filter/search layout while keeping state management intact:
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name" filter sort search>{user.name}</:col>
<:col :let={user} field="status" filter={:select}>{user.status}</:col>
<:controls :let={controls}>
<Cinder.Controls.render_header {controls} />
<div class="flex gap-4">
<Cinder.Controls.render_search search={controls.search} theme={controls.theme} target={controls.target} />
<Cinder.Controls.render_filter
:for={{_name, filter} <- controls.filters}
filter={filter} theme={controls.theme} target={controls.target}
/>
</div>
</:controls>
</Cinder.collection>
```
### Controls Data Map (`:let` binding)
- `filters` - keyword list of filters keyed by field atom
- `search` - search input data (or nil)
- `active_filter_count` - number of active filters
- `target` - LiveComponent target for `phx-target`
- `theme` - resolved theme map
- `table_id`, `filters_label`, `filter_mode`, `filter_values`, `raw_filter_params`
- `total_count` - how many records match, or nil until counted (and when `count: false`)
### Available Helpers
- `Cinder.Controls.render_filter/1` - single filter (label + input + clear)
- `Cinder.Controls.render_search/1` - search input
- `Cinder.Controls.render_header/1` - default header (title, active count, clear all, toggle)
## Loading, Empty & Error State Slots
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user}>
<:col :let={user} field="name">{user.name}</:col>
<:loading>
<div class="flex items-center gap-2 p-8 justify-center">Loading...</div>
</:loading>
<:empty :let={context}>
<%= if context.filtered? do %>
<p>No results match your filters.</p>
<% else %>
<p>No records yet.</p>
<% end %>
</:empty>
<:error>
<p>Something went wrong.</p>
</:error>
</Cinder.collection>
```
Empty slot context: `filtered?`, `filters`, `search_term`. State precedence: loading > error > empty > data.
## URL State Management
Enable bookmarkable, shareable collection states:
```elixir
defmodule MyAppWeb.UsersLive do
use MyAppWeb, :live_view
use Cinder.UrlSync
def handle_params(params, uri, socket) do
socket = Cinder.UrlSync.handle_params(params, uri, socket)
{:noreply, socket}
end
def render(assigns) do
~H"""
<Cinder.collection resource={MyApp.User} actor={@current_user} url_state={@url_state} id="users">
<:col :let={user} field="name" filter sort>{user.name}</:col>
</Cinder.collection>
"""
end
end
```
## Collection Refresh
Refresh data while preserving filters, sorting, and pagination:
```elixir
import Cinder.Refresh
def handle_event("delete", %{"id" => id}, socket) do
# ... delete logic ...
{:noreply, refresh_table(socket, "collection-id")}
end
# Refresh multiple collections
{:noreply, refresh_tables(socket, ["collection1", "collection2"])}
```
Keep current rows visible while an asynchronous requery runs:
```elixir
{:noreply, refresh_table(socket, "collection-id", silent: true)}
```
Use a boolean for `:silent` (defaults to `false`). Silent refreshes keep existing
rows visible without a loading indicator. On failure, they log the error and
retain those rows without showing an error indicator. Collections without rows
use normal loading and error behavior. The option is also available on
`refresh_tables/3`.
### In-Memory Changes
For PubSub-driven updates without re-querying:
```elixir
import Cinder.Update
# Update single item by ID
{:noreply, update_item(socket, "table-id", user_id, fn user -> %{user | status: :active} end)}
# Update multiple items
{:noreply, update_items(socket, "table-id", user_ids, fn user -> %{user | active: false} end)}
# Remove deleted items: drops the rows, prunes their selection, and keeps the footer count in step
{:noreply, remove_item(socket, "table-id", user_id)}
{:noreply, remove_items(socket, "table-id", user_ids)}
# Only update if visible on current page (avoids unnecessary DB calls)
{:noreply, update_if_visible(socket, "table-id", raw_user, fn raw ->
{:ok, loaded} = Ash.load(raw, [:department])
loaded
end)}
```
Removal does not backfill the page or recalculate aggregates or ordering, unless it empties
the page: then the page is read again (or the previous one, if it was the last). Follow it
with `refresh_table/2` when the collection query must be reconciled.
## Query Access
Receive the built query and the total record count in the parent LiveView:
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user} on_query_change={:query_changed} id="users">
```
```elixir
def handle_info({:query_changed, %{query: query, count: count, id: "users"}}, socket) do
{:noreply, assign(socket, current_query: query, total: count)}
end
```
- Fires on initial load and whenever filters, sorting, or search change
- `query` has all filters and sorts applied but no pagination - use it directly for exports
- `count` is the total matching records, the same number the pagination footer shows. Cinder already asked Ash for it, so never run your own `Ash.count!` to display a total
- `count` is `nil` when the read produced no count; an action without pagination reports the number of records it loaded
## Custom Filters
### 1. Configuration
```elixir
# config/config.exs
config :cinder, :filters, [
slider: MyApp.Filters.Slider
]
```
### 2. Application Setup
```elixir
# application.ex
def start(_type, _args) do
Cinder.setup() # Registers configured filters
# ... rest of startup
end
```
### 3. Filter Module
```elixir
defmodule MyApp.Filters.Slider do
@behaviour Cinder.Filter
use Phoenix.Component
@impl true
def render(column, current_value, theme, assigns), do: # HEEx template
@impl true
def process(raw_value, column), do: %{type: :slider, value: raw_value}
@impl true
def validate(filter_value), do: true
@impl true
def default_options, do: [min: 0, max: 100, step: 1]
@impl true
def empty?(value), do: is_nil(value)
@impl true
def build_query(query, field, filter_value), do: # Ash query filter
end
```
### 4. Usage
```heex
<:col field="price" filter={[type: :slider, min: 0, max: 1000]} />
```
## Theming
### Global Configuration
```elixir
# config/config.exs
config :cinder, default_theme: "modern"
```
### Per-Collection Theme
```heex
<Cinder.collection theme="dark" resource={MyApp.User} actor={@current_user}>
```
### Available Themes
- `"default"` - minimal styling
- `"modern"` - clean, contemporary design
- `"dark"` - dark mode styling
- `"retro"` - cyberpunk aesthetic
- `"futuristic"` - sci-fi inspired
- `"daisy_ui"` - DaisyUI component styles
- `"flowbite"` - Flowbite design system
- `"compact"` - dense layout
### Custom Theme Module
```elixir
defmodule MyApp.CustomTheme do
use Cinder.Theme
set :container_class, "bg-white shadow rounded-lg"
set :th_class, "px-4 py-2 text-left font-semibold"
end
```
## Selection & Bulk Actions
Enable checkbox selection and bulk operations on selected records:
```heex
<Cinder.collection resource={MyApp.User} actor={@current_user} selectable>
<:col :let={user} field="name" filter sort>{user.name}</:col>
<!-- Themed buttons (recommended): use label and variant for auto-styled buttons -->
<:bulk_action action={:archive} label="Archive ({count})" variant={:primary} />
<:bulk_action action={:export} label="Export" variant={:secondary} />
<:bulk_action action={:destroy} label="Delete" variant={:danger} confirm="Delete {count}?" />
<!-- Custom buttons: provide inner content for full control -->
<:bulk_action action={&MyApp.Users.soft_delete/2} on_success={:deleted} :let={ctx}>
<button disabled={ctx.selected_count == 0}>Delete Selected</button>
</:bulk_action>
</Cinder.collection>
```
### Bulk Action Slot Attributes
- `action` - Ash action atom or function/2 (required)
- `label` - Button text (enables themed button, supports `{count}` interpolation)
- `variant` - Button style: `:primary` (default), `:secondary`, `:danger`
- `confirm` - Confirmation message (`{count}` interpolates selection count)
- `on_success` - Event name sent to parent on success
- `on_error` - Event name sent to parent on error
- `action_opts` - Additional Ash options (e.g., `[return_records?: true]`)
### Selection Attributes
- `selectable` - Enable checkboxes (works in table/grid/list)
- `on_selection_change` - Event name for selection state changes
### Handling Callbacks
```elixir
def handle_info({:deleted, %{count: count}}, socket) do
{:noreply, put_flash(socket, :info, "Deleted #{count} users")}
end
def handle_info({:delete_failed, %{reason: reason}}, socket) do
{:noreply, put_flash(socket, :error, "Failed: #{inspect(reason)}")}
end
```
## Localization
All user-facing strings use `dgettext("cinder", ...)`. Supported locales: Brazilian Portuguese (pt_BR), Danish (da), Dutch (nl), English (en), French (fr), German (de), Norwegian (no), Spanish (es), Swedish (sv).
Set locale in mount: `Gettext.put_locale("nl")`
## Testing
Use `render_async` for data-dependent assertions:
```elixir
{:ok, view, html} = live(conn, ~p"/users")
assert html =~ "Loading..."
assert render_async(view) =~ "John Doe"
```