PhoenixKitCatalogue.Catalogue.BrowseState (PhoenixKitCatalogue v0.19.1)

Copy Markdown View Source

Pure browse-state reducer for paged, searchable, category-filtered item browsing — the shared brain of ItemSelectorModal and any embedded browse surface.

Holds no process and runs no queries. A caller feeds it commands, it answers with what to fetch; the caller runs the query (however it likes — synchronously in handle_event/3 today, start_async tomorrow) and hands the result back to ingest/4. That split is what makes every transition testable without a database, and what lets two different LiveComponents drive one implementation instead of drifting copies.

{state, effect} = BrowseState.command(state, {:search, "screw"})
# effect: {:fetch, query_opts, gen} | :noop

case effect do
  {:fetch, opts, gen} ->
    items = Search.search_items(state.search, opts)
    total = Search.count_search_items(state.search, opts)
    BrowseState.ingest(state, gen, items, total)

  :noop ->
    state
end

Scope is a boundary, not a default

init/1 fixes the host-supplied scope for the state's lifetime and every query_opts/1 re-derives from it, so no later command can widen it: a category command narrows WITHIN scope.category_uuids (and is rejected outside it), search composes with it, and nothing can un-set :catalogue_uuids / :only. A crafted client event can therefore never make the fetch layer return items the host did not allow.

Generations

Every effectful command bumps gen, and ingest/4 discards results carrying a stale one. With synchronous fetches events serialize and the guard never fires; it exists so moving to async fetches is a drop-in rather than a redesign — a page 1 of "a" resolving after the state reset to "ab" must not append the wrong results.

Summary

Functions

Applies a command. Returns {state, {:fetch, query_opts, gen}} when the caller should run a fetch, {state, :noop} otherwise.

Folds a fetched page into the state. A stale gen is discarded whole — the state that requested it no longer exists.

Builds the initial state. opts

The Search.search_items/2 opts for the current state — always derived from the immutable scope, never from anything a client event set directly.

Types

t()

@type t() :: %PhoenixKitCatalogue.Catalogue.BrowseState{
  category_uuid: term(),
  exhausted?: term(),
  gen: term(),
  items: term(),
  known_uuids: term(),
  loading?: term(),
  page: term(),
  per_page: term(),
  scope: term(),
  search: term(),
  total: term()
}

Functions

command(state, arg2)

Applies a command. Returns {state, {:fetch, query_opts, gen}} when the caller should run a fetch, {state, :noop} otherwise.

Commands:

  • :reset — first load / clear everything back to the scope.
  • {:search, q} — replace the search string (no-op when unchanged).
  • {:set_category, uuid | nil} — narrow to one category (nil = all within scope). Rejected with :noop when the uuid falls outside scope.category_uuids — scope only ever narrows.

  • :load_more — next page. No-op while loading or exhausted.

ingest(state, gen, items, total)

Folds a fetched page into the state. A stale gen is discarded whole — the state that requested it no longer exists.

Appends are de-duplicated by uuid: offset paging over a live catalogue can re-serve a row when the sort shifts between fetches, and a duplicate card (same DOM id twice) is worse than a briefly missing one.

init(opts \\ [])

Builds the initial state. opts:

  • :scope — map with any of :catalogue_uuids, :category_uuids, :only, :statuses, :include_descendants. Fixed for the state's lifetime. Unknown keys raise ArgumentError.
  • :per_page — page size (default 24).

query_opts(state)

The Search.search_items/2 opts for the current state — always derived from the immutable scope, never from anything a client event set directly.