Faceted search with Phoenix LiveView

Copy Markdown View Source

This guide is the companion narrative for FACET-08: a movies-shaped example (genre, year, rating, director) that stays on the common Scrypath.search/3 path with facets:, facet_filter:, and URL-friendly handle_params/3. It lives at guides/faceted-search-with-phoenix-liveview.md so DocsContractTest and ExDoc can anchor stable strings.

Overview

Faceted search combines full-text search with facet distributions (counts per attribute value) and optional facet filters that narrow results. Scrypath keeps the contract explicit:

  • Declarations live on the schema (faceting: aligned with filterable:).
  • Runtime options on Scrypath.search/3 include :facets and :facet_filter.
  • Meilisearch wire keys (facetDistribution, facetStats) decode into %Scrypath.SearchResult.Facets{}.

LiveView owns assigns, loading, and URL state. Your context still owns repo access, hydration options, and the call to Scrypath.search/3.

Prerequisites

  • Read guides/relevance-tuning.md for how schema settings: maps into Meilisearch index settings before you tune ranking alongside facets.
  • A running Meilisearch is not required to follow the patterns — tests in the library use FakeBackend and Req.Test-style fixtures for deterministic bodies.

Declare facets on the schema

Use the same movie shape as the tests: filterable: must be a superset of faceting.attributes:.

defmodule MyApp.Movies.Movie do
  use Ecto.Schema
  use Scrypath,
    fields: [:title, :genre, :year, :rating, :director],
    filterable: [:genre, :year, :rating, :director],
    faceting: [
      attributes: [:genre, :year, :rating, :director],
      max_values_per_facet: 100
    ]

  schema "movies" do
    field :title, :string
    field :genre, :string
    field :year, :integer
    field :rating, :float
    field :director, :string
  end
end

If you request a facet not listed in faceting.attributes, Scrypath.search/3 returns {:error, {:unknown_facet, attr}}. The user-facing dev copy for that situation is: That attribute is not declared on this schema's faceting: list.

Primary path: handle_params + URL sync

Recommended: normalize query + facet params in handle_params/3, then call your context with a keyword list that mirrors what you will pass to Scrypath.search/3.

def handle_params(params, _uri, socket) do
  q = Map.get(params, "q", "")
  genres = parse_genres(params["genre"])
  years = parse_int_list(params["year"])

  facet_filter =
    []
    |> maybe_put(:genre, genres)
    |> maybe_put(:year, years)

  {:noreply, run_search(socket, q, facet_filter)}
end

Use push_patch/2 or <.link navigate={~p"/movies?#{params}"}> so refresh and deep links restore the same facet state. Example URLs in this guide use fictional hosts such as https://example.com only.

Render facet buckets from result.facets.distribution (not raw Meilisearch JSON in templates). Checkbox groups map cleanly to disjunctive within field semantics for facet_filter::

example_facet_filter = [genre: ["Horror", "Sci-Fi"]]

Layer: UI checklist rows use gap-2 (8px) vertical rhythm between rows per UI-SPEC.

Chip row (active filters)

Active facet_filter entries should render as removable chips between the search rail and the results list. Each chip exposes aria-label Remove {facet name}: {value} (for example Remove genre: Horror).

Primary CTA copy is locked as Search catalog — it submits the text query and applies the current facet state to Scrypath.search/3.

Numeric range (rating)

Use result.facets.stats for numeric min/max labels when present, then issue range filters with the common filter operators:

Scrypath.search(MyApp.Movies.Movie, "space",
  backend: MyApp.SearchBackend,
  facets: [:rating],
  facet_filter: [rating: [gte: 3.0, lte: 5.0]]
)

Search-within-facet (director list)

For long director lists, add a text input that filters rows client-side (LiveView assign only). This does not call the Meilisearch facet-search API (deferred on the roadmap); it is assign-filter only for v1.3.

Loading and errors

While Scrypath.search/3 is in flight, show Searching… on the primary CTA (disabled) and aria-busy="true" on the results region.

On {:error, reason}, show Search could not complete. with inspect(reason) in monospace, then Retry search and Remove the filter you added last as actions.

Empty results use:

  • Heading: No movies match these filters
  • Body: Try removing one filter or shortening your search. Bucket counts update as you change filters.

Destructive reset copy: Clear all filters with confirmation Reset genre, year, rating, and director? — confirm Reset filters, cancel Keep filters.

Progressive disclosure: handle_event only

You can prototype with handle_event/3 alone for classroom demos. Add an explicit disclaimer: bookmarking and refresh will not restore facet state until you move the same parameters through handle_params/3.

Anti-pattern appendix

Single index; bands are API, Meilisearch, then UI (D-04). Each entry lists Layer → mistake → consequence → why → do instead.

API

API — Wildcard facet attributes

Layer: API
The mistake: Declaring :* or hierarchical dotted atoms in faceting.attributes.
User-visible consequence: Compile error or confusing ArgumentError instead of a searchable index.
Why: FACET-10 locks wildcards and hierarchical names out of the schema layer to keep atoms and settings predictable.
Do instead: List explicit atoms that are already filterable:.
See also: Schema section above.

API — Facet filter as raw string

Layer: API
The mistake: Passing a Meilisearch filter string into facet_filter:.
User-visible consequence: Validation rejects the call or you bypass Scrypath’s field checks.
Why: The common path only accepts keyword-shaped filters so keys can be checked against faceting.attributes.
Do instead: Use keyword lists and structured range maps; only bypass the common Scrypath.search/3 contract when you intentionally issue raw Meilisearch HTTP requests yourself.
See also: Scrypath.search/3 docs.

API — Unknown facet atom

Layer: API
The mistake: Adding :studio to :facets without declaring it on the schema.
User-visible consequence: {:error, {:unknown_facet, :studio}} from Scrypath.search/3.
Why: FACET-03 requires an explicit declaration surface.
Do instead: Extend faceting: [attributes: ...] and managed settings before requesting the facet.

Meilisearch

Meilisearch — Ignoring AND between filter and facetFilters

Layer: Meilisearch
The mistake: Assuming filter replaces facetFilters in the JSON body.
User-visible consequence: Results still narrowed by a facet you thought you cleared.
Why: Meilisearch ANDs filter and facetFilters together.
Do instead: Clear both layers when resetting UI state; consult Scrypath.Meilisearch.Query payload helpers.

Meilisearch — Expecting facet-search hits from facets:

Layer: Meilisearch
The mistake: Treating facetDistribution as a second search hit list.
User-visible consequence: UI shows counts but no “facet value documents”.
Why: Distribution is counts per bucket, not nested documents.
Do instead: Keep document hits in result.hits and counts in result.facets.

UI

UI — Mutating URL only in handle_event

Layer: UI
The mistake: Patching assigns without syncing the browser URL.
User-visible consequence: Shared links miss facet state.
Why: Deep links are a first-class expectation for faceted catalog UX.
Do instead: Mirror params in handle_params/3 and push_patch/2.

UI — Hiding loading state on slow networks

Layer: UI
The mistake: Leaving buttons enabled while a search is in flight.
User-visible consequence: Double submits and conflicting results.
Why: Operators cannot tell whether a slow facet toggle applied.
Do instead: Disable Search catalog and mark the results region aria-busy="true" until the tuple returns.

UI — Silent unknown facet failures

Layer: UI
The mistake: Dropping {:error, {:unknown_facet, _}} on the floor.
User-visible consequence: Empty panes with no explanation.
Why: Developers need the explicit FACET-03 string to correct declarations.
Do instead: Surface That attribute is not declared on this schema's faceting: list. beside the control that triggered the request.


Fixture cross-reference

The compile-checked fixture Scrypath.TestSupport.Docs.PhoenixExampleCase.FacetedBrowseLive mirrors the handle_params flow without importing your application code. Pair it with this guide when you extend DocsContractTest.

See also