FN-7357: add all workflows to top-level selectors
Extend the dashboard aggregate workflow view across top-level workflow selectors without leaking its sentinel into task creation. - Add All workflows as an aggregate option for List, Header, Planning/Missions, and Graph selectors.\n- Preserve real workflow handoffs for quick create, planning, missions, and workflow editor entry points.\n- Include aggregate workflow counts and cross-surface regression coverage, plus docs and a changeset.\n\nFiles changed:\n .changeset/fn-7357-all-workflows-selectors.md | 7 +++\n docs/dashboard-guide.md | 9 ++--\n .../workflow-selection-cross-surface.test.tsx | 20 ++++---\n .../app/components/GraphWorkflowSwitcherSlot.tsx | 12 +++--\n .../app/components/HeaderWorkflowSwitcherSlot.tsx | 10 ++--\n packages/dashboard/app/components/ListView.tsx | 63 +++++++++++++++++-----\n .../components/PlanningWorkflowSwitcherSlot.tsx | 5 +-\n .../__tests__/GraphWorkflowSwitcherSlot.test.tsx | 34 ++++++++++++\n .../__tests__/HeaderWorkflowSwitcherSlot.test.tsx | 27 ++++++++++\n .../app/components/__tests__/ListView.test.tsx | 41 +++++++++++++-\n .../app/components/dashboard/MainContent.tsx | 17 ++++--\n .../app/components/workflowStatusCounts.ts | 5 ++\n packages/dashboard/app/hooks/useBoardWorkflows.ts | 20 +++++--\n .../dashboard/app/utils/boardWorkflowSelection.ts | 5 +-\n 14 files changed, 233 insertions(+), 42 deletions(-) Fusion-Task-Id: FN-7357 Fusion-Task-Lineage: b8658cad-7b12-4a06-b627-e2bde7de6951 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7357-all-workflows-selectors.md
Normal file
7
.changeset/fn-7357-all-workflows-selectors.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Add All workflows to top-level dashboard workflow selectors.
|
||||
category: feature
|
||||
dev: Extends the dashboard aggregate workflow sentinel to List, Planning, Missions, and Graph without backend handoff.
|
||||
@@ -150,13 +150,14 @@ FNXC:DoneTaskRefine 2026-07-01-00:00: Completed Board card context menus must la
|
||||
- Done-column sorting has two descending modes: **Completion date (newest first)** keeps the default completion-time order, while **Task ID (newest first)** places the highest numeric task IDs first. The sort actions are only shown in Done/complete column action menus, including custom workflow completion lanes; Archive All Done lives in the same menu when available.
|
||||
- On mobile, both default and workflow-mode boards fill the project viewport while the column strip remains the internal horizontal scroller with contained edge overscroll.
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-13:34: Board, List, Header, and Graph workflow selectors now share a durable per-project selection so operators return to the same lane after remounts, task refreshes, or respecification flows; stale saved workflow ids must fall back to a valid default/first workflow instead of hiding all tasks. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The Board-only All workflows option renders an aggregate column set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
|
||||
FNXC:WorkflowSelection 2026-06-30-00:00: The Board view preference persists either a real workflow id or the Board-only All workflows sentinel so refresh/remount restores the operator's last board view without treating the sentinel as a backend workflow id. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-18:37: The All workflows option renders an aggregate column/task set across workflows while keeping workflow-specific creates and edits scoped to real workflow ids.
|
||||
FNXC:WorkflowSelection 2026-06-30-00:00: The view preference persists either a real workflow id or the All workflows sentinel so refresh/remount restores the operator's last top-level workflow context without treating the sentinel as a backend workflow id.
|
||||
FNXC:WorkflowSelection 2026-07-01-00:00: All workflows is available on Board, List, Planning, Missions, and Graph top-level selectors; Planning/Missions task creation receives default/no-specific-workflow behavior instead of the sentinel. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:58: All workflows quick-create must use a real workflow intake/default column rather than a synthesized lifecycle column, so custom-default boards do not create tasks into invalid or disappearing columns. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-23:59: Workflow counts and All workflows grouping resolve each task's effective workflow before evaluating column visibility, so a shared column id hidden in one workflow does not leak that workflow's hidden tasks into another workflow's visible aggregate lane. -->
|
||||
<!-- FNXC:WorkflowSelection 2026-06-29-21:40: Refinement creation from Task Detail and done-task chat must preserve both the source task workflow and the operator's selected Board/List lane, so non-default workflow users do not get bounced back to Coding/default after refinement. -->
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board also shows **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, is persisted only as a Board view preference, and its quick-create affordance appears only on the chosen real workflow intake/default column so task creation still sends a real workflow id and column. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board also restores the dashboard-only **All workflows** aggregate view when that was the last selected Board view. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
- Board and List workflow switchers use a themed dropdown instead of a native select. The closed trigger shows the workflow identity (Fusion icon for built-ins, optional custom icon for custom workflows), name, and chevron only; compact Todo / In Progress / Done counts derived from workflow column flags (excluding archived and board-hidden columns) refresh each time the dropdown opens and appear while the dropdown is expanded, including on each workflow option. Built-in lanes with synthesized trait-less lifecycle columns fall back to canonical column ids (`todo`, `in-progress`, `done`, and `archived`) for those counts. Board and List also show **All workflows** before real workflows as a dashboard-only aggregate view with combined counts and a deterministic union of visible workflow columns; shared column ids use the default workflow label/flags when available, otherwise the first workflow definition that declares the column. Hidden columns stay workflow-scoped in the aggregate: a task whose effective workflow hides a shared column is omitted from that aggregate column even if another workflow exposes the same column id. That option is not editable, persists as top-level workflow view state, and quick-create/Plan/Subtask/Mission handoffs translate it to a real default workflow id or no-specific-workflow behavior so task creation never sends the sentinel. Each real workflow option row also exposes an inline edit action, and a persistent **New workflow** footer stays visible below the scrollable option list. The open listbox grows from the longest workflow name plus its count/edit decorations while remaining viewport-bounded; the closed trigger stays narrow and ellipsized. Those inline count badges intentionally use the same board column color tokens as cards: `--todo`, `--in-progress`, and `--done`.
|
||||
- When workflow columns are enabled, Board and List hydrate the last successful workflow-lane payload from a per-project session cache; cold loads show a neutral skeleton until settings and workflow metadata are known, avoiding a legacy single-lane flash. The selected workflow is remembered per project in durable browser storage and restored when returning to Board/List after task refreshes, route changes, respecification flows, or refinement creation from Task Detail and done-task chat; Board, List, Planning/Missions header selectors, and Graph also restore the dashboard-only **All workflows** aggregate view when that was the last selected top-level workflow context. If a saved real workflow is later deleted, Fusion falls back to a valid default/first workflow so tasks remain visible.
|
||||
- Briefly leaving Board/List for a task detail or another non-task-SSE view preserves the current in-memory task snapshot. Returning to Board/List reuses that fresh snapshot immediately and restores live SSE updates without an extra all-task fetch; Fusion still runs one catch-up fetch when task data is missing, stale, or from a failed refresh.
|
||||
<!-- FNXC:BoardTaskCache 2026-06-29-20:05: Board/List returns from non-task-SSE views should reuse a fresh in-memory task snapshot to avoid redundant all-task fetches and loading flashes, while stale, missing, or errored snapshots still trigger one catch-up fetch and restore SSE updates. -->
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ function CrossSurfaceHarness({ projectId = "project-cross", tasks = TASKS }: { p
|
||||
<div id="header-workflow-slot" data-testid="header-workflow-slot" />
|
||||
<HeaderWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setHeaderSelection} />
|
||||
<GraphWorkflowSwitcherSlot projectId={projectId} onWorkflowSelectionChange={setGraphSelection} />
|
||||
<output data-testid="header-selection">{headerSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<output data-testid="graph-selection">{graphSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<output data-testid="header-selection">{headerSelection?.isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : headerSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<output data-testid="graph-selection">{graphSelection?.isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : graphSelection?.selectedWorkflow.id ?? "none"}</output>
|
||||
<ul data-testid="graph-tasks">
|
||||
{graphTasks.map((task) => (
|
||||
<li key={task.id} data-testid={`graph-task-${task.id}`}>{task.title}</li>
|
||||
@@ -213,17 +213,23 @@ describe("workflow selection across dashboard surfaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("filters the Board-only all-workflows sentinel out of Header and Graph real-workflow surfaces", async () => {
|
||||
it("shares the all-workflows aggregate sentinel without filtering Graph tasks or editing aggregate rows", async () => {
|
||||
localStorage.setItem("kb:project-cross:kb-dashboard-board-workflow-selection", ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
|
||||
render(<CrossSurfaceHarness />);
|
||||
|
||||
expect(await screen.findAllByTestId("workflow-switcher")).toHaveLength(2);
|
||||
const switchers = await screen.findAllByTestId("workflow-switcher");
|
||||
expect(switchers).toHaveLength(2);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(DEFAULT_WORKFLOW.id);
|
||||
expect(screen.getByTestId("header-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
expect(screen.getByTestId("graph-selection")).toHaveTextContent(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
});
|
||||
expect(screen.queryByText("All workflows")).toBeNull();
|
||||
for (const task of TASKS) {
|
||||
expect(screen.getByTestId(`graph-task-${task.id}`)).toBeInTheDocument();
|
||||
}
|
||||
fireEvent.click(switchers[0]);
|
||||
expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
|
||||
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
|
||||
expect(localStorage.getItem("kb:project-cross:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
@@ -9,6 +10,7 @@ import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
export interface GraphWorkflowSelection {
|
||||
boardWorkflows: BoardWorkflowsPayload;
|
||||
selectedWorkflow: BoardWorkflowDefinition;
|
||||
isAllWorkflowsSelected: boolean;
|
||||
}
|
||||
|
||||
interface GraphWorkflowSwitcherSlotProps {
|
||||
@@ -29,7 +31,7 @@ export function filterTasksByGraphWorkflowSelection<T extends { id: string }>(
|
||||
projectId: string | undefined,
|
||||
selection: GraphWorkflowSelection | null,
|
||||
): T[] {
|
||||
if (!projectId || !selection) return tasks;
|
||||
if (!projectId || !selection || selection.isAllWorkflowsSelected) return tasks;
|
||||
const workflowIds = new Set(selection.boardWorkflows.workflows.map((workflow) => workflow.id));
|
||||
return tasks.filter((task) => {
|
||||
const rawAssignedWorkflowId = selection.boardWorkflows.taskWorkflowIds[task.id];
|
||||
@@ -55,6 +57,7 @@ export function GraphWorkflowSwitcherSlot({
|
||||
workflowMode,
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
isAllWorkflowsSelected,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
} = useBoardWorkflows({ projectId });
|
||||
@@ -87,8 +90,8 @@ export function GraphWorkflowSwitcherSlot({
|
||||
|
||||
const selection = useMemo<GraphWorkflowSelection | null>(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
|
||||
return { boardWorkflows, selectedWorkflow };
|
||||
}, [boardWorkflows, selectedWorkflow, workflowMode]);
|
||||
return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onWorkflowSelectionChange?.(selection);
|
||||
@@ -107,9 +110,10 @@ export function GraphWorkflowSwitcherSlot({
|
||||
<div className="board-workflow-selector">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={EMPTY_COUNTS}
|
||||
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../api";
|
||||
import { useBoardWorkflows } from "../hooks/useBoardWorkflows";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { WorkflowSwitcher } from "./WorkflowSwitcher";
|
||||
import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
@@ -9,6 +10,7 @@ import type { WorkflowStatusCounts } from "./workflowStatusCounts";
|
||||
export interface HeaderWorkflowSelection {
|
||||
boardWorkflows: BoardWorkflowsPayload;
|
||||
selectedWorkflow: BoardWorkflowDefinition;
|
||||
isAllWorkflowsSelected: boolean;
|
||||
}
|
||||
|
||||
interface HeaderWorkflowSwitcherSlotProps {
|
||||
@@ -37,6 +39,7 @@ export function HeaderWorkflowSwitcherSlot({
|
||||
workflowMode,
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
isAllWorkflowsSelected,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
} = useBoardWorkflows({ projectId });
|
||||
@@ -69,8 +72,8 @@ export function HeaderWorkflowSwitcherSlot({
|
||||
|
||||
const selection = useMemo<HeaderWorkflowSelection | null>(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
|
||||
return { boardWorkflows, selectedWorkflow };
|
||||
}, [boardWorkflows, selectedWorkflow, workflowMode]);
|
||||
return { boardWorkflows, selectedWorkflow, isAllWorkflowsSelected };
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
|
||||
|
||||
useEffect(() => {
|
||||
onWorkflowSelectionChange?.(selection);
|
||||
@@ -89,9 +92,10 @@ export function HeaderWorkflowSwitcherSlot({
|
||||
<div className="board-workflow-selector">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={EMPTY_COUNTS}
|
||||
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
onCreateWorkflow={onCreateWorkflow}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isTaskStuck } from "../utils/taskStuck";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
import { getUnifiedTaskProgress } from "../utils/taskProgress";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { extractDependencyDeleteConflict, extractLineageDeleteConflict } from "../utils/taskDelete";
|
||||
@@ -371,6 +372,7 @@ export function ListView({
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
selectedWorkflowId,
|
||||
isAllWorkflowsSelected,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
setBoardWorkflowsState,
|
||||
@@ -607,8 +609,28 @@ export function ListView({
|
||||
|
||||
const listColumns = useMemo<BoardWorkflowColumn[]>(() => {
|
||||
if (!workflowMode || !selectedWorkflow) return LEGACY_LIST_COLUMNS;
|
||||
return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
|
||||
}, [selectedWorkflow, workflowMode]);
|
||||
if (!isAllWorkflowsSelected || !boardWorkflows) {
|
||||
return selectedWorkflow.columns.filter((column) => !column.flags.hiddenFromBoard);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowAggregation 2026-07-01-00:00:
|
||||
The aggregate List view is a dashboard-only cross-workflow context. Build its column metadata from real workflows with the default workflow first so shared columns keep stable names/flags, then append the first non-hidden declaration from other workflows for tasks that would otherwise have no visible group.
|
||||
*/
|
||||
const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow]));
|
||||
const orderedWorkflows = [
|
||||
workflowsById.get(boardWorkflows.defaultWorkflowId),
|
||||
...boardWorkflows.workflows.filter((workflow) => workflow.id !== boardWorkflows.defaultWorkflowId),
|
||||
].filter((workflow): workflow is BoardWorkflowsPayload["workflows"][number] => Boolean(workflow));
|
||||
const columnsById = new Map<ColumnId, BoardWorkflowColumn>();
|
||||
for (const workflow of orderedWorkflows) {
|
||||
for (const column of workflow.columns) {
|
||||
if (column.flags.hiddenFromBoard || columnsById.has(column.id)) continue;
|
||||
columnsById.set(column.id, column);
|
||||
}
|
||||
}
|
||||
return [...columnsById.values()];
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
|
||||
|
||||
const columnNameById = useMemo(() => {
|
||||
const map = new Map<ColumnId, string>();
|
||||
@@ -644,25 +666,41 @@ export function ListView({
|
||||
}, [columnFlagsById, workflowMode]);
|
||||
|
||||
const selectedWorkflowTaskIds = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return null;
|
||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow || isAllWorkflowsSelected) return null;
|
||||
const ids = new Set<string>();
|
||||
const workflowIds = new Set(boardWorkflows.workflows.map((workflow) => workflow.id));
|
||||
for (const task of tasks) {
|
||||
const workflowId = boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId;
|
||||
const rawWorkflowId = boardWorkflows.taskWorkflowIds[task.id];
|
||||
const workflowId = rawWorkflowId && workflowIds.has(rawWorkflowId) ? rawWorkflowId : boardWorkflows.defaultWorkflowId;
|
||||
if (workflowId === selectedWorkflow.id) ids.add(task.id);
|
||||
}
|
||||
return ids;
|
||||
}, [boardWorkflows, selectedWorkflow, tasks, workflowMode]);
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, tasks, workflowMode]);
|
||||
|
||||
const workflowStatusCounts = useMemo(
|
||||
() => computeWorkflowStatusCounts(tasks, boardWorkflows),
|
||||
[boardWorkflows, tasks],
|
||||
);
|
||||
|
||||
const createTargetWorkflowId = useMemo(() => {
|
||||
if (!workflowMode || !boardWorkflows) return null;
|
||||
if (!isAllWorkflowsSelected) return selectedWorkflow?.id ?? null;
|
||||
return boardWorkflows.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId)?.id
|
||||
?? boardWorkflows.workflows[0]?.id
|
||||
?? null;
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, selectedWorkflow, workflowMode]);
|
||||
|
||||
const createTargetColumn = useMemo(() => {
|
||||
if (workflowMode && boardWorkflows && createTargetWorkflowId) {
|
||||
const workflow = boardWorkflows.workflows.find((candidate) => candidate.id === createTargetWorkflowId);
|
||||
const target = workflow?.columns.find((column) => column.flags.intake && !column.flags.archived && !column.flags.hiddenFromBoard)
|
||||
?? workflow?.columns.find((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
|
||||
if (target) return target.id;
|
||||
}
|
||||
const target = listColumns.find((column) => column.flags.intake && !column.flags.archived)
|
||||
?? listColumns.find((column) => !column.flags.archived);
|
||||
return target?.id;
|
||||
}, [listColumns]);
|
||||
}, [boardWorkflows, createTargetWorkflowId, listColumns, workflowMode]);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowList 2026-06-21-21:37:
|
||||
@@ -698,8 +736,8 @@ export function ListView({
|
||||
|
||||
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
||||
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
||||
const workflowId = typeof input.workflowId === "string" ? input.workflowId : selectedWorkflow.id;
|
||||
if (workflowMode && createTargetWorkflowId && createTargetColumn) {
|
||||
const workflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID ? input.workflowId : createTargetWorkflowId;
|
||||
const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn;
|
||||
const created = await create({
|
||||
...input,
|
||||
@@ -714,13 +752,13 @@ export function ListView({
|
||||
return created;
|
||||
}
|
||||
return create(input);
|
||||
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, selectedWorkflow, t, workflowMode]);
|
||||
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, createTargetWorkflowId, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, t, workflowMode]);
|
||||
|
||||
/*
|
||||
FNXC:ListWorkflowSelection 2026-06-29-00:00:
|
||||
List quick-add Plan/Subtask handoffs must inherit the same active workflow as direct quick-create. Passing null only while workflow mode has no selected workflow preserves stale-id fallback behavior without reverting to the project default lane.
|
||||
*/
|
||||
const listQuickEntryWorkflowId = workflowMode ? selectedWorkflow?.id ?? null : undefined;
|
||||
const listQuickEntryWorkflowId = workflowMode ? createTargetWorkflowId : undefined;
|
||||
|
||||
// Column display labels
|
||||
const COLUMN_LABELS_MAP: Record<ListColumn, string> = {
|
||||
@@ -2033,9 +2071,10 @@ export function ListView({
|
||||
<div className="list-workflow-control">
|
||||
<WorkflowSwitcher
|
||||
workflows={workflowOptions}
|
||||
value={selectedWorkflow.id}
|
||||
value={isAllWorkflowsSelected ? ALL_WORKFLOWS_BOARD_VIEW_ID : selectedWorkflow.id}
|
||||
onChange={setSelectedWorkflowId}
|
||||
counts={workflowStatusCounts}
|
||||
aggregateOption={{ id: ALL_WORKFLOWS_BOARD_VIEW_ID, name: "All workflows" }}
|
||||
onOpen={refreshBoardWorkflows}
|
||||
label={t("listView.workflowLabel", "Workflow")}
|
||||
onEditWorkflow={onOpenWorkflowEditor}
|
||||
@@ -2366,7 +2405,7 @@ export function ListView({
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
workflowId={listQuickEntryWorkflowId}
|
||||
workflowOptions={workflowMode ? workflowOptions : undefined}
|
||||
defaultWorkflowId={workflowMode ? selectedWorkflow?.id ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
|
||||
defaultWorkflowId={workflowMode ? createTargetWorkflowId ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
|
||||
projectId={projectId}
|
||||
autoExpand={false}
|
||||
defaultExpanded={false}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HeaderWorkflowSwitcherSlot } from "./HeaderWorkflowSwitcherSlot";
|
||||
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "./HeaderWorkflowSwitcherSlot";
|
||||
|
||||
/*
|
||||
FNXC:PlanningWorkflowSwitcher 2026-06-25-00:00:
|
||||
@@ -7,8 +7,9 @@ Planning keeps this compatibility wrapper while the neutral HeaderWorkflowSwitch
|
||||
|
||||
interface PlanningWorkflowSwitcherSlotProps {
|
||||
projectId?: string;
|
||||
onOpenWorkflowEditor?: () => void;
|
||||
onOpenWorkflowEditor?: (workflowId?: string) => void;
|
||||
onCreateWorkflow?: () => void;
|
||||
onWorkflowSelectionChange?: (selection: HeaderWorkflowSelection | null) => void;
|
||||
}
|
||||
|
||||
export function PlanningWorkflowSwitcherSlot(props: PlanningWorkflowSwitcherSlotProps) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
|
||||
import { filterTasksByGraphWorkflowSelection, GraphWorkflowSwitcherSlot, type GraphWorkflowSelection } from "../GraphWorkflowSwitcherSlot";
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn();
|
||||
@@ -74,6 +75,7 @@ describe("filterTasksByGraphWorkflowSelection", () => {
|
||||
},
|
||||
}),
|
||||
selectedWorkflow: DEFAULT_WORKFLOW,
|
||||
isAllWorkflowsSelected: false,
|
||||
};
|
||||
|
||||
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection).map((task) => task.id)).toEqual([
|
||||
@@ -86,11 +88,23 @@ describe("filterTasksByGraphWorkflowSelection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves all graph tasks for the aggregate workflow selection", () => {
|
||||
const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
|
||||
const selection: GraphWorkflowSelection = {
|
||||
boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
|
||||
selectedWorkflow: DEFAULT_WORKFLOW,
|
||||
isAllWorkflowsSelected: true,
|
||||
};
|
||||
|
||||
expect(filterTasksByGraphWorkflowSelection(tasks, "project-graph", selection)).toBe(tasks);
|
||||
});
|
||||
|
||||
it("preserves unfiltered graph tasks without a project or workflow payload", () => {
|
||||
const tasks = [{ id: "FN-a" }, { id: "FN-b" }];
|
||||
const selection: GraphWorkflowSelection = {
|
||||
boardWorkflows: workflowPayload({ taskWorkflowIds: { "FN-b": CUSTOM_WORKFLOW.id } }),
|
||||
selectedWorkflow: CUSTOM_WORKFLOW,
|
||||
isAllWorkflowsSelected: false,
|
||||
};
|
||||
|
||||
expect(filterTasksByGraphWorkflowSelection(tasks, undefined, selection)).toBe(tasks);
|
||||
@@ -112,6 +126,7 @@ describe("GraphWorkflowSwitcherSlot", () => {
|
||||
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith({
|
||||
boardWorkflows: workflowPayload(),
|
||||
selectedWorkflow: DEFAULT_WORKFLOW,
|
||||
isAllWorkflowsSelected: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -198,6 +213,25 @@ describe("GraphWorkflowSwitcherSlot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes all workflows as an aggregate non-editable graph selection", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
const onWorkflowSelectionChange = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
render(<GraphWorkflowSwitcherSlot projectId="project-graph-all" onWorkflowSelectionChange={onWorkflowSelectionChange} onOpenWorkflowEditor={onOpenWorkflowEditor} />);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workflow-switcher"));
|
||||
expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
|
||||
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const lastSelection = onWorkflowSelectionChange.mock.calls.at(-1)?.[0] as GraphWorkflowSelection | null;
|
||||
expect(lastSelection?.isAllWorkflowsSelected).toBe(true);
|
||||
expect(lastSelection?.selectedWorkflow.id).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
expect(onOpenWorkflowEditor).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
});
|
||||
|
||||
it("forwards dropdown edit workflow ids to the graph editor launcher", async () => {
|
||||
appendHeaderWorkflowSlot();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWorkflowDefinition, BoardWorkflowsPayload } from "../../api";
|
||||
import { HeaderWorkflowSwitcherSlot, type HeaderWorkflowSelection } from "../HeaderWorkflowSwitcherSlot";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../../utils/boardWorkflowSelection";
|
||||
import { PlanningWorkflowSwitcherSlot } from "../PlanningWorkflowSwitcherSlot";
|
||||
|
||||
const fetchBoardWorkflowsMock = vi.fn();
|
||||
@@ -131,6 +132,32 @@ describe("HeaderWorkflowSwitcherSlot", () => {
|
||||
expect(localStorage.getItem("kb:project-header-stale:kb-dashboard-board-workflow-selection")).toBe(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
|
||||
it("exposes all workflows as an aggregate non-editable header selection", async () => {
|
||||
const onWorkflowSelectionChange = vi.fn<(selection: HeaderWorkflowSelection | null) => void>();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
renderWithHeader(
|
||||
<HeaderWorkflowSwitcherSlot
|
||||
projectId="project-header-all"
|
||||
onWorkflowSelectionChange={onWorkflowSelectionChange}
|
||||
onOpenWorkflowEditor={onOpenWorkflowEditor}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByTestId("workflow-switcher"));
|
||||
expect(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toHaveTextContent("All workflows");
|
||||
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
|
||||
fireEvent.click(screen.getByTestId(`workflow-switcher-option-${ALL_WORKFLOWS_BOARD_VIEW_ID}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowSelectionChange).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
isAllWorkflowsSelected: true,
|
||||
selectedWorkflow: expect.objectContaining({ id: DEFAULT_WORKFLOW.id }),
|
||||
}));
|
||||
});
|
||||
expect(onOpenWorkflowEditor).not.toHaveBeenCalledWith(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
expect(localStorage.getItem("kb:project-header-all:kb-dashboard-board-workflow-selection")).toBe(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
});
|
||||
|
||||
it("forwards dropdown edit workflow ids from the shared header slot", async () => {
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
renderWithHeader(<HeaderWorkflowSwitcherSlot projectId="project-header-edit" onOpenWorkflowEditor={onOpenWorkflowEditor} />);
|
||||
|
||||
@@ -6,7 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { ListView } from "../ListView";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
import { BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID, BOARD_WORKFLOW_SELECTION_STORAGE_KEY } from "../../utils/boardWorkflowSelection";
|
||||
import { loadAllAppCss } from "../../test/cssFixture";
|
||||
|
||||
// Mock the API
|
||||
@@ -1143,6 +1143,45 @@ describe("ListView", () => {
|
||||
mobileSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("shows all workflows in ListView without submitting the aggregate sentinel", async () => {
|
||||
const mockOnQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new" });
|
||||
vi.mocked(fetchBoardWorkflows).mockResolvedValue({
|
||||
flagEnabled: true,
|
||||
defaultWorkflowId: "builtin:coding",
|
||||
workflows: [
|
||||
{ id: "builtin:coding", name: "Coding", columns: [{ id: "triage", name: "Triage", flags: { intake: true } }, { id: "done", name: "Done", flags: { complete: true } }] },
|
||||
{ id: "wf-custom", name: "Custom", columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }, { id: "review", name: "Review", flags: { countsTowardWip: true } }] },
|
||||
],
|
||||
taskWorkflowIds: { "FN-002": "wf-custom", "FN-003": "wf-deleted" },
|
||||
});
|
||||
|
||||
renderListView({
|
||||
tasks: [
|
||||
createMockTask({ id: "FN-001", column: "triage", title: "Coding task" }),
|
||||
createMockTask({ id: "FN-002", column: "backlog", title: "Custom task" }),
|
||||
createMockTask({ id: "FN-003", column: "triage", title: "Stale workflow task" }),
|
||||
],
|
||||
onQuickCreate: mockOnQuickCreate,
|
||||
});
|
||||
|
||||
await selectWorkflow(ALL_WORKFLOWS_BOARD_VIEW_ID);
|
||||
|
||||
expect(screen.getByText("Coding task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Custom task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stale workflow task")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-switcher")).toHaveTextContent("All workflows");
|
||||
expect(screen.queryByTestId(`workflow-switcher-edit-${ALL_WORKFLOWS_BOARD_VIEW_ID}`)).toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Aggregate quick add" } });
|
||||
fireEvent.keyDown(screen.getByTestId("quick-entry-input"), { key: "Enter" });
|
||||
|
||||
await waitFor(() => expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: "Aggregate quick add",
|
||||
workflowId: "builtin:coding",
|
||||
})));
|
||||
expect(mockOnQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: ALL_WORKFLOWS_BOARD_VIEW_ID }));
|
||||
});
|
||||
|
||||
it("shows workflow edit and New actions inside the dropdown", async () => {
|
||||
const onCreateWorkflow = vi.fn();
|
||||
const onOpenWorkflowEditor = vi.fn();
|
||||
|
||||
@@ -175,6 +175,7 @@ export function MainContent({
|
||||
_WorkflowEditorView,
|
||||
}: MainContentProps) {
|
||||
const [missionWorkflowId, setMissionWorkflowId] = useState<string | null>(null);
|
||||
const [planningHeaderWorkflowId, setPlanningHeaderWorkflowId] = useState<string | null>(null);
|
||||
|
||||
if (showBackendConnectionErrorPage) {
|
||||
return (
|
||||
@@ -364,11 +365,14 @@ export function MainContent({
|
||||
{/*
|
||||
FNXC:MissionWorkflows 2026-06-25-00:00:
|
||||
Missions intentionally shares Planning's header workflow-selection surface because feature and slice triage create tasks. Keep the selected workflow local to this project view and thread only the resolved id into mission task creation.
|
||||
|
||||
FNXC:WorkflowAggregation 2026-07-01-00:00:
|
||||
The All workflows row is view-only context for Missions; mission task creation receives `null` default behavior instead of the aggregate sentinel.
|
||||
*/}
|
||||
<HeaderWorkflowSwitcherSlot
|
||||
projectId={currentProject?.id}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection?.selectedWorkflow.id ?? null)}
|
||||
onWorkflowSelectionChange={(selection) => setMissionWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
|
||||
/>
|
||||
<MissionManager
|
||||
isInline={true}
|
||||
@@ -584,8 +588,15 @@ export function MainContent({
|
||||
{/*
|
||||
FNXC:Navigation 2026-06-22-00:00:
|
||||
Planning shows the same board WorkflowSwitcher in the same Header workflow slot as Board/List (portaled by PlanningWorkflowSwitcherSlot), so workflow selection is reachable from the left-sidebar Planning destination.
|
||||
|
||||
FNXC:WorkflowAggregation 2026-07-01-00:00:
|
||||
The Planning header selector may choose All workflows for aggregate browsing, but embedded PlanningModeModal receives a real workflow id from the header or `null` default behavior; explicit modalManager workflow entry points still win.
|
||||
*/}
|
||||
<PlanningWorkflowSwitcherSlot projectId={currentProject?.id} onOpenWorkflowEditor={openWorkflowEditorWithNav} />
|
||||
<PlanningWorkflowSwitcherSlot
|
||||
projectId={currentProject?.id}
|
||||
onOpenWorkflowEditor={openWorkflowEditorWithNav}
|
||||
onWorkflowSelectionChange={(selection) => setPlanningHeaderWorkflowId(selection && !selection.isAllWorkflowsSelected ? selection.selectedWorkflow.id : null)}
|
||||
/>
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={closePlanningView}
|
||||
@@ -594,7 +605,7 @@ export function MainContent({
|
||||
tasks={tasks}
|
||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||
projectId={currentProject?.id}
|
||||
workflowId={modalManager.planningWorkflowId}
|
||||
workflowId={modalManager.planningWorkflowId ?? planningHeaderWorkflowId}
|
||||
resumeSessionId={modalManager.planningResumeSessionId}
|
||||
presentation="embedded"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { BoardWorkflowColumn, BoardWorkflowsPayload } from "../api";
|
||||
import { ALL_WORKFLOWS_BOARD_VIEW_ID } from "../utils/boardWorkflowSelection";
|
||||
|
||||
export interface WorkflowStatusCounts {
|
||||
todo: number;
|
||||
@@ -71,6 +72,8 @@ export function computeWorkflowStatusCounts(
|
||||
);
|
||||
}
|
||||
|
||||
const aggregateCounts = EMPTY_COUNTS();
|
||||
countsByWorkflow.set(ALL_WORKFLOWS_BOARD_VIEW_ID, aggregateCounts);
|
||||
if (!tasks?.length) return countsByWorkflow;
|
||||
|
||||
for (const task of tasks) {
|
||||
@@ -93,12 +96,14 @@ export function computeWorkflowStatusCounts(
|
||||
|
||||
const counts = countsByWorkflow.get(workflow.id) ?? EMPTY_COUNTS();
|
||||
counts[bucket] += 1;
|
||||
aggregateCounts[bucket] += 1;
|
||||
if (MERGING_STATUSES.has(task.status ?? "")) {
|
||||
/*
|
||||
FNXC:WorkflowSwitcher 2026-06-22-20:30:
|
||||
Workflow boards need a visible flashing indicator in the workflow dropdown when any task assigned to that workflow is actively merging, independent of whether the workflow's review/merge column buckets as Todo or In Progress.
|
||||
*/
|
||||
counts.merging += 1;
|
||||
aggregateCounts.merging += 1;
|
||||
}
|
||||
countsByWorkflow.set(workflow.id, counts);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
writeBoardWorkflowsCache as defaultWriteBoardWorkflowsCache,
|
||||
} from "../utils/boardWorkflowsCache";
|
||||
import {
|
||||
readBoardWorkflowSelection,
|
||||
ALL_WORKFLOWS_BOARD_VIEW_ID,
|
||||
readBoardWorkflowViewSelection,
|
||||
removeBoardWorkflowSelection,
|
||||
writeBoardWorkflowSelection,
|
||||
} from "../utils/boardWorkflowSelection";
|
||||
@@ -45,9 +46,11 @@ export interface UseBoardWorkflowsResult {
|
||||
workflowMode: boolean;
|
||||
/** Workflows sorted with the default first, then alphabetical. Empty unless in workflow mode. */
|
||||
workflowOptions: BoardWorkflowDefinition[];
|
||||
/** Currently selected workflow (resolved from selection / default / first), or null. */
|
||||
/** Currently selected real workflow (resolved from selection / default / first), or null. */
|
||||
selectedWorkflow: BoardWorkflowDefinition | null;
|
||||
selectedWorkflowId: string | null;
|
||||
/** True when the dashboard-only aggregate workflow view is selected. */
|
||||
isAllWorkflowsSelected: boolean;
|
||||
setSelectedWorkflowId: Dispatch<SetStateAction<string | null>>;
|
||||
/** Force a fresh fetch (used on switcher open, since task assignment changes emit no workflow SSE). */
|
||||
refreshBoardWorkflows: () => void;
|
||||
@@ -73,7 +76,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
return cached ? { projectId, payload: cached } : null;
|
||||
});
|
||||
const boardWorkflows = boardWorkflowsState?.projectId === projectId && boardWorkflowsState ? boardWorkflowsState.payload : null;
|
||||
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowSelection(projectId));
|
||||
const [selectedWorkflowId, setSelectedWorkflowIdState] = useState<string | null>(() => readBoardWorkflowViewSelection(projectId));
|
||||
const storedSelectionRef = useRef<string | null>(selectedWorkflowId);
|
||||
|
||||
const setSelectedWorkflowId = useCallback<Dispatch<SetStateAction<string | null>>>((nextSelection) => {
|
||||
@@ -97,7 +100,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
// Re-hydrate from the per-project cache on project change (and gate change).
|
||||
useEffect(() => {
|
||||
const cached = shouldHydrateCache ? readBoardWorkflowsCache(projectId) : null;
|
||||
const storedSelection = readBoardWorkflowSelection(projectId);
|
||||
const storedSelection = readBoardWorkflowViewSelection(projectId);
|
||||
storedSelectionRef.current = storedSelection;
|
||||
setSelectedWorkflowIdState(storedSelection);
|
||||
setBoardWorkflowsState(cached ? { projectId, payload: cached } : null);
|
||||
@@ -153,6 +156,8 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
});
|
||||
}, [boardWorkflows, workflowMode]);
|
||||
|
||||
const isAllWorkflowsSelected = selectedWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID;
|
||||
|
||||
const selectedWorkflow = useMemo<BoardWorkflowDefinition | null>(() => {
|
||||
if (!workflowMode) return null;
|
||||
return workflowOptions.find((workflow) => workflow.id === selectedWorkflowId)
|
||||
@@ -184,6 +189,10 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAllWorkflowsSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedWorkflow && selectedWorkflow.id !== selectedWorkflowId) {
|
||||
const shouldRepairStoredSelection = storedSelectionRef.current !== null;
|
||||
setSelectedWorkflowIdState(selectedWorkflow.id);
|
||||
@@ -192,7 +201,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
storedSelectionRef.current = selectedWorkflow.id;
|
||||
}
|
||||
}
|
||||
}, [boardWorkflows, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
|
||||
}, [boardWorkflows, isAllWorkflowsSelected, projectId, selectedWorkflow, selectedWorkflowId, workflowMode, workflowOptions.length]);
|
||||
|
||||
return {
|
||||
boardWorkflows,
|
||||
@@ -200,6 +209,7 @@ export function useBoardWorkflows(params: UseBoardWorkflowsParams): UseBoardWork
|
||||
workflowOptions,
|
||||
selectedWorkflow,
|
||||
selectedWorkflowId,
|
||||
isAllWorkflowsSelected,
|
||||
setSelectedWorkflowId,
|
||||
refreshBoardWorkflows,
|
||||
setBoardWorkflowsState,
|
||||
|
||||
@@ -19,7 +19,10 @@ function isValidWorkflowSelection(value: unknown): value is string {
|
||||
* Persist the last selected Board workflow view in project-scoped localStorage so Board, Header, Graph, and List selectors can restore the operator's workflow after board remounts, task state changes, respecification returns, and browser/server restarts. Storage is best-effort because private-mode, SSR, missing APIs, and quota failures must never block board rendering.
|
||||
*
|
||||
* FNXC:BoardWorkflowSelection 2026-06-30-00:00:
|
||||
* The durable Board view preference may be either a real workflow id or the Board-only All workflows sentinel so a refresh can restore the aggregate board. Shared real-workflow consumers must call `readBoardWorkflowSelection`; it filters the sentinel out so Header, Graph, List, task creation, and workflow APIs never receive `__all_workflows__` as a backend workflow id.
|
||||
* The durable Board view preference may be either a real workflow id or the Board-only All workflows sentinel so a refresh can restore the aggregate board. Shared real-workflow consumers must call `readBoardWorkflowSelection`; it filters the sentinel out so task creation, workflow APIs, and other backend handoffs never receive `__all_workflows__` as a real workflow id.
|
||||
*
|
||||
* FNXC:WorkflowAggregation 2026-07-01-00:00:
|
||||
* Top-level dashboard selectors (Board/List/Planning/Missions/Graph) may display the aggregate sentinel as view state, but any task-creating or workflow-editing path must translate it to a real workflow id or `null` default behavior before crossing component/API boundaries.
|
||||
*/
|
||||
export function readBoardWorkflowViewSelection(projectId?: string): string | null {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user