Skip to content

Add the item selector modal and embeddable browse components - #76

Merged
ddon merged 1 commit into
BeamLabEU:mainfrom
mdon:feat/item-selector-and-browse-components
Aug 22, 2026
Merged

Add the item selector modal and embeddable browse components#76
ddon merged 1 commit into
BeamLabEU:mainfrom
mdon:feat/item-selector-and-browse-components

Conversation

@mdon

@mdon mdon commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Two deliverables in one architecture: the client-facing item selector modal (the catalogue's MediaSelectorModal analogue, with per-item quantities) and the embeddable browse components it is assembled from.

The picker

<.live_component
  :if={@show_item_selector}
  module={PhoenixKitCatalogue.Web.Components.ItemSelectorModal}
  id="order-item-selector"
  scope={%{catalogue_uuids: [@catalogue.uuid]}}
  selected={@lines_by_uuid}
/>

A logged-in client browses inside a modal — always-visible search (250ms debounce), category chips, a photo-forward card grid (square medium-variant images, lazy, skeletons; no-photo items get a deliberate SKU tile, not a broken image). Tapping a card selects at qty 1 (or the item's default_value — the paint case), a stepper appears under the photo (never covering it), and a footer tray shows "N items · total" with an expandable review list. Confirm hands the host:

handle_info({:items_selected, %{id: id, mode: mode, picks: picks}}, socket)
handle_info({:item_selector_closed, %{id: id}}, socket)

Each pick: uuid, qty (always Decimal), unit, plus a display snapshot (name, sku, price, line_total, signed photo_url) so the host renders its summary without a re-query — documented as display-only, re-price server-side. Works from any host LiveView including a parent app; mode: :single and immediate are supported.

Scope is enforced, not suggested

scope takes the search_items/2 vocabulary (catalogue_uuids / category_uuids / only / statuses). Every fetch re-derives from it; a category event outside it is rejected; selection accepts only uuids the component itself rendered. Preselects are hydrated scope-exempt (the tray must render what the host handed in) but out-of-scope rows — :only exclusions included — are shown flagged and excluded from the confirm picks. Both clamps were sabotaged to confirm their tests fail without them.

Quantities are hostile input too: Decimal-parsed with the ru/et decimal comma accepted, exponent forms rejected, min/max/precision re-clamped server-side, invalid commits recreate the input with the committed value (morphdom won't clear typed garbage on an unchanged attr — a per-row revision in the id forces it).

The embeddable components

The same stack, exposed for hosts:

  • Catalogue.BrowseState — pure reducer (scope/search/category/offset paging, generation counter, uuid-dedup ingest). No process, no queries.
  • Web.Components.Browseitem_card, item_grid, category_chips, qty_stepper, grid_skeleton, present_items/2 (translations + signed photo URL resolved once per fetch, never in render). All take target.
  • Web.Components.CatalogueBrowse — the drop-in browse surface (search + chips + grid, no selection) for embedding a catalogue view on any logged-in page; reports {:catalogue_browse, %{event: :item_clicked, item: …}}.

Design notes and the deferred list (paste-a-list bulk add, category counts, ProductCard detail view, IntersectionObserver sentinel) are in dev_docs/design/2026-08-22-item-selector-and-browse-components.md.

Process

Designed via a 3-model brainstorm + adversarial plan review, built, then a second adversarial review of the finished code — which found real things that were fixed and pinned by tests (:only missing from hydration availability, exponent quantities, string-keyed scope silently widening, crafted payloads crashing the LV, unbounded search strings).

Checks

  • 1781 tests, 0 failures (46 new: reducer {command, scope-shape} product, component render contracts, both LCs driven through a test-only host LV asserting the real message contract, adversarial cases).
  • mix precommit green — credo --strict clean, dialyzer at its pre-existing ignore set.
  • et/ru complete: every new string catalogued + translated; the code-vs-.pot sweep also caught one pre-existing string absent from every catalogue ("Shown on category landing pages.") — now catalogued.
  • Deployed to max-dev; app healthy (7 pages, 0 failing, 0 error lines).

The catalogue's analogue of core's MediaSelectorModal, with quantities: a
client browses the catalogue inside a modal (search, category chips,
photo-forward card grid), picks items, sets an amount per item, reviews
the selection in a tray and confirms. The host LiveView receives
{:items_selected, %{picks: ...}} / {:item_selector_closed, _} process
messages -- the MediaSelectorModal contract, so it drops into any host
including a parent app.

One stack, three layers, so the picker and any embedded browse can never
drift apart:

  * Catalogue.BrowseState -- a pure reducer (scope, search, category,
    offset paging, generation counter). No process, no queries; callers
    run the fetch it requests and hand results back. Every fetch's opts
    re-derive from the host's scope, category commands are rejected
    outside it, and ingest de-dups by uuid because offset paging over a
    live catalogue can re-serve rows.
  * Web.Components.Browse -- pure function components (item_card,
    item_grid, category_chips, qty_stepper, grid_skeleton) plus
    present_items/2, which resolves translations and the signed
    featured-photo URL once per fetch instead of per render. All take a
    target, so they compose inside any LiveComponent or straight in a
    LiveView.
  * ItemSelectorModal and CatalogueBrowse -- the two surfaces: the modal
    picker, and the same browse surface without selection chrome for
    embedding a catalogue view on any logged-in page.

Quantities are Decimal end to end. Whole numbers by default; a positive
qty_precision makes the same stepper decimal-capable (2.5 L of paint --
Item.default_value/unit already model this). Input commits on blur/Enter,
accepts the decimal comma ru/et keyboards produce, rejects exponent
forms, and is re-clamped server-side; an invalid commit bumps a per-row
revision so the input is recreated with the committed value (morphdom
leaves unchanged attrs alone, so re-assigning cannot clear typed
garbage).

Selection is enforced, not assumed: only uuids the component itself
rendered (or host preselects) are selectable; preselects are hydrated
scope-exempt so the tray can render what the host handed in, but rows
outside the scope -- including :only exclusions -- are shown flagged and
never emitted in the confirm picks. Crafted payloads with missing keys
no-op instead of crashing the LiveView; searches are capped; a
string-keyed scope raises at mount instead of silently widening to the
whole catalogue.

Every new string ships with et and ru translations, and the code-vs-.pot
sweep also caught one pre-existing string absent from every catalogue
("Shown on category landing pages.") -- now catalogued in all three.

Tests: the reducer's {command, scope-shape} product, component render
contracts, and both LiveComponents driven through a test-only host
LiveView that renders the received messages back into assertable DOM.
The scope and quantity clamps were each sabotaged to confirm their tests
fail without them. Design notes in
dev_docs/design/2026-08-22-item-selector-and-browse-components.md.
@ddon
ddon merged commit c6fbd1f into BeamLabEU:main Aug 22, 2026
ddon added a commit that referenced this pull request Aug 22, 2026
…view

BrowseState now validates the search_items/2 vocabulary (including
:statuses and :include_descendants), Search honours the status filter,
hydration no longer crashes on nil category_uuid or misses
:categorized_only / descendant expansion, cards show the selling price,
starting qty is 1, and invalid qty commits actually bump the morphdom
revision. CatalogueBrowse gets the same catch-all and scope check as
the picker. Chips and preselects stop N+1-ing the catalogue.

Also documents #76/#77 and bumps to 0.18.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants