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
2 changes: 1 addition & 1 deletion docs/content/preprocessing.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ scaled to match, and the response is never cached: the spec is the request's own

| | |
| --- | --- |
| UI | The Dataset section's **Pre-processing** view: the project's recipes as a list, an editor of four steps (target model, resize, augmentation, preview), and *Save recipe*; the Export dialog's **Pre-processing recipe** control chooses one by name, `None` by default. See [ui.md](ui.md#the-dataset-its-releases-and-getting-the-data-out). |
| UI | The Dataset section's **Pre-processing** view: the project's recipes as a list, an editor of four steps (target model, resize, augmentation, preview), *Save recipe*, and a delete control on each row that asks first; the Export dialog's **Pre-processing recipe** control chooses one by name, `None` by default. See [ui.md](ui.md#the-dataset-its-releases-and-getting-the-data-out). |
| REST | `POST`/`GET /projects/{id}/preprocessing-recipes`, `GET`/`PUT`/`DELETE /projects/{id}/preprocessing-recipes/{name}`, `POST /projects/{id}/preprocessing-preview`; `recipe=` on `POST /releases/{id}/export` and `GET /releases/{id}/export-compatibility`. The job carries the recipe as a snapshot. |
| CLI | `visionset recipe create NAME -p P --spec FILE` or `--resize letterbox:640x640 --augment hflip,brightness_contrast --variants 2 --target yolo11`; `recipe list`, `show`, `update`, `delete`; `export --recipe NAME`. See [cli.md](cli.md#visionset-release-and-visionset-export). |
| MCP | `create_preprocessing_recipe`, `list_preprocessing_recipes`, `delete_preprocessing_recipe` (only with `--allow-destructive`); `recipe` on `export_release` and `check_export`. See [mcp.md](mcp.md#datasets-releases-and-export). |
Expand Down
5 changes: 4 additions & 1 deletion docs/content/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,10 @@ not a JPEG, PNG or WebP — is prose in the cell.

Save is a create for a new draft and a whole-replace `PUT` at the recipe's current name for
an open one; Discard puts the stored spec back; `PREPROCESSING_RECIPE_NAME_TAKEN` and every
other refusal render through the vocabulary. The Export dialog's second control,
other refusal render through the vocabulary. Each row carries a delete control beside it
that asks first — the recipe alone goes, and an export already run keeps its own copy — and
closes the editor when the recipe it held is the one deleted; a refusal such as
`PREPROCESSING_RECIPE_NOT_FOUND` is prose in the dialog. The Export dialog's second control,
**Pre-processing recipe**, lists the same recipes with the same one-line summary under
*None*, and sends the chosen name as `recipe=`; a project with none says so in a line rather
than offering a picker with one row. The two 409s an export can answer —
Expand Down
44 changes: 33 additions & 11 deletions frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,33 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await expectRecipeArchive(download);
});

await test.step("delete the recipe, and watch the list and the count answer", async () => {
/*
* The last verb a recipe has, after the export that needed it has run:
* the row's control, the confirmation, the real `DELETE`, and the view
* back to its invitation with the tab no longer counting. The editor was
* holding this recipe, so its closing is part of what is asserted.
*/
// The export dialog is still up, and deliberately: it holds the outcome so
// the badge can announce it once the poll has stopped, which means it closes
// the way every other dialog here does rather than on its own. So this step
// starts by doing what the person who has just taken the download does. The
// walk dismisses a dialog this way once already, when the batch is deleted.
await page.keyboard.press("Escape");
await expect(page.getByTestId("export-dialog")).toHaveCount(0);

await page.getByTestId("dataset-tab-preprocessing").click();
await expect(page.getByTestId("recipe-editor")).toBeVisible();
await page.getByTestId(`recipe-delete-${RECIPE}`).click();
await expect(page.getByTestId("delete-recipe-dialog")).toContainText(`Delete ${RECIPE}?`);
await page.getByTestId("delete-recipe-submit").click();

await expect(page.getByTestId("delete-recipe-dialog")).toHaveCount(0);
await expect(page.getByTestId("recipes-empty")).toBeVisible();
await expect(page.getByTestId("recipe-editor")).toHaveCount(0);
await expect(page.getByTestId("dataset-tab-preprocessing")).toContainText("0");
});

await test.step("edit the connection, and watch the row answer for it", async () => {
/*
* A stub cannot referee this body, because it is written by whoever wrote
Expand All @@ -1633,14 +1660,6 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
* transcription. Last in the walk and nothing is put back, since no later
* step reads this connection.
*/
// The export dialog is still up, and deliberately: it holds the outcome so
// the badge can announce it once the poll has stopped, which means it closes
// the way every other dialog here does rather than on its own. So this step
// starts by doing what the person who has just taken the download does. The
// walk dismisses a dialog this way once already, in the delete step.
await page.keyboard.press("Escape");
await expect(page.getByTestId("export-dialog")).toHaveCount(0);

await page.getByTestId("rail-models").click();
await expect(page.getByTestId("models-screen")).toBeVisible();
// Two locators because the row's id is its name and the rename moves it.
Expand Down Expand Up @@ -1700,7 +1719,7 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
/*
* **The aborted API calls, pinned rather than filtered away.**
*
* Both entries answer `204 No Content`, and Chromium reports every such
* Every entry answers `204 No Content`, and Chromium reports every such
* request as `net::ERR_ABORTED` — there is no body for the renderer to read,
* so the network stack tears the stream down and files it as cancelled.
* Measured, not assumed, for the first one: the deletion is committed (the
Expand All @@ -1713,9 +1732,11 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
* sends; the step that calls it asserts the batch is gone from the table and
* the trunk did not move, which is what "committed" means there. The
* annotation delete had no coverage before this walk at all, because nothing
* in the browser had ever deleted an annotation.
* in the browser had ever deleted an annotation. The recipe delete is the
* third, committed the same way: the view was back at its invitation and
* the tab had stopped counting before the walk moved on.
*
* Asserted as an exact list, in walk order: a *third* aborted call, or one on
* Asserted as an exact list, in walk order: a *fourth* aborted call, or one on
* another route, is the shape of a request the app really did abandon, and
* that is worth failing on.
*
Expand All @@ -1727,6 +1748,7 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
expect(abortedApiCalls).toEqual([
expect.stringMatching(/^DELETE .*\/batches\/[0-9a-f-]+$/),
expect.stringMatching(/^DELETE .*\/annotations$/),
expect.stringMatching(/^DELETE .*\/preprocessing-recipes\/[^/]+$/),
]);
/*
* **The refused API calls, pinned rather than merely tolerated.**
Expand Down
31 changes: 24 additions & 7 deletions frontend/ui-core/src/patterns/RecipeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
* summary and, as a `quiet` chip, the target its hints were read from: a fact
* beside other facts, never a state.
*
* Data-only. The screen decides what is selected and what `New` does.
* Data-only. The screen decides what is selected, what `New` does, and what
* deleting asks first. The delete control sits beside the row rather than
* inside it: the row is itself a button, and a button cannot hold one.
*/

import { Plus } from "lucide-react";
import { Plus, Trash2 } from "lucide-react";
import type { JSX } from "react";

import { cn } from "../lib/cn";
Expand All @@ -30,6 +32,7 @@ export interface RecipeListProps {
readonly selected: string | null;
readonly onSelect: (name: string) => void;
readonly onNew: () => void;
readonly onDelete: (name: string) => void;
/** The label a target's `name` is shown as; an unknown target shows its name. */
readonly labelFor: (target: string) => string;
readonly className?: string;
Expand All @@ -40,6 +43,7 @@ export function RecipeList({
selected,
onSelect,
onNew,
onDelete,
labelFor,
className,
}: RecipeListProps): JSX.Element {
Expand All @@ -56,18 +60,22 @@ export function RecipeList({
{recipes.map((recipe) => {
const chosen = recipe.name === selected;
return (
<li key={recipe.name}>
<li
key={recipe.name}
className={cn(
"flex items-center border-l-2 pr-1 transition-colors",
chosen ? "border-l-primary bg-primary/10" : "border-l-transparent",
)}
>
<button
type="button"
data-testid={`recipe-${recipe.name}`}
data-selected={chosen ? "true" : undefined}
aria-current={chosen ? "true" : undefined}
onClick={() => onSelect(recipe.name)}
className={cn(
"flex w-full items-center gap-2 border-l-2 px-3 py-2 text-left transition-colors",
chosen
? "border-l-primary bg-primary/10"
: "border-l-transparent hover:bg-muted focus-visible:bg-muted",
"flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left transition-colors",
!chosen && "hover:bg-muted focus-visible:bg-muted",
)}
>
<span className="flex min-w-0 flex-1 flex-col">
Expand All @@ -82,6 +90,15 @@ export function RecipeList({
<Badge variant="quiet">{labelFor(recipe.spec.target)}</Badge>
)}
</button>
<Button
variant="ghost"
size="icon"
aria-label={`Delete recipe ${recipe.name}`}
data-testid={`recipe-delete-${recipe.name}`}
onClick={() => onDelete(recipe.name)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
</li>
);
})}
Expand Down
99 changes: 92 additions & 7 deletions frontend/ui-core/src/screens/PreprocessingTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
* unconditional, so this screen gates nothing. What it holds is a draft — the
* stored recipe as typed fields — and `dirty` is the draft disagreeing with the
* spec it opened from. Save is a create for a new draft and a whole-replace
* `PUT` for an open one; Discard puts the stored spec back.
* `PUT` for an open one; Discard puts the stored spec back; Delete asks first,
* and closes the editor when the recipe it held is the one that went.
*
* ## The preview is the export's own path
*
Expand Down Expand Up @@ -37,9 +38,18 @@ import { RecipeEditor } from "../patterns/RecipeEditor";
import { RecipeList } from "../patterns/RecipeList";
import { StaticAnnotationOverlay } from "../patterns/StaticAnnotationOverlay";
import { Button } from "../primitives/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "../primitives/Dialog";
import { FieldError } from "../primitives/Input";
import {
useActiveSchema,
useCreatePreprocessingRecipe,
useDeletePreprocessingRecipe,
useExportTargets,
usePreprocessingPreview,
usePreprocessingRecipes,
Expand Down Expand Up @@ -83,6 +93,7 @@ export function PreprocessingTab({ projectId, datasetId }: PreprocessingTabProps
const create = useCreatePreprocessingRecipe(projectId);
const update = useUpdatePreprocessingRecipe(projectId);
const [editing, setEditing] = useState<Editing | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);

const items = recipes.data?.items ?? [];
const catalog = targets.data?.items ?? [];
Expand Down Expand Up @@ -157,8 +168,22 @@ export function PreprocessingTab({ projectId, datasetId }: PreprocessingTabProps
}
}}
onNew={startNew}
onDelete={setDeleting}
labelFor={labelFor}
/>
<DeleteRecipeDialog
name={deleting}
projectId={projectId}
onClose={() => setDeleting(null)}
onDeleted={(name) => {
// The list still holds the deleted row until its refetch lands, so
// the next recipe is chosen here rather than left to the effect,
// which would reopen the one that has just gone.
if (editing?.name !== name) return;
const next = items.find((one) => one.name !== name);
setEditing(next === undefined ? null : open(next));
}}
/>
{editing !== null && (
<Editor
key={editing.name ?? "~new"}
Expand Down Expand Up @@ -215,6 +240,69 @@ export function PreprocessingTab({ projectId, datasetId }: PreprocessingTabProps
);
}

/**
* The confirmation. What a delete reaches is small and fully known — the
* recipe alone. An export that already ran carries its snapshot, and a batch
* is never touched — so the sentence says that and nothing it cannot source.
*/
function DeleteRecipeDialog({
name,
projectId,
onClose,
onDeleted,
}: {
readonly name: string | null;
readonly projectId: string;
readonly onClose: () => void;
readonly onDeleted: (name: string) => void;
}): JSX.Element {
const remove = useDeletePreprocessingRecipe(projectId);

return (
<Dialog
open={name !== null}
onOpenChange={(next) => {
if (next) return;
remove.reset();
onClose();
}}
>
<DialogContent data-testid="delete-recipe-dialog">
<DialogTitle>Delete {name}?</DialogTitle>
<DialogDescription>
The recipe is removed from this project and can no longer be chosen at export. Releases
already exported through it keep their files: an export carries its own copy of the
recipe it ran.
</DialogDescription>
{remove.isError && (
<FieldError data-testid="delete-recipe-error">{refusalProse(remove.error)}</FieldError>
)}
<DialogFooter>
<Button variant="secondary" data-testid="delete-recipe-cancel" onClick={onClose}>
Cancel
</Button>
<Button
variant="destructive"
data-testid="delete-recipe-submit"
disabled={remove.isPending}
onClick={() =>
name !== null &&
remove.mutate(name, {
onSuccess: () => {
onClose();
onDeleted(name);
},
})
}
>
{remove.isPending ? "Deleting…" : "Delete recipe"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

function Editor({
projectId,
datasetId,
Expand Down Expand Up @@ -348,21 +436,18 @@ function PreviewGrid({

return (
<div className="flex flex-col gap-2" data-testid="preview-grid">
<div className="grid grid-cols-[6rem_repeat(3,minmax(0,1fr))] gap-2 text-xs text-muted-foreground">
<span />
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
<span>Original</span>
<span>After resize</span>
<span>After augmentation</span>
</div>
{samples.map((assetId, index) => (
<div
key={assetId}
className="grid grid-cols-[6rem_repeat(3,minmax(0,1fr))] items-center gap-2"
className="grid grid-cols-3 items-center gap-2"
data-testid={`preview-row-${index}`}
title={`Sample ${assetId.slice(0, 8)}`}
>
<span className="truncate font-mono text-xs text-muted-foreground" title={assetId}>
{assetId.slice(0, 8)}
</span>
<PreviewCell
projectId={projectId}
assetId={assetId}
Expand Down
Loading
Loading