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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-10 23:33:41 +00:00
1 parent e457290823
commit a669ffa595
16 files changed
+2060 -44

No files matched your search

+2
View File
@@ -1145,6 +1145,8 @@ export {
pipelineStageConfigSchema,
pipelineStageVariableSchema,
pipelineStageRunRoutineSchema,
pipelineStageDisableConfigSchema,
pipelineStageApprovalConfigSchema,
pipelineStatusSchema,
pipelineStageKindSchema,
pipelineCaseIssueLinkRoleSchema,
+2
View File
@@ -469,6 +469,8 @@ export {
pipelineStageConfigSchema,
pipelineStageVariableSchema,
pipelineStageRunRoutineSchema,
pipelineStageDisableConfigSchema,
pipelineStageApprovalConfigSchema,
pipelineStatusSchema,
pipelineStageKindSchema,
pipelineCaseIssueLinkRoleSchema,
@@ -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<string>();
value.variables.forEach((variable, index) => {
@@ -276,4 +298,6 @@ export type PipelineCaseTransitionConfig = Record<string, unknown>;
export type PipelineCaseConfig = Record<string, unknown>;
export type PipelineStageConfig = z.infer<typeof pipelineStageConfigSchema>;
export type PipelineStageVariable = z.infer<typeof pipelineStageVariableSchema>;
export type PipelineStageDisableConfig = z.infer<typeof pipelineStageDisableConfigSchema>;
export type PipelineStageApprovalConfig = z.infer<typeof pipelineStageApprovalConfigSchema>;
export type PipelineStageRunRoutineConfig = z.infer<typeof pipelineStageRunRoutineSchema>;
+5 -1
View File
@@ -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,
});
});
+7 -1
View File
@@ -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 };
+45
View File
@@ -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<string, unknown> | null | undefined) {
return fields ?? {};
}
@@ -248,6 +260,9 @@ async function resolvePipelineIntakeStage(db: PipelineDb, pipelineId: string, st
}
function validateStageRequiredFieldsForIngest(stage: PipelineStageRow, fields: Record<string, unknown>) {
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({
+294
View File
@@ -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<ReturnType<APIRequestContext["get"]>>, 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<Pipeline> {
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<Pipeline>;
}
async function createStage(
board: APIRequestContext,
pipelineId: string,
data: { name: string; kind: string; position: number },
): Promise<Stage> {
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<Stage>;
}
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<PipelineItem[]> {
const response = await board.get(`/api/pipelines/${pipelineId}/cases`);
await expectOk(response, "list pipeline items");
return response.json() as Promise<PipelineItem[]>;
}
async function createItem(
board: APIRequestContext,
pipelineId: string,
data: { title: string; stageId: string; parentCaseId?: string; fields?: Record<string, unknown> },
): Promise<PipelineItem> {
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<PipelineItem>;
}
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();
});
});
+2 -2
View File
@@ -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() {
<Route path="pipelines" element={<Pipelines />} />
<Route path="pipelines/:pipelineId" element={<PipelineBoardStub />} />
<Route path="pipelines/:pipelineId/items/:caseId" element={<PipelineItemStub />} />
<Route path="pipelines/:pipelineId/settings" element={<PipelineSettingsStub />} />
<Route path="pipelines/:pipelineId/settings" element={<PipelineSettings />} />
<Route path="learnings" element={<LearningsStub />} />
<Route path="issues" element={<Issues />} />
<Route path="search" element={<Search />} />
+3
View File
@@ -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;
+7 -4
View File
@@ -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");
+44
View File
@@ -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);
});
});
+1
View File
@@ -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,
+243
View File
@@ -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<HTMLAnchorElement> & { to: string }) => (
<a href={to} {...props}>{children}</a>
),
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(
<QueryClientProvider client={queryClient}>
<PipelineSettings />
</QueryClientProvider>,
);
});
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<HTMLSelectElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>("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();
});
});
+800
View File
@@ -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 (
<section className="space-y-3 border-t border-border pt-5">
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
{children}
</section>
);
}
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<SettingsTab>("stages");
const [selectedStageId, setSelectedStageId] = useState<string | null>(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<EditorVariable[]>([]);
const [transitionTargets, setTransitionTargets] = useState<Set<string>>(() => 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<EditorVariable>) => {
setVariables((current) =>
current.map((variable) => variable.id === id ? { ...variable, ...patch } : variable),
);
};
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to edit pipeline settings." />;
}
if (!pipelineId) {
return <EmptyState icon={Hexagon} message="No pipeline selected." />;
}
if (pipelineQuery.isLoading) {
return <PageSkeleton variant="list" />;
}
if (pipelineQuery.error) {
return <p className="text-sm text-destructive">{pipelineQuery.error.message}</p>;
}
if (!pipeline) {
return <EmptyState icon={Hexagon} message="Pipeline not found." />;
}
const deleteEnabled = deleteConfirmation === pipeline.name && !deletePipeline.isPending;
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<Link to={`/pipelines/${pipeline.id}`} className="text-sm text-muted-foreground hover:text-foreground">
Back to board
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">{pipeline.name} settings</h1>
{pipeline.description ? <p className="mt-1 text-sm text-muted-foreground">{pipeline.description}</p> : null}
</div>
</div>
<div className="flex border-b border-border" role="tablist" aria-label="Pipeline settings tabs">
{TAB_LABELS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
data-tab-value={tab.id}
aria-selected={activeTab === tab.id}
className={cn(
"border-b-2 px-4 py-2 text-sm font-semibold",
activeTab === tab.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{activeTab === "stages" ? (
<div className="space-y-6">
{stages.length === 0 ? (
<EmptyState
icon={GitBranch}
message="No stages configured."
action="Add first stage"
onAction={() => addStage.mutate(null)}
/>
) : (
<div className="overflow-x-auto border-y border-border py-4">
<div className="flex min-w-max items-center gap-2">
{stages.map((stage, index) => (
<div key={stage.id} className="flex items-center gap-2">
<button
type="button"
className={cn(
"min-h-20 w-48 rounded-md border px-3 py-2 text-left text-sm transition-colors",
selectedStage?.id === stage.id
? "border-foreground bg-accent/50"
: "border-border hover:bg-accent/40",
)}
onClick={() => setSelectedStageId(stage.id)}
>
<span className="block font-semibold text-foreground">{stage.name}</span>
<span className="mt-1 block text-xs text-muted-foreground">Position {stage.position}</span>
{stageNewEntriesDisabled(stage) ? (
<span className="mt-2 inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-3 w-3" />
New entries disabled
</span>
) : null}
</button>
<button
type="button"
aria-label={`Insert stage after ${stage.name}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-dashed border-border text-muted-foreground hover:border-foreground hover:text-foreground"
onClick={() => addStage.mutate(stage)}
disabled={addStage.isPending}
>
<Plus className="h-4 w-4" />
</button>
{index === stages.length - 1 ? null : (
<span className="h-px w-8 bg-border" aria-hidden="true" />
)}
</div>
))}
</div>
</div>
)}
{selectedStage ? (
<form
className="space-y-5"
onSubmit={(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
saveStage.mutate();
}}
>
<Section title="Basics">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_220px]">
<label className="block space-y-1.5 text-sm font-medium">
<span>Name</span>
<Input value={stageName} onChange={(event) => setStageName(event.target.value)} required />
</label>
<label className="block space-y-1.5 text-sm font-medium">
<span>Kind</span>
<select
value={stageKind}
onChange={(event) => setStageKind(event.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="open">Open</option>
<option value="working">Working</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
</select>
</label>
</div>
</Section>
<Section title="Disable">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Block new entry</div>
<p className="mt-1 text-sm text-muted-foreground">
Existing items stay visible on the board.
</p>
</div>
<ToggleSwitch checked={newEntriesDisabled} onCheckedChange={setNewEntriesDisabled} />
</div>
{newEntriesDisabled ? (
<label className="block space-y-1.5 text-sm font-medium">
<span>Reason</span>
<Textarea
value={disableReason}
onChange={(event) => setDisableReason(event.target.value)}
rows={2}
/>
</label>
) : null}
</Section>
<Section title="Approval">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">Require approval</div>
<p className="mt-1 text-sm text-muted-foreground">
Approval routing is recorded on the stage configuration.
</p>
</div>
<ToggleSwitch checked={approvalRequired} onCheckedChange={setApprovalRequired} />
</div>
{approvalRequired ? (
<label className="block max-w-md space-y-1.5 text-sm font-medium">
<span>Approver</span>
<select
aria-label="Approval picker"
value={selectedApproval}
onChange={(event) => setSelectedApproval(event.target.value)}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="any_human">Any human</option>
<optgroup label="Humans">
{(usersQuery.data?.users ?? []).map((user) => (
<option key={user.principalId} value={`human:${user.principalId}`}>
{humanLabel(user)}
</option>
))}
</optgroup>
<optgroup label="Agents">
{(agentsQuery.data ?? []).map((agent) => (
<option key={agent.id} value={`agent:${agent.id}`}>
{agentLabel(agent)}
</option>
))}
</optgroup>
</select>
</label>
) : null}
</Section>
<Section title="What happens here">
<Textarea
value={whatHappensHere}
onChange={(event) => setWhatHappensHere(event.target.value)}
rows={4}
placeholder="Describe the work that should happen in this stage."
/>
</Section>
<Section title="Routine variables">
<div className="space-y-3">
{variables.map((variable) => (
<div key={variable.id} className="grid gap-2 border-b border-border pb-3 md:grid-cols-[160px_1fr_140px_1fr_auto]">
<Input
aria-label="Variable key"
value={variable.key}
onChange={(event) => updateVariable(variable.id, { key: event.target.value })}
placeholder="field_key"
/>
<Input
aria-label="Variable label"
value={variable.label}
onChange={(event) => updateVariable(variable.id, { label: event.target.value })}
placeholder="Field label"
/>
<select
aria-label="Variable type"
value={variable.type}
onChange={(event) => updateVariable(variable.id, { type: event.target.value as VariableType })}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="text">Text</option>
<option value="multiline">Multiline</option>
<option value="select">Select</option>
</select>
<Input
aria-label="Variable options"
value={variable.optionsText}
onChange={(event) => updateVariable(variable.id, { optionsText: event.target.value })}
placeholder="Options, comma separated"
disabled={variable.type !== "select"}
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${variable.label || variable.key}`}
onClick={() => setVariables((current) => current.filter((item) => item.id !== variable.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
<label className="flex items-center gap-2 text-sm text-muted-foreground md:col-span-2">
<input
type="checkbox"
checked={variable.required}
onChange={(event) => updateVariable(variable.id, { required: event.target.checked })}
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-muted-foreground md:col-span-3">
<input
type="checkbox"
checked={variable.showInAddForm}
onChange={(event) => updateVariable(variable.id, { showInAddForm: event.target.checked })}
/>
Show in Add-items form
</label>
</div>
))}
<Button type="button" variant="outline" onClick={addVariable}>
<Plus className="h-4 w-4" />
Add variable
</Button>
</div>
</Section>
<Section title="Connections">
<div className="grid gap-2 sm:grid-cols-2">
{stages.filter((stage) => stage.id !== selectedStage.id).map((stage) => (
<label key={stage.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<input
type="checkbox"
checked={transitionTargets.has(stage.id)}
onChange={(event) => {
setTransitionTargets((current) => {
const next = new Set(current);
if (event.target.checked) next.add(stage.id);
else next.delete(stage.id);
return next;
});
}}
/>
{selectedStage.name} can move to {stage.name}
</label>
))}
</div>
</Section>
<Section title="Advanced identifiers">
<dl className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt className="text-muted-foreground">Pipeline ID</dt>
<dd className="font-mono text-xs">{pipeline.id}</dd>
</div>
<div>
<dt className="text-muted-foreground">Stage ID</dt>
<dd className="font-mono text-xs">{selectedStage.id}</dd>
</div>
<div>
<dt className="text-muted-foreground">Position</dt>
<dd>{selectedStage.position}</dd>
</div>
<div>
<dt className="text-muted-foreground">Kind</dt>
<dd>{selectedStage.kind}</dd>
</div>
</dl>
</Section>
{saveStage.error ? <p className="text-sm text-destructive">{saveStage.error.message}</p> : null}
<Button type="submit" disabled={saveStage.isPending || !stageName.trim()}>
{saveStage.isPending ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{saveStage.isPending ? "Saving..." : "Save stage"}
</Button>
</form>
) : null}
</div>
) : null}
{activeTab === "guidance" ? (
<form
className="max-w-3xl space-y-4"
onSubmit={(event) => {
event.preventDefault();
saveGuidance.mutate();
}}
>
<div>
<h2 className="text-lg font-semibold text-foreground">Pipeline guidance</h2>
<p className="mt-1 text-sm text-muted-foreground">
Plain-language instructions agents and operators can use when handling this pipeline.
</p>
</div>
<Textarea
value={guidanceBody}
onChange={(event) => setGuidanceBody(event.target.value)}
rows={12}
placeholder="Write guidance for how work should enter, move through, and leave this pipeline."
/>
{saveGuidance.error ? <p className="text-sm text-destructive">{saveGuidance.error.message}</p> : null}
<Button type="submit" disabled={saveGuidance.isPending || !guidanceBody.trim()}>
<Save className="h-4 w-4" />
{saveGuidance.isPending ? "Saving..." : "Save guidance"}
</Button>
</form>
) : null}
{activeTab === "advanced" ? (
<div className="max-w-2xl space-y-4">
<div className="rounded-md border border-destructive/30 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 text-destructive" />
<div className="min-w-0 flex-1 space-y-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Danger zone</h2>
<p className="mt-1 text-sm text-muted-foreground">
Delete permanently removes this pipeline, its stages, guidance, and pipeline items.
</p>
</div>
<label className="block space-y-1.5 text-sm font-medium">
<span>Type {pipeline.name} to confirm</span>
<Input
value={deleteConfirmation}
onChange={(event) => setDeleteConfirmation(event.target.value)}
autoComplete="off"
/>
</label>
{deletePipeline.error ? <p className="text-sm text-destructive">{deletePipeline.error.message}</p> : null}
<Button
type="button"
variant="destructive"
disabled={!deleteEnabled}
onClick={() => deletePipeline.mutate()}
>
<Trash2 className="h-4 w-4" />
{deletePipeline.isPending ? "Deleting..." : "Delete permanently"}
</Button>
</div>
</div>
</div>
</div>
) : null}
</div>
);
}
+11 -10
View File
@@ -1,7 +1,8 @@
// @vitest-environment jsdom
import { act, useState } from "react";
import { useState } from "react";
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 { Pipeline, PipelineAttentionItem, PipelineCase } from "../api/pipelines";
@@ -104,7 +105,7 @@ function renderTable({
);
}
act(() => {
flushSync(() => {
root.render(<Harness />);
});
@@ -135,7 +136,7 @@ describe("PipelinesIndexTable", () => {
const collapse = container.querySelector<HTMLButtonElement>('button[aria-label="Collapse Release"]');
expect(collapse).not.toBeNull();
act(() => {
flushSync(() => {
root.unmount();
});
});
@@ -153,14 +154,14 @@ describe("PipelinesIndexTable", () => {
);
expect(flatButton).toBeTruthy();
act(() => {
flushSync(() => {
flatButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).not.toContain("feeds into Release");
expect(container.textContent).not.toContain("feeds into Features");
act(() => {
flushSync(() => {
root.unmount();
});
});
@@ -181,7 +182,7 @@ describe("PipelinesIndexTable", () => {
expect(container.textContent).toContain("Support knowledge base");
expect(container.textContent).toContain("Sales decks");
act(() => {
flushSync(() => {
root.unmount();
});
});
@@ -216,7 +217,7 @@ describe("PipelinesIndexTable", () => {
expect(content).not.toContain("0 to review");
expect(content).not.toContain("0 in motion");
act(() => {
flushSync(() => {
root.unmount();
});
});
@@ -230,7 +231,7 @@ describe("PipelinesIndexTable", () => {
expect(container.textContent).toContain("No pipelines match your search.");
act(() => {
flushSync(() => {
root.unmount();
});
});
@@ -259,7 +260,7 @@ function renderReviewQueue() {
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
act(() => {
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<ReviewQueueStub />
@@ -347,7 +348,7 @@ describe("ReviewQueue", () => {
note: null,
});
act(() => {
flushSync(() => {
root.unmount();
});
queryClient.clear();
+570 -26
View File
@@ -41,6 +41,7 @@ import type {
PipelineCaseEvent,
PipelineConnectionRef,
PipelineConnections,
PipelineIntakeForm,
PipelineIssueLink,
PipelineStage,
PipelineTransition,
@@ -826,6 +827,7 @@ function PipelineBoardColumn({
return (
<div
key={stage.id}
aria-label={`${stage.name} column`}
className={`flex min-w-[260px] max-w-[320px] shrink-0 flex-col rounded-md border border-border ${isBlockedDropTarget ? "ring-1 ring-red-500/45" : ""}`}
>
<div className="flex items-center justify-between border-b border-border px-3 py-2 text-sm font-semibold text-muted-foreground">
@@ -924,6 +926,191 @@ function NewPipelineDialog({
);
}
function PipelineAddItemDialog({
open,
onOpenChange,
intakeForm,
onSubmit,
pending,
error,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
intakeForm: PipelineIntakeForm | null;
onSubmit: (data: { items: Array<{ title: string; fields: Record<string, unknown> }> }) => void;
pending: boolean;
error: string | null;
}) {
type DraftItem = { id: string; title: string; fields: Record<string, string> };
const createDraftItem = () => {
const initialFields: Record<string, string> = {};
for (const field of intakeForm?.fields ?? []) {
initialFields[field.key] = "";
}
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
title: "",
fields: initialFields,
};
};
const [items, setItems] = useState<DraftItem[]>(() => [createDraftItem()]);
useEffect(() => {
if (!open) return;
setItems([createDraftItem()]);
}, [intakeForm, open]);
const updateItem = (itemId: string, patch: Partial<DraftItem>) => {
setItems((current) =>
current.map((item) => item.id === itemId ? { ...item, ...patch } : item),
);
};
const updateItemField = (itemId: string, key: string, value: string) => {
setItems((current) =>
current.map((item) =>
item.id === itemId ? { ...item, fields: { ...item.fields, [key]: value } } : item,
),
);
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const nextItems = items
.map((item) => {
const nextFields: Record<string, unknown> = {};
for (const field of intakeForm?.fields ?? []) {
const value = item.fields[field.key]?.trim() ?? "";
if (value || field.required) {
nextFields[field.key] = value;
}
}
return { title: item.title.trim(), fields: nextFields };
})
.filter((item) => item.title.length > 0);
if (nextItems.length === 0) return;
onSubmit({ items: nextItems });
};
const submitCount = items.filter((item) => item.title.trim()).length;
const fieldSource = intakeForm?.stageName ?? "the first stage";
const renderFieldControl = (item: DraftItem, field: PipelineIntakeForm["fields"][number]) => {
if (field.type === "multiline") {
return (
<Textarea
value={item.fields[field.key] ?? ""}
onChange={(event) => updateItemField(item.id, field.key, event.target.value)}
required={field.required}
rows={2}
/>
);
}
if (field.type === "select") {
return (
<select
value={item.fields[field.key] ?? ""}
onChange={(event) => updateItemField(item.id, field.key, event.target.value)}
required={field.required}
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm"
>
<option value="">Select...</option>
{(field.options ?? []).map((option) => (
<option key={option} value={option}>{option}</option>
))}
</select>
);
}
return (
<Input
value={item.fields[field.key] ?? ""}
onChange={(event) => updateItemField(item.id, field.key, event.target.value)}
required={field.required}
/>
);
};
const addAnotherItem = () => {
setItems((current) => [...current, createDraftItem()]);
};
const removeItem = (itemId: string) => {
setItems((current) => {
if (current.length === 1) {
return [createDraftItem()];
}
return current.filter((item) => item.id !== itemId);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl">
<form onSubmit={submit} className="space-y-4">
<DialogHeader>
<DialogTitle>Add items</DialogTitle>
<DialogDescription>
New items enter {fieldSource}. These fields come from Pipeline settings.
</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-3 overflow-y-auto pr-1">
{items.map((item, index) => (
<section key={item.id} className="space-y-3 rounded-md border border-border p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Item {index + 1}</h3>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove item ${index + 1}`}
onClick={() => removeItem(item.id)}
disabled={pending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<label className="block space-y-1.5 text-sm font-medium">
<span>Title</span>
<Input
value={item.title}
onChange={(event) => updateItem(item.id, { title: event.target.value })}
required
autoFocus={index === 0}
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
{(intakeForm?.fields ?? []).map((field) => (
<label key={field.key} className="block space-y-1.5 text-sm font-medium">
<span>
{field.label}
{field.required ? <span className="text-destructive"> *</span> : null}
</span>
{renderFieldControl(item, field)}
</label>
))}
</div>
</section>
))}
<Button type="button" variant="outline" onClick={addAnotherItem} disabled={pending}>
<Plus className="h-4 w-4" />
Add another item
</Button>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button type="submit" disabled={pending || submitCount === 0}>
{pending ? "Adding..." : `Submit ${formatNumber(submitCount)} item${submitCount === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function Pipelines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -1028,6 +1215,16 @@ export function PipelineBoardStub() {
const navigate = useNavigate();
const [activeCaseId, setActiveCaseId] = useState<string | null>(null);
const [activeOverId, setActiveOverId] = useState<string | null>(null);
const [addItemOpen, setAddItemOpen] = useState(false);
const [pendingMove, setPendingMove] = useState<{
caseId: string;
itemTitle: string;
sourceName: string;
targetStageId: string;
targetName: string;
allowed: boolean;
} | null>(null);
const [overrideReason, setOverrideReason] = useState("");
const hasBoardContext = !!selectedCompanyId && !!pipelineId;
const pipelineQuery = useQuery({
@@ -1042,6 +1239,12 @@ export function PipelineBoardStub() {
enabled: hasBoardContext,
});
const intakeFormQuery = useQuery({
queryKey: pipelineId ? queryKeys.pipelines.intakeForm(pipelineId) : ["pipelines", "intake-form", "none"],
queryFn: () => pipelinesApi.getIntakeForm(pipelineId!),
enabled: hasBoardContext,
});
const pipeline = pipelineQuery.data;
const cases = casesQuery.data ?? [];
@@ -1097,9 +1300,11 @@ export function PipelineBoardStub() {
}, [boardColumns.columns]);
const transitionCase = useMutation({
mutationFn: ({ caseId, toStageId }: { caseId: string; toStageId: string }) =>
pipelinesApi.transitionCase(caseId, { toStageId }),
mutationFn: ({ caseId, toStageId, reason }: { caseId: string; toStageId: string; reason?: string | null }) =>
pipelinesApi.transitionCase(caseId, { toStageId, reason }),
onSuccess: async () => {
setPendingMove(null);
setOverrideReason("");
await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.detail(pipelineId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.cases(pipelineId!) });
},
@@ -1114,6 +1319,22 @@ export function PipelineBoardStub() {
},
});
const addItem = useMutation({
mutationFn: (data: { items: Array<{ title: string; fields: Record<string, unknown> }> }) =>
pipelinesApi.ingestCasesBatch(pipelineId!, {
items: data.items.map((item) => ({
title: item.title,
fields: item.fields,
})),
}),
onSuccess: async () => {
setAddItemOpen(false);
await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.detail(pipelineId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.cases(pipelineId!) });
await queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.intakeForm(pipelineId!) });
},
});
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
function handleDragStart(event: DragStartEvent) {
@@ -1144,22 +1365,15 @@ export function PipelineBoardStub() {
if (!targetStageId || sourceStageId === targetStageId) return;
if (!isGuardedTransitionAllowed(transitions, sourceStageId, targetStageId)) {
const sourceName = stageNameById.get(sourceStageId ?? "") ?? "Unassigned";
const targetName = stageNameById.get(targetStageId) ?? UNASSIGNED_STAGE_NAME;
pushToast({
title: "Move blocked",
body: transitionsEnabled
? `Cannot move "${activeCase.title}" from ${sourceName} to ${targetName}.`
: `Cannot move "${activeCase.title}" because its source stage is missing.`,
tone: "warn",
});
return;
}
transitionCase.mutate({
const sourceName = stageNameById.get(sourceStageId ?? "") ?? "Unassigned";
const targetName = stageNameById.get(targetStageId) ?? UNASSIGNED_STAGE_NAME;
setPendingMove({
caseId: activeCase.id,
toStageId: targetStageId,
itemTitle: getCaseTitle(activeCase),
sourceName,
targetStageId,
targetName,
allowed: isGuardedTransitionAllowed(transitions, sourceStageId, targetStageId),
});
}
@@ -1213,6 +1427,9 @@ export function PipelineBoardStub() {
}
const activeCase = activeCaseId ? boardColumns.caseById.get(activeCaseId) ?? null : null;
const intakeForm = intakeFormQuery.data ?? null;
const newEntriesDisabled = Boolean(intakeForm?.newEntriesDisabled);
const addItemDisabled = !intakeForm?.stageId || newEntriesDisabled || intakeFormQuery.isLoading;
return (
<div className="space-y-4">
@@ -1222,11 +1439,29 @@ export function PipelineBoardStub() {
{pipeline.description ? <p className="mt-1 text-sm text-muted-foreground">{pipeline.description}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">{cases.length} total item{cases.length === 1 ? "" : "s"}</p>
</div>
<Button variant="outline" onClick={() => navigate(`/pipelines/${pipeline.id}/settings`)}>
Settings
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => setAddItemOpen(true)}
disabled={addItemDisabled}
title={newEntriesDisabled ? "New entries are disabled for the first stage" : undefined}
>
<Plus className="h-4 w-4" />
Add items
</Button>
<Button variant="outline" onClick={() => navigate(`/pipelines/${pipeline.id}/settings`)}>
Settings
</Button>
</div>
</div>
{newEntriesDisabled ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-300/30 dark:bg-amber-400/10 dark:text-amber-200">
New entries are disabled for {intakeForm?.stageName ?? "the first stage"}. Existing items remain visible.
{intakeForm?.disabledReason ? ` ${intakeForm.disabledReason}` : ""}
</p>
) : null}
{transitionsEnabled ? (
<p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-300/30 dark:bg-amber-400/10 dark:text-amber-200">
Drag is guarded by pipeline transition rules.
@@ -1277,6 +1512,94 @@ export function PipelineBoardStub() {
{activeCase ? <PipelineCaseCard caseItem={activeCase} isOverlay /> : null}
</DragOverlay>
</DndContext>
<PipelineAddItemDialog
open={addItemOpen}
onOpenChange={setAddItemOpen}
intakeForm={intakeForm}
onSubmit={(data) => addItem.mutate(data)}
pending={addItem.isPending}
error={addItem.error ? addItem.error.message : null}
/>
<Dialog
open={Boolean(pendingMove)}
onOpenChange={(open) => {
if (!open) {
setPendingMove(null);
setOverrideReason("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{pendingMove?.allowed ? `Move ${pendingMove.itemTitle}?` : "This skips the normal flow"}
</DialogTitle>
<DialogDescription>
{pendingMove?.allowed
? `Move ${pendingMove.itemTitle} to ${pendingMove.targetName} yourself? Usually the agent suggests this when it is ready.`
: pendingMove
? `${pendingMove.itemTitle} would jump from ${pendingMove.sourceName} to ${pendingMove.targetName}. Add a reason before overriding.`
: "Review this move before continuing."}
</DialogDescription>
</DialogHeader>
{pendingMove && !pendingMove.allowed ? (
<label className="block space-y-1.5 text-sm font-medium">
<span>Reason</span>
<Textarea
value={overrideReason}
onChange={(event) => setOverrideReason(event.target.value)}
rows={3}
placeholder="Explain why this item should skip the normal flow."
autoFocus
/>
</label>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={transitionCase.isPending}
onClick={() => {
setPendingMove(null);
setOverrideReason("");
}}
>
Cancel
</Button>
{pendingMove?.allowed ? (
<Button
type="button"
disabled={transitionCase.isPending}
onClick={() =>
transitionCase.mutate({
caseId: pendingMove.caseId,
toStageId: pendingMove.targetStageId,
})
}
>
Move it
</Button>
) : pendingMove ? (
<Button
type="button"
variant="destructive"
disabled={transitionCase.isPending || !overrideReason.trim()}
onClick={() =>
transitionCase.mutate({
caseId: pendingMove.caseId,
toStageId: pendingMove.targetStageId,
reason: overrideReason.trim(),
})
}
>
Override and move
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1296,13 +1619,234 @@ function StubPage({ title }: { title: string }) {
}
export function PipelineItemStub() {
useParams<{ pipelineId: string; caseId: string }>();
return <StubPage title="Item" />;
}
const { pipelineId, caseId } = useParams<{ pipelineId: string; caseId: string }>();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
export function PipelineSettingsStub() {
useParams<{ pipelineId: string }>();
return <StubPage title="Pipeline settings" />;
const pipelineQuery = useQuery({
queryKey: pipelineId ? queryKeys.pipelines.detail(pipelineId) : ["pipelines", "detail", "none"],
queryFn: () => pipelinesApi.get(pipelineId!),
enabled: !!selectedCompanyId && !!pipelineId,
});
const itemQuery = useQuery({
queryKey: caseId ? queryKeys.pipelines.caseDetail(caseId) : ["pipelines", "case", "none"],
queryFn: () => pipelinesApi.getCase(caseId!),
enabled: !!selectedCompanyId && !!caseId,
});
const eventsQuery = useQuery({
queryKey: caseId ? queryKeys.pipelines.caseEvents(caseId) : ["pipelines", "case", "none", "events"],
queryFn: () => pipelinesApi.listCaseEvents(caseId!),
enabled: !!selectedCompanyId && !!caseId,
});
const childrenQuery = useQuery({
queryKey: caseId ? queryKeys.pipelines.caseChildren(caseId) : ["pipelines", "case", "none", "children"],
queryFn: () => pipelinesApi.listChildren(caseId!),
enabled: !!selectedCompanyId && !!caseId,
});
const issueLinksQuery = useQuery({
queryKey: caseId ? queryKeys.pipelines.caseIssueLinks(caseId) : ["pipelines", "case", "none", "issue-links"],
queryFn: () => pipelinesApi.listIssueLinks(caseId!),
enabled: !!selectedCompanyId && !!caseId,
});
const pipeline = pipelineQuery.data ?? null;
const item = itemQuery.data ?? null;
const stages = useMemo(() => [...(pipeline?.stages ?? [])].sort((left, right) => left.position - right.position), [pipeline?.stages]);
const currentStage = stages.find((stage) => stage.id === item?.stageId) ?? null;
const pendingSuggestion = getPendingTransitionBannerState(item, stages);
const fieldEntries = displayPipelineItemFields(item?.fields ?? null);
const children = childrenQuery.data ?? [];
const events = eventsQuery.data ?? [];
const workLink = (issueLinksQuery.data ?? []).find((link) => link.role === "work" || link.role === "conversation")
?? (issueLinksQuery.data ?? [])[0]
?? null;
useEffect(() => {
if (!pipeline || !item) return;
setBreadcrumbs([
{ label: "Pipelines", href: "/pipelines" },
{ label: pipeline.name, href: `/pipelines/${pipeline.id}` },
{ label: getCaseTitle(item) },
]);
}, [item, pipeline, setBreadcrumbs]);
const resolveSuggestion = useMutation({
mutationFn: (decision: "accept" | "decline") =>
pipelinesApi.resolveSuggestion(caseId!, {
decision,
note: decision === "accept" ? "Accepted from the item page." : "Kept in the current stage.",
}),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.caseDetail(caseId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.caseEvents(caseId!) }),
pipelineId
? queryClient.invalidateQueries({ queryKey: queryKeys.pipelines.cases(pipelineId) })
: Promise.resolve(),
]);
},
});
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view this item." />;
}
if (!pipelineId || !caseId) {
return <EmptyState icon={Hexagon} message="No item selected." />;
}
if (pipelineQuery.isLoading || itemQuery.isLoading) {
return <PageSkeleton variant="detail" />;
}
if (pipelineQuery.error || itemQuery.error) {
return <p className="text-sm text-destructive">Could not load this item.</p>;
}
if (!pipeline || !item) {
return <EmptyState icon={Hexagon} message="Item not found." />;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 border-b border-border pb-5 sm:flex-row sm:items-start sm:justify-between">
<div>
<Link to={`/pipelines/${pipeline.id}`} className="text-sm text-muted-foreground hover:text-foreground">
Back to board
</Link>
<h1 className="mt-2 text-2xl font-semibold tracking-normal text-foreground">{getCaseTitle(item)}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm">
<span className="rounded-full border border-border px-2 py-0.5 font-semibold text-muted-foreground">
{currentStage?.name ?? "No stage"}
</span>
<span className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
{humanizePipelineItemStatus(item.status)}
</span>
{itemHasChangedNotice(item) ? (
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 font-semibold text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/30 dark:text-amber-300">
This changed
</span>
) : null}
</div>
</div>
{workLink ? (
<Button asChild variant="outline">
<Link to={`/issues/${workLink.issueId}`}>
<ExternalLink className="h-4 w-4" />
Open full view
</Link>
</Button>
) : null}
</div>
{pendingSuggestion.visible ? (
<section className="rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/70 dark:bg-amber-950/30">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-sm font-semibold text-foreground">Agent suggests moving this item</h2>
<p className="mt-1 text-sm text-muted-foreground">
Move to <span className="font-semibold text-foreground">{pendingSuggestion.toStageName}</span>
{pendingSuggestion.reason ? ` - ${pendingSuggestion.reason}` : ""}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={resolveSuggestion.isPending}
onClick={() => resolveSuggestion.mutate("decline")}
>
Decline
</Button>
<Button
type="button"
disabled={resolveSuggestion.isPending}
onClick={() => resolveSuggestion.mutate("accept")}
>
Accept
</Button>
</div>
</div>
</section>
) : null}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-foreground">Item details</h2>
{fieldEntries.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{fieldEntries.map((field) => (
<div key={field.key} className="grid gap-2 px-3 py-2 text-sm sm:grid-cols-[180px_1fr]">
<span className="text-muted-foreground">{field.label}</span>
<span className="text-foreground">{field.value}</span>
</div>
))}
</div>
) : (
<p className="rounded-md border border-border px-3 py-3 text-sm text-muted-foreground">
No details have been added yet.
</p>
)}
</section>
{children.length > 0 ? (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-foreground">Built from {formatNumber(children.length)} items</h2>
<div className="divide-y divide-border rounded-md border border-border">
{children.map((child) => (
<Link
key={child.id}
to={`/pipelines/${child.pipelineId}/items/${child.id}`}
className="grid min-h-10 grid-cols-[1fr_auto] items-center gap-3 px-3 py-2 text-sm hover:bg-accent/40"
>
<span className="font-medium text-foreground">{getCaseTitle(child)}</span>
<span className="rounded-full border border-border px-2 py-0.5 text-xs text-muted-foreground">
{humanizePipelineItemStatus(child.status)}
</span>
</Link>
))}
</div>
</section>
) : null}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-foreground">Activity</h2>
{events.length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{events.slice(0, 8).map((event) => {
const presentation = formatPipelineItemEvent(event, item.title);
return (
<div key={event.id} className="px-3 py-2 text-sm text-foreground">
{presentation.sentence}
</div>
);
})}
</div>
) : (
<p className="rounded-md border border-border px-3 py-3 text-sm text-muted-foreground">
No activity yet.
</p>
)}
</section>
<section className="space-y-3">
<h2 className="text-sm font-semibold text-foreground">Activity & conversation</h2>
{workLink ? (
<p className="rounded-md border border-border px-3 py-3 text-sm text-muted-foreground">
A work conversation is linked to this item. Open the full view to continue the thread.
</p>
) : (
<p className="rounded-md border border-border px-3 py-3 text-sm text-muted-foreground">
No work conversation is linked yet.
</p>
)}
</section>
</div>
);
}
function ReviewQueueStatusChip({ failed }: { failed: boolean }) {