feat: preview task canvases with workspace data (#3887)

This commit is contained in:
Carlos Florêncio
2026-09-23 20:51:55 +01:00
committed by GitHub
parent 9874ac4cf5
commit 4bebf7c511
79 changed files with 3369 additions and 282 deletions
@@ -4,9 +4,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/api/client";
import type { Canvas, CanvasRelease } from "@/lib/api/domains/canvas-api";
const ENABLE_WORKSPACE_DATA_TEXT = vi.hoisted(() => "Enable workspace data");
const READ_TASK_DATA_TEXT = vi.hoisted(() => "Read task data");
const {
mockRequestCanvasPromotion,
mockConfirmCanvasPromotion,
mockRequestCanvasWorkspaceData,
mockEnableCanvasWorkspaceData,
mockListCanvasReleases,
mockApproveCanvasRelease,
mockRejectCanvasRelease,
@@ -14,6 +19,8 @@ const {
} = vi.hoisted(() => ({
mockRequestCanvasPromotion: vi.fn(),
mockConfirmCanvasPromotion: vi.fn(),
mockRequestCanvasWorkspaceData: vi.fn(),
mockEnableCanvasWorkspaceData: vi.fn(),
mockListCanvasReleases: vi.fn(),
mockApproveCanvasRelease: vi.fn(),
mockRejectCanvasRelease: vi.fn(),
@@ -28,6 +35,24 @@ const translate = vi.hoisted(() => {
"canvases:promoteCanvasDescription": "Review permissions before promotion.",
"canvases:loadingPermissions": "Loading requested permissions",
"canvases:promotionScopeChange": "Promotion changes the canvas scope.",
"canvases:promotionDataAccessPreserved":
"Promotion changes placement only. Data access stays the same.",
"canvases:promotionDataAccessChanges": "Promotion expands data access.",
"canvases:promotionSourceScope": "Placement before promotion",
"canvases:promotionTargetScope": "Placement after promotion",
"canvases:promotionCurrentDataScope": "Data access before promotion",
"canvases:promotionTargetDataScope": "Data access after promotion",
"canvases:taskDataScope": "Task data",
"canvases:workspaceDataScope": "Workspace data",
"canvases:taskPlacementScope": "Task",
"canvases:workspacePlacementScope": "Workspace",
"canvases:enableWorkspaceData": ENABLE_WORKSPACE_DATA_TEXT,
"canvases:enableWorkspaceDataHelp": "Enable declared permissions for this workspace.",
"canvases:workspaceDataReviewDescription": "Review permissions for {{title}}.",
"canvases:workspaceDataReviewScopeChange":
"Only data access changes. The canvas stays in this task.",
"canvases:enablingWorkspaceData": "Enabling workspace data",
"canvases:activeRelease": "Active release",
"canvases:noAdditionalPermissions": "No additional permissions were requested.",
"canvases:permissionDeclaration": "Declared permissions",
"canvases:missingPermissions": "Permissions still needed",
@@ -50,13 +75,15 @@ const translate = vi.hoisted(() => {
"canvases:permissionExternalOrigins": "External origins",
"canvases:permissionExternalOrigin": "External HTTPS origin",
"canvases:permissionSharedState": "Read and write shared canvas state",
"canvases:permissionReadTasks": "Read task data",
"canvases:permissionReadTasks": READ_TASK_DATA_TEXT,
"canvases:permissionReadWorkflows": "Read workflow data",
"canvases:permissionWriteTasks": "Write task data",
"canvases:permissionWriteMessages": "Write task messages",
"canvases:permissionEventTaskUpdated": "Receive task update events",
"canvases:permissionEventWorkflowUpdated": "Receive workflow update events",
"canvases:unsupportedPermission": "Unsupported permission: {{value}}",
"canvases:workspaceDataReviewStale": "The review changed. Open it again.",
"canvases:workspaceOwnerRequired": "Only the workspace owner can enable workspace data.",
"canvases:selectRelease": "Select a release to review",
"canvases:releaseOption": "{{date}} ({{status}})",
"canvases:releaseDate": "Created",
@@ -71,11 +98,9 @@ const translate = vi.hoisted(() => {
"canvases:sourceActorSystem": "Kandev",
"canvases:sourceActorUnknown": "Unknown author",
"canvases:sharedState": "Shared state",
"canvases:promotionSourceScope": "Source scope",
"canvases:promotionSourceActor": "Source actor",
"canvases:promotionSourceTask": "Source task",
"canvases:promotionSourceSession": "Source session",
"canvases:promotionTargetScope": "Target scope",
"canvases:promotionPlacement": "Workspace placement",
"common:cancel": "Cancel",
"common:close": "Close",
@@ -114,13 +139,19 @@ vi.mock("@kandev/ui/dialog", () => ({
vi.mock("@/lib/api/domains/canvas-api", () => ({
approveCanvasRelease: mockApproveCanvasRelease,
confirmCanvasPromotion: mockConfirmCanvasPromotion,
enableCanvasWorkspaceData: mockEnableCanvasWorkspaceData,
listCanvasReleases: mockListCanvasReleases,
rejectCanvasRelease: mockRejectCanvasRelease,
requestCanvasWorkspaceData: mockRequestCanvasWorkspaceData,
requestCanvasPromotion: mockRequestCanvasPromotion,
rollbackCanvas: mockRollbackCanvas,
}));
import { CanvasPromotionDialog, CanvasReleaseDialog } from "./canvas-lifecycle-dialogs";
import {
CanvasPromotionDialog,
CanvasReleaseDialog,
CanvasWorkspaceDataDialog,
} from "./canvas-lifecycle-dialogs";
const canvas: Canvas = {
id: "canvas-1",
@@ -143,6 +174,8 @@ const COPY = {
rollbackRelease: "Roll back release",
promotingCanvas: "Promoting canvas",
confirmPromotion: "Confirm promotion",
enableWorkspaceData: ENABLE_WORKSPACE_DATA_TEXT,
enablingWorkspaceData: "Enabling workspace data",
cancel: "Cancel",
} as const;
@@ -180,6 +213,8 @@ beforeEach(() => {
source_session_name: "Source session name",
current_scope: "task",
target_scope: "workspace",
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
placement: "workspace_sidebar",
permissions: {
reads: [TASKS_READ_PERMISSION],
@@ -190,6 +225,16 @@ beforeEach(() => {
},
});
mockConfirmCanvasPromotion.mockReset();
mockRequestCanvasWorkspaceData.mockReset().mockResolvedValue({
canvas,
active_release_id: "release-1",
permission_digest: "digest-1",
grant_generation: 7,
current_data_scope_kind: "task",
target_data_scope_kind: "workspace",
permissions: { reads: ["tasks"] },
});
mockEnableCanvasWorkspaceData.mockReset();
mockListCanvasReleases.mockReset().mockResolvedValue({ releases: [] });
mockApproveCanvasRelease.mockReset();
mockRejectCanvasRelease.mockReset();
@@ -206,7 +251,7 @@ describe("CanvasPromotionDialog", () => {
render(<CanvasPromotionDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await waitFor(() =>
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("task"),
expect(screen.getByTestId("canvas-promotion-source-scope").textContent).toContain("Task"),
);
expect(screen.getByTestId("canvas-promotion-source-task").textContent).toContain(
@@ -219,7 +264,7 @@ describe("CanvasPromotionDialog", () => {
"workspace_sidebar",
);
expect(screen.getByText("API reads")).toBeTruthy();
expect(screen.getByText("Read task data")).toBeTruthy();
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
expect(screen.getByText("API writes")).toBeTruthy();
expect(screen.getByText("Write task data")).toBeTruthy();
expect(screen.getByText("Events")).toBeTruthy();
@@ -227,6 +272,11 @@ describe("CanvasPromotionDialog", () => {
expect(screen.getByText("Shared state")).toBeTruthy();
expect(screen.getByText("External origins")).toBeTruthy();
expect(screen.getByText("https://example.test")).toBeTruthy();
expect(screen.getByTestId("canvas-promotion-current-data-scope").textContent).toBe("Task data");
expect(screen.getByTestId("canvas-promotion-target-data-scope").textContent).toBe(
"Workspace data",
);
expect(screen.getByText("Promotion expands data access.")).toBeTruthy();
});
it("maps stable API error codes to localized copy", async () => {
@@ -309,6 +359,72 @@ describe("CanvasPromotionDialog", () => {
});
});
describe("CanvasWorkspaceDataDialog", () => {
it("shows the active release, current and target data scopes, and declared permissions", async () => {
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
expect((await screen.findByTestId("canvas-workspace-data-release")).textContent).toBe(
"release-1",
);
expect(screen.getByTestId("canvas-workspace-data-current-scope").textContent).toBe("Task data");
expect(screen.getByTestId("canvas-workspace-data-target-scope").textContent).toBe(
"Workspace data",
);
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
});
it("enables exactly the reviewed release and grant generation", async () => {
const updated = { ...canvas, data_scope_kind: "workspace" };
const onCompleted = vi.fn();
const onOpenChange = vi.fn();
mockEnableCanvasWorkspaceData.mockResolvedValue(updated);
render(
<CanvasWorkspaceDataDialog
canvas={canvas}
open
onOpenChange={onOpenChange}
onCompleted={onCompleted}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: COPY.enableWorkspaceData }));
await waitFor(() =>
expect(mockEnableCanvasWorkspaceData).toHaveBeenCalledWith(canvas.id, {
expected_release_id: "release-1",
expected_permission_digest: "digest-1",
expected_grant_generation: 7,
}),
);
await waitFor(() => expect(onCompleted).toHaveBeenCalledWith(updated));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("cancels without changing workspace data access", async () => {
const onOpenChange = vi.fn();
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={onOpenChange} />);
fireEvent.click(await screen.findByRole("button", { name: COPY.cancel }));
expect(mockEnableCanvasWorkspaceData).not.toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("uses a full-height phone review with one scroll region and safe-area actions", async () => {
responsive.isMobile = true;
render(<CanvasWorkspaceDataDialog canvas={canvas} open onOpenChange={vi.fn()} />);
await screen.findByTestId("canvas-workspace-data-release");
expect(screen.getByTestId("canvas-workspace-data-dialog").className).toContain("h-dvh");
expect(screen.getByTestId("canvas-workspace-data-review-scroll").className).toContain(
"overflow-y-auto",
);
expect(screen.getByRole("button", { name: COPY.enableWorkspaceData }).className).toContain(
"min-h-12",
);
});
});
describe("CanvasReleaseDialog validation", () => {
it("localizes stable release validation codes", async () => {
mockListCanvasReleases.mockResolvedValue({
@@ -353,7 +469,7 @@ describe("CanvasReleaseDialog permission review", () => {
expect(screen.getByTestId("canvas-release-permissions-release-pending")).toBeTruthy(),
);
expect(screen.getByText("Declared permissions")).toBeTruthy();
expect(screen.getByText("Read task data")).toBeTruthy();
expect(screen.getByText(READ_TASK_DATA_TEXT)).toBeTruthy();
expect(screen.getByText("Write task messages")).toBeTruthy();
expect(screen.getByText(EXTERNAL_ORIGIN)).toBeTruthy();
expect(screen.getByText("New")).toBeTruthy();