From 44b64ccb1c13aa5541eec3db1f3ea56fb465e6ea Mon Sep 17 00:00:00 2001 From: Jesus Armando Anaya <1445792+JArmandoAnaya@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:31:50 -0700 Subject: [PATCH] fix(ui-core): offer ingest target batches by edit_membership, not state (#851) --- .../ui-core/src/screens/ImageIngestFlow.tsx | 14 +++++++---- frontend/ui-core/src/screens/ingest.test.tsx | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/frontend/ui-core/src/screens/ImageIngestFlow.tsx b/frontend/ui-core/src/screens/ImageIngestFlow.tsx index 8ebf09a9..0aec2c9f 100644 --- a/frontend/ui-core/src/screens/ImageIngestFlow.tsx +++ b/frontend/ui-core/src/screens/ImageIngestFlow.tsx @@ -82,6 +82,7 @@ import { type JSX, } from "react"; +import { BATCH_ACTION, declares } from "../data/capabilities"; import { refusalProse } from "../data/refusals"; import { Alert, AlertDescription, AlertTitle, Badge, Button, Card, CardContent, Progress, Input, Label, FieldDescription, FieldError, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@robomous/ui-core"; import { formatBytes, formatCount } from "../lib/format"; @@ -208,9 +209,12 @@ export function ImageIngestFlow({ void queries.invalidateQueries({ queryKey: ["projects", projectId] }); }, [settled, projectId, queries]); - // Only a draft batch may take an ingest. Anything else is refused at the launch - // with 409 `BATCH_NOT_EDITABLE`, so offering one would be offering a refusal. - const draftBatches = (batches.data?.items ?? []).filter((batch) => batch.state === "draft"); + // Only a batch declaring `edit_membership` may take an ingest. Anything else is + // refused at the launch with 409 `BATCH_NOT_EDITABLE`, so offering one would be + // offering a refusal. + const editableBatches = (batches.data?.items ?? []).filter((batch) => + declares(batch, BATCH_ACTION.editMembership), + ); // What the source is called unless the user types otherwise. // Without a stated name the server calls the source by its staged directory, @@ -305,7 +309,7 @@ export function ImageIngestFlow({ ? batchName.trim() === "" ? (source?.name ?? "") : batchName.trim() - : (draftBatches.find((batch) => batch.id === batchChoice)?.name ?? "the batch"); + : (editableBatches.find((batch) => batch.id === batchChoice)?.name ?? "the batch"); return (
    @@ -385,7 +389,7 @@ export function ImageIngestFlow({ New batch - {draftBatches.map((batch) => ( + {editableBatches.map((batch) => ( {batch.name} ({batch.asset_count}) diff --git a/frontend/ui-core/src/screens/ingest.test.tsx b/frontend/ui-core/src/screens/ingest.test.tsx index 70f0fe96..8d19402b 100644 --- a/frontend/ui-core/src/screens/ingest.test.tsx +++ b/frontend/ui-core/src/screens/ingest.test.tsx @@ -409,6 +409,29 @@ describe("launching a run", () => { expect(screen.queryByRole("option", { name: /frozen/ })).toBeNull(); }); + it("reads the offer off `edit_membership`, not off the batch's state", async () => { + on("GET", /\/batches$/, { + status: 200, + body: { + items: [ + { id: "b1", project_id: PROJECT, name: "declared", state: "in_annotation", schema_version: 1, asset_count: 4, progress: NO_PROGRESS, allowed_actions: ["edit_membership"], promoted_asset_count: 0, parent_batch_id: null, pre_label_run: null }, + { id: "b2", project_id: PROJECT, name: "withheld", state: "draft", schema_version: null, asset_count: 9, progress: NO_PROGRESS, allowed_actions: [], promoted_asset_count: 0, parent_batch_id: null, pre_label_run: null }, + ], + total: 2, + }, + }); + on("POST", /\/sources\/images$/, { status: 201, body: IMAGE_SOURCE }); + + renderWithData(); + await choose([pick("a.png", "image/png")]); + await userEvent.click(screen.getByTestId("register-source")); + await screen.findByTestId("source-card"); + + await userEvent.click(screen.getByTestId("target-batch")); + expect(screen.queryByRole("option", { name: /declared/ })).not.toBeNull(); + expect(screen.queryByRole("option", { name: /withheld/ })).toBeNull(); + }); + it("names a new batch after the source when nobody typed one", async () => { on("GET", /\/batches$/, { status: 200, body: { items: [], total: 0 } }); on("POST", /\/sources\/images$/, { status: 201, body: IMAGE_SOURCE });