diff --git a/docs/content/preprocessing.md b/docs/content/preprocessing.md index d8804985..e19920c8 100644 --- a/docs/content/preprocessing.md +++ b/docs/content/preprocessing.md @@ -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). | diff --git a/docs/content/ui.md b/docs/content/ui.md index bc5c15b6..e4f5a5bb 100644 --- a/docs/content/ui.md +++ b/docs/content/ui.md @@ -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 — diff --git a/frontend/app/cycle/cycle.spec.ts b/frontend/app/cycle/cycle.spec.ts index dc90f7ad..460723c6 100644 --- a/frontend/app/cycle/cycle.spec.ts +++ b/frontend/app/cycle/cycle.spec.ts @@ -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 @@ -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. @@ -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 @@ -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. * @@ -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.** diff --git a/frontend/ui-core/src/patterns/RecipeList.tsx b/frontend/ui-core/src/patterns/RecipeList.tsx index d0792d5e..3d83fa95 100644 --- a/frontend/ui-core/src/patterns/RecipeList.tsx +++ b/frontend/ui-core/src/patterns/RecipeList.tsx @@ -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"; @@ -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; @@ -40,6 +43,7 @@ export function RecipeList({ selected, onSelect, onNew, + onDelete, labelFor, className, }: RecipeListProps): JSX.Element { @@ -56,7 +60,13 @@ export function RecipeList({ {recipes.map((recipe) => { const chosen = recipe.name === selected; return ( -