mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
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:
1 parent
e457290823
commit
a669ffa595
16 files changed
+2060
-44
No files matched your search
@@ -1145,6 +1145,8 @@ export {
|
||||
pipelineStageConfigSchema,
|
||||
pipelineStageVariableSchema,
|
||||
pipelineStageRunRoutineSchema,
|
||||
pipelineStageDisableConfigSchema,
|
||||
pipelineStageApprovalConfigSchema,
|
||||
pipelineStatusSchema,
|
||||
pipelineStageKindSchema,
|
||||
pipelineCaseIssueLinkRoleSchema,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 }) {
|
||||
|
||||
Reference in new issue
Block a user