diff --git a/frontend/ui-core/src/data/refusals.ts b/frontend/ui-core/src/data/refusals.ts index 77c1b4c6..175baf1a 100644 --- a/frontend/ui-core/src/data/refusals.ts +++ b/frontend/ui-core/src/data/refusals.ts @@ -154,6 +154,10 @@ export const REFUSAL_PROSE: Record = { LOSSY_EXPORT_NOT_CONSENTED: "This target cannot take every shape in the dataset.", EXPORT_FORMAT_NOT_FOUND: "No exporter for that format is installed on this server.", EXPORT_TARGET_NOT_FOUND: "No installed exporter writes for that model.", + EXPORT_TARGET_CONFLICT: + "Two installed export plugins claim that model's name — remove one of them before exporting.", + INVALID_EXPORT_TARGET: + "An installed export plugin declares a model it cannot write for — check the installed export plugins.", UNSERIALIZABLE_MANIFEST: "This release's manifest cannot be read back — the workspace may be damaged.", EMPTY_RELEASE: "This dataset has no frames yet — promote a completed batch first.", // The dialog lists the classes beside this, from the refusal's own `blockers`. diff --git a/frontend/ui-core/src/index.ts b/frontend/ui-core/src/index.ts index 341130f3..b13fe75f 100644 --- a/frontend/ui-core/src/index.ts +++ b/frontend/ui-core/src/index.ts @@ -445,7 +445,10 @@ export { useExportTargets, type ExportTarget } from "./screens/queries.js"; export { ExportTargetSelect, exportTargetMeta, + EXPORT_TARGET_FAMILIES, + exportTargetFamily, groupExportTargets, + type ExportTargetFamily, type ExportTargetGroup, type ExportTargetSelectProps, } from "./patterns/ExportTargetSelect.js"; diff --git a/frontend/ui-core/src/patterns/ExportTargetSelect.tsx b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx index 42adb512..1b052418 100644 --- a/frontend/ui-core/src/patterns/ExportTargetSelect.tsx +++ b/frontend/ui-core/src/patterns/ExportTargetSelect.tsx @@ -27,11 +27,30 @@ import { } from "../primitives/Select"; import type { ExportTarget } from "../screens/queries"; -const FAMILY_HEADINGS: readonly (readonly [family: string, heading: string])[] = [ - ["ultralytics-yolo", "Ultralytics YOLO"], - ["community-yolo", "Community YOLO"], +export interface ExportTargetFamily { + readonly family: string; + /** Over a group of targets, in the select. */ + readonly heading: string; + /** Beside one target, inline. */ + readonly word: string; +} + +/** + * The families this build names, in reading order. The last row is the + * catch-all: a family the wire declares and no row names reads under it. + */ +export const EXPORT_TARGET_FAMILIES: readonly ExportTargetFamily[] = [ + { family: "ultralytics-yolo", heading: "Ultralytics YOLO", word: "Ultralytics YOLO" }, + { family: "community-yolo", heading: "Community YOLO", word: "Community YOLO" }, + { family: "other", heading: "Other formats", word: "Other format" }, ]; -const OTHER_HEADING = "Other formats"; + +export function exportTargetFamily(family: string): ExportTargetFamily { + return ( + EXPORT_TARGET_FAMILIES.find((one) => one.family === family) ?? + EXPORT_TARGET_FAMILIES[EXPORT_TARGET_FAMILIES.length - 1]! + ); +} export interface ExportTargetGroup { readonly heading: string; @@ -40,13 +59,10 @@ export interface ExportTargetGroup { /** The catalog under its headings, in heading order, groups with nothing omitted. */ export function groupExportTargets(targets: readonly ExportTarget[]): readonly ExportTargetGroup[] { - const known = new Set(FAMILY_HEADINGS.map(([family]) => family)); - const groups = FAMILY_HEADINGS.map(([family, heading]) => ({ - heading, - targets: targets.filter((one) => one.family === family), - })); - groups.push({ heading: OTHER_HEADING, targets: targets.filter((one) => !known.has(one.family)) }); - return groups.filter((group) => group.targets.length > 0); + return EXPORT_TARGET_FAMILIES.map((row) => ({ + heading: row.heading, + targets: targets.filter((one) => exportTargetFamily(one.family) === row), + })).filter((group) => group.targets.length > 0); } /** The option's second line, or nothing when the target declares neither tasks nor geometries. */ diff --git a/frontend/ui-core/src/patterns/RecipeEditor.tsx b/frontend/ui-core/src/patterns/RecipeEditor.tsx index 029d028d..9128a2ce 100644 --- a/frontend/ui-core/src/patterns/RecipeEditor.tsx +++ b/frontend/ui-core/src/patterns/RecipeEditor.tsx @@ -36,7 +36,7 @@ import { type RecipeDraft, type ResizeChoice, } from "../screens/recipeDraft"; -import { ExportTargetSelect } from "./ExportTargetSelect"; +import { ExportTargetSelect, exportTargetFamily } from "./ExportTargetSelect"; import { StepMarker, type StepState } from "./StepMarker"; const STRATEGIES: readonly { readonly value: ResizeChoice; readonly label: string }[] = [ @@ -418,17 +418,11 @@ function SizeField({ /** `Ultralytics YOLO · detect, segment · data.yaml (ultralytics)` */ export function targetSubtitle(target: ExportTarget): string { - const family = FAMILY_WORDS[target.family] ?? target.family; + const family = exportTargetFamily(target.family).word; const tasks = target.tasks.length === 0 ? "no task vocabulary" : target.tasks.join(", "); return `${family} · ${tasks} · writes ${target.format}`; } -const FAMILY_WORDS: Record = { - "ultralytics-yolo": "Ultralytics YOLO", - "community-yolo": "Community YOLO", - other: "Other format", -}; - /** `Carries boxes and polygons · 12 classes` */ export function targetCarries(target: ExportTarget, classCount: number | undefined): string { const words = target.geometries.map( diff --git a/frontend/ui-core/src/screens/dataset.test.tsx b/frontend/ui-core/src/screens/dataset.test.tsx index 00668c36..121f0b89 100644 --- a/frontend/ui-core/src/screens/dataset.test.tsx +++ b/frontend/ui-core/src/screens/dataset.test.tsx @@ -1300,4 +1300,27 @@ describe("export, and the recipe beside the target", () => { expect(said).toContain("cannot follow a polyline"); expect(said).not.toContain("PREPROCESSING_STEP_UNSUPPORTED_GEOMETRY"); }); + + // The two 500s a misdeclared export plugin raises. Neither is a rule the + // person broke, so the sentence has to say where the problem is. + it.each([ + ["EXPORT_TARGET_CONFLICT", "two export plugins declare target 'dummy'", "Two installed export plugins"], + ["INVALID_EXPORT_TARGET", "target 'dummy' claims geometry 'polyline'", "cannot write for"], + ])("renders %s as prose", async (code, message, expected) => { + baseline(); + handlers.push((request) => + request.method === "POST" && request.url.includes("/export") + ? { status: 500, body: { code, message } } + : undefined, + ); + render(mount()); + await userEvent.click(await screen.findByTestId("export-v1")); + await userEvent.click(screen.getByTestId("export-target")); + await userEvent.click(await screen.findByRole("option", { name: /dummy/ })); + await userEvent.click(screen.getByTestId("export-submit")); + + const said = (await screen.findByTestId("export-error")).textContent ?? ""; + expect(said).toContain(expected); + expect(document.body.textContent).not.toContain(code); + }); });