Add the item selector modal and embeddable browse components - #76
Merged
ddon merged 1 commit intoAug 22, 2026
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two deliverables in one architecture: the client-facing item selector modal (the catalogue's
MediaSelectorModalanalogue, with per-item quantities) and the embeddable browse components it is assembled from.The picker
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'sdefault_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:Each pick:
uuid,qty(always Decimal),unit, plus a display snapshot (name,sku,price,line_total, signedphoto_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: :singleandimmediateare supported.Scope is enforced, not suggested
scopetakes thesearch_items/2vocabulary (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 —:onlyexclusions 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.Browse—item_card,item_grid,category_chips,qty_stepper,grid_skeleton,present_items/2(translations + signed photo URL resolved once per fetch, never in render). All taketarget.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 (
:onlymissing from hydration availability, exponent quantities, string-keyed scope silently widening, crafted payloads crashing the LV, unbounded search strings).Checks
mix precommitgreen — credo--strictclean, dialyzer at its pre-existing ignore set..potsweep also caught one pre-existing string absent from every catalogue ("Shown on category landing pages.") — now catalogued.