feat(review): #90 wood primary category + derived thickness band
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
// (METADATA-10: "parents group only and are never assigned"); only the
|
||||
// leaf rows are selectable. Filtering hides a parent group when none of
|
||||
// its leaves match.
|
||||
//
|
||||
// #90 closed state shows selected NAMES (not only a count). Done dispatches
|
||||
// `commit` so the parent can persist + re-review.
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { selectedSummary } from './woodPrimary';
|
||||
|
||||
export interface Opt { id: number; name: string; ai: boolean }
|
||||
export interface Group { parent: { id: number; name: string }; leaves: Opt[] }
|
||||
@@ -33,6 +37,7 @@
|
||||
|
||||
// Effective selectable set (leaves when grouped, options when flat).
|
||||
$: allOpts = (grouped ? groups.flatMap((g) => g.leaves) : options) as Opt[];
|
||||
$: nameById = new Map(allOpts.map((o) => [o.id, o.name] as [number, string]));
|
||||
$: q = filter.trim().toLowerCase();
|
||||
$: filteredFlat = q ? allOpts.filter((o) => o.name.toLowerCase().includes(q)) : allOpts;
|
||||
// Grouped: keep a parent only if at least one of its leaves survives the filter.
|
||||
@@ -42,6 +47,7 @@
|
||||
.filter((g) => g.leaves.length > 0)
|
||||
: [];
|
||||
$: count = selected.length;
|
||||
$: summary = selectedSummary(selected, nameById, placeholder);
|
||||
$: allFilteredSelected = filteredFlat.length > 0 && filteredFlat.every((o) => selected.includes(o.id));
|
||||
|
||||
function toggle(id: number) {
|
||||
@@ -55,13 +61,17 @@
|
||||
for (const o of [...filteredFlat]) if (selected.includes(o.id)) onToggle(o.id);
|
||||
}
|
||||
function close() { open = false; filter = ''; dispatch('commit'); }
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) open = false; }
|
||||
function onKey(e: KeyboardEvent) { if (e.key === 'Escape' && open) { open = false; filter = ''; dispatch('commit'); } }
|
||||
// Rows are focusable options (role=option): Enter/Space toggles, matching click.
|
||||
function onRowKey(e: KeyboardEvent, id: number) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(id); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ms" class:open data-testid={testid} on:keydown={onKey}>
|
||||
<div class="ms" class:open role="group" aria-label={label} data-testid={testid} on:keydown={onKey}>
|
||||
<button type="button" class="ms-btn" data-testid={testid ? `${testid}-btn` : 'ms-btn'} on:click={() => (open = !open)} aria-expanded={open}>
|
||||
<span class="ms-label">{open ? 'Choose ' + label.toLowerCase() : placeholder}</span>
|
||||
<span class="ms-c">{count === 1 ? '1 selected' : `${count} selected`}</span>
|
||||
<span class="ms-label" data-testid={testid ? `${testid}-summary` : 'ms-summary'}>{open ? 'Choose ' + label.toLowerCase() : summary}</span>
|
||||
<span class="ms-c" data-testid={testid ? `${testid}-count` : 'ms-count'}>{count === 0 ? '' : count === 1 ? '1 selected' : `${count} selected`}</span>
|
||||
<span class="ms-arrow" aria-hidden="true">{open ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
|
||||
@@ -85,9 +95,11 @@
|
||||
class="ms-row leaf"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -104,9 +116,11 @@
|
||||
class="ms-row"
|
||||
class:on={selected.includes(o.id)}
|
||||
role="option"
|
||||
tabindex="0"
|
||||
aria-selected={selected.includes(o.id)}
|
||||
data-testid={testid ? `${testid}-opt-${o.id}` : `ms-opt-${o.id}`}
|
||||
on:click={() => toggle(o.id)}
|
||||
on:keydown={(e) => onRowKey(e, o.id)}
|
||||
>
|
||||
<span class="ck" aria-hidden="true">{selected.includes(o.id) ? '✓' : ''}</span>
|
||||
<span class="nm">{o.name}{#if o.ai}<span class="ai-tag">AI</span>{/if}</span>
|
||||
@@ -127,8 +141,9 @@
|
||||
|
||||
<style>
|
||||
.ms { border: 1px solid #444; border-radius: 3px; background: #1a1a1a; overflow: hidden; }
|
||||
.ms-btn { width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
.ms-btn { box-sizing: border-box; width: 100%; display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||
padding: 9px 10px; background: #1d1d1d; border: 0; color: #eee; font-size: .9rem; cursor: pointer; text-align: left; }
|
||||
.ms-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-btn .ms-c { color: #00e5ff; font-weight: 700; font-size: .8rem; white-space: nowrap; }
|
||||
.ms-arrow { color: #777; }
|
||||
.ms-panel { border-top: 1px solid #333; background: #161616; }
|
||||
@@ -148,6 +163,7 @@
|
||||
.ai-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ck { width: 16px; height: 16px; border: 1px solid #555; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: .72rem; color: #002; background: transparent; flex: none; }
|
||||
.ms-row.on .ck { background: #00e5ff; border-color: #00e5ff; }
|
||||
.ms-row:focus-visible { outline: 2px solid #00e5ff; outline-offset: -2px; }
|
||||
.ms-empty { padding: 12px; color: #777; font-size: .82rem; }
|
||||
.ms-foot { padding: 7px 12px; border-top: 1px solid #333; font-size: .74rem; color: #777; display: flex; justify-content: space-between; align-items: center; }
|
||||
.ms-foot button { background: none; border: 0; color: #00e5ff; font-size: .78rem; cursor: pointer; padding: 0; }
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
import { nextPrimary } from './woodPrimary';
|
||||
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
@@ -91,7 +92,6 @@
|
||||
let edgeTypeId: number | '' = '';
|
||||
let figureIds: number[] = [];
|
||||
let gradeId: number | '' = '';
|
||||
let thicknessId: number | '' = '';
|
||||
let moistureId: number | '' = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
@@ -307,7 +307,6 @@
|
||||
edgeTypeId = s.edge_type_term_id ?? '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeId = s.grade_term_id ?? '';
|
||||
thicknessId = s.thickness_term_id ?? '';
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
@@ -318,7 +317,8 @@
|
||||
price = s.price != null ? String(s.price) : '';
|
||||
priceSource = s.price_source ?? 'recommendation';
|
||||
sku = s.sku ?? '';
|
||||
primaryCatId = woodCatIds[0] ?? '';
|
||||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
||||
// its weight behind the value the owner kept).
|
||||
aiRecommendations = [];
|
||||
@@ -328,8 +328,30 @@
|
||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
||||
}
|
||||
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
// AI-marked option views (badge on rows after Call 1 / stored confidences).
|
||||
$: speciesOptsView = speciesOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
||||
parent: g.parent,
|
||||
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
||||
}));
|
||||
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
||||
$: woodLeafById = new Map(
|
||||
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
||||
);
|
||||
$: primaryOpts = woodCatIds
|
||||
.map((id) => woodLeafById.get(id))
|
||||
.filter((o): o is Opt => !!o)
|
||||
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
||||
|
||||
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
||||
// measured thickness against the live synced Woo bands. There is no owner
|
||||
// control for it — this is the read-only display of the band the server assigned.
|
||||
$: thicknessBandName =
|
||||
thicknessOpts.find((o) => o.id === (slab?.thickness_term_id ?? -1))?.name ?? '';
|
||||
|
||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
||||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||
async function commitReview(): Promise<void> {
|
||||
if (!slab) return;
|
||||
@@ -343,7 +365,6 @@
|
||||
edge_type_term_id: edgeTypeId === '' ? null : Number(edgeTypeId),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeId === '' ? null : Number(gradeId),
|
||||
thickness_term_id: thicknessId === '' ? null : Number(thicknessId),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
@@ -419,9 +440,10 @@
|
||||
}
|
||||
|
||||
// #89: the lock is available once the pattern inputs exist (species + a wood
|
||||
// category). The button is the unmistakable "Call 1 → review → lock → copy" step.
|
||||
// category). Prefer the live editor model so the button enables before the
|
||||
// next commit finishes writing the slab row.
|
||||
let canLock = false;
|
||||
$: canLock = !!slab?.species_id && (slab?.wood_category_ids?.length ?? 0) > 0;
|
||||
$: canLock = speciesId !== '' && woodCatIds.length > 0 && primaryCatId !== '';
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
@@ -434,7 +456,7 @@
|
||||
if (s.species_id) { speciesId = s.species_id; recs.push(s.species_id); }
|
||||
if (s.wood_category_ids) {
|
||||
woodCatIds = [...s.wood_category_ids];
|
||||
if (!primaryCatId) primaryCatId = woodCatIds[0];
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
recs.push(...s.wood_category_ids);
|
||||
}
|
||||
if (s.edge_type_term_id) edgeTypeId = s.edge_type_term_id;
|
||||
@@ -485,6 +507,18 @@
|
||||
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||||
}
|
||||
|
||||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||||
function toggleWoodCat(id: number) {
|
||||
woodCatIds = toggleMulti(woodCatIds, id);
|
||||
primaryCatId = nextPrimary(woodCatIds, primaryCatId);
|
||||
}
|
||||
|
||||
function pickPrimary(id: number) {
|
||||
if (!woodCatIds.includes(id)) return;
|
||||
primaryCatId = id;
|
||||
void commitReview();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [s, t, set] = await Promise.all([getSlab(slabId), getTaxonomy(), getSettings()]);
|
||||
@@ -590,21 +624,49 @@
|
||||
<label class="lbl" for="species">Species <span class="ai">AI assist</span></label>
|
||||
<select id="species" class="sel" data-testid="species" bind:value={speciesId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each speciesOpts as o (o.id)}
|
||||
{#each speciesOptsView as o (o.id)}
|
||||
<option value={o.id} data-ai={o.ai ? 'true' : ''}>{o.name}{o.ai ? ' (AI)' : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Wood categories (multi, searchable list, AI-assist) -->
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroups}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={(id) => (woodCatIds = toggleMulti(woodCatIds, id))}
|
||||
/>
|
||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||
<div class="field" data-testid="wood-cat-field">
|
||||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Wood categories"
|
||||
groups={woodCatGroupsView}
|
||||
selected={woodCatIds}
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={toggleWoodCat}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
{#if primaryOpts.length}
|
||||
<div class="primary-set" data-testid="primary-wood-set" role="radiogroup" aria-label="Primary wood category">
|
||||
<div class="ps-lbl">Primary wood category <span class="ps-req">drives title</span></div>
|
||||
{#each primaryOpts as o (o.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="ps-opt"
|
||||
class:on={primaryCatId === o.id}
|
||||
role="radio"
|
||||
aria-checked={primaryCatId === o.id}
|
||||
data-testid={`primary-wood-${o.id}`}
|
||||
on:click={() => pickPrimary(o.id)}
|
||||
>
|
||||
<span class="ps-radio" aria-hidden="true"></span>
|
||||
<span class="ps-name">{o.name}</span>
|
||||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<p class="ps-note">
|
||||
Primary selects the title & short-description pattern (METADATA-85).
|
||||
Secondary categories feed the description. Default = first selected.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edge type (single, AI-assist) -->
|
||||
<div class="field">
|
||||
@@ -622,11 +684,12 @@
|
||||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="Figure"
|
||||
options={figureOpts}
|
||||
options={figureOptsView}
|
||||
selected={figureIds}
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -649,6 +712,7 @@
|
||||
placeholder="Choose fig-* tags"
|
||||
testid="fig-tag"
|
||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -657,20 +721,20 @@
|
||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
||||
<MultiSelect
|
||||
label="feat-* tags"
|
||||
options={featTagOpts}
|
||||
options={featTagOptsView}
|
||||
selected={featTagIds}
|
||||
placeholder="Choose feat-* tags"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="lbl" for="thickness">Thickness band</label>
|
||||
<select id="thickness" class="sel" data-testid="thickness-band" bind:value={thicknessId} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each thicknessOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
<label class="lbl">Thickness band <span class="sug">set from the measurement</span></label>
|
||||
<p class="ro" data-testid="thickness-band">
|
||||
{#if thicknessBandName}{thicknessBandName}{:else}— not yet resolved{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
@@ -788,18 +852,41 @@
|
||||
.ai-note { color: #aaa; font-size: .78rem; margin: 6px 0 0; }
|
||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
||||
.inp, .sel { width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||||
and a border pokes out of its container by that padding+border (20px here) —
|
||||
which becomes real page overflow whenever the column sits at the viewport edge
|
||||
(measured: 375px mobile scrolled 20px). Border-box keeps the box inside. */
|
||||
.inp, .sel { box-sizing: border-box; width: 100%; padding: 9px; background: #1d1d1d; border: 1px solid #444; border-radius: 3px; color: #eee; font-size: .9rem; text-align: left; }
|
||||
.sel { appearance: none; background-image: linear-gradient(45deg,transparent 50%,#777 50%),linear-gradient(135deg,#777 50%,transparent 50%); background-position: calc(100% - 16px) 55%,calc(100% - 11px) 55%; background-size: 5px 5px; background-repeat: no-repeat; }
|
||||
.ta { resize: vertical; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
/* Read-only derived value (thickness band): reads as data, not as a control. */
|
||||
.ro { margin: 0; padding: 9px; background: #161616; border: 1px solid #2f2f2f; border-radius: 3px;
|
||||
color: #cfcfcf; font-size: .9rem; font-variant-numeric: tabular-nums; }
|
||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a4a2a; border-radius: 3px; background: #12210f; }
|
||||
/* Lock chrome matches agreed #90 wireframe: muted dark green, not jarring neon. */
|
||||
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a3a2a; border-radius: 3px; background: #141a14; }
|
||||
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
|
||||
.lock-btn { width: 100%; padding: 11px; border: 0; border-radius: 3px; background: #37d05f; color: #002; font-weight: 700; font-size: .9rem; }
|
||||
.lock-btn:disabled { background: #2a2a2a; color: #777; }
|
||||
.lock-btn { box-sizing: border-box; width: 100%; padding: 11px; border: 1px solid #2a3a2a; border-radius: 3px; background: #1e3a28; color: #9fd3a8; font-weight: 700; font-size: .9rem; }
|
||||
.lock-btn:disabled { background: #2a2a2a; color: #777; border-color: #2a2a2a; }
|
||||
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
|
||||
|
||||
/* Primary wood category — inline under wood MultiSelect (REVIEW-15 / #90 wireframe). */
|
||||
.primary-set { margin-top: 8px; padding: 10px 12px; border: 1px solid #333; border-radius: 3px; background: #141414; }
|
||||
.primary-set .ps-lbl { font-size: .74rem; color: #9a9a9a; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.primary-set .ps-req { font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #2a2a2a; color: #888; }
|
||||
.ps-opt { box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 9px; padding: 8px 6px; margin: 0; border: 0; border-radius: 3px; background: transparent; color: #ddd; font-size: .88rem; cursor: pointer; text-align: left; }
|
||||
.ps-opt:hover { background: #1c1c1c; }
|
||||
.ps-opt.on { background: #12262b; color: #fff; }
|
||||
.ps-opt:focus-visible { outline: 2px solid #00e5ff; outline-offset: 1px; }
|
||||
.ps-radio { width: 14px; height: 14px; border: 2px solid #555; border-radius: 50%; flex: none; position: relative; }
|
||||
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
||||
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
||||
.ps-name { flex: 1; }
|
||||
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
||||
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { nextPrimary, selectedSummary } from './woodPrimary';
|
||||
|
||||
test('nextPrimary: empty selection clears primary', () => {
|
||||
assert.equal(nextPrimary([], 21), '');
|
||||
assert.equal(nextPrimary([], ''), '');
|
||||
});
|
||||
|
||||
test('nextPrimary: default is first selected (REVIEW-15)', () => {
|
||||
assert.equal(nextPrimary([21, 22], ''), 21);
|
||||
assert.equal(nextPrimary([21, 22], null), 21);
|
||||
});
|
||||
|
||||
test('nextPrimary: keeps current when still selected', () => {
|
||||
assert.equal(nextPrimary([21, 22], 22), 22);
|
||||
assert.equal(nextPrimary([22], 22), 22);
|
||||
});
|
||||
|
||||
test('nextPrimary: deselecting primary auto-promotes first remaining', () => {
|
||||
assert.equal(nextPrimary([22, 23], 21), 22);
|
||||
assert.equal(nextPrimary([23], 21), 23);
|
||||
});
|
||||
|
||||
test('selectedSummary: placeholder when empty', () => {
|
||||
assert.equal(selectedSummary([], new Map(), 'Choose…'), 'Choose…');
|
||||
});
|
||||
|
||||
test('selectedSummary: shows names, then +N overflow', () => {
|
||||
const m = new Map([
|
||||
[1, 'Live Edge'],
|
||||
[2, 'Book Match'],
|
||||
[3, 'Cookie'],
|
||||
]);
|
||||
assert.equal(selectedSummary([1], m, 'x'), 'Live Edge');
|
||||
assert.equal(selectedSummary([1, 2], m, 'x'), 'Live Edge, Book Match');
|
||||
assert.equal(selectedSummary([1, 2, 3], m, 'x'), 'Live Edge, Book Match +1');
|
||||
});
|
||||
|
||||
test('selectedSummary: falls back to count when names unknown', () => {
|
||||
assert.equal(selectedSummary([9, 10], new Map(), 'x'), '2 selected');
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* REVIEW-15: owner declares one primary wood category among the selected set.
|
||||
* Default primary = first selected. Deselecting the current primary promotes
|
||||
* the first remaining selected id. Empty selection clears primary.
|
||||
*/
|
||||
export function nextPrimary(
|
||||
selected: number[],
|
||||
current: number | '' | null | undefined,
|
||||
): number | '' {
|
||||
if (!selected.length) return '';
|
||||
const cur = typeof current === 'number' ? current : '';
|
||||
if (cur !== '' && selected.includes(cur)) return cur;
|
||||
return selected[0];
|
||||
}
|
||||
|
||||
/** Closed MultiSelect summary: names when known, else a count. */
|
||||
export function selectedSummary(
|
||||
selected: number[],
|
||||
nameById: Map<number, string>,
|
||||
placeholder: string,
|
||||
maxNames = 2,
|
||||
): string {
|
||||
if (!selected.length) return placeholder;
|
||||
const names = selected
|
||||
.map((id) => nameById.get(id))
|
||||
.filter((n): n is string => !!n && n.length > 0);
|
||||
if (!names.length) {
|
||||
return selected.length === 1 ? '1 selected' : `${selected.length} selected`;
|
||||
}
|
||||
if (names.length <= maxNames) return names.join(', ');
|
||||
return `${names.slice(0, maxNames).join(', ')} +${names.length - maxNames}`;
|
||||
}
|
||||
Reference in New Issue
Block a user