Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/ui-core/src/data/refusals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ export const REFUSAL_PROSE: Record<string, string> = {
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`.
Expand Down
3 changes: 3 additions & 0 deletions frontend/ui-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
38 changes: 27 additions & 11 deletions frontend/ui-core/src/patterns/ExportTargetSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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. */
Expand Down
10 changes: 2 additions & 8 deletions frontend/ui-core/src/patterns/RecipeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] = [
Expand Down Expand Up @@ -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<string, string> = {
"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(
Expand Down
23 changes: 23 additions & 0 deletions frontend/ui-core/src/screens/dataset.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<DatasetScreen projectId={PROJECT} tab="releases" />));
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);
});
});
Loading