Flicker.Facet (Flicker v0.1.0)

Copy Markdown View Source

A facet definition for faceted search (Spec 003).

A facet definition tells Flicker.Query.parse/2 how to recognise a key:value token in typed input: which key it answers to, what shape a value must have to count as a match rather than degrading to free text, and which comparison operators (>=, <, !=, ...) are legal for it.

Fields

  • :key — the facet's identifier, matched against the token's key (e.g. :status matches status:active).
  • :label — user-facing facet name (used by later Spec 003 stages — autocomplete, the cursor state machine — not by the parser itself).
  • :type — how a raw value string is cast. One of :string (default, no casting), :integer, :float, :boolean, :date (ISO 8601, or a relative offset like 7d / 2w / 1m / 1y), or :enum (the raw value must match one of :values).
  • :operators — comparison operators this facet accepts when the user types them explicitly (after>=7d). Defaults to [:eq]. A value not built from this list degrades the whole token to free text.
  • :default_op — the operator used for the bare : form (after:7d). Defaults to :eq; a date facet meaning "after" would set this to :gte. Should be a member of :operators.
  • :values — for :type: :enum, the closed list of atoms a value may cast to. A value not in this list degrades to free text. Ignored for other types.
  • :value_labels — for :type: :enum, a map of value atom to its user-facing label (an Ash.Type.Enum's own label/1, or a humanised fallback for a plain one_of-constrained attribute). nil for other types.
  • :value_colors — optional map of value atom to a host-supplied colour string (any CSS colour), shown as a dot on that value's facet pill (Spec 017). nil for no colours; unlisted values render uncoloured.
  • :target — the Ash filter path this facet resolves to: a list of atoms (an attribute name, an aggregate/calculation name, or a relationship path ending in an attribute, e.g. [:worker, :full_name]). nil means [key]. Filled in by Flicker.Providers.AshResource.facets/1 (the facet registry, Spec 003); hand-built facets can set it directly.
  • :related — for a belongs_to/has_* facet, %{resource: module, search: [atom()], option_label: atom() | (struct() -> String.t())} describing the related resource a nested search over this facet's values would run against (the same Flicker.Providers.AshResource :search/:option_label shape, so the nested search is a plain inner Flicker.select-style provider call) — nil for non-relationship facets.

Hand-build a Flicker.Facet directly, or — for an Ash resource — derive it from facets: [:status, :worker, ...] via Flicker.Providers.AshResource.facets/1, which introspects the resource's own type system per the spec's type table and lets an explicit [type:, path:, attribute:, op:] override any derived field.

Summary

Types

Advisory numeric bounds for an editor, and a validation floor/ceiling.

Whether to request a match count per value for this facet.

A comparison operator a facet value can be compared with.

The related resource a belongs_to/has_* facet searches over.

t()

A facet definition.

How a facet value is cast.

An extra validator run after a successful cast, on the cast value.

Functions

Casts raw for this facet — delegates to Flicker.Facet.Cast.cast/4.

Builds a facet, filling in the defaults implied by its :type for any field the caller didn't set.

Whether this facet's cast value is a Flicker.Facet.Range.

The type each endpoint of a range facet casts as — :scalar when set, otherwise the one implied by the range type. For a non-range facet this is just its own type, so callers can use it unconditionally.

The Ash filter path this facet resolves to: facet.target, or [facet.key] when :target is nil.

Validates raw for this facet without keeping the value — :ok, or {:error, {reason, params}} naming what was wrong (ADR-012).

A single value's display label — :value_labels when it has an entry, otherwise the value as a string.

This facet's value candidates, as a Flicker.Provider source — or nil for a facet whose values aren't drawn from a set.

Types

bounds()

@type bounds() :: %{
  optional(:min) => number(),
  optional(:max) => number(),
  optional(:step) => number()
}

Advisory numeric bounds for an editor, and a validation floor/ceiling.

counted()

@type counted() :: boolean()

Whether to request a match count per value for this facet.

operator()

@type operator() ::
  :eq | :neq | :gt | :gte | :lt | :lte | :contains | :between | :in | :not_in

A comparison operator a facet value can be compared with.

related()

@type related() :: %{
  resource: module(),
  search: [atom()],
  option_label: atom() | (struct() -> String.t())
}

The related resource a belongs_to/has_* facet searches over.

t()

@type t() :: %Flicker.Facet{
  bounds: bounds() | nil,
  count: counted(),
  default_op: operator(),
  editor: module() | nil,
  key: atom(),
  label: String.t() | nil,
  multiple?: boolean(),
  operators: [operator()],
  presets: [Flicker.Facet.Preset.t()] | nil,
  related: related() | nil,
  scalar: type() | nil,
  suggested: [atom()] | nil,
  target: [atom()] | nil,
  type: type(),
  validate: validator() | nil,
  value_colors: %{required(atom()) => String.t()} | nil,
  value_labels: %{required(atom()) => String.t()} | nil,
  values: [atom()] | nil
}

A facet definition.

type()

@type type() ::
  :string
  | :integer
  | :float
  | :boolean
  | :date
  | :datetime
  | :enum
  | :date_range
  | :datetime_range
  | :number_range
  | :duration

How a facet value is cast.

validator()

@type validator() :: (term() ->
                  :ok | {:error, {atom(), map()}} | {:error, String.t()})

An extra validator run after a successful cast, on the cast value.

Functions

cast_value(facet, raw, operator, opts \\ [])

@spec cast_value(t(), String.t(), operator(), keyword()) ::
  {:ok, operator(), term()} | {:error, {atom(), map()}}

Casts raw for this facet — delegates to Flicker.Facet.Cast.cast/4.

Returns {:ok, operator, value} with the operator resolved: casting can refine it, so : on a range facet comes back as :between and a comma-separated list on a multiple?: true facet as :in.

Examples

iex> facet = Flicker.Facet.new(key: :created, type: :date_range)
...> Flicker.Facet.cast_value(facet, "2026-06-01..2026-06-30", :between)
{:ok, :between, %Flicker.Facet.Range{from: ~D[2026-06-01], to: ~D[2026-06-30]}}

new(fields)

@spec new(keyword() | map()) :: t()

Builds a facet, filling in the defaults implied by its :type for any field the caller didn't set.

Prefer this to a bare struct literal for the range types: a %Flicker.Facet{type: :date_range} literal keeps the struct's own operators: [:eq] default, which no range value can satisfy, so created:2026-06-01..2026-06-30 would degrade to free text. new/1 gives it [:between] instead.

Only absent keys are defaulted — an explicit operators: always wins, including an explicit [:eq].

Examples

iex> Flicker.Facet.new(key: :created, type: :date_range).operators
[:between]

iex> Flicker.Facet.new(key: :created, type: :date_range).scalar
:date

iex> Flicker.Facet.new(key: :price, type: :number_range).scalar
:integer

iex> Flicker.Facet.new(key: :status, type: :enum, operators: [:eq]).operators
[:eq]

iex> Flicker.Facet.new(key: :name).type
:string

range?(facet)

@spec range?(t()) :: boolean()

Whether this facet's cast value is a Flicker.Facet.Range.

Examples

iex> Flicker.Facet.range?(%Flicker.Facet{key: :created, type: :date_range})
true

iex> Flicker.Facet.range?(%Flicker.Facet{key: :created, type: :date})
false

scalar(facet)

@spec scalar(t()) :: type() | nil

The type each endpoint of a range facet casts as — :scalar when set, otherwise the one implied by the range type. For a non-range facet this is just its own type, so callers can use it unconditionally.

Examples

iex> Flicker.Facet.scalar(%Flicker.Facet{key: :created, type: :date_range})
:date

iex> Flicker.Facet.scalar(%Flicker.Facet{key: :price, type: :number_range, scalar: :float})
:float

iex> Flicker.Facet.scalar(%Flicker.Facet{key: :name, type: :string})
:string

target(facet)

@spec target(t()) :: [atom()]

The Ash filter path this facet resolves to: facet.target, or [facet.key] when :target is nil.

Examples

iex> Flicker.Facet.target(%Flicker.Facet{key: :status})
[:status]

iex> Flicker.Facet.target(%Flicker.Facet{key: :worker, target: [:worker, :full_name]})
[:worker, :full_name]

validate_value(facet, raw, operator, opts \\ [])

@spec validate_value(t(), String.t(), operator(), keyword()) ::
  :ok | {:error, {atom(), map()}}

Validates raw for this facet without keeping the value — :ok, or {:error, {reason, params}} naming what was wrong (ADR-012).

Examples

iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active, :inactive])
...> Flicker.Facet.validate_value(facet, "activ", :eq)
{:error, {:not_in_values, %{value: "activ", values: [:active, :inactive]}}}

iex> facet = Flicker.Facet.new(key: :status, type: :enum, values: [:active])
...> Flicker.Facet.validate_value(facet, "active", :eq)
:ok

value_label(facet, value)

@spec value_label(t(), term()) :: String.t()

A single value's display label — :value_labels when it has an entry, otherwise the value as a string.

Examples

iex> facet = %Flicker.Facet{key: :status, value_labels: %{active: "Currently active"}}
...> Flicker.Facet.value_label(facet, :active)
"Currently active"

iex> Flicker.Facet.value_label(%Flicker.Facet{key: :status}, :active)
"active"

value_source(facet)

@spec value_source(t()) :: {module(), keyword()} | nil

This facet's value candidates, as a Flicker.Provider source — or nil for a facet whose values aren't drawn from a set.

Per ADR-011, a set-valued facet is edited by a nested Flicker.select, and the only thing that differs between an enum facet and a relationship facet is the provider behind it. This is the accessor that erases that difference: one editor module, one counting path, one suggestion path.

:enum and :boolean resolve to a Flicker.Providers.Static over their own closed set, with display labels from :value_labels, so an enum editor shows Established rather than established and Spec 017's :value_colors key off the same value atoms. A relationship facet resolves to the same Flicker.Providers.AshResource source Flicker.FacetSuggest.related_search/3 already builds — that clause only exists when ash is present (ADR-006), so an enum facet's Static source keeps working without it.

This is about candidate values, not about controls. Which editor opens for a type is a separate mapping (Spec 019): :boolean appears here because its true/false pair is genuinely a closed set worth suggesting, but it is edited by a switch and never opens a select.

Examples

iex> facet =
...>   Flicker.Facet.new(
...>     key: :status,
...>     type: :enum,
...>     values: [:active],
...>     value_labels: %{active: "Active"}
...>   )
...>
...> {Flicker.Providers.Static, results: [result]} = Flicker.Facet.value_source(facet)
...> {result.value, result.label}
{:active, "Active"}

iex> facet = Flicker.Facet.new(key: :verified?, type: :boolean)
...> {Flicker.Providers.Static, results: results} = Flicker.Facet.value_source(facet)
...> Enum.map(results, & &1.value)
[true, false]

iex> Flicker.Facet.value_source(Flicker.Facet.new(key: :created, type: :date_range))
nil