From a669ffa595c3d778b31abe6c443f35720505461b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 10 Jun 2026 23:33:41 +0000 Subject: [PATCH] PAP-10836/PAP-10838 pipeline settings page + tutorial e2e spec (Line B rescue) Rescue commit: uncommitted PipelineSettings page + tests, shared stage-config validators, server route/service edits, and the pipelines tutorial Playwright spec from the shared main checkout. Co-Authored-By: Paperclip --- packages/shared/src/index.ts | 2 + packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/pipeline.ts | 24 + server/src/routes/pipelines.ts | 6 +- server/src/services/pipelines.test.ts | 8 +- server/src/services/pipelines.ts | 45 ++ tests/e2e/pipelines-tutorial-flow.spec.ts | 294 ++++++++ ui/src/App.tsx | 4 +- ui/src/api/pipelines.ts | 3 + ui/src/lib/pipeline-item-detail.ts | 11 +- ui/src/lib/pipeline-settings.test.ts | 44 ++ ui/src/lib/queryKeys.ts | 1 + ui/src/pages/PipelineSettings.test.tsx | 243 +++++++ ui/src/pages/PipelineSettings.tsx | 800 +++++++++++++++++++++ ui/src/pages/Pipelines.test.tsx | 21 +- ui/src/pages/Pipelines.tsx | 596 ++++++++++++++- 16 files changed, 2060 insertions(+), 44 deletions(-) create mode 100644 tests/e2e/pipelines-tutorial-flow.spec.ts create mode 100644 ui/src/lib/pipeline-settings.test.ts create mode 100644 ui/src/pages/PipelineSettings.test.tsx create mode 100644 ui/src/pages/PipelineSettings.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 62d391deccd..a5417b6ab17 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1145,6 +1145,8 @@ export { pipelineStageConfigSchema, pipelineStageVariableSchema, pipelineStageRunRoutineSchema, + pipelineStageDisableConfigSchema, + pipelineStageApprovalConfigSchema, pipelineStatusSchema, pipelineStageKindSchema, pipelineCaseIssueLinkRoleSchema, diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 78ca6f75f9e..41989567797 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -469,6 +469,8 @@ export { pipelineStageConfigSchema, pipelineStageVariableSchema, pipelineStageRunRoutineSchema, + pipelineStageDisableConfigSchema, + pipelineStageApprovalConfigSchema, pipelineStatusSchema, pipelineStageKindSchema, pipelineCaseIssueLinkRoleSchema, diff --git a/packages/shared/src/validators/pipeline.ts b/packages/shared/src/validators/pipeline.ts index e14556000fe..73e779d305e 100644 --- a/packages/shared/src/validators/pipeline.ts +++ b/packages/shared/src/validators/pipeline.ts @@ -18,6 +18,25 @@ const pipelineStageOnEnterSchema = z.object({ run_routine: pipelineStageRunRoutineSchema, }).strict(); +export const pipelineStageDisableConfigSchema = z.object({ + newEntries: z.boolean().optional().default(false), + reason: z.string().trim().max(1_000).nullable().optional().default(null), +}).strict(); + +export const pipelineStageApprovalConfigSchema = z.object({ + required: z.boolean().optional().default(false), + approverType: z.enum(["any_human", "human", "agent"]).optional().default("any_human"), + approverId: z.string().trim().max(200).nullable().optional().default(null), +}).strict().superRefine((value, ctx) => { + if (value.required && value.approverType !== "any_human" && !value.approverId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["approverId"], + message: "A human or agent approver must be selected", + }); + } +}); + export const pipelineStageVariableSchema = z.object({ key: routineVariableLikeNameSchema, label: z.string().trim().max(120), @@ -45,6 +64,9 @@ export const pipelineStageVariableSchema = z.object({ export const pipelineStageConfigSchema = z.object({ variables: z.array(pipelineStageVariableSchema).default([]), onEnter: pipelineStageOnEnterSchema.optional(), + disable: pipelineStageDisableConfigSchema.optional(), + approval: pipelineStageApprovalConfigSchema.optional(), + whatHappensHere: z.string().trim().max(10_000).optional(), }).passthrough().superRefine((value, ctx) => { const keys = new Set(); value.variables.forEach((variable, index) => { @@ -276,4 +298,6 @@ export type PipelineCaseTransitionConfig = Record; export type PipelineCaseConfig = Record; export type PipelineStageConfig = z.infer; export type PipelineStageVariable = z.infer; +export type PipelineStageDisableConfig = z.infer; +export type PipelineStageApprovalConfig = z.infer; export type PipelineStageRunRoutineConfig = z.infer; diff --git a/server/src/routes/pipelines.ts b/server/src/routes/pipelines.ts index ad5105f4a12..45c412a257b 100644 --- a/server/src/routes/pipelines.ts +++ b/server/src/routes/pipelines.ts @@ -22,7 +22,7 @@ import { updatePipelineSchema, updatePipelineStageSchema, } from "@paperclipai/shared"; -import { extractPipelineIntakeFormFields } from "../services/pipelines.js"; +import { extractPipelineIntakeFormFields, isStageNewEntryDisabled } from "../services/pipelines.js"; import { validate } from "../middleware/validate.js"; import { pipelineService, issueService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; @@ -826,9 +826,13 @@ export function pipelineRoutes(db: Db) { const pipeline = await svc.getPipelineWithRelations(pipelineId); const firstStage = pipeline?.stages?.[0] ?? null; const fields = firstStage ? extractPipelineIntakeFormFields([firstStage]) : []; + const disabledConfig = firstStage?.config?.disable; res.json({ pipelineId, stageId: firstStage?.id ?? null, + stageName: firstStage?.name ?? null, + newEntriesDisabled: firstStage ? isStageNewEntryDisabled(firstStage.config) : false, + disabledReason: disabledConfig?.reason ?? null, fields, }); }); diff --git a/server/src/services/pipelines.test.ts b/server/src/services/pipelines.test.ts index f94382cd681..26a9cd20b88 100644 --- a/server/src/services/pipelines.test.ts +++ b/server/src/services/pipelines.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { extractPipelineIntakeFormFields, mapConfiguredFields, triggerStageOnEnter, validateStageRequiredFields } from "./pipelines.js"; +import { extractPipelineIntakeFormFields, isStageNewEntryDisabled, mapConfiguredFields, triggerStageOnEnter, validateStageRequiredFields } from "./pipelines.js"; describe("pipeline service helpers", () => { it("derives intake fields from first stage variables", () => { @@ -62,6 +62,12 @@ describe("pipeline service helpers", () => { ).toThrowError(/Missing required field\(s\): customer/); }); + it("detects stages that block new entries from config", () => { + expect(isStageNewEntryDisabled({ variables: [], disable: { newEntries: true } })).toBe(true); + expect(isStageNewEntryDisabled({ variables: [], disable: { newEntries: false } })).toBe(false); + expect(isStageNewEntryDisabled({ variables: [], disabled: true })).toBe(true); + }); + it("maps configured fields and triggers run_routine with resolved payload and actor", async () => { const runRoutine = vi.fn().mockResolvedValue({ id: "run-1" }); const routineSvc = { runRoutine }; diff --git a/server/src/services/pipelines.ts b/server/src/services/pipelines.ts index 07e030959c7..5bb4bf5b709 100644 --- a/server/src/services/pipelines.ts +++ b/server/src/services/pipelines.ts @@ -131,6 +131,18 @@ export function validateStageRequiredFields( } } +export function isStageNewEntryDisabled(config: PipelineStageConfig | unknown) { + const stageConfig = normalizeStageConfig(config); + if (stageConfig.disable?.newEntries) return true; + if ("disabled" in stageConfig && typeof stageConfig.disabled === "boolean") { + return stageConfig.disabled; + } + if ("newEntriesDisabled" in stageConfig && typeof stageConfig.newEntriesDisabled === "boolean") { + return stageConfig.newEntriesDisabled; + } + return false; +} + function coerceCaseFields(fields: Record | null | undefined) { return fields ?? {}; } @@ -248,6 +260,9 @@ async function resolvePipelineIntakeStage(db: PipelineDb, pipelineId: string, st } function validateStageRequiredFieldsForIngest(stage: PipelineStageRow, fields: Record) { + if (isStageNewEntryDisabled(stage.config)) { + throw unprocessable(`Stage "${stage.name}" is disabled for new entries`); + } const config = normalizeStageConfig(stage.config); const requiredVariables = Array.isArray(config.variables) ? config.variables.filter((variable) => variable.showInAddForm) @@ -519,6 +534,36 @@ export function pipelineService(db: Db) { }); try { + if (input.position !== undefined) { + const stage = await db.transaction(async (tx) => { + await tx + .update(pipelineStages) + .set({ position: sql`${pipelineStages.position} + 10000`, updatedAt: new Date() }) + .where(and(eq(pipelineStages.pipelineId, pipelineId), sql`${pipelineStages.position} >= ${nextPos}`)); + + const [inserted] = await tx + .insert(pipelineStages) + .values({ + ...input, + pipelineId, + position: nextPos, + config: input.config ?? { variables: [] }, + }) + .returning(); + + await tx + .update(pipelineStages) + .set({ position: sql`${pipelineStages.position} - 9999`, updatedAt: new Date() }) + .where(and(eq(pipelineStages.pipelineId, pipelineId), sql`${pipelineStages.position} >= ${nextPos + 10000}`)); + + return inserted; + }); + if (!stage) { + throw conflict("Failed to create pipeline stage"); + } + return stage as PipelineStageRow; + } + const [stage] = await db .insert(pipelineStages) .values({ diff --git a/tests/e2e/pipelines-tutorial-flow.spec.ts b/tests/e2e/pipelines-tutorial-flow.spec.ts new file mode 100644 index 00000000000..f935b9f42a3 --- /dev/null +++ b/tests/e2e/pipelines-tutorial-flow.spec.ts @@ -0,0 +1,294 @@ +import { expect, request as pwRequest, test, type APIRequestContext, type Page } from "@playwright/test"; + +const PORT = Number(process.env.PAPERCLIP_E2E_PORT ?? 3199); +const BASE_URL = `http://127.0.0.1:${PORT}`; + +type Pipeline = { id: string; name: string }; +type Stage = { id: string; name: string; position: number }; +type PipelineItem = { id: string; title: string; stageId: string | null }; + +async function expectOk(response: Awaited>, label: string) { + if (!response.ok()) { + throw new Error(`${label} failed: ${response.status()} ${await response.text()}`); + } +} + +async function createCompany(board: APIRequestContext) { + const response = await board.post("/api/companies", { + data: { name: `E2E Pipelines ${Date.now()}` }, + }); + await expectOk(response, "create company"); + return response.json() as Promise<{ id: string; issuePrefix: string }>; +} + +async function createPipeline(board: APIRequestContext, companyId: string): Promise { + const response = await board.post(`/api/companies/${companyId}/pipelines`, { + data: { + name: "Content production", + description: "Draft, review, and publish launch content.", + }, + }); + await expectOk(response, "create pipeline"); + return response.json() as Promise; +} + +async function createStage( + board: APIRequestContext, + pipelineId: string, + data: { name: string; kind: string; position: number }, +): Promise { + const response = await board.post(`/api/pipelines/${pipelineId}/stages`, { + data: { ...data, config: { variables: [] } }, + }); + await expectOk(response, `create ${data.name} stage`); + return response.json() as Promise; +} + +async function setTransitions( + board: APIRequestContext, + pipelineId: string, + transitions: Array<{ fromStageId: string; toStageId: string }>, +) { + const response = await board.put(`/api/pipelines/${pipelineId}/transitions`, { + data: { + transitions: transitions.map((transition) => ({ ...transition, config: {} })), + }, + }); + await expectOk(response, "set transitions"); +} + +async function listItems(board: APIRequestContext, pipelineId: string): Promise { + const response = await board.get(`/api/pipelines/${pipelineId}/cases`); + await expectOk(response, "list pipeline items"); + return response.json() as Promise; +} + +async function createItem( + board: APIRequestContext, + pipelineId: string, + data: { title: string; stageId: string; parentCaseId?: string; fields?: Record }, +): Promise { + const response = await board.post(`/api/pipelines/${pipelineId}/cases/ingest`, { + data: { + title: data.title, + stageId: data.stageId, + parentCaseId: data.parentCaseId, + fields: data.fields ?? {}, + }, + }); + await expectOk(response, `create item ${data.title}`); + return response.json() as Promise; +} + +async function suggestTransition(board: APIRequestContext, itemId: string, toStageId: string) { + const response = await board.post(`/api/cases/${itemId}/suggest-transition`, { + data: { + toStageId, + reason: "Draft is ready for the next review.", + }, + }); + await expectOk(response, "seed transition suggestion"); +} + +async function moveItem( + board: APIRequestContext, + itemId: string, + toStageId: string, + reason?: string, +) { + const response = await board.post(`/api/cases/${itemId}/transition`, { + data: { + toStageId, + reason, + }, + }); + await expectOk(response, "move item"); +} + +async function visibleText(page: Page) { + return page.locator("body").innerText(); +} + +async function expectProsumerVocabulary(page: Page) { + const text = await visibleText(page); + expect(text).not.toMatch(/\bcase\b/i); + expect(text).not.toMatch(/\breview_decided\b|\btransition_forced\b/); + expect(text).not.toMatch(/\b(?:400|401|403|404|409|422|500)\b/); +} + +async function dragItemToColumn(page: Page, itemName: string, columnName: string) { + const item = page.getByRole("button", { name: itemName }).first(); + const column = page.getByLabel(`${columnName} column`); + await expect(item).toBeVisible(); + await expect(column).toBeVisible(); + + const itemBox = await item.boundingBox(); + const columnBox = await column.boundingBox(); + if (!itemBox || !columnBox) { + throw new Error(`Unable to measure ${itemName} or ${columnName} for drag`); + } + + const startX = itemBox.x + itemBox.width / 2; + const startY = itemBox.y + itemBox.height / 2; + const targetX = columnBox.x + columnBox.width / 2; + const targetY = columnBox.y + Math.min(columnBox.height - 24, Math.max(88, columnBox.height / 2)); + + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX + 8, startY + 8, { steps: 4 }); + await page.mouse.move(targetX, targetY, { steps: 24 }); + await page.mouse.up(); +} + +test.describe("Pipelines tutorial UI flow", () => { + test.setTimeout(180_000); + + test("walks setup, intake, board moves, item detail, review queue, and learnings", async ({ page }) => { + const board = await pwRequest.newContext({ baseURL: BASE_URL }); + const company = await createCompany(board); + const pipeline = await createPipeline(board, company.id); + const drafting = await createStage(board, pipeline.id, { name: "Drafting", kind: "working", position: 0 }); + const published = await createStage(board, pipeline.id, { name: "Published", kind: "done", position: 2 }); + await setTransitions(board, pipeline.id, [{ fromStageId: drafting.id, toStageId: published.id }]); + await page.goto("/"); + await page.evaluate((companyId) => { + window.localStorage.setItem("paperclip.selectedCompanyId", companyId); + }, company.id); + const companyPath = `/${company.issuePrefix}`; + + await page.goto(`${companyPath}/pipelines/${pipeline.id}/settings`); + await expect(page.getByRole("heading", { name: "Content production settings" })).toBeVisible(); + await page.getByRole("button", { name: "Add variable" }).click(); + await page.getByLabel("Variable key").fill("content_type"); + await page.getByLabel("Variable label").fill("Content type"); + await page.getByLabel("Variable type").selectOption("select"); + await page.getByLabel("Variable options").fill("Blog post, Changelog entry, Launch tweet"); + await page.getByRole("button", { name: "Save stage" }).click(); + await expect(page.getByText("Stage saved")).toBeVisible(); + + await page.getByRole("button", { name: "Insert stage after Drafting" }).click(); + await expect(page.getByRole("button", { name: /^New stage Position/ })).toBeVisible(); + + await page.getByLabel("Name").fill("Assets"); + await page.getByLabel("Kind").selectOption("review"); + await page.getByRole("switch").nth(1).click(); + await expect(page.getByLabel("Approval picker")).toHaveValue("any_human"); + await page.getByPlaceholder("Describe the work that should happen in this stage.").fill( + "Review draft quality and ask for assets before publishing.", + ); + await page.getByRole("button", { name: "Save stage" }).click(); + await expect(page.getByText("Stage saved")).toBeVisible(); + await expectProsumerVocabulary(page); + + await page.goto(`${companyPath}/pipelines/${pipeline.id}`); + await page.getByRole("button", { name: "Add items" }).click(); + await expect(page.getByRole("heading", { name: "Add items" })).toBeVisible(); + await page.getByLabel("Title").nth(0).fill("Launch blog post"); + await page.getByLabel("Content type").nth(0).selectOption("Blog post"); + await page.getByRole("button", { name: "Add another item" }).click(); + await page.getByLabel("Title").nth(1).fill("Changelog entry"); + await page.getByLabel("Content type").nth(1).selectOption("Changelog entry"); + await page.getByRole("button", { name: "Add another item" }).click(); + await page.getByLabel("Title").nth(2).fill("Launch tweet"); + await page.getByLabel("Content type").nth(2).selectOption("Launch tweet"); + await expect(page.getByRole("button", { name: "Submit 3 items" })).toBeEnabled(); + await page.getByRole("button", { name: "Submit 3 items" }).click(); + await expect(page.getByText("Launch blog post")).toBeVisible(); + await expect(page.getByText("Changelog entry")).toBeVisible(); + await expect(page.getByText("Launch tweet")).toBeVisible(); + await expectProsumerVocabulary(page); + + const stagesResponse = await board.get(`/api/pipelines/${pipeline.id}/stages`); + await expectOk(stagesResponse, "list stages"); + const stages = await stagesResponse.json() as Stage[]; + const assets = stages.find((stage) => stage.name === "Assets"); + const refreshedPublished = stages.find((stage) => stage.name === "Published"); + expect(assets).toBeTruthy(); + expect(refreshedPublished).toBeTruthy(); + + let items = await listItems(board, pipeline.id); + const blog = items.find((item) => item.title === "Launch blog post"); + const changelog = items.find((item) => item.title === "Changelog entry"); + const tweet = items.find((item) => item.title === "Launch tweet"); + expect(blog).toBeTruthy(); + expect(changelog).toBeTruthy(); + expect(tweet).toBeTruthy(); + + await dragItemToColumn(page, "Launch blog post", "Assets"); + await expect(page.getByRole("heading", { name: "Move Launch blog post?" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Move it" })).toBeEnabled(); + await moveItem(board, blog!.id, assets!.id); + await page.goto(`${companyPath}/pipelines/${pipeline.id}`); + await expect(page.getByLabel("Assets column").getByText("Launch blog post")).toBeVisible(); + + await dragItemToColumn(page, "Changelog entry", "Assets"); + await expect(page.getByRole("button", { name: "Move it" })).toBeEnabled(); + await moveItem(board, changelog!.id, assets!.id); + await page.goto(`${companyPath}/pipelines/${pipeline.id}`); + await expect(page.getByLabel("Assets column").getByText("Changelog entry")).toBeVisible(); + + await dragItemToColumn(page, "Launch tweet", "Published"); + await expect(page.getByRole("heading", { name: "This skips the normal flow" })).toBeVisible(); + await page.getByLabel("Reason").fill("Tweet can skip review because the blog post already covers the announcement."); + await expect(page.getByRole("button", { name: "Override and move" })).toBeEnabled(); + await moveItem( + board, + tweet!.id, + refreshedPublished!.id, + "Tweet can skip review because the blog post already covers the announcement.", + ); + await page.goto(`${companyPath}/pipelines/${pipeline.id}`); + await expect(page.getByLabel("Published column").getByText("Launch tweet")).toBeVisible(); + await expectProsumerVocabulary(page); + + await suggestTransition(board, blog!.id, refreshedPublished!.id); + const root = await createItem(board, pipeline.id, { + title: "Pipeline primitives launch", + stageId: drafting.id, + fields: { content_type: "Blog post" }, + }); + await createItem(board, pipeline.id, { + title: "Launch package draft", + stageId: drafting.id, + parentCaseId: root.id, + fields: { content_type: "Blog post" }, + }); + await createItem(board, pipeline.id, { + title: "Launch package changelog", + stageId: drafting.id, + parentCaseId: root.id, + fields: { content_type: "Changelog entry" }, + }); + + await page.goto(`${companyPath}/pipelines/${pipeline.id}/items/${blog!.id}`); + await expect(page.getByRole("heading", { name: "Launch blog post" })).toBeVisible(); + await expect(page.getByText("Agent suggests moving this item")).toBeVisible(); + await page.getByRole("button", { name: "Accept" }).click(); + await expect(page.getByText("You approved the suggestion for Launch blog post.")).toBeVisible(); + await expectProsumerVocabulary(page); + + await page.goto(`${companyPath}/pipelines/${pipeline.id}/items/${root.id}`); + await expect(page.getByRole("heading", { name: "Pipeline primitives launch" })).toBeVisible(); + await expect(page.getByText("Built from 2 items")).toBeVisible(); + await expectProsumerVocabulary(page); + + await page.goto(`${companyPath}/review-queue`); + await expect(page.locator("main").getByRole("heading", { name: "Review queue" })).toBeVisible(); + await expect(page.getByText("Needs your attention")).toBeVisible(); + await page.getByRole("button", { name: /Launch blog post Content production/ }).first().click(); + await page.getByRole("button", { name: "Approve" }).last().click(); + await expect(page.getByText("Launch blog post")).toHaveCount(0); + await page.getByRole("button", { name: /Changelog entry Content production/ }).first().click(); + await page.getByLabel("Note").fill("Tighten the framing before publishing."); + await page.getByRole("button", { name: "Request changes" }).last().click(); + await expectProsumerVocabulary(page); + + await page.goto(`${companyPath}/learnings`); + await expect(page.locator("main").getByRole("heading", { name: "Learnings" })).toBeVisible(); + await expect(page.getByText("Tighten the framing before publishing.")).toBeVisible(); + await expect(page.getByText("Tweet can skip review because the blog post already covers the announcement.")).toBeVisible(); + await expectProsumerVocabulary(page); + + await board.dispose(); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8d005320da4..cf428d778d9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,9 +18,9 @@ import { PipelineBoardStub, PipelineItemStub, Pipelines, - PipelineSettingsStub, ReviewQueueStub, } from "./pages/Pipelines"; +import { PipelineSettings } from "./pages/PipelineSettings"; import { Issues } from "./pages/Issues"; import { Search } from "./pages/Search"; import { IssueDetail } from "./pages/IssueDetail"; @@ -129,7 +129,7 @@ function boardRoutes() { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/api/pipelines.ts b/ui/src/api/pipelines.ts index 6c4945b5c5b..ebc450e2885 100644 --- a/ui/src/api/pipelines.ts +++ b/ui/src/api/pipelines.ts @@ -136,6 +136,9 @@ export interface PipelineAttentionItem { export interface PipelineIntakeForm { pipelineId: string; stageId: string | null; + stageName?: string | null; + newEntriesDisabled?: boolean; + disabledReason?: string | null; fields: Array<{ key: string; label: string; diff --git a/ui/src/lib/pipeline-item-detail.ts b/ui/src/lib/pipeline-item-detail.ts index 02c60f13854..b7a4c538e79 100644 --- a/ui/src/lib/pipeline-item-detail.ts +++ b/ui/src/lib/pipeline-item-detail.ts @@ -58,8 +58,8 @@ function payloadText(event: PipelineCaseEvent, ...keys: string[]): string | null return null; } -function sentenceSubject(event: PipelineCaseEvent): string { - return event.caseTitle ?? payloadText(event, "itemTitle", "title") ?? "This item"; +function sentenceSubject(event: PipelineCaseEvent, fallbackTitle?: string | null): string { + return event.caseTitle ?? payloadText(event, "itemTitle", "title") ?? asString(fallbackTitle) ?? "This item"; } export function humanizePipelineItemStatus(status: string | null | undefined): string { @@ -111,8 +111,11 @@ function formatReviewDecision(decision: string | null): string { return "approved this item"; } -export function formatPipelineItemEvent(event: PipelineCaseEvent): PipelineItemEventPresentation { - const title = sentenceSubject(event); +export function formatPipelineItemEvent( + event: PipelineCaseEvent, + fallbackTitle?: string | null, +): PipelineItemEventPresentation { + const title = sentenceSubject(event, fallbackTitle); if (event.kind === "case.ingested") { const stage = event.toStageName ?? payloadText(event, "toStageName", "stageName"); diff --git a/ui/src/lib/pipeline-settings.test.ts b/ui/src/lib/pipeline-settings.test.ts new file mode 100644 index 00000000000..9ad70bcd773 --- /dev/null +++ b/ui/src/lib/pipeline-settings.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { pipelineStageConfigSchema } from "@paperclipai/shared"; + +describe("pipeline settings stage config", () => { + it("accepts disable and approval settings stored in stage config", () => { + const parsed = pipelineStageConfigSchema.safeParse({ + variables: [ + { + key: "customer", + label: "Customer", + type: "text", + options: [], + required: true, + showInAddForm: true, + }, + ], + disable: { + newEntries: true, + reason: "Pause intake while the team clears the queue.", + }, + approval: { + required: true, + approverType: "agent", + approverId: "agent-1", + }, + whatHappensHere: "Triage every incoming item before work starts.", + }); + + expect(parsed.success).toBe(true); + }); + + it("requires a concrete approver when approval targets a human or agent", () => { + const parsed = pipelineStageConfigSchema.safeParse({ + variables: [], + approval: { + required: true, + approverType: "human", + approverId: null, + }, + }); + + expect(parsed.success).toBe(false); + }); +}); diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 097957fb187..75b6c8646ef 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -140,6 +140,7 @@ export const queryKeys = { list: (companyId: string) => ["pipelines", companyId] as const, detail: (id: string) => ["pipelines", "detail", id] as const, cases: (id: string) => ["pipelines", "cases", id] as const, + intakeForm: (id: string) => ["pipelines", "intake-form", id] as const, caseDetail: (id: string) => ["pipelines", "case", id] as const, caseChildren: (id: string) => ["pipelines", "case", id, "children"] as const, caseEvents: (id: string) => ["pipelines", "case", id, "events"] as const, diff --git a/ui/src/pages/PipelineSettings.test.tsx b/ui/src/pages/PipelineSettings.test.tsx new file mode 100644 index 00000000000..4aa16409ab8 --- /dev/null +++ b/ui/src/pages/PipelineSettings.test.tsx @@ -0,0 +1,243 @@ +// @vitest-environment jsdom + +import { createRoot } from "react-dom/client"; +import { flushSync } from "react-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Agent } from "@paperclipai/shared"; +import type { CompanyUserDirectoryResponse } from "../api/access"; +import type { Pipeline } from "../api/pipelines"; +import { agentsApi } from "../api/agents"; +import { accessApi } from "../api/access"; +import { pipelinesApi } from "../api/pipelines"; +import { PipelineSettings } from "./PipelineSettings"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + ...props + }: React.AnchorHTMLAttributes & { to: string }) => ( + {children} + ), + useNavigate: () => vi.fn(), + useParams: () => ({ pipelineId: "pipeline-1" }), +})); + +vi.mock("../context/CompanyContext", () => ({ + useCompany: () => ({ selectedCompanyId: "company-1" }), +})); + +vi.mock("../context/BreadcrumbContext", () => ({ + useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), +})); + +vi.mock("../context/ToastContext", () => ({ + useToastActions: () => ({ pushToast: vi.fn() }), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function makePipeline(): Pipeline { + return { + id: "pipeline-1", + companyId: "company-1", + name: "Content pipeline", + description: "Publish useful work", + status: "active", + stages: [ + { + id: "stage-1", + pipelineId: "pipeline-1", + name: "Intake", + kind: "open", + position: 0, + config: { + variables: [ + { + key: "customer", + label: "Customer", + type: "text", + options: [], + required: true, + showInAddForm: true, + }, + ], + approval: { required: false, approverType: "any_human", approverId: null }, + disable: { newEntries: false, reason: null }, + whatHappensHere: "Collect requests.", + }, + }, + { + id: "stage-2", + pipelineId: "pipeline-1", + name: "Review", + kind: "review", + position: 1, + config: { variables: [] }, + }, + ], + transitions: [{ fromStageId: "stage-1", toStageId: "stage-2", config: {} }], + guidanceDocuments: [{ id: "guidance-1", pipelineId: "pipeline-1", key: "plain-language", title: "Pipeline guidance", body: "Be clear." }], + }; +} + +function renderSettings() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + flushSync(() => { + root.render( + + + , + ); + }); + + return { container, root, queryClient }; +} + +async function flushQueries() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("PipelineSettings", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.spyOn(pipelinesApi, "get").mockResolvedValue(makePipeline()); + vi.spyOn(pipelinesApi, "updateStage").mockResolvedValue(makePipeline().stages![0]!); + vi.spyOn(pipelinesApi, "setTransitions").mockResolvedValue({ transitions: [] }); + vi.spyOn(pipelinesApi, "createStage").mockResolvedValue({ + id: "stage-3", + pipelineId: "pipeline-1", + name: "New stage", + kind: "working", + position: 1, + config: { variables: [] }, + }); + vi.spyOn(pipelinesApi, "upsertGuidanceDocument").mockResolvedValue({ + id: "guidance-1", + pipelineId: "pipeline-1", + key: "plain-language", + title: "Pipeline guidance", + body: "Updated.", + }); + vi.spyOn(pipelinesApi, "remove").mockResolvedValue(makePipeline()); + vi.spyOn(agentsApi, "list").mockResolvedValue([ + { id: "agent-1", name: "QA Agent", role: "QA", status: "active" } as unknown as Agent, + ]); + vi.spyOn(accessApi, "listUserDirectory").mockResolvedValue({ + users: [ + { + principalId: "user-1", + status: "active", + user: { id: "user-1", name: "Ada Human", email: "ada@example.com", image: null }, + }, + ], + } satisfies CompanyUserDirectoryResponse); + }); + + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("renders only Stages, Guidance, and Advanced top-level tabs", async () => { + const { container, root, queryClient } = renderSettings(); + await flushQueries(); + + const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); + expect(tabLabels).toEqual(["Stages", "Guidance", "Advanced"]); + expect(container.textContent).not.toContain("Automation"); + + flushSync(() => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("renders selected stage sections in the required order", async () => { + const { container, root, queryClient } = renderSettings(); + await flushQueries(); + + const expected = [ + "Basics", + "Disable", + "Approval", + "What happens here", + "Routine variables", + "Connections", + "Advanced identifiers", + ]; + const headings = Array.from(container.querySelectorAll("h2")) + .map((heading) => heading.textContent ?? "") + .filter((heading) => expected.includes(heading)); + expect(headings).toEqual(expected); + + flushSync(() => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("hides the approval picker until approval is required", async () => { + const { container, root, queryClient } = renderSettings(); + await flushQueries(); + + expect(container.querySelector('[aria-label="Approval picker"]')).toBeNull(); + const switches = Array.from(container.querySelectorAll('[role="switch"]')) as HTMLButtonElement[]; + expect(switches.length).toBeGreaterThanOrEqual(2); + + flushSync(() => { + switches[1]!.click(); + }); + + const picker = container.querySelector('[aria-label="Approval picker"]'); + expect(picker).not.toBeNull(); + const options = Array.from(picker!.querySelectorAll("option")).map((option) => option.textContent); + expect(options).toContain("Any human"); + expect(options).toContain("Ada Human"); + expect(options).toContain("QA Agent"); + + flushSync(() => { + root.unmount(); + }); + queryClient.clear(); + }); + + it("gates permanent delete behind the pipeline name", async () => { + const { container, root, queryClient } = renderSettings(); + await flushQueries(); + + const advancedTab = container.querySelector('[data-tab-value="advanced"]')!; + flushSync(() => { + advancedTab.click(); + }); + + const deleteButton = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.includes("Delete permanently"), + ) as HTMLButtonElement | undefined; + expect(deleteButton?.disabled).toBe(true); + + const input = container.querySelector("input")!; + flushSync(() => { + const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set; + valueSetter?.call(input, "Content pipeline"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + + expect(deleteButton?.disabled).toBe(false); + + flushSync(() => { + root.unmount(); + }); + queryClient.clear(); + }); +}); diff --git a/ui/src/pages/PipelineSettings.tsx b/ui/src/pages/PipelineSettings.tsx new file mode 100644 index 00000000000..2bc805ecd59 --- /dev/null +++ b/ui/src/pages/PipelineSettings.tsx @@ -0,0 +1,800 @@ +import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Agent } from "@paperclipai/shared"; +import { + AlertTriangle, + Check, + GitBranch, + Hexagon, + Plus, + Save, + Trash2, +} from "lucide-react"; +import { agentsApi } from "../api/agents"; +import { accessApi, type CompanyUserDirectoryEntry } from "../api/access"; +import type { Pipeline, PipelineStage, PipelineTransition } from "../api/pipelines"; +import { pipelinesApi } from "../api/pipelines"; +import { EmptyState } from "../components/EmptyState"; +import { PageSkeleton } from "../components/PageSkeleton"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { ToggleSwitch } from "@/components/ui/toggle-switch"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; +import { useToastActions } from "../context/ToastContext"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; +import { Link, useNavigate, useParams } from "@/lib/router"; + +type SettingsTab = "stages" | "guidance" | "advanced"; +type VariableType = "text" | "multiline" | "select"; +type ApprovalType = "any_human" | "human" | "agent"; + +type StageConfig = { + variables?: Array<{ + key: string; + label: string; + type?: VariableType; + options?: string[]; + required?: boolean; + showInAddForm?: boolean; + }>; + disable?: { + newEntries?: boolean; + reason?: string | null; + }; + approval?: { + required?: boolean; + approverType?: ApprovalType; + approverId?: string | null; + }; + whatHappensHere?: string; + [key: string]: unknown; +}; + +type EditorVariable = { + id: string; + key: string; + label: string; + type: VariableType; + optionsText: string; + required: boolean; + showInAddForm: boolean; +}; + +const TAB_LABELS: Array<{ id: SettingsTab; label: string }> = [ + { id: "stages", label: "Stages" }, + { id: "guidance", label: "Guidance" }, + { id: "advanced", label: "Advanced" }, +]; + +const PIPELINE_GUIDANCE_KEY = "plain-language"; + +function stageConfig(stage: PipelineStage | null | undefined): StageConfig { + const config = stage?.config; + if (!config || typeof config !== "object" || Array.isArray(config)) { + return { variables: [] }; + } + return config as StageConfig; +} + +function stageNewEntriesDisabled(stage: PipelineStage | null | undefined) { + const config = stageConfig(stage); + if (config.disable?.newEntries) return true; + if (typeof config.disabled === "boolean") return config.disabled; + if (typeof config.newEntriesDisabled === "boolean") return config.newEntriesDisabled; + return false; +} + +function variableRows(stage: PipelineStage | null | undefined): EditorVariable[] { + return (stageConfig(stage).variables ?? []).map((variable, index) => ({ + id: `${variable.key || "variable"}-${index}`, + key: variable.key, + label: variable.label, + type: variable.type ?? "text", + optionsText: (variable.options ?? []).join(", "), + required: Boolean(variable.required), + showInAddForm: Boolean(variable.showInAddForm), + })); +} + +function cleanVariables(variables: EditorVariable[]) { + return variables + .map((variable) => { + const type = variable.type; + const options = type === "select" + ? variable.optionsText + .split(",") + .map((option) => option.trim()) + .filter(Boolean) + : []; + return { + key: variable.key.trim(), + label: variable.label.trim() || variable.key.trim(), + type, + options, + required: variable.required, + showInAddForm: variable.showInAddForm, + }; + }) + .filter((variable) => variable.key); +} + +function approvalValue(config: StageConfig) { + const approval = config.approval; + if (!approval?.required || approval.approverType === "any_human" || !approval.approverType) { + return "any_human"; + } + return `${approval.approverType}:${approval.approverId ?? ""}`; +} + +function parseApprovalValue(value: string): { approverType: ApprovalType; approverId: string | null } { + if (value === "any_human") { + return { approverType: "any_human", approverId: null }; + } + const [type, id] = value.split(":", 2); + if (type === "human" || type === "agent") { + return { approverType: type, approverId: id || null }; + } + return { approverType: "any_human", approverId: null }; +} + +function sortedStages(pipeline: Pipeline | null | undefined) { + return [...(pipeline?.stages ?? [])].sort((left, right) => left.position - right.position); +} + +function transitionKey(transition: PipelineTransition) { + return `${transition.fromStageId}:${transition.toStageId}`; +} + +function transitionPayload(transition: PipelineTransition): PipelineTransition { + return { + fromStageId: transition.fromStageId, + toStageId: transition.toStageId, + config: transition.config ?? {}, + }; +} + +function humanLabel(entry: CompanyUserDirectoryEntry) { + return entry.user?.name || entry.user?.email || entry.principalId; +} + +function agentLabel(agent: Agent) { + return agent.name || agent.role || agent.id; +} + +function Section({ + title, + children, +}: { + title: string; + children: ReactNode; +}) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function PipelineSettings() { + const { pipelineId } = useParams<{ pipelineId: string }>(); + const { selectedCompanyId } = useCompany(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { pushToast } = useToastActions(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("stages"); + const [selectedStageId, setSelectedStageId] = useState(null); + const [stageName, setStageName] = useState(""); + const [stageKind, setStageKind] = useState("open"); + const [newEntriesDisabled, setNewEntriesDisabled] = useState(false); + const [disableReason, setDisableReason] = useState(""); + const [approvalRequired, setApprovalRequired] = useState(false); + const [selectedApproval, setSelectedApproval] = useState("any_human"); + const [whatHappensHere, setWhatHappensHere] = useState(""); + const [variables, setVariables] = useState([]); + const [transitionTargets, setTransitionTargets] = useState>(() => new Set()); + const [guidanceBody, setGuidanceBody] = useState(""); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + + const pipelineQuery = useQuery({ + queryKey: pipelineId ? queryKeys.pipelines.detail(pipelineId) : ["pipelines", "detail", "none"], + queryFn: () => pipelinesApi.get(pipelineId!), + enabled: !!pipelineId && !!selectedCompanyId, + }); + + const agentsQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none"], + queryFn: () => agentsApi.list(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const usersQuery = useQuery({ + queryKey: selectedCompanyId ? queryKeys.access.companyUserDirectory(selectedCompanyId) : ["access", "users", "none"], + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); + + const pipeline = pipelineQuery.data ?? null; + const stages = useMemo(() => sortedStages(pipeline), [pipeline]); + const selectedStage = stages.find((stage) => stage.id === selectedStageId) ?? stages[0] ?? null; + const guidanceDocument = pipeline?.guidanceDocuments?.find((doc) => doc.key === PIPELINE_GUIDANCE_KEY) + ?? pipeline?.guidanceDocuments?.[0] + ?? null; + + useEffect(() => { + if (!pipeline) return; + setBreadcrumbs([ + { label: "Pipelines", href: "/pipelines" }, + { label: pipeline.name, href: `/pipelines/${pipeline.id}` }, + { label: "Settings" }, + ]); + }, [pipeline, setBreadcrumbs]); + + useEffect(() => { + if (!selectedStageId && stages[0]) { + setSelectedStageId(stages[0].id); + } + }, [selectedStageId, stages]); + + useEffect(() => { + if (!selectedStage) return; + const config = stageConfig(selectedStage); + setStageName(selectedStage.name); + setStageKind(selectedStage.kind); + setNewEntriesDisabled(stageNewEntriesDisabled(selectedStage)); + setDisableReason(config.disable?.reason ?? ""); + setApprovalRequired(Boolean(config.approval?.required)); + setSelectedApproval(approvalValue(config)); + setWhatHappensHere(config.whatHappensHere ?? ""); + setVariables(variableRows(selectedStage)); + setTransitionTargets( + new Set( + (pipeline?.transitions ?? []) + .filter((transition) => transition.fromStageId === selectedStage.id) + .map((transition) => transition.toStageId), + ), + ); + }, [pipeline?.transitions, selectedStage]); + + useEffect(() => { + setGuidanceBody(guidanceDocument?.body ?? ""); + }, [guidanceDocument?.body]); + + const refreshPipeline = async () => { + if (!pipelineId) return; + await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.detail(pipelineId) }); + await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.intakeForm(pipelineId) }); + }; + + const saveStage = useMutation({ + mutationFn: async () => { + if (!pipelineId || !selectedStage || !pipeline) return null; + const parsedApproval = parseApprovalValue(selectedApproval); + const config: StageConfig = { + ...stageConfig(selectedStage), + variables: cleanVariables(variables), + disable: { + newEntries: newEntriesDisabled, + reason: disableReason.trim() || null, + }, + approval: { + required: approvalRequired, + approverType: approvalRequired ? parsedApproval.approverType : "any_human", + approverId: approvalRequired ? parsedApproval.approverId : null, + }, + whatHappensHere: whatHappensHere.trim(), + }; + + const existingTransitions = pipeline.transitions ?? []; + const selectedTransitionKeys = new Set( + [...transitionTargets].map((targetId) => `${selectedStage.id}:${targetId}`), + ); + const nextTransitions = [ + ...existingTransitions + .filter((transition) => transition.fromStageId !== selectedStage.id) + .map(transitionPayload), + ...[...transitionTargets].map((targetId) => ({ + fromStageId: selectedStage.id, + toStageId: targetId, + config: + existingTransitions.find((transition) => transitionKey(transition) === `${selectedStage.id}:${targetId}`) + ?.config ?? {}, + })), + ].filter((transition, index, all) => { + if (transition.fromStageId === transition.toStageId) return false; + const key = transitionKey(transition); + return selectedTransitionKeys.has(key) || all.findIndex((item) => transitionKey(item) === key) === index; + }); + + await pipelinesApi.updateStage(pipelineId, selectedStage.id, { + name: stageName.trim(), + kind: stageKind, + config, + }); + await pipelinesApi.setTransitions(pipelineId, { transitions: nextTransitions }); + return null; + }, + onSuccess: async () => { + await refreshPipeline(); + pushToast({ title: "Stage saved", tone: "success" }); + }, + }); + + const addStage = useMutation({ + mutationFn: async (afterStage: PipelineStage | null) => { + if (!pipelineId || !pipeline) return null; + const insertPosition = afterStage ? afterStage.position + 1 : stages.length; + const nextStage = afterStage + ? stages.find((stage) => stage.position > afterStage.position) ?? null + : null; + const created = await pipelinesApi.createStage(pipelineId, { + name: "New stage", + kind: "working", + position: insertPosition, + config: { variables: [] }, + }); + if (afterStage) { + const existingTransitions = pipeline.transitions ?? []; + const nextTransitions = existingTransitions + .filter( + (transition) => !(nextStage && transition.fromStageId === afterStage.id && transition.toStageId === nextStage.id), + ) + .map(transitionPayload); + nextTransitions.push({ fromStageId: afterStage.id, toStageId: created.id, config: {} }); + if (nextStage) { + nextTransitions.push({ fromStageId: created.id, toStageId: nextStage.id, config: {} }); + } + await pipelinesApi.setTransitions(pipelineId, { transitions: nextTransitions }); + } + return created; + }, + onSuccess: async (created) => { + await refreshPipeline(); + if (created) { + setSelectedStageId(created.id); + } + pushToast({ title: "Stage added", tone: "success" }); + }, + }); + + const saveGuidance = useMutation({ + mutationFn: () => + pipelinesApi.upsertGuidanceDocument(pipelineId!, PIPELINE_GUIDANCE_KEY, { + title: "Pipeline guidance", + body: guidanceBody.trim(), + }), + onSuccess: async () => { + await refreshPipeline(); + pushToast({ title: "Guidance saved", tone: "success" }); + }, + }); + + const deletePipeline = useMutation({ + mutationFn: () => pipelinesApi.remove(pipelineId!), + onSuccess: async () => { + if (selectedCompanyId) { + await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.list(selectedCompanyId) }); + } + navigate("/pipelines"); + }, + }); + + const addVariable = () => { + const nextIndex = variables.length + 1; + setVariables((current) => [ + ...current, + { + id: `new-${Date.now()}`, + key: `field_${nextIndex}`, + label: `Field ${nextIndex}`, + type: "text", + optionsText: "", + required: false, + showInAddForm: true, + }, + ]); + }; + + const updateVariable = (id: string, patch: Partial) => { + setVariables((current) => + current.map((variable) => variable.id === id ? { ...variable, ...patch } : variable), + ); + }; + + if (!selectedCompanyId) { + return ; + } + + if (!pipelineId) { + return ; + } + + if (pipelineQuery.isLoading) { + return ; + } + + if (pipelineQuery.error) { + return

{pipelineQuery.error.message}

; + } + + if (!pipeline) { + return ; + } + + const deleteEnabled = deleteConfirmation === pipeline.name && !deletePipeline.isPending; + + return ( +
+
+
+ + Back to board + +

{pipeline.name} settings

+ {pipeline.description ?

{pipeline.description}

: null} +
+
+ +
+ {TAB_LABELS.map((tab) => ( + + ))} +
+ + {activeTab === "stages" ? ( +
+ {stages.length === 0 ? ( + addStage.mutate(null)} + /> + ) : ( +
+
+ {stages.map((stage, index) => ( +
+ + + {index === stages.length - 1 ? null : ( +
+ ))} +
+
+ )} + + {selectedStage ? ( +
) => { + event.preventDefault(); + saveStage.mutate(); + }} + > +
+
+ + +
+
+ +
+
+
+
Block new entry
+

+ Existing items stay visible on the board. +

+
+ +
+ {newEntriesDisabled ? ( +