PhoenixKitCatalogue.Web.Components.ItemSelectorModal (PhoenixKitCatalogue v0.20.0)

Copy Markdown View Source

Catalogue item selector modal: the catalogue's analogue of core's MediaSelectorModal. A logged-in user browses the catalogue inside a modal — search, category chips, photo-forward card grid — picks items, sets a quantity per item, reviews the selection in a tray, and confirms.

Usage

# In the parent LiveView's template. Mount it with :if — unmounting
# on close is what gives clean reopen semantics (fresh search, fresh
# scroll, preselects re-read).
<.live_component
  :if={@show_item_selector}
  module={PhoenixKitCatalogue.Web.Components.ItemSelectorModal}
  id="order-item-selector"
  scope={%{catalogue_uuids: [@catalogue.uuid]}}
  selected={@order_lines_by_uuid}
/>

Required host wiring (do not skip — silent failure otherwise)

This is a LiveComponent; it reports through process messages to the host LiveView, exactly like MediaSelectorModal. The host MUST handle both, or a confirmed selection is silently dropped:

  • handle_info({:items_selected, %{id: id, mode: mode, picks: picks}}, socket) — fired on Confirm, and only when at least one available pick exists: with nothing confirmable the event is refused server-side, exactly as the button is disabled client-side, so picks is never []. Each pick is a map with :uuid, :qty (always a Decimal, integers included — hosts write one clause), :unit, and a display snapshot: :name, :sku, :price, :line_total (price × qty, nil when the item has no price) and :photo_url (a signed URL — it expires, render it, never persist it). The snapshot is for rendering the host's own summary without a re-query; it is NOT an order record — re-read and re-price items server-side when the selection becomes something real.
  • handle_info({:item_selector_closed, %{id: id}}, socket) — fired on cancel/ESC/backdrop, AND after a confirm. Reset the :if assign here.

Views and columns

Two presentations over the same fetch: view: "table" (the default — a compact admin-look list) and view: "card" (the photo-forward grid). A toggle beside the search box switches them; the choice is transient and the host attr only sets the STARTING view — like scope, it is read at init and not refreshed by later parent renders.

Table columns are a host contract, because the popup can be client-facing: pass columns as a non-empty list from Browse.table_columns/0 (:thumb :breadcrumb :name :sku :manufacturer :category :unit :price :base_price :qty) in display order, and only those render. :breadcrumb is a headerless muted "Category /" prefix column beside Name (granted and hidden by default, like :sku). Unknown entries raise. :price is the customer-facing selling price (markup and discounts applied) shown as "6.40 / piece" — the default set carries it and NOT :base_price, the raw internal number, which an embed must ask for explicitly; :unit is the standalone column for price-free lists. Omitted, the full set applies minus what show_sku: false / show_prices: false already opt out of. Omitting :qty hides the inline stepper — quantities are then edited in the tray only.

Granted columns are additionally staged by viewport so the modal never scrolls sideways: identity and the pick-driving numbers (thumb, name, price, qty) hold down to phone width; unit returns at sm, SKU at md, manufacturer and category at lg. The modal box itself widens on large viewports (xl/2xl) beyond core Modal's 4xl cap. On phones the card grid remains the roomier alternative, one toggle away.

Selection modes

selection_mode: "click" (default) is the classic picker: clicking a row/card toggles it, and the stepper appears once selected. selection_mode: "quantity" is the order-sheet flavour: EVERY rendered row shows its stepper at 0, entering a positive quantity (plus button or typing) IS the selection, stepping back to 0 removes it, and rows/cards are not click-targets at all — no separate "select" step. The tray, Confirm, and every guard behave identically in both modes.

Scope

scope fixes what the user may browse: any of :catalogue_uuids, :category_uuids, :only, :statuses, :include_descendants (the Catalogue.search_items/2 vocabulary). It is enforced in BrowseState — every fetch re-derives from it, and client events can only narrow within it, so a crafted event cannot browse or select outside what the host allowed. Selection events are additionally accepted only for uuids the component itself has rendered (or that arrived preselected).

Preselection

selected is %{uuid => qty} (string uuids, Decimal or integer quantities). Preselected items are hydrated at mount ignoring scope — the tray must be able to render what the host handed it. A hydrated item that falls OUTSIDE the scope renders in the tray marked unavailable and is excluded from the confirm picks, never silently dropped. A uuid that no longer resolves at all (deleted or unknown) IS dropped — there is nothing to render. Hydrated quantities are clamped like typed ones (min/max/precision and the absolute ceiling), and in :single mode at most one preselected entry survives (first by uuid).

Quantities

qty_precision: 0 (default) is whole numbers; a positive precision turns the same stepper decimal-capable ("2.5" of unit "L"). The input commits on blur/Enter; decimal commas are accepted ("2,5" — ru/et keyboards); all limits re-clamped server-side.