guides/faceted-search-with-phoenix-liveview.md

# Faceted search with Phoenix LiveView

This guide walks through 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`. The patterns mirror the library’s own contract tests so prose and code stay aligned as APIs evolve.

## 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:`.

```elixir
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.**

## Hierarchical facets

Meilisearch represents hierarchical menus as **multiple filterable facet attributes** rather than a nested JSON tree under one key. A common pattern uses **dotted paths** such as `"categories.lvl0"` and `"categories.lvl1"` that align with **flat** `facetDistribution` maps per attribute in search responses.

**Opt-in:** set `nested_facet_paths: true` under `faceting:` before declaring dotted atoms such as `:"categories.lvl0"` in `faceting.attributes`. Schemas that omit this flag keep the same flat-only behavior as earlier releases.

**Sugar:** optional `hierarchy: [base: :categories, depth: 2]` expands into `:"categories.lvl0"` and `:"categories.lvl1"` style names so you list the base field once; expanded names still must appear in `filterable:`.

**Counts and filters:** drilling down typically applies filters **AND between facet attributes** (conjunctive across the per-level fields) while values within one attribute stay **disjunctive** when you pass a list to `facet_filter:` for that field.

**Wire shape:** `facetDistribution` returns one map per declared attribute string key, not a nested hierarchy object. Keys in `result.facets.distribution` use the **same atoms** you pass to `facets:` and declare under `faceting.attributes`.

**Wildcards remain mistakes:** declaring `:*` or dotted names outside the supported single-dot **`lvlN`** suffix pattern still fails fast at compile time.

## Disjunctive facet counts

Facet UX often combines **OR within the same facet field** (for example two genres) with **AND across different facet fields** (for example genre OR plus a specific year). `Scrypath.Meilisearch.Query` already encodes that filter shape; this section is about **`facetDistribution`** counts.

A **single search** response always intersects facet refinements with the hit set. Bucket counts therefore reflect what Meilisearch computed **after** every active facet filter — not “unrefined OR-group” counts from that response alone.

Operators who want disjunctive bucket counts follow Meilisearch’s **multi-search** recipe: keep the tightened main query for hits, issue auxiliary searches that drop each disjunctive group’s refinements for that group’s distribution (often `limit: 0`), then merge raw `facetDistribution` maps with `Scrypath.Facets.Disjunctive.merge_distributions/2` before decoding into `%Scrypath.SearchResult.Facets{}`.

Reference scenario **Genre OR + year AND on the movies catalog**: pass two genre values under one `facet_filter:` key (OR inside `genre`) and one year value under `year` (AND against the genre group). Hits and counts both narrow on the conjunctive document set until you add auxiliary queries for disjunctive count UX.

| Query role | What `facetDistribution` answers |
|------------|-----------------------------------|
| Main search with all filters | Counts on the narrowed catalog (honest **single search** semantics). |
| Auxiliary per-field queries | Counts as if that facet group were relaxed, for merging via **multi-search**. |

## Searching within a facet selection

Catalog UIs often show a facet bucket (for example **Genre → Action**) and need the next search to stay **inside that bucket** while still using the normal `Scrypath.search/3` options for sort, pagination, non-facet `filter:`, and additional `facet_filter:` keys on *other* attributes.

Use **`Scrypath.search_within_facet/4`**, passing **`{facet_attribute, value}`** as the third argument. The library merges that bucket into the effective **`facet_filter:`** and runs **one** validated search on the same Meilisearch **`/search`** path as **`Scrypath.search/3`**, so operators still see the **`[:scrypath, :search]`** span with extra metadata that marks the call as scoped.

For the general full-text path without a positional bucket, keep using **`Scrypath.search/3`**.

## Composing facet filters with scoped search

**AND** semantics apply across **`filter:`**, the locked bucket, and any other **`facet_filter:`** keys: a hit must satisfy every constraint Meilisearch composes from **`filter`** plus **`facetFilters`**.

If **`facet_filter:`** already contains the same facet attribute as the bucket, the library raises **`ArgumentError`** — do **not** duplicate the same attribute from URL params **and** LiveView assigns (a common footgun). Prefer one source of truth: either pass the refinement only in **`facet_filter:`** with **`search/3`**, or lock the bucket with **`search_within_facet/4`** and drop that key from **`facet_filter:`**.

Dotted hierarchical facet atoms follow the same **one-attribute** rule: the bucket attribute must not appear twice across **`facet_filter:`** and the positional tuple.

`search_within_facet:` does **not** change disjunctive count mechanics — that remains the separate **multi-search** merge story documented under **Disjunctive facet counts** above.

## 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`.

```elixir
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.

## Sidebar checklist (genre + director)

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

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

**Layer:** UI checklist rows use `gap-2` (8px) vertical rhythm between rows for consistent spacing.

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

```elixir
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**. Each entry lists **Layer → mistake → consequence → why → do instead**.

### API

#### API — Wildcard facet attributes

**Layer:** API  
**The mistake:** Declaring `:*` in `faceting.attributes`, or dotted atoms without `nested_facet_paths: true`, or dotted shapes that do not follow the single-dot **`lvlN`** suffix pattern.  
**User-visible consequence:** Compile error or confusing `ArgumentError` instead of a searchable index.  
**Why:** Wildcards stay unsupported. Dotted Meilisearch-style hierarchical paths require the explicit opt-in and the documented `lvlN` suffix shape so facet atoms and index settings stay predictable.  
**Do instead:** List explicit flat atoms, or follow **Hierarchical facets** above with `nested_facet_paths: true` and supported `lvlN` paths that are also `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:** Facets must be declared explicitly on the schema so Scrypath can validate requests against `faceting.attributes`.  
**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`.

#### Meilisearch — Counts ignore my OR group

**Layer:** Meilisearch  
**The mistake:** Expecting one response’s `facetDistribution` to show counts **as if** an OR-selected facet group were ignored for counting purposes.  
**User-visible consequence:** Operators think Scrypath “lost” their OR semantics when bucket numbers shrink with refinements.  
**Why:** Meilisearch intersects counts with the same filtered hit set the UI already narrowed.  
**Do instead:** Reread **Disjunctive facet counts** above and implement the documented **multi-search** merge path when you need disjunctive count UX.

### 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:** The `{:error, {:unknown_facet, _}}` tuple is the signal that a facet was requested without a matching `faceting:` declaration.  
**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—useful when extending library tests that keep this guide and ExDoc snippets in sync.

## See also

- `guides/phoenix-liveview.md` — context boundary for LiveView.
- `Scrypath.search/3`, `Scrypath.schema_faceting/1`, and `Scrypath.Meilisearch.Query.to_payload/1` in ExDoc for the exact option keys and wire mapping.