feat(review): View prompt + Call 2 declared attrs, Create-desc gate
Shared assemble_call2 for GET .../call2/prompt and POST call2 (seed + DECLARED ATTRIBUTES + dimension fact + brand/geo; photo slots without base64). Create desc disabled until content LLM + title/short + species + wood cats/primary + edge + ≥1 figure + grade + moisture + price; show missing. figure_term_ids required for ready. Moisture defaults to Kiln dried (kd). Pin snl / liveedge in wood-cat UI. Ready-on-change via MultiSelect onCommit.
This commit is contained in:
@@ -379,6 +379,26 @@ export function inferContent(slabId: string): Promise<Call2Result> {
|
||||
}).then((r) => parse<Call2Result>(r));
|
||||
}
|
||||
|
||||
/** View prompt: assembled Call 2 text + photo slots (no base64, no model call). */
|
||||
export interface Call2PromptPhoto {
|
||||
seq: number;
|
||||
id: string;
|
||||
bytes: number;
|
||||
long_edge_px: number | null;
|
||||
}
|
||||
|
||||
export interface Call2Prompt {
|
||||
text: string;
|
||||
photos: Call2PromptPhoto[];
|
||||
prompt_revision_id: number | null;
|
||||
}
|
||||
|
||||
export function getCall2Prompt(slabId: string): Promise<Call2Prompt> {
|
||||
return fetch(`/api/v1/slabs/${encodeURIComponent(slabId)}/inference/call2/prompt`).then((r) =>
|
||||
parse<Call2Prompt>(r),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Taxonomy (METADATA-5/15/30; only legal picker options)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent,
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferContent, getCall2Prompt,
|
||||
listPhotos, persistLineup, photoUrl, ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView, type PhotoRow,
|
||||
type Call2Prompt,
|
||||
} from '../../api/slabs';
|
||||
import type { CalibratedCapture } from '../flow';
|
||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||
@@ -15,6 +16,9 @@
|
||||
type LineupItem,
|
||||
} from './lineup';
|
||||
import { nextPrimary } from './woodPrimary';
|
||||
import { missingCreateDescLabels } from './createDescGate';
|
||||
import { orderWoodCatGroups } from './woodCatOrder';
|
||||
import { applyMoistureDefault } from './moistureDefault';
|
||||
|
||||
// Props handed off by App (one-column flow, UX.md §1).
|
||||
export let slabId: string;
|
||||
@@ -29,7 +33,7 @@
|
||||
let loading = true;
|
||||
|
||||
// Picker option types (id + name + whether the AI recommended it).
|
||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number }
|
||||
interface Opt { id: number; name: string; ai: boolean; lowIn?: number; slug?: string }
|
||||
const byId = <T extends { id: number }>(arr: T[]) => {
|
||||
const m = new Map<number, T>();
|
||||
for (const x of arr) m.set(x.id, x);
|
||||
@@ -70,13 +74,18 @@
|
||||
// assigned). Each direct child of `wcat` that has its own children becomes a parent
|
||||
// group with those children as leaves; a child with no children is a leaf under the
|
||||
// (implicit) top group. This keeps the full tree visible with non-selectable parents.
|
||||
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||
// UI order only: pin Slabs & Lumber (snl) parent first; Live edge wood (liveedge) leaf first.
|
||||
const rawGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||
const kids = cats.filter((c) => c.parent_id === parent.id);
|
||||
const leaves: Opt[] = kids.length
|
||||
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false }))
|
||||
: [{ id: parent.id, name: decodeName(parent.name), ai: false }];
|
||||
return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves };
|
||||
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false, slug: c.slug }))
|
||||
: [{ id: parent.id, name: decodeName(parent.name), ai: false, slug: parent.slug }];
|
||||
return {
|
||||
parent: { id: parent.id, name: decodeName(parent.name), slug: parent.slug },
|
||||
leaves,
|
||||
};
|
||||
});
|
||||
woodCatGroups = orderWoodCatGroups(rawGroups);
|
||||
edgeOpts = termsOf('edge type');
|
||||
figureOpts = termsOf('figure');
|
||||
gradeOpts = termsOf('grade');
|
||||
@@ -104,7 +113,7 @@
|
||||
let gradeSel: string = '';
|
||||
let woodCatIds: number[] = [];
|
||||
let figureIds: number[] = [];
|
||||
let moistureId: number | '' = '';
|
||||
let moistureSel: string = '';
|
||||
let figTagIds: number[] = [];
|
||||
let featTagIds: number[] = [];
|
||||
let primaryCatId: number | '' = '';
|
||||
@@ -135,8 +144,21 @@
|
||||
// Prefer slab.species_id; fall back to Capture handoff if createSlab omitted it.
|
||||
$: resolvedSpeciesId = effectiveSpeciesId(slab?.species_id, capture?.speciesId ?? null);
|
||||
$: canCreateTitle = canCreateTitleFromSpecies(resolvedSpeciesId, woodCatIds.length);
|
||||
$: canCreateDesc =
|
||||
canGenerateContent && title.trim() !== '' && shortDescription.trim() !== '';
|
||||
// Create-desc gate: recompute on every field change (no blur required). Description not required.
|
||||
$: createDescMissing = missingCreateDescLabels({
|
||||
contentLlmEnabled: canGenerateContent,
|
||||
title,
|
||||
shortDescription,
|
||||
speciesId: resolvedSpeciesId,
|
||||
woodCategoryIds: woodCatIds,
|
||||
primaryCatId,
|
||||
edgeTermId: edgeSel,
|
||||
figureTermIds: figureIds,
|
||||
gradeTermId: gradeSel,
|
||||
moistureTermId: moistureSel,
|
||||
price,
|
||||
});
|
||||
$: canCreateDesc = createDescMissing.length === 0;
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// result if a newer commit has since started. Without this, two concurrent
|
||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||
@@ -323,7 +345,15 @@
|
||||
edgeSel = s.edge_type_term_id != null ? String(s.edge_type_term_id) : '';
|
||||
figureIds = [...(s.figure_term_ids ?? [])];
|
||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||
moistureId = s.moisture_term_id ?? '';
|
||||
// Moisture default Kiln dried (slug kd) when empty — persist via commitReview after load.
|
||||
{
|
||||
const moistAttr = t?.attributes.find((a) => a.name.toLowerCase() === 'moisture');
|
||||
const moistTerms = (moistAttr?.terms ?? []).map((x) => ({
|
||||
id: x.id, name: x.name, slug: x.slug,
|
||||
}));
|
||||
const applied = applyMoistureDefault(s.moisture_term_id, moistTerms);
|
||||
moistureSel = applied === '' ? '' : String(applied);
|
||||
}
|
||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||
title = s.title ?? '';
|
||||
shortDescription = s.short_description ?? '';
|
||||
@@ -396,7 +426,7 @@
|
||||
edge_type_term_id: edgeSel === '' ? null : Number(edgeSel),
|
||||
figure_term_ids: figureIds,
|
||||
grade_term_id: gradeSel === '' ? null : Number(gradeSel),
|
||||
moisture_term_id: moistureId === '' ? null : Number(moistureId),
|
||||
moisture_term_id: moistureSel === '' ? null : Number(moistureSel),
|
||||
fig_tag_ids: figTagIds,
|
||||
feat_tag_ids: featTagIds,
|
||||
title: title || null,
|
||||
@@ -529,6 +559,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
// View prompt: read-only assembled Call 2 text + photo slots (shared server assembler).
|
||||
let viewPromptOpen = false;
|
||||
let viewPromptBusy = false;
|
||||
let viewPromptError = '';
|
||||
let viewPrompt: Call2Prompt | null = null;
|
||||
let viewPromptCopied = false;
|
||||
|
||||
async function loadViewPrompt() {
|
||||
viewPromptBusy = true;
|
||||
viewPromptError = '';
|
||||
viewPromptCopied = false;
|
||||
try {
|
||||
// Persist current Review fields first so the assembled text matches what Create desc would send.
|
||||
await commitReview();
|
||||
viewPrompt = await getCall2Prompt(slabId);
|
||||
viewPromptOpen = true;
|
||||
} catch (e) {
|
||||
viewPromptError = e instanceof ApiError ? e.message : 'Could not load the prompt.';
|
||||
viewPromptOpen = true;
|
||||
} finally {
|
||||
viewPromptBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyViewPrompt() {
|
||||
if (!viewPrompt?.text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(viewPrompt.text);
|
||||
viewPromptCopied = true;
|
||||
} catch {
|
||||
viewPromptError = 'Clipboard unavailable — select the text and copy manually.';
|
||||
}
|
||||
}
|
||||
|
||||
const missingCount = () => (review ? review.missing.length : null);
|
||||
const ready = () => review?.ready === true;
|
||||
// The button's enabled state must derive from the SAME reactive `review` object
|
||||
@@ -554,9 +618,11 @@
|
||||
await tick();
|
||||
if (edgeEl && edgeEl.value !== edgeSel) edgeEl.value = edgeSel;
|
||||
if (gradeEl && gradeEl.value !== gradeSel) gradeEl.value = gradeSel;
|
||||
if (moistureEl && moistureEl.value !== moistureSel) moistureEl.value = moistureSel;
|
||||
}
|
||||
let edgeEl: HTMLSelectElement | undefined;
|
||||
let gradeEl: HTMLSelectElement | undefined;
|
||||
let moistureEl: HTMLSelectElement | undefined;
|
||||
/** Wood categories: toggle leaf + keep primary valid (REVIEW-15 / wireframe #90). */
|
||||
function toggleWoodCat(id: number) {
|
||||
woodCatIds = toggleMulti(woodCatIds, id);
|
||||
@@ -675,6 +741,7 @@
|
||||
placeholder="Choose wood categories"
|
||||
testid="wood-cat"
|
||||
onToggle={toggleWoodCat}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
{#if primaryOpts.length}
|
||||
@@ -723,6 +790,7 @@
|
||||
placeholder="Choose figure"
|
||||
testid="figure"
|
||||
onToggle={(id) => (figureIds = toggleMulti(figureIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
@@ -746,6 +814,7 @@
|
||||
placeholder="Choose features"
|
||||
testid="feat-tag"
|
||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||
onCommit={commitReview}
|
||||
on:commit={commitReview}
|
||||
/>
|
||||
</div>
|
||||
@@ -754,7 +823,7 @@
|
||||
<div class="field">
|
||||
|
||||
<label class="lbl" for="moisture">Moisture</label>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:value={moistureId} on:change={commitReview}>
|
||||
<select id="moisture" class="sel" data-testid="moisture" bind:this={moistureEl} bind:value={moistureSel} on:change={commitReview}>
|
||||
<option value="">— select —</option>
|
||||
{#each moistureOpts as o (o.id)}<option value={o.id}>{o.name}</option>{/each}
|
||||
</select>
|
||||
@@ -784,11 +853,59 @@
|
||||
<div class="field">
|
||||
<label class="lbl" for="desc">Description</label>
|
||||
<textarea id="desc" class="inp ta" data-testid="desc" rows="3" bind:value={description} on:blur={commitDescription}></textarea>
|
||||
{#if canCreateDesc}
|
||||
<div class="gen">
|
||||
<button type="button" class="gen-btn" data-testid="create-desc" on:click={generateContent} disabled={genBusy}>
|
||||
{genBusy ? 'Writing…' : 'Create desc'}
|
||||
</button>
|
||||
<div class="gen">
|
||||
<button
|
||||
type="button"
|
||||
class="gen-btn"
|
||||
data-testid="create-desc"
|
||||
on:click={generateContent}
|
||||
disabled={!canCreateDesc || genBusy}
|
||||
title={canCreateDesc ? 'Generate long description (Call 2)' : `Missing: ${createDescMissing.join(', ')}`}
|
||||
>
|
||||
{genBusy ? 'Writing…' : 'Create desc'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="view-prompt-btn"
|
||||
data-testid="view-prompt"
|
||||
on:click={() => { if (viewPromptOpen) viewPromptOpen = false; else void loadViewPrompt(); }}
|
||||
disabled={viewPromptBusy}
|
||||
>
|
||||
{viewPromptBusy ? 'Loading…' : (viewPromptOpen ? 'Hide prompt' : 'View prompt')}
|
||||
</button>
|
||||
</div>
|
||||
{#if !canCreateDesc}
|
||||
<p class="nudge" data-testid="create-desc-missing">
|
||||
Create desc needs: {createDescMissing.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
{#if viewPromptOpen}
|
||||
<div class="prompt-panel" data-testid="view-prompt-panel">
|
||||
<div class="prompt-head">
|
||||
<span class="prompt-title">Call 2 prompt (read-only)</span>
|
||||
<button type="button" class="mini" data-testid="copy-prompt" on:click={copyViewPrompt} disabled={!viewPrompt?.text}>
|
||||
{viewPromptCopied ? 'Copied' : 'Copy text'}
|
||||
</button>
|
||||
<button type="button" class="mini" data-testid="close-prompt" on:click={() => (viewPromptOpen = false)}>Close</button>
|
||||
</div>
|
||||
{#if viewPromptError}<p class="nudge" role="alert">{viewPromptError}</p>{/if}
|
||||
{#if viewPrompt}
|
||||
<pre class="prompt-text" data-testid="prompt-text">{viewPrompt.text}</pre>
|
||||
<div class="prompt-photos" data-testid="prompt-photos">
|
||||
<div class="prompt-photos-lbl">Photo slots (no base64) — up to 8 processed PNGs</div>
|
||||
{#if viewPrompt.photos.length === 0}
|
||||
<p class="nudge">No processed photos on this slab.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each viewPrompt.photos as ph (ph.id)}
|
||||
<li data-testid="prompt-photo-slot">
|
||||
seq={ph.seq} · id={ph.id} · {ph.bytes} bytes · long-edge {ph.long_edge_px ?? '?'}px
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if genError}<p class="nudge" role="alert">{genError}</p>{/if}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
canCreateDesc,
|
||||
missingCreateDesc,
|
||||
missingCreateDescLabels,
|
||||
} from './createDescGate';
|
||||
|
||||
const full = {
|
||||
contentLlmEnabled: true,
|
||||
title: 'Figured Walnut Live Edge Slab',
|
||||
shortDescription: 'Figured Walnut Live Edge Slab, 9ft …',
|
||||
speciesId: 11,
|
||||
woodCategoryIds: [21],
|
||||
primaryCatId: 21,
|
||||
edgeTermId: '321',
|
||||
figureTermIds: [328],
|
||||
gradeTermId: 342,
|
||||
moistureTermId: '326',
|
||||
price: '184.00',
|
||||
};
|
||||
|
||||
test('Create desc gate: full set passes; does not require description', () => {
|
||||
assert.equal(canCreateDesc(full), true);
|
||||
assert.deepEqual(missingCreateDesc(full), []);
|
||||
});
|
||||
|
||||
test('Create desc gate: lists each missing field; no description key', () => {
|
||||
const miss = missingCreateDesc({
|
||||
...full,
|
||||
contentLlmEnabled: false,
|
||||
title: '',
|
||||
shortDescription: ' ',
|
||||
speciesId: null,
|
||||
woodCategoryIds: [],
|
||||
primaryCatId: '',
|
||||
edgeTermId: '',
|
||||
figureTermIds: [],
|
||||
gradeTermId: '',
|
||||
moistureTermId: '',
|
||||
price: '',
|
||||
});
|
||||
assert.deepEqual(miss, [
|
||||
'content_llm',
|
||||
'title',
|
||||
'short_description',
|
||||
'species',
|
||||
'wood_categories',
|
||||
'primary',
|
||||
'edge',
|
||||
'figure',
|
||||
'grade',
|
||||
'moisture',
|
||||
'price',
|
||||
]);
|
||||
assert.equal(miss.includes('description'), false);
|
||||
const labels = missingCreateDescLabels({
|
||||
...full,
|
||||
figureTermIds: [],
|
||||
price: '',
|
||||
});
|
||||
assert.ok(labels.includes('≥1 figure'));
|
||||
assert.ok(labels.includes('price'));
|
||||
});
|
||||
|
||||
test('Create desc gate: recomputes on field change (reactive-ready)', () => {
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [] }), false);
|
||||
assert.equal(canCreateDesc({ ...full, figureTermIds: [1] }), true);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: '' }), false);
|
||||
assert.equal(canCreateDesc({ ...full, moistureTermId: 326 }), true);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Create-desc gate (Call 2): disable Create desc unless these are present.
|
||||
* Does NOT require description (that is what Create desc writes).
|
||||
* Price included for parity with publish-ready.
|
||||
*/
|
||||
|
||||
export type CreateDescInput = {
|
||||
contentLlmEnabled: boolean;
|
||||
title: string;
|
||||
shortDescription: string;
|
||||
speciesId: number | null | undefined;
|
||||
woodCategoryIds: number[];
|
||||
primaryCatId: number | '' | null | undefined;
|
||||
edgeTermId: number | string | '' | null | undefined;
|
||||
figureTermIds: number[];
|
||||
gradeTermId: number | string | '' | null | undefined;
|
||||
moistureTermId: number | string | '' | null | undefined;
|
||||
price: string | number | null | undefined;
|
||||
};
|
||||
|
||||
/** Human labels shown under Create desc when the gate fails. */
|
||||
export const CREATE_DESC_LABELS: Record<string, string> = {
|
||||
content_llm: 'content LLM on',
|
||||
title: 'title',
|
||||
short_description: 'short description',
|
||||
species: 'species',
|
||||
wood_categories: '≥1 wood category',
|
||||
primary: 'primary wood category',
|
||||
edge: 'edge type',
|
||||
figure: '≥1 figure',
|
||||
grade: 'grade',
|
||||
moisture: 'moisture',
|
||||
price: 'price',
|
||||
};
|
||||
|
||||
function presentId(v: number | string | '' | null | undefined): boolean {
|
||||
if (v === null || v === undefined || v === '') return false;
|
||||
if (typeof v === 'number') return Number.isFinite(v);
|
||||
return String(v).trim() !== '';
|
||||
}
|
||||
|
||||
function presentPrice(v: string | number | null | undefined): boolean {
|
||||
if (v === null || v === undefined || v === '') return false;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n);
|
||||
}
|
||||
|
||||
/** Return missing field keys (stable order). Empty ⇒ Create desc enabled. */
|
||||
export function missingCreateDesc(input: CreateDescInput): string[] {
|
||||
const missing: string[] = [];
|
||||
if (!input.contentLlmEnabled) missing.push('content_llm');
|
||||
if (!String(input.title ?? '').trim()) missing.push('title');
|
||||
if (!String(input.shortDescription ?? '').trim()) missing.push('short_description');
|
||||
if (input.speciesId == null || !Number.isFinite(Number(input.speciesId))) missing.push('species');
|
||||
if (!input.woodCategoryIds?.length) missing.push('wood_categories');
|
||||
if (!presentId(input.primaryCatId)) missing.push('primary');
|
||||
if (!presentId(input.edgeTermId)) missing.push('edge');
|
||||
if (!input.figureTermIds?.length) missing.push('figure');
|
||||
if (!presentId(input.gradeTermId)) missing.push('grade');
|
||||
if (!presentId(input.moistureTermId)) missing.push('moisture');
|
||||
if (!presentPrice(input.price)) missing.push('price');
|
||||
return missing;
|
||||
}
|
||||
|
||||
export function canCreateDesc(input: CreateDescInput): boolean {
|
||||
return missingCreateDesc(input).length === 0;
|
||||
}
|
||||
|
||||
export function missingCreateDescLabels(input: CreateDescInput): string[] {
|
||||
return missingCreateDesc(input).map((k) => CREATE_DESC_LABELS[k] ?? k);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { applyMoistureDefault, resolveKilnDriedId } from './moistureDefault';
|
||||
|
||||
const terms = [
|
||||
{ id: 1, name: 'Air dried', slug: 'ad' },
|
||||
{ id: 326, name: 'Kiln dried', slug: 'kd' },
|
||||
];
|
||||
|
||||
test('moisture default: slug kd wins; name match fallback; scoped empty stays empty', () => {
|
||||
assert.equal(resolveKilnDriedId(terms), 326);
|
||||
assert.equal(resolveKilnDriedId([{ id: 9, name: 'Kiln dried', slug: 'kiln-dried' }]), 9);
|
||||
assert.equal(resolveKilnDriedId([{ id: 1, name: 'Air dried', slug: 'ad' }]), null);
|
||||
assert.equal(applyMoistureDefault('', terms), 326);
|
||||
assert.equal(applyMoistureDefault(null, terms), 326);
|
||||
assert.equal(applyMoistureDefault(1, terms), 1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Moisture default: Kiln dried (slug `kd` / name match). METADATA-15.
|
||||
* Resolve by synced taxonomy — never a hardcoded Woo term id.
|
||||
*/
|
||||
|
||||
export type MoistureTerm = { id: number; name: string; slug: string };
|
||||
|
||||
export function resolveKilnDriedId(terms: MoistureTerm[]): number | null {
|
||||
const bySlug = terms.find((t) => t.slug.toLowerCase() === 'kd');
|
||||
if (bySlug) return bySlug.id;
|
||||
const byName = terms.find((t) => {
|
||||
const n = t.name.trim().toLowerCase();
|
||||
return n === 'kiln dried' || n === 'kiln-dried';
|
||||
});
|
||||
return byName ? byName.id : null;
|
||||
}
|
||||
|
||||
/** If moisture is empty, return kiln-dried id; else keep current. */
|
||||
export function applyMoistureDefault(
|
||||
current: number | '' | null | undefined,
|
||||
terms: MoistureTerm[],
|
||||
): number | '' {
|
||||
if (current !== null && current !== undefined && current !== '') {
|
||||
return typeof current === 'number' ? current : Number(current) || '';
|
||||
}
|
||||
const kd = resolveKilnDriedId(terms);
|
||||
return kd == null ? '' : kd;
|
||||
}
|
||||
@@ -32,3 +32,15 @@ test('Tag family scope uses hyphenated slug prefix', () => {
|
||||
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
||||
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
||||
});
|
||||
|
||||
test('Review UI: View prompt + Create desc gate wiring', () => {
|
||||
assert.match(reviewSrc, /data-testid="view-prompt"/);
|
||||
assert.match(reviewSrc, /getCall2Prompt/);
|
||||
assert.match(reviewSrc, /createDescMissing/);
|
||||
assert.match(reviewSrc, /missingCreateDescLabels/);
|
||||
assert.match(reviewSrc, /orderWoodCatGroups/);
|
||||
assert.match(reviewSrc, /applyMoistureDefault/);
|
||||
assert.match(reviewSrc, /onCommit=\{commitReview\}/);
|
||||
// Description is not part of the Create-desc gate label list
|
||||
assert.equal(reviewSrc.includes("Create desc needs: description"), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { orderWoodCatGroups, PIN_LEAF_SLUG, PIN_PARENT_SLUG } from './woodCatOrder';
|
||||
|
||||
test('wood cat UI: snl parent first; liveedge leaf first under its parent', () => {
|
||||
const groups = orderWoodCatGroups([
|
||||
{
|
||||
parent: { id: 2, name: 'Turning', slug: 'turn' },
|
||||
leaves: [
|
||||
{ id: 20, name: 'Bowl blanks', slug: 'bowl' },
|
||||
{ id: 21, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
{
|
||||
parent: { id: 1, name: 'Slabs & Lumber', slug: PIN_PARENT_SLUG },
|
||||
leaves: [
|
||||
{ id: 11, name: 'Dimensional', slug: 'dim' },
|
||||
{ id: 10, name: 'Live edge wood', slug: PIN_LEAF_SLUG },
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(groups[0].parent.slug, PIN_PARENT_SLUG);
|
||||
assert.equal(groups[0].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
assert.equal(groups[0].leaves[1].slug, 'dim');
|
||||
assert.equal(groups[1].parent.slug, 'turn');
|
||||
// liveedge also pinned first under Turning when present
|
||||
assert.equal(groups[1].leaves[0].slug, PIN_LEAF_SLUG);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* UI-only wood-category display order on Review.
|
||||
* Pin parent Slabs & Lumber (`snl`) first; pin leaf Live edge wood (`liveedge`)
|
||||
* first under its parent. Never changes assignment ids — sort only.
|
||||
*/
|
||||
|
||||
export const PIN_PARENT_SLUG = 'snl';
|
||||
export const PIN_LEAF_SLUG = 'liveedge';
|
||||
|
||||
export type OrderedLeaf = { id: number; name: string; slug?: string; ai?: boolean };
|
||||
export type OrderedGroup = {
|
||||
parent: { id: number; name: string; slug?: string };
|
||||
leaves: OrderedLeaf[];
|
||||
};
|
||||
|
||||
function pinFirst<T>(items: T[], isPinned: (item: T) => boolean): T[] {
|
||||
const pinned: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const it of items) {
|
||||
if (isPinned(it)) pinned.push(it);
|
||||
else rest.push(it);
|
||||
}
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
/** Sort groups + leaves for the wood-category MultiSelect. */
|
||||
export function orderWoodCatGroups<T extends OrderedGroup>(groups: T[]): T[] {
|
||||
const withLeaves = groups.map((g) => ({
|
||||
...g,
|
||||
leaves: pinFirst(g.leaves, (l) => (l.slug ?? '') === PIN_LEAF_SLUG),
|
||||
})) as T[];
|
||||
return pinFirst(withLeaves, (g) => (g.parent.slug ?? '') === PIN_PARENT_SLUG);
|
||||
}
|
||||
Reference in New Issue
Block a user