From df00e56d6efe5ba829873c8cc3d1b823be54dae3 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 13:12:10 -0300 Subject: [PATCH 1/4] fix(vendors): gate trades to canonical list, preserve legacy values (SH-249) --- .../vendor-trade-specialties-field.tsx | 103 +++++++---- .../vendor-trade-specialties-field.test.tsx | 168 ++++++++++++++++++ 2 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index ff63ef4c..90329e41 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -1,19 +1,11 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Controller, type Control } from "react-hook-form"; import AddIcon from "@mui/icons-material/Add"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import CloseIcon from "@mui/icons-material/Close"; -import { - Autocomplete, - Box, - Button, - Chip, - FormLabel, - Stack, - TextField, - Typography, -} from "@mui/material"; +import { Autocomplete, Box, Button, Chip, FormLabel, Stack, TextField } from "@mui/material"; +import { Text } from "@/components/ui/text"; import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; function splitTrades(value: string | undefined): string[] { @@ -23,6 +15,25 @@ function splitTrades(value: string | undefined): string[] { .filter(Boolean); } +function dedupeTrades(options: string[], current: string[]): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const trade of [...options, ...current]) { + const key = trade.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + merged.push(trade); + } + return merged; +} + +function matchTradeOption(input: string, options: string[]): string | null { + const exact = options.find((option) => option === input); + if (exact) return exact; + const normalized = input.toLowerCase(); + return options.find((option) => option.toLowerCase() === normalized) ?? null; +} + interface VendorTradeSpecialtiesFieldProps { control: Control; index: number; @@ -35,22 +46,44 @@ export function VendorTradeSpecialtiesField({ tradeOptions, }: VendorTradeSpecialtiesFieldProps) { const [tradeInput, setTradeInput] = useState(""); + const [feedbackMessage, setFeedbackMessage] = useState(null); + const knownTradesRef = useRef>(new Set()); return ( Trade Specialties - + First trade is primary. Reorder with the arrows. - + { const trades = splitTrades(field.value); + for (const trade of trades) knownTradesRef.current.add(trade); + const selectableTrades = dedupeTrades(tradeOptions, [...knownTradesRef.current]); const commit = (next: string[]) => field.onChange(next.join(", ")); const add = (value: string) => { const normalized = value.trim(); - if (normalized && !trades.includes(normalized)) commit([...trades, normalized]); + setFeedbackMessage(null); + if (!normalized) { + setTradeInput(""); + return; + } + const canonical = matchTradeOption(normalized, selectableTrades); + if (tradeOptions.length > 0 && !canonical) { + setFeedbackMessage( + `"${normalized}" is not in the trades list. Pick a trade from the list.`, + ); + return; + } + const trade = canonical ?? normalized; + if (trades.some((existing) => existing.toLowerCase() === trade.toLowerCase())) { + setFeedbackMessage(`${trade} is already on this technician.`); + setTradeInput(""); + return; + } + commit([...trades, trade]); setTradeInput(""); }; const move = (tradeIndex: number, direction: -1 | 1) => { @@ -64,31 +97,31 @@ export function VendorTradeSpecialtiesField({ return ( - {trades.length === 0 ? ( - - No trades selected. - - ) : ( - trades.map((trade, tradeIndex) => ( - - commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) - } - deleteIcon={} - /> - )) - )} + + No trades selected. + + {trades.map((trade, tradeIndex) => ( + + commit(trades.filter((_, itemIndex) => itemIndex !== tradeIndex)) + } + deleteIcon={} + /> + ))} { - if (reason === "input") setTradeInput(value); + if (reason === "input") { + setTradeInput(value); + setFeedbackMessage(null); + } }} onChange={(_event, value) => { if (typeof value === "string") add(value); @@ -117,6 +150,12 @@ export function VendorTradeSpecialtiesField({ + + Trade list is unavailable right now — you can still type a trade manually. + + + {feedbackMessage} + {trades.length > 1 && ( {trades.map((trade, tradeIndex) => ( diff --git a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx new file mode 100644 index 00000000..bff2a587 --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx @@ -0,0 +1,168 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { useEffect } from "react"; +import { useForm, useWatch, type Control } from "react-hook-form"; +import { describe, expect, it } from "vitest"; +import { VendorTradeSpecialtiesField } from "@/app/(protected)/vendors/_components/vendor-trade-specialties-field"; +import { + emptyRosterTechnician, + emptyVendorCompanyRosterForm, + type VendorCompanyRosterFormValues, +} from "@/domain/vendors/schemas/vendor-roster-schema"; +import { renderWithProviders } from "@/test/test-utils"; + +function TradeSpecialtiesSpy({ + control, + valuesRef, +}: { + control: Control; + valuesRef: { current: string }; +}) { + const value = useWatch({ control, name: "technicians.0.tradeSpecialties" }) ?? ""; + useEffect(() => { + valuesRef.current = value; + }, [value, valuesRef]); + return null; +} + +function TradeSpecialtiesHarness({ + tradeOptions, + initialTradeSpecialties = "", + valuesRef, +}: { + tradeOptions: string[]; + initialTradeSpecialties?: string; + valuesRef: { current: string }; +}) { + const { control } = useForm({ + defaultValues: { + ...emptyVendorCompanyRosterForm, + technicians: [{ ...emptyRosterTechnician, tradeSpecialties: initialTradeSpecialties }], + }, + }); + return ( + <> + + + + ); +} + +function getAddTradeInput(): HTMLElement { + return screen.getByRole("combobox", { name: "Add Trade" }); +} + +function typeAndCommitTrade(input: HTMLElement, value: string, commit: "enter" | "button") { + fireEvent.change(input, { target: { value } }); + if (commit === "enter") { + fireEvent.keyDown(input, { key: "Enter" }); + } else { + fireEvent.click(screen.getByRole("button", { name: "Add trade" })); + } +} + +describe("VendorTradeSpecialtiesField", () => { + it("offers facet trades as selectable options when the facets query is populated", async () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "H" } }); + + fireEvent.click(await screen.findByRole("option", { name: "HVAC" })); + + expect(await screen.findByText("HVAC (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC"); + }); + + it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => { + const valuesRef = { current: "" }; + renderWithProviders(); + + expect(screen.getByText(/trade list is unavailable right now/i)).toBeInTheDocument(); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "P" } }); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + + typeAndCommitTrade(input, "Plumbing", "enter"); + + expect(screen.getByText("Plumbing (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("Plumbing"); + }); + + it("rejects trades outside the canonical list with visible feedback and keeps the draft", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "Sprinkler Fitting", "button"); + + expect(screen.getByText(/is not in the trades list/i)).toBeInTheDocument(); + expect(screen.queryByText("Sprinkler Fitting (primary)")).toBeNull(); + expect(valuesRef.current).toBe(""); + expect(input).toHaveValue("Sprinkler Fitting"); + }); + + it("commits the canonical casing when a typed trade matches case-insensitively", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "hvac", "enter"); + + expect(screen.getByText("HVAC (primary)")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC"); + }); + + it("keeps legacy free-text trades visible, removable, and re-selectable", async () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + expect(screen.getByText("Backflow Testing (primary)")).toBeInTheDocument(); + expect(screen.getByText("HVAC")).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("remove-trade-Backflow Testing")); + expect(screen.queryByText(/Backflow Testing/)).toBeNull(); + expect(valuesRef.current).toBe("HVAC"); + + const input = getAddTradeInput(); + fireEvent.change(input, { target: { value: "Back" } }); + fireEvent.click(await screen.findByRole("option", { name: "Backflow Testing" })); + + expect(await screen.findByText("Backflow Testing")).toBeInTheDocument(); + expect(valuesRef.current).toBe("HVAC, Backflow Testing"); + }); + + it("flags duplicate trades instead of silently doing nothing", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "plumbing", "enter"); + + expect(screen.getByText(/is already on this technician/i)).toBeInTheDocument(); + expect(valuesRef.current).toBe("Plumbing"); + }); +}); From 63e37dd0bb2f80404ca02b0feeacd0c1d3a70238 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 14:27:42 -0300 Subject: [PATCH 2/4] fix(vendors): keep trades live region out of Stack spacing (SH-249) The always-mounted aria-live feedback region inherited MUI Stack spacing, adding a permanent gap under the trades input and shifting the approved Vendor edit drawer layout. vendor-visual-regression caught it. Move the region outside the spaced Stack and collapse it while silent, so the live region stays in the DOM for announcements without claiming layout. Restores the caption to MUI Typography; that swap was cosmetic and out of scope for SH-249. --- .../vendor-trade-specialties-field.tsx | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index 90329e41..490f554d 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -4,10 +4,53 @@ import AddIcon from "@mui/icons-material/Add"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import CloseIcon from "@mui/icons-material/Close"; -import { Autocomplete, Box, Button, Chip, FormLabel, Stack, TextField } from "@mui/material"; +import { + Autocomplete, + Box, + Button, + Chip, + FormLabel, + Stack, + TextField, + Typography, +} from "@mui/material"; import { Text } from "@/components/ui/text"; import type { VendorCompanyRosterFormValues } from "@/domain/vendors/schemas/vendor-roster-schema"; +/** + * `Text variant="feedback"` stays mounted when silent so the aria-live region can announce later. + * An empty paragraph still claims a line box, which would add a permanent gap under the trades + * input and shift the approved drawer layout. Collapse the box while keeping the node in the DOM + * (never `display: none`, which would stop announcements). + */ +function collapsedWhenSilent(visible: boolean) { + return visible ? undefined : { height: 0, margin: 0, overflow: "hidden" as const }; +} + +function TradeFeedbackRegions({ + listUnavailable, + message, +}: { + listUnavailable: boolean; + message: string | null; +}) { + return ( + <> + + Trade list is unavailable right now — you can still type a trade manually. + + + {message} + + + ); +} + function splitTrades(value: string | undefined): string[] { return (value ?? "") .split(",") @@ -52,9 +95,9 @@ export function VendorTradeSpecialtiesField({ return ( Trade Specialties - + First trade is primary. Reorder with the arrows. - + - - Trade list is unavailable right now — you can still type a trade manually. - - - {feedbackMessage} - {trades.length > 1 && ( {trades.map((trade, tradeIndex) => ( @@ -186,6 +223,9 @@ export function VendorTradeSpecialtiesField({ ); }} /> + {/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack + spacing and add a permanent gap under the trades input, shifting the approved layout. */} + ); } From 64caab18326eb34e81d793b7e837f1f1ec01f19e Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 17:25:44 -0300 Subject: [PATCH 3/4] fix(vendors): do not fail open while the trades facet is loading (SH-249) An empty tradeOptions array meant two different things: "facets query still in flight" and "vocabulary genuinely unavailable". Both enabled fail-open free text and showed the outage warning, so a normal cold fetch looked like an outage. Anything committed in that window was added to knownTradesRef and stayed selectable after the canonical list arrived, permanently undermining the gate. Expose the facets query isLoading as tradesLoading, thread it to the field as tradeOptionsLoading, and keep the canonical gate active while loading. The outage warning now shows only once the query has settled empty. --- .../_components/use-vendor-roster-form.ts | 4 ++- .../_components/vendor-create-modal.tsx | 1 + .../_components/vendor-detail-drawer.tsx | 1 + .../_components/vendor-roster-form-fields.tsx | 22 ++++++++++++++-- .../_components/vendor-roster-page.tsx | 1 + .../vendor-trade-specialties-field.tsx | 18 ++++++++++--- .../vendor-trade-specialties-field.test.tsx | 26 ++++++++++++++++++- 7 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts index 6bf89b1f..c285f592 100644 --- a/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts +++ b/src/app/(protected)/vendors/_components/use-vendor-roster-form.ts @@ -64,6 +64,7 @@ export interface VendorRosterForm { roster: VendorCompanyRoster | undefined; companies: VendorFacetCompany[]; trades: string[]; + tradesLoading: boolean; selectedCompanyId: string | number | null; selectCompany: (company: VendorFacetCompany | null) => Promise; clearSelectedCompany: (nextName?: string) => void; @@ -81,7 +82,7 @@ export function useVendorRosterForm({ mode === "update" ? vendorId : undefined, mode === "update" ? companyId : undefined, ); - const { data: facets } = useVendorFacets(); + const { data: facets, isLoading: facetsLoading } = useVendorFacets(); const save = useSaveVendorCompanyRoster(); const [conflict, setConflict] = useState(null); @@ -168,6 +169,7 @@ export function useVendorRosterForm({ roster: routeRoster, companies, trades, + tradesLoading: facetsLoading, selectedCompanyId: selection.selectedCompanyId, selectCompany: selection.selectCompany, clearSelectedCompany: selection.clearSelectedCompany, diff --git a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx index 31804a3c..002a81bf 100644 --- a/src/app/(protected)/vendors/_components/vendor-create-modal.tsx +++ b/src/app/(protected)/vendors/_components/vendor-create-modal.tsx @@ -68,6 +68,7 @@ export function VendorCreateModal({ open, onClose }: VendorCreateModalProps) { errors={form.errors} companies={form.companies} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} selectedCompanyId={form.selectedCompanyId} onSelectCompany={form.selectCompany} onClearSelectedCompany={form.clearSelectedCompany} diff --git a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx index 74d6ef02..7a365a48 100644 --- a/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx +++ b/src/app/(protected)/vendors/_components/vendor-detail-drawer.tsx @@ -288,6 +288,7 @@ function DrawerEditor({ control={form.control} errors={form.errors} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} showTechnicianStatus={false} /> {selectedIndex >= 0 && ( diff --git a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx index d6d5cbf7..93a10fd9 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-form-fields.tsx @@ -25,6 +25,7 @@ interface VendorRosterFormFieldsProps { errors: FieldErrors; companies?: VendorFacetCompany[]; tradeOptions?: string[]; + tradeOptionsLoading?: boolean; selectedCompanyId?: string | number | null; onSelectCompany?: (company: VendorFacetCompany | null) => Promise; onClearSelectedCompany?: (nextName?: string) => void; @@ -236,6 +237,7 @@ interface TechnicianRowProps { onRemove: () => void; canRemove: boolean; tradeOptions: string[]; + tradeOptionsLoading?: boolean; showStatus: boolean; } @@ -246,6 +248,7 @@ function TechnicianRow({ onRemove, canRemove, tradeOptions, + tradeOptionsLoading, showStatus, }: TechnicianRowProps) { return ( @@ -315,7 +318,12 @@ function TechnicianRow({ )} /> - + {showStatus && ( ; errors: FieldErrors; tradeOptions: string[]; + tradeOptionsLoading?: boolean; showStatus: boolean; }) { const { fields, append, remove } = useFieldArray({ control, name: "technicians" }); @@ -389,6 +399,7 @@ function TechniciansFieldArray({ onRemove={() => remove(index)} canRemove tradeOptions={tradeOptions} + tradeOptionsLoading={tradeOptionsLoading} showStatus={showStatus} /> )) @@ -399,7 +410,13 @@ function TechniciansFieldArray({ } export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { - const { control, errors, tradeOptions = [], showTechnicianStatus = true } = props; + const { + control, + errors, + tradeOptions = [], + tradeOptionsLoading = false, + showTechnicianStatus = true, + } = props; return ( @@ -408,6 +425,7 @@ export function VendorRosterFormFields(props: VendorRosterFormFieldsProps) { control={control} errors={errors} tradeOptions={tradeOptions} + tradeOptionsLoading={tradeOptionsLoading} showStatus={showTechnicianStatus} /> diff --git a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx index f0316957..45f1bb19 100644 --- a/src/app/(protected)/vendors/_components/vendor-roster-page.tsx +++ b/src/app/(protected)/vendors/_components/vendor-roster-page.tsx @@ -124,6 +124,7 @@ export default function VendorRosterPage({ vendorId, companyId }: VendorRosterPa control={form.control} errors={form.errors} tradeOptions={form.trades} + tradeOptionsLoading={form.tradesLoading} {...companySelectionProps} /> diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index 490f554d..ef7b0043 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -81,16 +81,26 @@ interface VendorTradeSpecialtiesFieldProps { control: Control; index: number; tradeOptions: string[]; + tradeOptionsLoading?: boolean; } export function VendorTradeSpecialtiesField({ control, index, tradeOptions, + tradeOptionsLoading = false, }: VendorTradeSpecialtiesFieldProps) { const [tradeInput, setTradeInput] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(null); const knownTradesRef = useRef>(new Set()); + // SH-249: an empty tradeOptions array means two different things. While the + // facets query is in flight it means "not loaded yet"; only once it settles + // does it mean "vocabulary genuinely unavailable". Failing open during the + // cold-fetch window let free text through the canonical gate permanently, + // because anything committed there is added to knownTradesRef and stays + // selectable after the real list arrives. + const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0; + const gateActive = tradeOptions.length > 0 || tradeOptionsLoading; return ( @@ -114,9 +124,11 @@ export function VendorTradeSpecialtiesField({ return; } const canonical = matchTradeOption(normalized, selectableTrades); - if (tradeOptions.length > 0 && !canonical) { + if (gateActive && !canonical) { setFeedbackMessage( - `"${normalized}" is not in the trades list. Pick a trade from the list.`, + tradeOptionsLoading + ? "Trades are still loading. Wait for the list, then pick a trade." + : `"${normalized}" is not in the trades list. Pick a trade from the list.`, ); return; } @@ -225,7 +237,7 @@ export function VendorTradeSpecialtiesField({ /> {/* Outside the spaced Stack: an always-mounted live region would otherwise inherit Stack spacing and add a permanent gap under the trades input, shifting the approved layout. */} - + ); } diff --git a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx index bff2a587..03990707 100644 --- a/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx +++ b/src/test/app/(protected)/vendors/vendor-trade-specialties-field.test.tsx @@ -26,10 +26,12 @@ function TradeSpecialtiesSpy({ function TradeSpecialtiesHarness({ tradeOptions, + tradeOptionsLoading = false, initialTradeSpecialties = "", valuesRef, }: { tradeOptions: string[]; + tradeOptionsLoading?: boolean; initialTradeSpecialties?: string; valuesRef: { current: string }; }) { @@ -41,7 +43,12 @@ function TradeSpecialtiesHarness({ }); return ( <> - + ); @@ -79,6 +86,23 @@ describe("VendorTradeSpecialtiesField", () => { expect(valuesRef.current).toBe("HVAC"); }); + it("does not fail open while the trades facet is still loading", () => { + const valuesRef = { current: "" }; + renderWithProviders( + , + ); + + // A cold fetch must not look like an outage. + expect(screen.queryByText(/trade list is unavailable right now/i)).toBeNull(); + + const input = getAddTradeInput(); + typeAndCommitTrade(input, "Plumbing", "enter"); + + expect(screen.getByText(/trades are still loading/i)).toBeInTheDocument(); + expect(screen.queryByText("Plumbing (primary)")).toBeNull(); + expect(valuesRef.current).toBe(""); + }); + it("has no selectable options when the trades facet is empty, warns, and still accepts typed trades", () => { const valuesRef = { current: "" }; renderWithProviders(); From cccb0e4ca7692e976b458829eafa9d404f6deac7 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Tue, 18 Aug 2026 18:09:23 -0300 Subject: [PATCH 4/4] refactor(vendors): extract trade rejection message to satisfy length gate (SH-249) The loading-aware gate pushed VendorTradeSpecialtiesField to 151 lines, one over the changed-file maintainability cap. Move the rejection-message resolution and the contract explanation to module scope; no behavior change. --- .../vendor-trade-specialties-field.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx index ef7b0043..11a7ea4b 100644 --- a/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx +++ b/src/app/(protected)/vendors/_components/vendor-trade-specialties-field.tsx @@ -84,6 +84,22 @@ interface VendorTradeSpecialtiesFieldProps { tradeOptionsLoading?: boolean; } +/** + * SH-249: an empty tradeOptions array means two different things. While the facets + * query is in flight it means "not loaded yet"; only once it settles does it mean + * "vocabulary genuinely unavailable". Failing open during the cold-fetch window let + * free text through the canonical gate permanently, because anything committed there + * is added to knownTradesRef and stays selectable after the real list arrives. + * + * SH-249: why a typed trade was refused. Extracted to module scope so the field + * component stays inside the maintainability gate's function-length cap. + */ +function resolveRejectionMessage(value: string, loading: boolean): string { + return loading + ? "Trades are still loading. Wait for the list, then pick a trade." + : `"${value}" is not in the trades list. Pick a trade from the list.`; +} + export function VendorTradeSpecialtiesField({ control, index, @@ -93,12 +109,6 @@ export function VendorTradeSpecialtiesField({ const [tradeInput, setTradeInput] = useState(""); const [feedbackMessage, setFeedbackMessage] = useState(null); const knownTradesRef = useRef>(new Set()); - // SH-249: an empty tradeOptions array means two different things. While the - // facets query is in flight it means "not loaded yet"; only once it settles - // does it mean "vocabulary genuinely unavailable". Failing open during the - // cold-fetch window let free text through the canonical gate permanently, - // because anything committed there is added to knownTradesRef and stays - // selectable after the real list arrives. const tradeListUnavailable = !tradeOptionsLoading && tradeOptions.length === 0; const gateActive = tradeOptions.length > 0 || tradeOptionsLoading; @@ -125,11 +135,7 @@ export function VendorTradeSpecialtiesField({ } const canonical = matchTradeOption(normalized, selectableTrades); if (gateActive && !canonical) { - setFeedbackMessage( - tradeOptionsLoading - ? "Trades are still loading. Wait for the list, then pick a trade." - : `"${normalized}" is not in the trades list. Pick a trade from the list.`, - ); + setFeedbackMessage(resolveRejectionMessage(normalized, tradeOptionsLoading)); return; } const trade = canonical ?? normalized;