Flicker.Integrations.Cinder (Flicker v0.1.0)

Copy Markdown View Source

The optional Level 2 adapter from Spec 009: removes the Flicker.search/1 + Cinder.collection/1 boilerplate Level 1 leaves to the host, and coordinates URL state between the two libraries.

This module only compiles when both ash and cinder are present (Code.ensure_loaded?/1, the same ADR-006 pattern Flicker.Providers.AshResource and Flicker.AshPhoenixForm use): it's inert, not merely undocumented, in a build without cinder in its deps — the core suite passes with no changes required.

Query composition

query/2 is Level 1's base_query/1 recipe, generalised: given a base Ash.Resource.t() or Ash.Query.t() and the filter half of Flicker.search/1's on_change payload, it returns the composed Ash.Query.t() for Cinder.collection's query attr.

alias Flicker.Integrations.Cinder, as: FlickerCinder

def handle_info({:artist_query_changed, query, filter}, socket) do
  socket =
    socket
    |> assign(:filtered_query, FlickerCinder.query(Artist, filter))
    |> FlickerCinder.push_patch("/artists", query)

  {:noreply, socket}
end

URL state

Both libraries want query params: Cinder's own Cinder.UrlSync owns page, sort, page_size, search, after, before, plus one param per filterable column field name (verified against cinder ~> 0.15's Cinder.UrlSync.build_url/3 — see that module's known_collection_keys and its _filter_fields metadata key). A bare ?q= risks colliding with a column literally named q; this adapter reserves "flicker_q" (url_param/0) — prefixed distinctly enough that no realistic column name collides with it, and disjoint from Cinder's own reserved names — as the one param it ever reads or writes. This resolves Spec 009's open question on namespacing convention; see that spec's Open Questions section for the decision record.

Serialisation round-trips Flicker.Query.t()'s :input field — the verbatim string the user typed — never :text or a reconstruction of :facets (both are lossy: facet tokens are stripped from :text, and :facets' cast values can't reproduce the original literal, e.g. TRUE vs. true, or 7d vs. the date it resolved to). Restoring a shared URL re-Flicker.Query.parse/2s that exact string, the same way the host's own Flicker.search/1 instance would have.

alias Flicker.Integrations.Cinder, as: FlickerCinder

def handle_params(params, uri, socket) do
  facets = FlickerCinder.facets(%{resource: Artist, facets: [:status, :tier]})
  {text, _query, filter} = FlickerCinder.restore(params, facets)

  socket =
    params
    |> Cinder.UrlSync.handle_params(uri, socket)
    |> assign(:search_text, text)
    |> assign(:filtered_query, FlickerCinder.query(Artist, filter))

  {:noreply, socket}
end

# render/1
# <Flicker.search id="artist-search" text={@search_text} resource={Artist} ... />

text (a new optional attr on Flicker.search/1) is adopted once, on the component's first mount only — it pre-populates the input from the restored string without fighting the component's own ownership of what the user types next.

Double-filter UX

Flicker's facets and Cinder's own column filters (filter/filter_fn on a <:col>, or show_filters) can both narrow the same collection. The convention: a field is filtered in one place, not both — pick whichever owns it. When Flicker owns filtering for the whole collection (the common case: one search bar above the table), pass show_filters={false} to Cinder.collection so its own filter UI doesn't render at all (see the playground page and guide). When a column needs filtering that no Flicker facet covers, let Cinder's own column filter own that one field instead — never configure a Flicker facet and a Cinder column filter over the same field at once, since the two would apply independently (an unsatisfiable AND, not visible to either component) and confuse users who cleared one filter and still see narrowed results.

overlapping_fields/2 is a drift guard for that convention: given the facet keys configured on a Flicker.search/1 and the field names a Cinder.collection's columns declare filter on, it returns the fields governed by both — empty when the convention holds. A test asserting this list stays empty catches a column filter and a facet quietly drifting onto the same field as either configuration changes.

Summary

Types

An Ash resource module, or an already-built Ash.Query.t().

Functions

Reads the raw Flicker input string back out of params (as produced by encode_params/1 / put_params/2), defaulting to "" when the param is absent — a fresh visit with no shared search.

Encodes query.input (the verbatim typed string — the round-trip source of truth, never :text or a reconstruction of :facets) as the URL param map this adapter owns. A cleared/empty query encodes as %{} so the param disappears from the URL entirely rather than persisting an empty flicker_q=.

Resolves the facet registry for assigns (e.g. %{resource: Artist, facets: [:status, :tier]}) the same way Flicker.search/1 does internally — so restore/2 (and any other Ash-aware Flicker.Query function keyed off the facet registry) sees the identical Flicker.Facet.t() list the live component parses against, without the host re-implementing Tier 1 introspection by hand.

Returns the fields present in both facet_keys (a Flicker.search/1's configured facet keys, e.g. [:status, :tier]) and cinder_fields (the field names a Cinder.collection's <:col filter>s declare) — a violation of the double-filter convention (see the moduledoc). Compares by string, so atoms and strings on either side both work; returns [] when the convention holds.

Pushes a push_patch/2 to path with existing_params merged with query's encoded param (via put_params/2) — the one-call handle_info-side counterpart to restore/2's handle_params/3 side. Drop the query string entirely (navigate to bare path) when the merged params end up empty, rather than a trailing ?.

Merges query's encoded param into existing_params (e.g. the current URL's own query params, or Cinder's own encoded collection state), replacing any prior flicker_q value — the rest of existing_params (Cinder's own managed keys, or any other custom param a host tracks) passes through untouched, so neither library's state clobbers the other's.

Composes filter (the third element of Flicker.search/1's on_change payload) onto base — an Ash.Resource.t() or an already-built Ash.Query.t() — via Ash.Query.filter_input/2. A nil filter (the search box cleared) is treated as %{}, Ash's own "no filter" shape, so base comes back unfiltered rather than raising. This is the whole of Level 1's base_query/1 recipe, so a host can drop that private function entirely and call this instead.

The full URL-restore step for handle_params/3: reads the raw input string out of params, re-Flicker.Query.parse/2s it against facet_defs, and returns {input, query, filter} — input to seed Flicker.search/1's new text attr, query/filter to seed the same composed query query/2 would build from a live on_change.

The URL param name this adapter reserves for Flicker's own query state ("flicker_q") — namespaced against cinder's own UrlSync-managed params (see the moduledoc for how this was chosen).

Types

queryable()

@type queryable() :: Ash.Resource.t() | Ash.Query.t()

An Ash resource module, or an already-built Ash.Query.t().

Functions

decode_input(params)

@spec decode_input(map()) :: String.t()

Reads the raw Flicker input string back out of params (as produced by encode_params/1 / put_params/2), defaulting to "" when the param is absent — a fresh visit with no shared search.

encode_params(query)

@spec encode_params(Flicker.Query.t()) :: %{optional(String.t()) => String.t()}

Encodes query.input (the verbatim typed string — the round-trip source of truth, never :text or a reconstruction of :facets) as the URL param map this adapter owns. A cleared/empty query encodes as %{} so the param disappears from the URL entirely rather than persisting an empty flicker_q=.

facets(assigns)

@spec facets(map()) :: [Flicker.Facet.t()]

Resolves the facet registry for assigns (e.g. %{resource: Artist, facets: [:status, :tier]}) the same way Flicker.search/1 does internally — so restore/2 (and any other Ash-aware Flicker.Query function keyed off the facet registry) sees the identical Flicker.Facet.t() list the live component parses against, without the host re-implementing Tier 1 introspection by hand.

overlapping_fields(facet_keys, cinder_fields)

@spec overlapping_fields([atom() | String.t()], [atom() | String.t()]) :: [String.t()]

Returns the fields present in both facet_keys (a Flicker.search/1's configured facet keys, e.g. [:status, :tier]) and cinder_fields (the field names a Cinder.collection's <:col filter>s declare) — a violation of the double-filter convention (see the moduledoc). Compares by string, so atoms and strings on either side both work; returns [] when the convention holds.

push_patch(socket, path, query, existing_params \\ %{})

Pushes a push_patch/2 to path with existing_params merged with query's encoded param (via put_params/2) — the one-call handle_info-side counterpart to restore/2's handle_params/3 side. Drop the query string entirely (navigate to bare path) when the merged params end up empty, rather than a trailing ?.

put_params(existing_params, query)

@spec put_params(map(), Flicker.Query.t()) :: map()

Merges query's encoded param into existing_params (e.g. the current URL's own query params, or Cinder's own encoded collection state), replacing any prior flicker_q value — the rest of existing_params (Cinder's own managed keys, or any other custom param a host tracks) passes through untouched, so neither library's state clobbers the other's.

query(base, filter \\ nil)

@spec query(queryable(), map() | nil) :: Ash.Query.t()

Composes filter (the third element of Flicker.search/1's on_change payload) onto base — an Ash.Resource.t() or an already-built Ash.Query.t() — via Ash.Query.filter_input/2. A nil filter (the search box cleared) is treated as %{}, Ash's own "no filter" shape, so base comes back unfiltered rather than raising. This is the whole of Level 1's base_query/1 recipe, so a host can drop that private function entirely and call this instead.

restore(params, facet_defs \\ [])

@spec restore(map(), [Flicker.Facet.t()]) :: {String.t(), Flicker.Query.t(), map()}

The full URL-restore step for handle_params/3: reads the raw input string out of params, re-Flicker.Query.parse/2s it against facet_defs, and returns {input, query, filter} — input to seed Flicker.search/1's new text attr, query/filter to seed the same composed query query/2 would build from a live on_change.

Given Flicker.Query.parse/2's own guarantees (pure, never raises), this never raises either — an absent or malformed param degrades to an empty query exactly the way an empty search box does.

url_param()

@spec url_param() :: String.t()

The URL param name this adapter reserves for Flicker's own query state ("flicker_q") — namespaced against cinder's own UrlSync-managed params (see the moduledoc for how this was chosen).