fix: address workflow PR feedback
This commit is contained in:
@@ -452,23 +452,25 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
|
||||
if (workflowMode && selectedWorkflow) {
|
||||
return (
|
||||
<div className="board-workflow-view">
|
||||
{workflowOptions.length > 1 && (
|
||||
{(workflowOptions.length > 1 || onCreateWorkflow || onOpenWorkflowEditor) && (
|
||||
<div className="board-workflow-toolbar">
|
||||
<label className="list-workflow-selector board-workflow-selector">
|
||||
<span>Workflow</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
value={selectedWorkflow.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label="Select workflow"
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{workflowOptions.length > 1 && (
|
||||
<label className="list-workflow-selector board-workflow-selector">
|
||||
<span>Workflow</span>
|
||||
<select
|
||||
className="select list-workflow-select"
|
||||
value={selectedWorkflow.id}
|
||||
onChange={(event) => setSelectedWorkflowId(event.target.value)}
|
||||
aria-label="Select workflow"
|
||||
>
|
||||
{workflowOptions.map((workflow) => (
|
||||
<option key={workflow.id} value={workflow.id}>
|
||||
{workflow.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{onCreateWorkflow && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -386,11 +386,10 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
|
||||
(input: TaskCreateInput) => {
|
||||
if (!onQuickCreate) return Promise.resolve();
|
||||
if (workflowMode) {
|
||||
const explicitWorkflowId = workflowId?.startsWith("builtin:") ? undefined : workflowId;
|
||||
return onQuickCreate({
|
||||
...input,
|
||||
column,
|
||||
...(explicitWorkflowId ? { workflowId: explicitWorkflowId } : {}),
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
});
|
||||
}
|
||||
return onQuickCreate(input);
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
.board-workflow-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -631,11 +631,10 @@ export function ListView({
|
||||
const handleListQuickCreate = useCallback((input: TaskCreateInput) => {
|
||||
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
||||
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
||||
const explicitWorkflowId = selectedWorkflow.id.startsWith("builtin:") ? undefined : selectedWorkflow.id;
|
||||
return create({
|
||||
...input,
|
||||
column: input.column ?? createTargetColumn,
|
||||
workflowId: input.workflowId ?? explicitWorkflowId,
|
||||
workflowId: input.workflowId ?? selectedWorkflow.id,
|
||||
});
|
||||
}
|
||||
return create(input);
|
||||
@@ -873,16 +872,16 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const archivedTasks = selectedTasks.filter((task) => task.column === "archived");
|
||||
const deletableTasks = selectedTasks.filter((task) => task.column !== "archived");
|
||||
const archivedTasks = selectedTasks.filter((task) => isArchivedColumn(task.column));
|
||||
const deletableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column));
|
||||
|
||||
if (deletableTasks.length === 0) {
|
||||
addToast(t("listView.bulkDeleteNoTasks", "No selected tasks can be deleted (archived tasks are excluded)"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const doneTasks = deletableTasks.filter((task) => task.column === "done");
|
||||
const otherTasks = deletableTasks.filter((task) => task.column !== "done");
|
||||
const doneTasks = deletableTasks.filter((task) => isCompleteColumn(task.column));
|
||||
const otherTasks = deletableTasks.filter((task) => !isCompleteColumn(task.column));
|
||||
|
||||
let shouldDeleteAll = false;
|
||||
let shouldArchiveDoneInstead = false;
|
||||
@@ -1070,7 +1069,7 @@ export function ListView({
|
||||
: t("listView.bulkDeleteSummary", { count: deletedIds.length, skipped: skippedIds.length, failed: failedIds.length, defaultValue_one: "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", defaultValue_other: "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed" });
|
||||
|
||||
addToast(summaryMessage, failedIds.length > 0 ? "error" : "success");
|
||||
}, [addToast, confirm, confirmWithChoice, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, confirm, confirmWithChoice, isArchivedColumn, isCompleteColumn, onArchiveTask, onDeleteTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkPause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -1082,7 +1081,7 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused !== true);
|
||||
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused !== true);
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
@@ -1121,7 +1120,7 @@ export function ListView({
|
||||
t("listView.bulkPauseSummary", "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", { paused: pausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onPauseTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, isArchivedColumn, onPauseTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkUnpause = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -1133,7 +1132,7 @@ export function ListView({
|
||||
const selectedTasks = Array.from(selectedTaskIds)
|
||||
.map((id) => tasks.find((task) => task.id === id))
|
||||
.filter((task): task is Task => Boolean(task));
|
||||
const actionableTasks = selectedTasks.filter((task) => task.column !== "archived" && task.paused === true);
|
||||
const actionableTasks = selectedTasks.filter((task) => !isArchivedColumn(task.column) && task.paused === true);
|
||||
const skippedCount = selectedTasks.length - actionableTasks.length;
|
||||
|
||||
if (actionableTasks.length === 0) {
|
||||
@@ -1172,7 +1171,7 @@ export function ListView({
|
||||
t("listView.bulkUnpauseSummary", "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", { unpaused: unpausedIds.length, skipped: skippedCount, failed: failedIds.length }),
|
||||
failedIds.length > 0 ? "error" : "success",
|
||||
);
|
||||
}, [addToast, onUnpauseTask, selectedTaskIds, tasks]);
|
||||
}, [addToast, isArchivedColumn, onUnpauseTask, selectedTaskIds, tasks]);
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
if (selectedTaskIds.size === 0) return;
|
||||
@@ -1512,7 +1511,7 @@ export function ListView({
|
||||
if (!taskId) return;
|
||||
|
||||
// Prevent dropping into archived column
|
||||
if (column === "archived" || columnFlagsById.get(column)?.archived) {
|
||||
if (isArchivedColumn(column)) {
|
||||
addToast(t("listView.archiveViaButton", "Tasks can only be archived via the archive button"), "error");
|
||||
return;
|
||||
}
|
||||
@@ -1520,7 +1519,10 @@ export function ListView({
|
||||
try {
|
||||
const task = tasks.find((candidate) => candidate.id === taskId);
|
||||
const hasStepProgress = task?.steps.some((step) => step.status !== "pending") ?? false;
|
||||
const shouldPrompt = (column === "todo" || column === "triage") && hasStepProgress;
|
||||
const targetFlags = columnFlagsById.get(column);
|
||||
const shouldPrompt = hasStepProgress && (
|
||||
column === "todo" || column === "triage" || Boolean(targetFlags?.intake || targetFlags?.hold)
|
||||
);
|
||||
|
||||
let moveOptions: { preserveProgress?: boolean } | undefined;
|
||||
if (shouldPrompt) {
|
||||
@@ -1552,7 +1554,7 @@ export function ListView({
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
},
|
||||
[addToast, columnFlagsById, confirm, onMoveTask, tasks, t]
|
||||
[addToast, columnFlagsById, confirm, isArchivedColumn, onMoveTask, tasks, t]
|
||||
);
|
||||
|
||||
const getSortIcon = (field: SortField) => {
|
||||
@@ -1941,7 +1943,7 @@ export function ListView({
|
||||
<div className="list-empty-cell list-card-empty">{t("listView.noTasks", "No tasks")}</div>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isDoneColumn = task.column === "done";
|
||||
const isDoneColumn = isCompleteColumn(task.column);
|
||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||
const isFailed = !isDoneColumn && task.status === "failed";
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
@@ -1975,7 +1977,7 @@ export function ListView({
|
||||
toggleTaskSelection(task.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
disabled={isArchivedColumn(task.column)}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</label>
|
||||
@@ -2134,7 +2136,7 @@ export function ListView({
|
||||
</tr>
|
||||
) : (
|
||||
columnTasks.map((task) => {
|
||||
const isDoneColumn = task.column === "done";
|
||||
const isDoneColumn = isCompleteColumn(task.column);
|
||||
const visualStatus = isDoneColumn ? "done" : task.status;
|
||||
const isFailed = !isDoneColumn && task.status === "failed";
|
||||
const isPaused = !isDoneColumn && task.paused === true;
|
||||
@@ -2171,7 +2173,7 @@ export function ListView({
|
||||
toggleTaskSelection(task.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={task.column === "archived"}
|
||||
disabled={isArchivedColumn(task.column)}
|
||||
aria-label={t("listView.selectTask", "Select {{taskId}}", { taskId: task.id })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -971,6 +971,26 @@ describe("Board", () => {
|
||||
expect(screen.getByTestId("column-idea").getAttribute("data-has-quick-create")).toBe("yes");
|
||||
});
|
||||
|
||||
it("keeps workflow create and edit actions visible when only one workflow exists", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
enableFlag({ "FN-1": "builtin:coding" }, [DEFAULT_WORKFLOW]);
|
||||
|
||||
renderBoard({
|
||||
tasks: [mkTask({ id: "FN-1", column: "triage" })],
|
||||
onCreateWorkflow,
|
||||
onOpenWorkflowEditor,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("column-triage")).toBeDefined());
|
||||
expect(screen.queryByLabelText("Select workflow")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New workflow" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit workflows" }));
|
||||
expect(onCreateWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenWorkflowEditor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders one selected workflow at a time and switches workflows from the dropdown", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
enableFlag(
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("../WorktreeGroup", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("../QuickEntryBox", () => ({
|
||||
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean }) => (
|
||||
QuickEntryBox: ({ favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, autoExpand, onCreate }: { favoriteProviders?: string[]; favoriteModels?: string[]; onToggleFavorite?: (provider: string) => void; onToggleModelFavorite?: (modelId: string) => void; autoExpand?: boolean; onCreate?: (input: { description: string }) => void }) => (
|
||||
<div
|
||||
data-testid="quick-entry-box"
|
||||
data-favorite-providers={JSON.stringify(favoriteProviders ?? [])}
|
||||
@@ -28,7 +28,9 @@ vi.mock("../QuickEntryBox", () => ({
|
||||
data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"}
|
||||
data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"}
|
||||
data-auto-expand={autoExpand === false ? "false" : "true"}
|
||||
/>
|
||||
>
|
||||
<button type="button" onClick={() => onCreate?.({ description: "Quick task" })}>create</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
@@ -409,6 +411,28 @@ describe("Column QuickEntryBox", () => {
|
||||
const quickEntry = screen.getByTestId("quick-entry-box");
|
||||
expect(quickEntry.getAttribute("data-auto-expand")).toBe("false");
|
||||
});
|
||||
|
||||
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({});
|
||||
render(
|
||||
<Column
|
||||
{...defaultProps}
|
||||
column="triage"
|
||||
workflowMode
|
||||
workflowId="builtin:coding"
|
||||
tasks={[]}
|
||||
onQuickCreate={onQuickCreate}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "create" }));
|
||||
|
||||
await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith({
|
||||
description: "Quick task",
|
||||
column: "triage",
|
||||
workflowId: "builtin:coding",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Column in-progress/in-review bulk actions", () => {
|
||||
|
||||
@@ -1350,6 +1350,52 @@ describe("ListView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prompts to preserve progress when dropping task with completed steps to a workflow hold column", async () => {
|
||||
const tasks = [createMockTask({
|
||||
id: "FN-001",
|
||||
column: "doing",
|
||||
steps: [
|
||||
{ title: "Step 1", status: "done" },
|
||||
{ title: "Step 2", status: "pending" },
|
||||
],
|
||||
})];
|
||||
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
|
||||
mockConfirm.mockResolvedValueOnce(true);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "queue", name: "Queue", flags: { hold: true } },
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
|
||||
renderListView({ tasks, onMoveTask: mockOnMoveTask });
|
||||
await waitFor(() => expect(document.querySelector('[data-column="queue"].list-drop-zone')).toBeTruthy());
|
||||
|
||||
fireEvent.drop(document.querySelector('[data-column="queue"].list-drop-zone')!, {
|
||||
preventDefault: vi.fn(),
|
||||
dataTransfer: {
|
||||
getData: vi.fn(() => "FN-001"),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({
|
||||
title: "Preserve Progress?",
|
||||
}));
|
||||
expect(mockOnMoveTask).toHaveBeenCalledWith("FN-001", "queue", { preserveProgress: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set draggable for paused tasks", () => {
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: true })];
|
||||
|
||||
@@ -2334,6 +2380,42 @@ describe("ListView Quick Entry", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves selected built-in workflow id when quick-creating in workflow mode", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:default",
|
||||
workflows: [
|
||||
{
|
||||
id: "builtin:default",
|
||||
name: "Default",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
{
|
||||
id: "builtin:coding",
|
||||
name: "Coding",
|
||||
columns: [{ id: "triage", name: "Triage", flags: { intake: true } }],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: {},
|
||||
});
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
|
||||
const selector = await screen.findByLabelText("Select workflow") as HTMLSelectElement;
|
||||
fireEvent.change(selector, { target: { value: "builtin:coding" } });
|
||||
const input = screen.getByTestId("quick-entry-input");
|
||||
fireEvent.change(input, { target: { value: "Built-in workflow task" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Built-in workflow task",
|
||||
column: "triage",
|
||||
workflowId: "builtin:coding",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when onQuickCreate fails and keeps input content", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockRejectedValue(new Error("Create failed"));
|
||||
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||
@@ -2728,6 +2810,33 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(checkbox).toBeDisabled();
|
||||
});
|
||||
|
||||
it("disables checkbox for workflow archived columns", async () => {
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "parked" }),
|
||||
];
|
||||
|
||||
render(<ListView tasks={tasks} onMoveTask={vi.fn()} onOpenDetail={vi.fn()} addToast={mockAddToast} projectId={TEST_PROJECT_ID} />);
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
enterBulkEditMode();
|
||||
|
||||
expect(screen.getByLabelText("Select FN-001")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows selection count when tasks are selected", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001" }),
|
||||
@@ -3020,6 +3129,78 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("skips workflow archived-column tasks when pausing in bulk", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "active", paused: false }),
|
||||
createMockTask({ id: "FN-002", column: "parked", paused: false }),
|
||||
];
|
||||
const onPauseTask = vi.fn(async () => createMockTask());
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
|
||||
|
||||
renderListView({ tasks, onPauseTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /^pause selected$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(onPauseTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Paused 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("skips workflow archived-column tasks when unpausing in bulk", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "active", name: "Active", flags: { countsTowardWip: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "active", paused: true }),
|
||||
createMockTask({ id: "FN-002", column: "parked", paused: true }),
|
||||
];
|
||||
const onUnpauseTask = vi.fn(async () => createMockTask());
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002"]));
|
||||
|
||||
renderListView({ tasks, onUnpauseTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Parked").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /^unpause selected$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUnpauseTask).toHaveBeenCalledTimes(1);
|
||||
expect(onUnpauseTask).toHaveBeenCalledWith("FN-001");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Unpaused 1 · 1 skipped · 0 failed", "success");
|
||||
});
|
||||
|
||||
it("shows error summary when pause has failures", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001", paused: false })];
|
||||
@@ -3108,6 +3289,48 @@ describe("ListView - Bulk Selection", () => {
|
||||
expect(screen.getByText("1 selected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses workflow complete and archived flags when bulk delete archives done tasks", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "wf-custom",
|
||||
workflows: [
|
||||
{
|
||||
id: "wf-custom",
|
||||
name: "Custom",
|
||||
columns: [
|
||||
{ id: "doing", name: "Doing", flags: { countsTowardWip: true } },
|
||||
{ id: "shipped", name: "Shipped", flags: { complete: true } },
|
||||
{ id: "parked", name: "Parked", flags: { archived: true } },
|
||||
],
|
||||
},
|
||||
],
|
||||
taskWorkflowIds: { "FN-001": "wf-custom", "FN-002": "wf-custom", "FN-003": "wf-custom" },
|
||||
});
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "shipped" }),
|
||||
createMockTask({ id: "FN-002", column: "doing" }),
|
||||
createMockTask({ id: "FN-003", column: "parked" }),
|
||||
];
|
||||
const onArchiveTask = vi.fn(async () => createMockTask());
|
||||
const onDeleteTask = vi.fn(async () => createMockTask());
|
||||
mockConfirmWithChoice.mockResolvedValueOnce("tertiary");
|
||||
localStorage.setItem(scopedStorageKey("kb-dashboard-selected-tasks"), JSON.stringify(["FN-001", "FN-002", "FN-003"]));
|
||||
|
||||
renderListView({ tasks, onArchiveTask, onDeleteTask });
|
||||
enterBulkEditMode();
|
||||
await waitFor(() => expect(screen.queryAllByText("Shipped").length).toBeGreaterThan(0));
|
||||
await user.click(screen.getByRole("button", { name: /delete selected/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onArchiveTask).toHaveBeenCalledTimes(1);
|
||||
expect(onArchiveTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(onDeleteTask).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteTask).toHaveBeenCalledWith("FN-002");
|
||||
});
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Archived 1, deleted 1, failed 0", "success");
|
||||
});
|
||||
|
||||
it("does nothing when delete confirm is cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const tasks = [createMockTask({ id: "FN-001" })];
|
||||
|
||||
@@ -213,6 +213,7 @@ describe("WorkflowNodeEditor", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("fusion:wf-sidebar-settings-collapsed");
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { buildBoardWorkflowsPayload, DEFAULT_WORKFLOW_LANE_ID } from "../board-workflows.js";
|
||||
import type { WorkflowDefinition } from "@fusion/core";
|
||||
import { parseWorkflowIr } from "@fusion/core";
|
||||
@@ -94,6 +94,27 @@ describe("buildBoardWorkflowsPayload", () => {
|
||||
expect(payload.workflows.map((w) => w.id).sort()).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]);
|
||||
});
|
||||
|
||||
it("logs when workflow definition listing fails and falls back to referenced workflows", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const store = {
|
||||
...makeStore({ flagOn: true, selections: {} }),
|
||||
async listWorkflowDefinitions() {
|
||||
throw new Error("db unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
|
||||
expect(payload.workflows.map((w) => w.id)).toContain(DEFAULT_WORKFLOW_LANE_ID);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("[board-workflows] listWorkflowDefinitions failed"),
|
||||
expect.any(Error),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("describes a custom workflow's columns with resolved trait flags", async () => {
|
||||
const store = makeStore({
|
||||
flagOn: true,
|
||||
|
||||
@@ -183,9 +183,11 @@ export async function buildBoardWorkflowsPayload(
|
||||
if (definition.kind === "fragment") continue;
|
||||
referenced.add(definition.id);
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Older/partial test stores may not expose definition listing; the referenced
|
||||
// workflow set above is still sufficient for task rendering.
|
||||
// workflow set above is still sufficient for task rendering. Production
|
||||
// failures are logged so empty workflow definitions do not disappear silently.
|
||||
console.warn("[board-workflows] listWorkflowDefinitions failed; using referenced workflows only", err);
|
||||
}
|
||||
|
||||
const workflows: BoardWorkflowDefinition[] = [];
|
||||
|
||||
@@ -1156,7 +1156,11 @@ describe("StuckTaskDetector", () => {
|
||||
|
||||
it("suppresses another loop classification while accepted recovery is pending", async () => {
|
||||
const onLoopDetected = vi.fn().mockResolvedValue(true);
|
||||
const customDetector = new StuckTaskDetector(store, { onLoopDetected });
|
||||
const onStuck = vi.fn();
|
||||
const customStore = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({ taskStuckTimeoutMs: 60000 }),
|
||||
});
|
||||
const customDetector = new StuckTaskDetector(customStore, { onLoopDetected, onStuck });
|
||||
const session = createMockSession();
|
||||
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
@@ -1177,13 +1181,11 @@ describe("StuckTaskDetector", () => {
|
||||
}
|
||||
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("no-progress-churn");
|
||||
|
||||
customDetector.recordProgress("FN-201");
|
||||
vi.advanceTimersByTime(61000);
|
||||
for (let i = 0; i < 80; i++) {
|
||||
customDetector.recordActivity("FN-201");
|
||||
}
|
||||
|
||||
expect(customDetector.classifyStuckReason("FN-201", 60000)).toBe("loop");
|
||||
await customDetector.checkNow();
|
||||
expect(onStuck).toHaveBeenCalledWith(expect.objectContaining({
|
||||
taskId: "FN-201",
|
||||
reason: "no-progress-churn",
|
||||
}));
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, TaskDetail, WorkflowDefinition, WorkflowIr } from "@fusion/core";
|
||||
|
||||
import { WorkflowGraphTaskRunner, type WorkflowGraphRunnerStore } from "../workflow-graph-task-runner.js";
|
||||
@@ -165,9 +165,10 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
|
||||
it("resolves built-in workflow selections without requiring the store to return a definition", async () => {
|
||||
const calls: string[] = [];
|
||||
const getWorkflowDefinition = vi.fn(async () => undefined);
|
||||
const store: WorkflowGraphRunnerStore = {
|
||||
getTaskWorkflowSelection: () => ({ workflowId: "builtin:coding", stepIds: [] }),
|
||||
getWorkflowDefinition: async () => undefined,
|
||||
getWorkflowDefinition,
|
||||
};
|
||||
const runner = new WorkflowGraphTaskRunner({
|
||||
store,
|
||||
@@ -180,6 +181,7 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => {
|
||||
expect(result.disposition).toBe("completed");
|
||||
expect(calls).toEqual(["execute", "review", "merge"]);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(getWorkflowDefinition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back (never strands the task) when the interpreter throws", async () => {
|
||||
|
||||
@@ -649,9 +649,6 @@ export class StuckTaskDetector {
|
||||
const stuckTasks: string[] = [];
|
||||
|
||||
for (const [taskId, entry] of this.tracked) {
|
||||
if (entry.recoveryInProgress) {
|
||||
continue;
|
||||
}
|
||||
const reason = this.classifyStuckReason(taskId, timeoutMs);
|
||||
if (reason !== null) {
|
||||
// U8: suppress flagging while the CLI session is waitingOnInput
|
||||
|
||||
Reference in New Issue
Block a user