Skip to content

Latest commit

 

History

History
482 lines (373 loc) · 14.5 KB

File metadata and controls

482 lines (373 loc) · 14.5 KB

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

<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

<!-- 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

<!-- 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

<!-- 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

<!-- 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

<!-- 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

<: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:

<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

<!-- 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

<!-- 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:

<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

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

<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:

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:

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"])}

In-Memory Updates

For PubSub-driven updates without re-querying:

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

# 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)}

Custom Filters

1. Configuration

# config/config.exs
config :cinder, :filters, [
  slider: MyApp.Filters.Slider
]

2. Application Setup

# application.ex
def start(_type, _args) do
  Cinder.setup()  # Registers configured filters
  # ... rest of startup
end

3. Filter Module

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

<:col field="price" filter={[type: :slider, min: 0, max: 1000]} />

Theming

Global Configuration

# config/config.exs
config :cinder, default_theme: "modern"

Per-Collection Theme

<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

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:

<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

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:

{:ok, view, html} = live(conn, ~p"/users")
assert html =~ "Loading..."
assert render_async(view) =~ "John Doe"