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
27 changes: 26 additions & 1 deletion frontend/app/cycle/cycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,33 @@ test("the whole cycle, from opening the app to a downloaded export", async ({ pa
await test.step("promote the completed batch into the trunk", async () => {
// The trunk carries assets only, and promotion is a **union** against current
// membership — idempotent, with no log entry when nothing changed.
//
// **This used to assert the button's label flipped to "Promoted", and that
// was the whole of the feedback.** A label flip is not a report: it could not
// say how many assets moved, it could not tell a first press from a repeat,
// and it made a second press look forbidden when it is merely a no-op.
// Promotion is not a transition either, so nothing else on the row could
// move — which is how a working call came to read as a broken button.
await page.getByTestId("promote-cycle-batch").click();
await expect(page.getByTestId("promote-cycle-batch")).toHaveText("Promoted");

const said = page.getByTestId("promoted-cycle-batch");
await expect(said).toBeVisible({ timeout: 30_000 });
// Three assets annotated in the step above, and every one of them promotable.
await expect(said).toHaveText(/Promoted 3 assets/);
// The button stays a button, so the batch can be promoted again after a
// curator removes something.
await expect(page.getByTestId("promote-cycle-batch")).toHaveText(/Promote/);
});

await test.step("the trunk count survives a reload, which the response cannot", async () => {
// The other half of making promotion observable: the response says what *this
// press* did and is gone on the next render, while `promoted_asset_count` is
// derived per read and is still right in a session that did no promoting.
await page.reload();
await expect(page.getByTestId("batches-table")).toBeVisible();
await expect(page.getByTestId("promoted-count-cycle-batch")).toHaveText(
/3 of 3 in the dataset/,
);
});

await test.step("publish a release", async () => {
Expand Down
1 change: 1 addition & 0 deletions frontend/app/e2e/annotate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ async function serveApi(
schema_version: 3,
asset_count: 2,
allowed_actions: batchActions(lifecycle.batch),
promoted_asset_count: 0,
progress: {
unannotated: 2,
annotated: 0,
Expand Down
3 changes: 3 additions & 0 deletions frontend/app/e2e/gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
asset_count: counts.total,
progress: counts,
allowed_actions: batchActions(current),
promoted_asset_count: 0,
},
});
}
Expand Down Expand Up @@ -233,6 +234,7 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
asset_count: counts.total,
progress: counts,
allowed_actions: batchActions(current),
promoted_asset_count: 0,
},
});
}
Expand All @@ -253,6 +255,7 @@ async function serveApi(page: Page, sent: Request[], options: Options = {}): Pro
asset_count: counts.total,
progress: counts,
allowed_actions: batchActions(current),
promoted_asset_count: 0,
},
});
}
Expand Down
1 change: 1 addition & 0 deletions frontend/app/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ async function serveApi(page: Page): Promise<void> {
name: "drive-01",
state: "in_annotation",
allowed_actions: batchActions("in_annotation"),
promoted_asset_count: 0,
schema_version: 1,
asset_count: 1,
progress: { ...NO_PROGRESS, unannotated: 1, total: 1 },
Expand Down
1 change: 1 addition & 0 deletions frontend/app/e2e/viewport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ async function serveApi(page: Page): Promise<void> {
state: "in_annotation",
schema_version: 1,
allowed_actions: batchActions("in_annotation"),
promoted_asset_count: 0,
asset_count: 1,
progress: NO_PROGRESS,
},
Expand Down
4 changes: 4 additions & 0 deletions frontend/app/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,10 @@ function Gallery(): JSX.Element {
// The approve dialog's SCHEMA_NOT_FOUND remedy (#291): the schema section
// is a `?tab=` on the project page, and spelling that URL is this file's job.
onOpenSchema={() => void navigate(`/projects/${projectId}?tab=schema`)}
// Where a promotion from this screen lands (F18). The gallery is where a
// batch is finished, and it had no way to reach the one screen that shows
// what finishing it produced.
onOpenDataset={() => void navigate(`/projects/${projectId}/dataset`)}
/>
);
}
Expand Down
1 change: 1 addition & 0 deletions frontend/ui-core/src/annotator/viewportFloor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ beforeEach(() => {
schema_version: 1,
asset_count: 1,
allowed_actions: batchActions("in_annotation"),
promoted_asset_count: 0,
progress: {
unannotated: 1,
annotated: 0,
Expand Down
2 changes: 2 additions & 0 deletions frontend/ui-core/src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1802,6 +1802,8 @@ export interface components {
* Format: uuid
*/
project_id: string;
/** Promoted Asset Count */
promoted_asset_count: number;
/** Schema Version */
schema_version: number | null;
state: components["schemas"]["BatchState"];
Expand Down
2 changes: 1 addition & 1 deletion frontend/ui-core/src/generated/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const checkProgressCounts: Check<Schemas["ProgressCounts"]> =
/*#__PURE__*/ object({ "accepted": [true, isInteger], "annotated": [true, isInteger], "review_pending": [true, isInteger], "skipped": [true, isInteger], "total": [true, isInteger], "unannotated": [true, isInteger] } as const);

export const checkBatchOut: Check<Schemas["BatchOut"]> =
/*#__PURE__*/ object({ "allowed_actions": [true, arrayOf(checkBatchAction)], "asset_count": [true, isInteger], "id": [true, isString], "name": [true, isString], "progress": [true, checkProgressCounts], "project_id": [true, isString], "schema_version": [true, either([isInteger, isNull] as const)], "state": [true, checkBatchState] } as const);
/*#__PURE__*/ object({ "allowed_actions": [true, arrayOf(checkBatchAction)], "asset_count": [true, isInteger], "id": [true, isString], "name": [true, isString], "progress": [true, checkProgressCounts], "project_id": [true, isString], "promoted_asset_count": [true, isInteger], "schema_version": [true, either([isInteger, isNull] as const)], "state": [true, checkBatchState] } as const);

export const checkBatchPage: Check<Schemas["BatchPage"]> =
/*#__PURE__*/ object({ "items": [true, arrayOf(checkBatchOut)], "total": [true, isInteger] } as const);
Expand Down
1 change: 1 addition & 0 deletions frontend/ui-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export { IngestScreen, type IngestScreenProps } from "./screens/IngestScreen.js"
export { BatchesScreen, type BatchesScreenProps } from "./screens/BatchesScreen.js";
export { GalleryScreen, type GalleryScreenProps } from "./screens/GalleryScreen.js";
export { ApproveDialog, BatchProgressBar } from "./screens/BatchLifecycle.js";
export { PromoteButton, promotionSummary, type PromoteButtonProps } from "./screens/PromoteButton.js";
export {
batchStateLabel,
segmentCounts,
Expand Down
43 changes: 22 additions & 21 deletions frontend/ui-core/src/screens/BatchesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
* UUIDs. A program has the SDK and the API.
*/

import { ArrowUpFromLine, Layers, Play } from "lucide-react";
import { Layers, Play } from "lucide-react";
import { useState, type JSX } from "react";

import { Async } from "../data/Async";
Expand All @@ -43,19 +43,28 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
import { BATCH_STATE_VARIANT, batchStateLabel } from "./batchState";
import { SchemaForeshadow } from "./SchemaForeshadow";
import { useBatchTransition, useBatches, usePromoteBatch, type Batch } from "./queries";
import { PromoteButton } from "./PromoteButton";
import { useBatchTransition, useBatches, type Batch } from "./queries";

export interface BatchesScreenProps {
readonly projectId: string;
readonly onOpenBatch: (batchId: string) => void;
/** Where "define your labels" goes — the schema tab, as the host spells it. */
readonly onOpenSchema?: () => void;
/**
* The dataset, so a promotion can be followed to where it landed.
*
* Promotion's entire evidence lives on that screen and nothing linked there
* from here, so a person was told something had happened and left to find it.
*/
readonly onOpenDataset?: () => void;
}

export function BatchesScreen({
projectId,
onOpenBatch,
onOpenSchema,
onOpenDataset,
}: BatchesScreenProps): JSX.Element {
const batches = useBatches(projectId);
const [approving, setApproving] = useState<Batch | null>(null);
Expand Down Expand Up @@ -135,6 +144,7 @@ export function BatchesScreen({
<Lifecycle
batch={{ ...batch, projectId }}
onApprove={() => setApproving(batch)}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
</TableCell>
</TableRow>
Expand Down Expand Up @@ -172,37 +182,28 @@ export function BatchesScreen({
function Lifecycle({
batch,
onApprove,
onOpenDataset,
}: {
readonly batch: Batch & { readonly projectId?: string };
readonly onApprove: () => void;
readonly onOpenDataset?: () => void;
}): JSX.Element | null {
const start = useBatchTransition(batch.id, "start");
const promote = usePromoteBatch(batch.projectId ?? "");

if (declares(batch, BATCH_ACTION.promote)) {
// The last move, and the only one that is not a state transition: promotion
// adds the batch's assets to the trunk. Idempotent — a **union** against
// current membership, with no log entry when nothing changed — so pressing it
// twice is safe and a curator's earlier removal is restored rather than
// remembered.
// remembered. Which is exactly why the control has to *say* what it did:
// "safe to press twice" and "you cannot tell whether it worked" were the
// same button until #307's successor. See `PromoteButton`.
return (
<div className="flex flex-col items-end gap-1">
<Button
variant="secondary"
size="sm"
data-testid={`promote-${batch.name}`}
disabled={promote.isPending}
onClick={() => promote.mutate(batch.id)}
>
<ArrowUpFromLine className="size-4" aria-hidden="true" />
{promote.isSuccess ? "Promoted" : "Promote"}
</Button>
{promote.isError && (
<FieldError data-testid={`promote-error-${batch.name}`}>
{refusalProse(promote.error)}
</FieldError>
)}
</div>
<PromoteButton
batch={batch}
projectId={batch.projectId ?? ""}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
);
}
if (declares(batch, BATCH_ACTION.approve)) {
Expand Down
33 changes: 33 additions & 0 deletions frontend/ui-core/src/screens/GalleryScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { AssetThumbnail } from "./AssetThumbnail";
import { BackLink } from "../patterns/BackLink";
import { parentLabel } from "../patterns/parentLabel";
import { ApproveDialog, BatchProgressBar, CompleteBatchButton } from "./BatchLifecycle";
import { PromoteButton } from "./PromoteButton";
import {
ASSET_ACTION,
BATCH_ACTION,
Expand Down Expand Up @@ -144,6 +145,14 @@ export interface GalleryScreenProps {
readonly onBack?: () => void;
/** The project's schema tab, for the approve dialog's `SCHEMA_NOT_FOUND` remedy (#291). */
readonly onOpenSchema?: () => void;
/**
* The dataset — where a promotion from this screen lands (audit F18).
*
* The `information-architecture` skill's rule that the dataset is reachable in
* one click from anywhere it is relevant, applied to the one screen that can
* put something into it.
*/
readonly onOpenDataset?: () => void;
}

export function GalleryScreen({
Expand All @@ -152,6 +161,7 @@ export function GalleryScreen({
onOpenAsset,
onBack,
onOpenSchema,
onOpenDataset,
}: GalleryScreenProps): JSX.Element {
const project = useProject(projectId);
const batch = useBatch(batchId);
Expand Down Expand Up @@ -300,8 +310,10 @@ export function GalleryScreen({

<BatchHeader
batch={batch.data}
projectId={projectId}
assets={loaded}
showsProgress={showsProgress}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
onApprove={() => setApproving(true)}
{...(onOpenAsset === undefined
? {}
Expand Down Expand Up @@ -458,13 +470,17 @@ export function GalleryScreen({
*/
function BatchHeader({
batch,
projectId,
assets,
showsProgress,
onApprove,
onStartAnnotating,
onOpenDataset,
}: {
readonly batch: Batch | undefined;
readonly projectId: string;
readonly assets: readonly BatchAsset[];
readonly onOpenDataset?: () => void;
/** False for a draft, whose counts are documented zeros rather than data. */
readonly showsProgress: boolean;
readonly onApprove: () => void;
Expand Down Expand Up @@ -577,6 +593,23 @@ function BatchHeader({
control is shared with that table rather than spelled twice, and it
withholds the press — with the count — while anything is outstanding.
*/}
{/*
Promotion, on the screen the work is finished from (audit F18).

It existed only on the batch table one tab away, so a person could
settle forty-eight frames here and have nowhere to put them — and the
gallery had no link to the dataset either, which is where a promotion's
evidence lives. Capability-gated and shared with that table rather than
spelled twice: `PromoteButton` owns the sentence and the reason.
*/}
{batch !== undefined && (
<PromoteButton
batch={batch}
projectId={projectId}
className="flex flex-col items-end gap-1"
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
)}
{batch !== undefined && batch.state === "in_annotation" && (
<CompleteBatchButton batch={batch} className="flex flex-col items-end gap-1" />
)}
Expand Down
1 change: 1 addition & 0 deletions frontend/ui-core/src/screens/ProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export function ProjectScreen({
{...(onTabChange === undefined
? {}
: { onOpenSchema: () => onTabChange("schema") })}
{...(onOpenDataset === undefined ? {} : { onOpenDataset })}
/>
</TabsContent>
)}
Expand Down
Loading
Loading