Cinder is a data-table component for Phoenix
LiveView with the same Ash-native pedigree as Flicker: read directly off
an Ash resource, authorise via actor:, no options plumbing. Cinder
answers "how do I render this collection?"; Flicker answers "which
records am I looking at?" — Flicker.search/1 above a Cinder.collection
is the pairing both libraries were built for.
This guide covers both interop levels from
Spec 009:
Level 1, the no-adapter recipe (pure query composition, no code
required in either library), and Level 2, the blessed
Flicker.Integrations.Cinder adapter (less boilerplate, plus URL state
coordinated with Cinder's own Cinder.UrlSync so back/forward/share
reproduces both libraries' state).
Add the dependency
Cinder isn't a Flicker dependency — add it to your own app:
def deps do
[
{:flicker, "~> 0.1"},
{:cinder, "~> 0.15"}
]
endFlicker.Integrations.Cinder compiles automatically once cinder is in
your deps (the same Code.ensure_loaded?/1 boundary Flicker's ash
integration uses) — nothing to configure, and nothing extra in a build
without it.
Level 1: the recipe, no adapter
Flicker.search/1 has no selection semantics — it never lists records
itself. Every keystroke it parses the typed text and sends the host
{on_change, %Flicker.Query{}, filter}, where filter is a map suitable
for Ash.Query.filter_input/2. The host composes that filter onto a base
Ash.Query and hands the result to Cinder.collection's query
attribute — Cinder accepts a pre-built query alongside (instead of) a
bare resource, so this is pure query composition, no adapter, no glue
code:
<Flicker.search
id="artist-search"
resource={Artist}
actor={@actor}
facets={[:status, :tier, :monthly_listeners]}
on_change={:artist_query_changed}
/>
<Cinder.collection
id="artist-collection"
query={@filtered_query}
actor={@actor}
show_filters={false}
>
<:col :let={artist} field="name" sort>{artist.name}</:col>
<:col :let={artist} field="status">{artist.status}</:col>
<:col :let={artist} field="tier">{artist.tier}</:col>
<:col :let={artist} field="monthly_listeners" sort>{artist.monthly_listeners}</:col>
</Cinder.collection>def handle_info({:artist_query_changed, _query, filter}, socket) do
{:noreply, assign(socket, :filtered_query, base_query(filter))}
end
defp base_query(nil), do: base_query(%{})
defp base_query(filter) do
Artist
|> Ash.Query.filter_input(filter)
|> Ash.Query.sort(:name)
endEvery keystroke re-runs base_query/1 with the newly emitted filter and
reassigns @filtered_query; Cinder re-reads under the new query and
narrows the table. Clearing the search emits an empty filter (%{}),
which Ash.Query.filter_input/2 turns into "no filter" — the unfiltered
query, and the full table, come back.
Both components read actor={@actor} — the search's own suggestions
(e.g. a relationship facet's nested search) and the query it composes
run under that actor, and Cinder reads the resulting query under the
same actor, so the whole path is actor-scoped end to end. A record the
actor can't read never reaches the table, whether it was excluded by the
facet filter or by policy.
One field, one filter owner
show_filters={false} above matters: Flicker owns filtering there, so
Cinder's own column filters are switched off rather than fighting over
the same fields. The convention (either level): a field is filtered in
one place, not both. If a column needs filtering that Flicker.search's
facets don't cover, that's the one field Cinder's own filter UI should
own instead — never configure a Flicker facet and a Cinder <:col filter> over the same field at once. The two would apply independently
(an unsatisfiable AND neither component can see), confusing anyone who
cleared one filter and still sees narrowed results. Sorting doesn't
count: <:col sort> on a faceted field is fine — ordering isn't
filtering.
Flicker.Integrations.Cinder.overlapping_fields/2 guards the convention
mechanically: give it your facet keys and the fields your columns declare
filter on, and assert the result stays empty in a test — a facet and a
column filter can then never quietly drift onto the same field as either
configuration changes.
Level 2: the adapter, with URL state
Level 1 leaves two things on the table: the base_query/1 boilerplate,
and the URL. Both libraries want query params — Cinder's Cinder.UrlSync
manages page, sort, page_size, search, after, before, and one
param per filterable column — so Flicker's search must live in a param of
its own that can't collide. Flicker.Integrations.Cinder reserves
flicker_q and gives you the pieces:
query/2— composes the emittedfilteronto a base resource/query: the whole ofbase_query/1, one call.push_patch/4— patches the URL with the raw typed string underflicker_q, preserving every other param (Cinder's included).restore/2— thehandle_params/3side: readsflicker_qback, re-parses it, and returns{text, query, filter}.facets/1— resolves the same facet registryFlicker.search/1uses internally, sorestore/2parses against identical facets.
The param carries the raw input string, exactly as typed — never a
reconstruction from the parsed struct, which is lossy (active?:TRUE
parses to true; after:7d resolves to a date; quoting disappears).
Restoring re-parses the original string, so a shared URL reproduces the
exact query, and Flicker.search/1's text attr (adopted once, on
mount) pre-fills the input with it.
The playground page, verbatim
The dev playground's /cinder-interop page (Dev.Live.CinderInterop) is
exactly this adapter recipe against the seeded Dev.Music.Artist domain,
plus an actor toggle so the policy-bearing resource's visibility is
visible (moduledoc, @docs, and the drift-test accessors elided here —
see the file for the full version):
defmodule Dev.Live.CinderInterop do
use Phoenix.LiveView
use Cinder.UrlSync
alias Dev.Music.Artist
alias Flicker.Integrations.Cinder, as: FlickerCinder
@path "/cinder-interop"
@facet_keys [:status, :tier, :monthly_listeners]
@actors [
{"Public (no label)", nil},
{"Indie label", "indie"},
{"Major label", "major"}
]
@impl true
def mount(_params, _session, socket) do
Dev.Music.seed!()
socket =
socket
|> assign(:actors, @actors)
|> assign(:actor_label, nil)
|> assign(:facets, FlickerCinder.facets(%{resource: Artist, facets: @facet_keys}))
{:ok, socket}
end
@impl true
def handle_params(params, uri, socket) do
{text, _query, filter} = FlickerCinder.restore(params, socket.assigns.facets)
socket =
params
|> Cinder.UrlSync.handle_params(uri, socket)
|> assign(:search_text, text)
|> assign(:filtered_query, FlickerCinder.query(base_query(), filter))
{:noreply, socket}
end
defp base_query, do: Ash.Query.sort(Artist, :name)
@impl true
def handle_event("set_actor", %{"label" => label}, socket) do
{:noreply, assign(socket, :actor_label, normalize_label(label))}
end
defp normalize_label(""), do: nil
defp normalize_label(label), do: label
@impl true
def handle_info({:artist_query_changed, query, filter}, socket) do
socket =
socket
|> assign(:filtered_query, FlickerCinder.query(base_query(), filter))
|> FlickerCinder.push_patch(@path, query, current_params(socket))
{:noreply, socket}
end
defp current_params(socket) do
case get_in(socket.assigns, [:url_state, :uri]) do
nil -> %{}
uri -> uri |> URI.parse() |> Map.get(:query) |> Kernel.||("") |> URI.decode_query()
end
end
@impl true
def render(assigns) do
assigns = assign(assigns, :actor, %{label: assigns.actor_label})
~H"""
<div class="space-y-4">
<h1 class="text-2xl font-semibold">Cinder interop (Level 2)</h1>
<fieldset class="flex flex-wrap items-center gap-2">
<legend class="mb-1 text-sm font-medium">Acting as</legend>
<button
:for={{name, label} <- @actors}
type="button"
phx-click="set_actor"
phx-value-label={label || ""}
class={[
"rounded px-3 py-1 text-sm",
if(@actor_label == label, do: "bg-indigo-600 text-white", else: "bg-gray-200")
]}
>
{name}
</button>
</fieldset>
<Flicker.search
id="artist-search"
resource={Artist}
actor={@actor}
facets={@facets}
text={@search_text}
on_change={:artist_query_changed}
/>
<Cinder.collection
id="artist-collection"
query={@filtered_query}
actor={@actor}
show_filters={false}
url_state={@url_state}
>
<:col :let={artist} field="name" sort>{artist.name}</:col>
<:col :let={artist} field="status">{artist.status}</:col>
<:col :let={artist} field="tier">{artist.tier}</:col>
<:col :let={artist} field="monthly_listeners" sort>{artist.monthly_listeners}</:col>
</Cinder.collection>
</div>
"""
end
endWorth noting in there:
use Cinder.UrlSync+url_state={@url_state}wire up Cinder's own URL sync (sort a column and watch?sort=appear); the adapter'spush_patch/4andrestore/2slot Flicker'sflicker_qin beside it. Neither clobbers the other: Cinder'sbuild_urltreatsflicker_qas a custom param and preserves it, andput_params/2(insidepush_patch/4) touches only its own key.current_params/1re-reads the current URL's params (Cinder patches the URL directly on a sort click, without this page seeing it), so a Flicker patch always merges into the browser's actual state, never a stale copy.text={@search_text}pre-fills the input from a restored URL. It's adopted once, on the component's first mount — the component owns the text thereafter, so the assign changing later (everyhandle_params) doesn't fight the user's typing.- The base query keeps an explicit sort — which rows land on Cinder's first page shouldn't depend on data-layer ordering.
Run mix dev and visit /cinder-interop to try it live: type
status:active, sort a column, copy the address bar into a new tab —
the same narrowed, sorted table comes back, and the search input arrives
pre-filled. test/flicker/cinder_interop_test.exs drives this exact
LiveView (including the URL round-trip and param coexistence), and
test/flicker/integrations/cinder_test.exs covers the adapter's own
contract, so neither level's recipe can quietly rot.
A caveat for Ash.DataLayer.Ets
Cinder's own data load runs in a start_async task — a different process
from whichever one seeded the data. Ash.DataLayer.Ets's private?: true
tables (like the ones Dev.Music.seed!/0 creates) are scoped to the
calling process, so a Cinder table over a private ETS resource reads an
empty table it can't see into. The playground and its test both run with
config :ash, disable_async?: true (Cinder honours this to load
synchronously in the same process) to work around it — a real app backed
by Postgres or a non-private ETS table never hits this.
What's next
Levels 1 and 2 are both shipped. Any upstream Cinder-side integration (Level 3 — Flicker as Cinder's own configured search control) is tracked, not promised; see Spec 009 for the plan.