Skip to main content

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

# Faceted search with Phoenix LiveView

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

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

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

```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** (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

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