Selector popup: language, either-or, instant feedback, catalogue-first drill, smart fees - #90
Selector popup: language, either-or, instant feedback, catalogue-first drill, smart fees#90mdon wants to merge 29 commits into
Conversation
tim-dev error report (2026-08-31): the language does not reach the popup or the item inspection popup. Two real causes: - Browse.present_items/2 read the wrong translation key — plain "name" where the multilang editor stores "_name" — so list names NEVER translated and always fell back to the primary-language column. The detail popup resolved "_name" and came out right, which is exactly how the miss stayed invisible until a bilingual catalogue: list in one language, inspection popup in another. Affected the selector, its tray names, and CatalogueBrowse. - ItemPicker defaulted :locale to a hardcoded "en" instead of the process gettext locale, so on a localized page its dropdown, breadcrumbs AND its product-card popup stayed English unless the host passed the attr. It now resolves the process locale per update, the same fallback ItemSelectorModal and CatalogueBrowse share; an explicit attr still wins. The test harness gained the integration shape that exposed this: the HOST process carries the gettext locale and passes no attr (put via ?loc=), with ?clocale= for the explicit-attr override pin. New tests cover list + detail through the process fallback for both components.
Max, 2026-08-31: a checkmark means no number entry (and it sits in the leftmost column); numbers mean no checkmark. The one path that still paired them was FORCING selection_mode="click" with a visible :qty — exactly the shape integrators pin — where a selected row showed the check icon/card badge plus a stepper. By default that flavour now leads with the checkbox column, quantities live in the tray, and the qty cell shows the picked amount read-only. inline_qty: true is the deliberate both-signals opt-in, restoring the legacy pairing (no checkbox column, check plus stepper-on-selected) for hosts that really want it; quantity mode ignores it. Derived defaults were already either-or and are untouched.
Latency audit of the popup (Max, 2026-08-31: 'I add 1 and it gets highlighted blue but only after a delay'). What must stay a server trip stays one — every fetch, every selection decision, the view toggle (a dual-face client switch would duplicate the qty-form ids) — but the FEEDBACK no longer waits for it: - The selected highlight keys off data-selected instead of a server-computed class, and the qty stepper carries a colocated QtySignal hook that flips the attribute the moment a keystroke or arrow changes the value. State stays server-authoritative; the next render reconciles the attribute, and garbage input flips nothing. - Every round-trip control now shows LiveView's client-applied loading state the instant it is clicked: pulse on rows, cards, tiles, chips, drills, Up, the mode switchers, column toggles and search-category hits; a spinner on Load more (its old server-assign gate could never show — the fetch is synchronous) and on Confirm; the search input pulses while its change is in flight. All pure phx-*-loading Tailwind variants — hosts without the variants just see today's behaviour.
The colocated hooks (ItemPicker's keyboard handling, Browse's QtySignal) extract fine, but LiveView writes the phoenix-colocated/<app>/index.js manifest from its mix compiler task — which this library never listed. Hosts importing "phoenix-colocated/phoenix_kit_catalogue" therefore could not resolve it, and the console showed 'unknown hook found' for every colocated hook: they have been silently dead on every deployment. Prepended like phx.new does — the task registers an after-:elixir callback, so it must run before :elixir to attach at all.
Max, 2026-08-31: 'Esc closes both popups, but only top one needs to go.' Chromium groups the close watchers of dialogs whose showModal() ran without user activation (ours run from LiveView patches), so one Esc fires cancel on BOTH stacked dialogs. Core's PkDialog now swallows a dialog's cancel while a child dialog is open inside it; this adds the server-side belt for hosts on an older core — a cancel arriving with the details open closes only the details, and the selector's data-show makes the hook reopen its natively-closed dialog with search, scroll and selection intact. Plus the quality-of-life sweep of the popup: - autofocus on the search box (a fresh native dialog otherwise focuses nothing and the first keystrokes go nowhere) - a clear (x) button in the search box - the confirmable count on the Confirm button — with the cart off by default there was no running tally anywhere - auto-load on scroll via core's InfiniteScroll sentinel (made component-aware in core), Load more staying as the fallback - the results region scrolls back to the top when the level, search, mode or view changes (colocated ScrollTop hook); load-more keeps the offset
A scope naming SEVERAL catalogues — tim-dev's wide order picker — got no tiles, no Categories|Items switcher and no search category hits at all: the tree only built for single-catalogue scopes (Max, 2026-08-31: 'check why the categories and the sub categories aren't showing up', against catalogues holding up to 49 categories each). The root level now groups every offered catalogue's top-level categories under the catalogue's display name (card sections / table divider rows); below the root, drilling is catalogue-agnostic — category uuids are global, Up and the scope enforcement work unchanged, and the fetch still re-ANDs catalogue_uuids. The Uncategorized entry sums the offered catalogues' loose items, and search category hits now run per offered catalogue (capped like the admin's). Harness gained c2= for multi-catalogue scopes.
Max, 2026-08-31: 'for multiple catalogues we should first have the user
choose a catalogue' — replacing the grouped flat root from the previous
commit. The root of a multi-catalogue popup now lists the offered
CATALOGUES as tiles/rows (the switcher reads Catalogues | Items, and a
catalogue tile carries its item total and a has-contents badge);
choosing one lands on that catalogue's own root — its top categories,
its own Uncategorized bucket, its items in Items mode — and Up climbs
subcategory, category, catalogue, catalogue list. BrowseState gained
{:set_catalogue, uuid | nil}: membership-checked against the scope's
offered list like every narrowing, clearing the category on change, and
overriding the fetch's catalogue_uuids from validated state only. Search
category hits narrow to the chosen catalogue. Tests pin the drill chain,
the per-level Uncategorized, the item narrowing per level, and that a
crafted out-of-scope catalogue uuid is a no-op.
Investigating 'smart catalogues show 0 items' (2026-08-31): that claim
was WRONG — smart catalogues hold their items directly and the popup
lists them fine; the demo's smart catalogues were simply empty seed
data. The real gap, verified against tim-dev's rule-priced service
item: a smart fee item rendered a BLANK price everywhere — list rows,
cards, and the details popup.
Browse.smart_fee/1 resolves what a surface can display: a flat
standalone fee IS the price per the smart-catalogues guide (it now
feeds the price column, the card price line, the detail Price row, and
line totals); a percent fee displays as its number ('12%'); a
rule-priced item with no displayable number shows a localized
'Computed' (et 'Arvutuslik', ru 'Рассчитывается' — hand-added to pot +
po + pinned). The fee honours show_prices exactly like prices do.
present_items grows a fee_note key; hand-built presented maps without
it keep rendering (Map.get).
The boss's post-merge review of BeamLabEU#89 (card select hit area, out-of-scope Up climbs to the popup root, category hits only on a fresh fetch) plus the 0.24.0 release commit and lib upgrades, merged under the local batch so all three fixes survive the multi-catalogue and latency rework. # Conflicts: # lib/phoenix_kit_catalogue/web/components/browse.ex
A 4-seat AI panel (risk-sliced briefs) plus the quality-sweep playbook's
four triage agents reviewed the batch; every finding was verified
against code before acting. The ones that survived:
- The auto-load sentinel routed load_more to the HOST LiveView on every
published core (core's InfiniteScroll pushes with no component
target; the CID-aware routing is unreleased) — a host without a
load_more clause crashed and remounted, dropping the popup and every
pick. Replaced with a colocated .AutoLoad hook pushing through the
sentinel's own phx-target, correct on every core.
- Both client-side list-identity keys (.ScrollTop, sentinel cursor)
omitted the new catalogue drill — drilling catalogues landed the user
mid-list and could wedge auto-load on equal page lengths.
- BrowseState: set_catalogue now refuses singleton scopes (the code
contradicted its own documented 'only when several' contract) and a
whitespace-only query no longer flips :direct drill to subtree
listing (the fetch layer trims it to no filter). First unit pins for
set_catalogue at all.
- The component refuses a crafted browse_category naming ANOTHER
catalogue's category while drilled — a contradictory dead-end level
the UI never offers (tiles and search hits are narrowed to the
drilled catalogue).
- Translated names: all six ad-hoc "_name" || "name" chains routed
through the presence-guarded Translations helpers — a stored blank
override is truthy and blanked the display. ItemPicker's locale
fallback also fires for an explicit locale={nil}, the common host
shape put_new_lazy skipped.
- QtySignal mirrors the server's accept set (select_floor /
zero_deselects) instead of flipping on > 0 — a rejected value
produces no diff to undo a premature highlight, so the row stayed
wrongly lit until the next real diff.
- Smart fees: the detail card formats a flat fee through
Browse.format_price like the listing (49.00, not 49.505 vs 49.51);
fee_note joined the documented presented-map shape AND the confirm
payload — a percent-fee pick was indistinguishable from a free item.
- README documents the phoenix-colocated host import (it lived only in
AGENTS.md, which the Hex package excludes).
- The multi-catalogue tree build logs its degrade instead of silently
rescuing to "no categories", and the two tree clauses share one
category_tree_base/4.
- Polish: smart_fee doc narrowed (rule-priced items with no fee fields
are indistinguishable from plain price-less at presentation time),
competing tooltips on the title button, @search_hit_cap named,
confirmable_selection? deduped, stale grouped-roots comment, hook
destroyed() guard, inline_qty x quantity and DB-scale decimal pins.
Plus dev_docs/pull_requests/2026/89-…/FOLLOW_UP.md for the boss's BeamLabEU#89
review (all three findings fixed by his own d8e545d; deliberate skips
recorded).
Gate: 2 doctests, 2194 tests, 0 failures; mix precommit clean.
The Categories|Items (Catalogues|Items at a multi-catalogue root) either-or read as a search-mode control — it sat beside the search input, matched the index's search-for switcher visually, even carried its 'Search for' aria-label — while doing something else entirely: flipping what the LEVEL lists. Max and the external UX review tripped over it independently, and its flat no-query list is served better by search (which already answers with category hits + item results). Default now: the root lists its tiles alone — catalogues at a multi-catalogue root, categories otherwise, the Uncategorized bucket included — and items come from entering a folder or searching. The tiles-only root also skips its page-1 item fetch, which the BeamLabEU#89 review had recorded as deliberately thrown away (the price of the instant Items switch — no switch, no price). A category-less root still lists the plain flat items. root_switcher: true restores the old either-or for hosts that want the flat browse mode (aria-label fixed to Browse — et/ru translated); a crafted set_root_mode without it is refused, since it would reveal an item block whose fetch was skipped. Harness grows rs=true; the flat root-list tests opt in, and the default (tiles only, no switcher, no item markup, crafted flip refused, search still answers) is pinned. Also drops the 'Switch to Items' translation pin ahead of that string's retirement in the detail-page commit.
⚠️ Reverses a fresh call: the index's three-state search mode (auto item results, ?mode=items/catalogues) is the boss's 2026-08-31 design — this commit is deliberately separate so it can be dropped alone if he prefers the modes. Max's ruling tonight: 'what if we did the search like we do in the sub-categories — if anything matches we show the categories and the items.' So the index now has ONE surface: a query filters the catalogue listing (which stays, as navigation) AND renders matching items below it — the popup search's two-list idiom. The attribute filter is always offered and engages the item-results section by itself. Gone with the modes: the Catalogues|Items switcher (which looked identical to the popup's browse switcher while meaning something else — the collision that started this), the ?mode= URL state, the items-mode-only toolbar hiding, and the auto-mode's whitespace-query wart (BeamLabEU#89's deliberate skip — the engagement check now trims). Legacy ?mode= URLs are ignored, not errors, and pinned as such.
⚠️ Reverses fresh design like the previous commit — the root either-or was the 2026-08-29/30 detail-page ruling the popup then copied. With the popup and the index both switcher-less, keeping it here would make the detail page the odd one out; separate commit so the boss can drop it alone. The root is a pure outline (categories + the Uncategorized bucket); its items live one drill down or behind search, which keeps the typed All|Categories|Items REFINEMENT chips — those describe what a search returns, which is exactly what a control next to a search box should do. A category-less root simply lists its loose items now (the popup's rule) instead of an empty outline pointing at the retired Items mode. Gone: the search_mode URL state and its prior_* change tracking, the set_search_mode handlers (including the return-to-outline expansion choreography), items_mode?/show_categories_section? and every gate they drove, and the 'Switch to Items' empty-state. The attribute filter is always_visible — with the modes gone it is a primary control on every level. Legacy ?mode=items URLs land on the outline, pinned.
|
Three commits added after Max's testing round tonight (
|
…Back
Two fixes straight from Max's tim-dev testing round (2026-08-31):
Pointer cursors ('the mouse doesn't change on hovering on clickable
things'): the popup's tiles are <button>s, and browsers give bare
buttons an ARROW cursor — the admin's identical tiles point because
they are patch <a> links. cursor-pointer added to the shared card-tile
trigger's button variant (figure + title), the level table's name
buttons and the Uncategorized row (daisyUI's .link styles the text,
not the cursor, on a button), and the detail popup's thumbnail strip +
more-files tile. The item rows/cards already carried it. Pinned in
both views.
Live header ('use the header to show where you are… and the up button
should be back instead'): the modal header now names the level being
browsed — the drilled category, the Uncategorized bucket, or the
chosen catalogue, image/name/description from the tree already in
memory, so navigating costs no queries — falling back to the
host-scoped record (2026-08-30 behaviour) and then the plain title at
the root, where an explicit title attr still wins. The way back moved
there too: Back (left arrow) beside the context, climbing exactly as
Up did — category chain, then the catalogue level, then the catalogue
list — absent at the root. The old Up row above the tiles is gone;
context_header={false} keeps the whole header static as before.
|
Final round from Max's live testing ( |
tim-dev error report (2026-08-31): WASTE SORTERS' subcategories were missing from the popup while the data was perfectly healthy — two active children in the same catalogue. Their per-category narrow pickers pass the popup category_uuids: [cat] with catalogue_uuids: nil, and build_category_tree keyed every clause off catalogue_uuids — that shape fell through to the EMPTY tree, so the level rendered no navigation at all (a flat subtree item list). Broken for category-only scopes since the tiles landed in BeamLabEU#89/0.24.0. The catalogue is implied by the scoped categories themselves, so the tree now derives it — for the TREE ONLY: the browse scope, and every fetch derived from it, stays exactly what the host passed (pinned: out-of-scope items stay invisible). Only a single unambiguous catalogue is injected; a scope whose categories span several keeps the flat behaviour rather than inventing a catalogue-first root the host never asked for. The Uncategorized gate is unaffected — the category restriction still blocks the bucket. Pinned with the report's exact shape: cat_scope with no catalogue, children tiles offered, drill + Back + live header work, the fetch stays scoped.
Three boss/Max calls from the morning testing round (2026-08-31): - ITEMS: only the thumbnail is the look-closer gesture — supersedes the earlier title-joins-the-photo ruling. The table's name cell follows the row's select behaviour; the card title moves INSIDE the select button, which means that button can never render empty and the BeamLabEU#89 review's min-height patch for that case retires with it. - CATEGORY/CATALOGUE tiles: the image enters the level exactly like the name — the level table's thumb cell (and the uncategorized row's folder icon) were inert; card tiles were already wired through the shared trigger. - ADMIN index search: an item result links straight to the item's EDIT page — whoever searched an item by name wants that item, not its category page with the query re-applied and every sibling around it.
The catalogue and category editors already badge the tab with the attached-file count; the item editor — the one with the most media — was the one missing it (Max's report, 2026-08-31). Same shape, same count as the section header below.
Two boss calls (2026-08-31): - Images (and files) are reorderable after being added: the attached- files grid rides core's <.draggable_list> (SortableGrid), drag restricted to a handle so the file links and remove buttons keep working. Files had NO order anywhere — they sorted by inserted_at — so the editor's order persists as an ordered uuid list in the record's data["media_order"] (same save lifecycle as the featured pointer, via inject_attachment_data), applied by the new Attachments.apply_media_order/2 wherever the media renders: the editor grid itself and the detail popup's carousel + file list (ProductCard.resolve_images/resolve_files). Unknown files (new uploads, legacy records) keep their inserted_at tail order; crafted reorder payloads can neither lose nor invent a file. The item and catalogue editors get the grid; the category editor has no files grid (featured image only), so nothing to reorder there. - The find-item-in-PDFs block moves from the top of the item edit form to the bottom, under the Save row.
Boss's trashcans follow-up (2026-08-31): after the subcategory-tiles fix the scoped popup showed WASTE SORTERS' children, but not the items filed directly on the category itself — with the root switcher retired there was no Items mode left to reveal them, so they were unreachable outside search. The model Max confirmed: like the admin — (sub)categories at the top, the level's own not-in-a-subcategory items at the bottom. So a scope naming exactly ONE category now initializes the browse ALREADY DRILLED into it: the first paint is the drilled level (child tiles + own items + the live header naming it), not a synthetic outline above it. Back stays hidden at that floor — the host said "this category", and a Back to an outline that repeats the same tiles would loop; a root tile's Back (level_up's "") remaps to the floor instead of the retired synthetic root. Search hits now cover the subtree BELOW the floor, which dissolves BeamLabEU#89's drill-to-the-standing-root dead-Up scenario entirely — its pin is reworked to guard the new contract (descendant-only hits, Back names the floor, never the out-of-scope ancestor).
|
Post-signoff rounds from the boss's morning feedback (all deployed to both dev boxes):
Suite 2203 green + precommit clean after each round. Companion: BeamLabEU/phoenix_kit_crm#31 (the manufacturers backfill the supplier import shipped without). |
Max, 2026-08-31: the popup and the admin listed the same category's items in DIFFERENT orders — the admin's default is document order (position, name, uuid; apply_item_order/2), the fetch layer's was name. The name default existed because position is per-(catalogue, category) scope, so interleaving raw positions across catalogues is meaningless — but the admin itself orders whole-catalogue lists by position, so for any single-catalogue listing the caution doesn't apply. search_items/2 grows an explicit `order: :position` opt carrying the admin's exact chain; BrowseState.query_opts/1 asks for it on every BROWSE fetch scoped to one catalogue (drilled categories, the scoped floor, the uncategorized bucket, a chosen catalogue in the multi drill, category-less roots). A multi-catalogue listing keeps name order — the one place position genuinely means nothing — and a live SEARCH stays name-ordered everywhere, like the admin's results. No global ordering system; the default look simply matches wherever "the same" is definable. Pinned at the reducer (which shapes get :order and which don't) and at the DB (positions inverted against names distinguish the chains).
The error report of the day (2026-08-31): column toggles silently never persisted on tim-dev — their embeds passed no current_user, and 'no user, no persistence' gives no signal. The popup had no README section at all (the moduledoc was its only doc surface), so the most-missed attr in real integrations now leads the README's usage snippet with the one-liner every admin page has at hand (@phoenix_kit_current_scope.user).
The boss's report: subcategory images in the admin tables were inert while the name beside them was a link. The thumb cell now wraps in the same link as the title everywhere a title links: the detail page's flat and tree category tables (patch to the category browser) and the catalogues index's tree row and both simple-table sites (navigate to the catalogue). Deleted and trashed rows keep the bare thumb, exactly like their names stay unlinked. Cards were already covered by the shared category_card_trigger.
The 'items reorder is missing' report was not a regression: every reorder affordance is position-sort-only by design, and the global catalogue_sort_detail_items setting on the reporting box was name:asc, which hides them all silently. The items section toolbar and the items-only page header now say where the Manual sort lives instead of just showing nothing.
Core's set_routing_info assigns url_path as the bare path, and the language switcher rebuilds locale URLs from it - so switching language inside a drilled category rebuilt the URL without ?category= and threw the user back to the root. Both browse LiveViews now publish the full path+query into :url_path at the end of handle_url_state (UrlState keeps its own bare-path base assign, so state building is unaffected). Pairs with the core fix teaching Tab.normalize_path to strip query and fragment before tab matching.
The boss's report: 'New subcategory' rendered in English in the Russian admin. Root cause: Gettext.gettext(Backend, ...) is the runtime API - a plain function call the extractor never sees - so a string used only in that form never enters the pot, and the completeness sweep (which counts untranslated entries after extraction) has nothing to count. Most such strings survive because the same msgid also appears at a macro call site somewhere; three did not: 'New subcategory' (the category row menu, three sites), 'Search for' (the search aria-label), and 'That category belongs to another catalogue.' (an item-form flash - that module never had use Gettext at all). All six call sites now use the extractable macro form, the pot and po files are regenerated (the merge's fuzzy guesses corrected - it matched 'New subcategory' to 'New category' and gave 'Search for' a msgstr with a binding the msgid does not have), et/ru filled in, and the three strings pinned in the gettext guard test alongside the drag-reorder hint. A workspace-wide sweep found the same defect class in core (fixed there) and queued gaps in staff, locations, db, ecommerce and billing.
The boss's report: photos invisible in the item selector popup while the same items show them everywhere else. Every server-side factor verified healthy on the reporting box - the items carry featured_image_uuid, the popup's own fetch+present pipeline produces signed thumb URLs for the exact items in the report, those URLs serve 200 with image/jpeg through the public proxy, the deployed source and compiled beams are current, and the served CSS carries the thumb classes. The rendered rows reserve the thumb column's width but paint nothing, and the no-image letter-box fallback is absent - so the img elements are there with a src and never load. The one browser-only ingredient the popup has that the healthy admin tables lack: loading=lazy images patched into an open top-layer dialog, where Chromium's lazy-load intersection handling is unreliable. Inside a size-capped modal lazy loading buys nothing - a page of 24 thumbnails is a handful of kilobytes - so the modal-context images (browse card + table row, the detail popup's gallery, the tray, the attribute-set modal) load eagerly now. Admin page images keep lazy.
|
Five more commits from today's error reports (
Suite 2205 green + precommit per commit; deployed to max-dev and the tim-dev preview. |
What this does
Continues the item-selector arc after 0.24.0 — six threads from Max's reports plus a full external review sweep:
"_name"(the multilang editor's key; the detail popup already did, masking the miss until a real bilingual catalogue), andItemPickerfalls back to the process gettext locale instead of a hardcoded "en" — including for an explicitlocale={nil}.:qtycolumn IS the amount flavour. Both only via the explicitinline_qty: trueopt-in.QtySignalhook flips the row/card highlight the moment a keystroke changes the value (mirroring the server's accept set),phx-click-loadingcues on every round-trip surface, and the:phoenix_live_viewcompiler so colocated hooks actually ship (they were silently dead on every deployment — the manifest never wrote). The README now documents the required hostapp.jsimport.BrowseState.{:set_catalogue, …}is membership-checked, singleton scopes refuse it, and the fetch re-ANDs the narrowed catalogue.fee_noteis part of the presented-map shape and the confirm payload.Review sweep
A 4-seat AI panel (risk-sliced briefs: state/security, rendering/fees, core JS, UX model) + the quality-sweep playbook's four triage agents reviewed the full batch; every finding was verified before acting (~⅓ did not survive). Fixes that came out of it are in the last commit — the notable one: the auto-load sentinel now ships as a colocated hook targeting the component, because core's
InfiniteScrollpushes to the root LiveView on every published core, which crashed hosts without aload_moreclause. Full record indev_docs/pull_requests/2026/(added under this PR's folder after numbering).Deferred, recorded in the review doc: scoped tile counts, a deliberate a11y pass over the table view, the 50-file detail cap.
Gate
mix precommitclean; 2 doctests, 2194 tests, 0 failures with a database attached. Browser-verified on max-dev (demo page: default/card/client/checkbox/cart/multi flavours).phoenix_kit.jsrefreshed from that core.🤖 Generated with Claude Code
https://claude.ai/code/session_018bbQqpwP4bRCYWNtsJAksH