FN-7276: add quick-add workflow selection
Add workflow-aware quick task creation across Board and List quick-add surfaces. - Add a quick-add workflow picker that filters out the aggregate workflow sentinel. - Route create, planning, subtask, and optional-step flows through the selected real workflow. - Resolve workflow-specific intake columns for Board/List quick creates and cover behavior with component tests. - Add a minor changeset for the published CLI package. Files changed: .changeset/fn-7276-quick-add-workflow-selector.md | 7 + packages/dashboard/app/components/Board.tsx | 43 +++++- packages/dashboard/app/components/Column.tsx | 12 +- packages/dashboard/app/components/ListView.tsx | 20 ++- .../dashboard/app/components/QuickEntryBox.css | 51 +++++++ .../dashboard/app/components/QuickEntryBox.tsx | 169 +++++++++++++++++++-- .../app/components/__tests__/Board.test.tsx | 42 ++++- .../app/components/__tests__/ListView.test.tsx | 67 +++++++- .../components/__tests__/QuickEntryBox.test.tsx | 73 +++++++++ 9 files changed, 453 insertions(+), 31 deletions(-) Fusion-Task-Id: FN-7276 Fusion-Task-Lineage: 422f0359-c71f-4610-8fdb-39d7a21a2a52 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7276-quick-add-workflow-selector.md
Normal file
7
.changeset/fn-7276-quick-add-workflow-selector.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Add a quick-add workflow selector for Board and List task creation.
|
||||||
|
category: feature
|
||||||
|
dev: The selector drives save, planning, subtask handoff, and workflow-step loading without submitting the aggregate workflow sentinel.
|
||||||
@@ -436,6 +436,18 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
: boardWorkflows.defaultWorkflowId;
|
: boardWorkflows.defaultWorkflowId;
|
||||||
}, [boardWorkflows, knownWorkflowIds]);
|
}, [boardWorkflows, knownWorkflowIds]);
|
||||||
|
|
||||||
|
const resolveWorkflowQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => {
|
||||||
|
if (targetWorkflowId === ALL_WORKFLOWS_BOARD_VIEW_ID) return undefined;
|
||||||
|
const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId);
|
||||||
|
if (!workflow) return undefined;
|
||||||
|
const visibleColumns = workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
|
||||||
|
const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined;
|
||||||
|
const column = preferredColumn
|
||||||
|
?? visibleColumns.find((candidate) => candidate.flags.intake)
|
||||||
|
?? visibleColumns[0];
|
||||||
|
return column?.id as ColumnId | undefined;
|
||||||
|
}, [boardWorkflows]);
|
||||||
|
|
||||||
const selectedWorkflowTasks = useMemo(() => {
|
const selectedWorkflowTasks = useMemo(() => {
|
||||||
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
|
if (!workflowMode || !boardWorkflows || !selectedWorkflow) return [];
|
||||||
return tasks.filter((task) => getEffectiveTaskWorkflowId(task) === selectedWorkflow.id);
|
return tasks.filter((task) => getEffectiveTaskWorkflowId(task) === selectedWorkflow.id);
|
||||||
@@ -464,14 +476,22 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
*/
|
*/
|
||||||
const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
const handleWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||||
if (!onQuickCreate || !selectedWorkflow) return undefined;
|
if (!onQuickCreate || !selectedWorkflow) return undefined;
|
||||||
const created = await onQuickCreate(input);
|
const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID
|
||||||
|
? input.workflowId
|
||||||
|
: selectedWorkflow.id;
|
||||||
|
const targetColumn = resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column);
|
||||||
|
const created = await onQuickCreate({
|
||||||
|
...input,
|
||||||
|
...(targetColumn ? { column: targetColumn } : {}),
|
||||||
|
workflowId: targetWorkflowId,
|
||||||
|
});
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? selectedWorkflow.id;
|
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
|
||||||
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
||||||
refreshBoardWorkflows();
|
refreshBoardWorkflows();
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, selectedWorkflow]);
|
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget, selectedWorkflow]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:WorkflowBoard 2026-06-29-23:58:
|
* FNXC:WorkflowBoard 2026-06-29-23:58:
|
||||||
@@ -479,15 +499,22 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
*/
|
*/
|
||||||
const handleAggregateWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
const handleAggregateWorkflowQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||||
if (!onQuickCreate) return undefined;
|
if (!onQuickCreate) return undefined;
|
||||||
const created = await onQuickCreate(input);
|
const targetWorkflowId = typeof input.workflowId === "string" && input.workflowId !== ALL_WORKFLOWS_BOARD_VIEW_ID
|
||||||
const targetWorkflowId = typeof input.workflowId === "string" ? input.workflowId : undefined;
|
? input.workflowId
|
||||||
|
: (boardWorkflows?.workflows.find((workflow) => workflow.id === boardWorkflows.defaultWorkflowId)?.id ?? boardWorkflows?.workflows[0]?.id);
|
||||||
|
const targetColumn = targetWorkflowId ? resolveWorkflowQuickCreateTarget(targetWorkflowId, input.column) : undefined;
|
||||||
|
const created = await onQuickCreate({
|
||||||
|
...input,
|
||||||
|
...(targetColumn ? { column: targetColumn } : {}),
|
||||||
|
...(targetWorkflowId ? { workflowId: targetWorkflowId } : {}),
|
||||||
|
});
|
||||||
if (created?.id && targetWorkflowId) {
|
if (created?.id && targetWorkflowId) {
|
||||||
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
|
const createdWorkflowId = (created as Task & { workflowId?: string }).workflowId ?? targetWorkflowId;
|
||||||
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
applyOptimisticTaskWorkflow(created.id, createdWorkflowId);
|
||||||
refreshBoardWorkflows();
|
refreshBoardWorkflows();
|
||||||
}
|
}
|
||||||
return created;
|
return created;
|
||||||
}, [applyOptimisticTaskWorkflow, onQuickCreate, refreshBoardWorkflows]);
|
}, [applyOptimisticTaskWorkflow, boardWorkflows, onQuickCreate, refreshBoardWorkflows, resolveWorkflowQuickCreateTarget]);
|
||||||
|
|
||||||
const selectedWorkflowArchivedColumn = useMemo(() => {
|
const selectedWorkflowArchivedColumn = useMemo(() => {
|
||||||
if (!selectedWorkflow) return null;
|
if (!selectedWorkflow) return null;
|
||||||
@@ -812,7 +839,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
prAuthAvailable={prAuthAvailable}
|
prAuthAvailable={prAuthAvailable}
|
||||||
autoMerge={autoMerge}
|
autoMerge={autoMerge}
|
||||||
mergeStrategy={mergeStrategy}
|
mergeStrategy={mergeStrategy}
|
||||||
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||||
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
@@ -890,7 +917,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
|||||||
prAuthAvailable={prAuthAvailable}
|
prAuthAvailable={prAuthAvailable}
|
||||||
autoMerge={autoMerge}
|
autoMerge={autoMerge}
|
||||||
mergeStrategy={mergeStrategy}
|
mergeStrategy={mergeStrategy}
|
||||||
{...(isCreateColumn ? { onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
{...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
|
||||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||||
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
|
|||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
import type { TaskContextMenuColumnMetadata } from "./TaskContextMenu";
|
||||||
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
|
import { ChevronDown, ChevronUp, MoreVertical } from "lucide-react";
|
||||||
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
|
import type { BoardWorkflowDefinition, ModelInfo, BoardWorkflowColumnFlags } from "../api";
|
||||||
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
|
||||||
import type { DoneColumnSortMode } from "./taskSorting";
|
import type { DoneColumnSortMode } from "./taskSorting";
|
||||||
|
|
||||||
@@ -167,6 +167,10 @@ interface ColumnProps {
|
|||||||
workflowMode?: boolean;
|
workflowMode?: boolean;
|
||||||
/** Workflow id for column-aware task creation in workflow mode. */
|
/** Workflow id for column-aware task creation in workflow mode. */
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
|
/** Real workflow choices for the quick-add selector in workflow mode. */
|
||||||
|
workflowOptions?: BoardWorkflowDefinition[];
|
||||||
|
/** Default workflow target for quick-add when the parent view is aggregate or stale. */
|
||||||
|
defaultWorkflowId?: string | null;
|
||||||
/** Display name for this column, from the workflow definition. */
|
/** Display name for this column, from the workflow definition. */
|
||||||
columnDisplayName?: string;
|
columnDisplayName?: string;
|
||||||
/** Resolved trait flags for this column (workflow mode). */
|
/** Resolved trait flags for this column (workflow mode). */
|
||||||
@@ -190,7 +194,7 @@ interface ColumnProps {
|
|||||||
getDraggingTaskId?: () => string | null;
|
getDraggingTaskId?: () => string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
|
||||||
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
// scopes `t` to the useTranslation binding, so the shared translateRejection
|
||||||
@@ -421,7 +425,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
return onQuickCreate({
|
return onQuickCreate({
|
||||||
...input,
|
...input,
|
||||||
column,
|
column,
|
||||||
...(workflowId ? { workflowId } : {}),
|
...(input.workflowId !== undefined ? { workflowId: input.workflowId } : (workflowId ? { workflowId } : {})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return onQuickCreate(input);
|
return onQuickCreate(input);
|
||||||
@@ -769,6 +773,8 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
|
|||||||
onPlanningMode={onPlanningMode}
|
onPlanningMode={onPlanningMode}
|
||||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||||
workflowId={workflowMode ? workflowId : undefined}
|
workflowId={workflowMode ? workflowId : undefined}
|
||||||
|
workflowOptions={workflowMode ? workflowOptions : undefined}
|
||||||
|
defaultWorkflowId={workflowMode ? defaultWorkflowId : undefined}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
autoExpand={false}
|
autoExpand={false}
|
||||||
favoriteProviders={favoriteProviders}
|
favoriteProviders={favoriteProviders}
|
||||||
|
|||||||
@@ -682,13 +682,25 @@ export function ListView({
|
|||||||
});
|
});
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
|
const resolveListQuickCreateTarget = useCallback((targetWorkflowId: string, preferredColumnId?: string | null): ColumnId | undefined => {
|
||||||
|
const workflow = boardWorkflows?.workflows.find((candidate) => candidate.id === targetWorkflowId);
|
||||||
|
if (!workflow) return undefined;
|
||||||
|
const visibleColumns = workflow.columns.filter((column) => !column.flags.archived && !column.flags.hiddenFromBoard);
|
||||||
|
const preferredColumn = preferredColumnId ? visibleColumns.find((column) => column.id === preferredColumnId) : undefined;
|
||||||
|
const column = preferredColumn
|
||||||
|
?? visibleColumns.find((candidate) => candidate.flags.intake)
|
||||||
|
?? visibleColumns[0];
|
||||||
|
return column?.id as ColumnId | undefined;
|
||||||
|
}, [boardWorkflows]);
|
||||||
|
|
||||||
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
const handleListQuickCreate = useCallback(async (input: TaskCreateInput) => {
|
||||||
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
const create = onQuickCreate ?? (async () => addToast(t("listView.taskCreationUnavailable", "Task creation not available"), "error"));
|
||||||
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
if (workflowMode && selectedWorkflow && createTargetColumn) {
|
||||||
const workflowId = input.workflowId ?? selectedWorkflow.id;
|
const workflowId = typeof input.workflowId === "string" ? input.workflowId : selectedWorkflow.id;
|
||||||
|
const targetColumn = resolveListQuickCreateTarget(workflowId, input.column) ?? createTargetColumn;
|
||||||
const created = await create({
|
const created = await create({
|
||||||
...input,
|
...input,
|
||||||
column: input.column ?? createTargetColumn,
|
column: targetColumn,
|
||||||
workflowId,
|
workflowId,
|
||||||
});
|
});
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
@@ -699,7 +711,7 @@ export function ListView({
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
return create(input);
|
return create(input);
|
||||||
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, selectedWorkflow, t, workflowMode]);
|
}, [addToast, applyOptimisticTaskWorkflow, createTargetColumn, onQuickCreate, refreshBoardWorkflows, resolveListQuickCreateTarget, selectedWorkflow, t, workflowMode]);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:ListWorkflowSelection 2026-06-29-00:00:
|
FNXC:ListWorkflowSelection 2026-06-29-00:00:
|
||||||
@@ -2350,6 +2362,8 @@ export function ListView({
|
|||||||
onPlanningMode={onPlanningMode}
|
onPlanningMode={onPlanningMode}
|
||||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||||
workflowId={listQuickEntryWorkflowId}
|
workflowId={listQuickEntryWorkflowId}
|
||||||
|
workflowOptions={workflowMode ? workflowOptions : undefined}
|
||||||
|
defaultWorkflowId={workflowMode ? selectedWorkflow?.id ?? boardWorkflows?.defaultWorkflowId ?? null : undefined}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
autoExpand={false}
|
autoExpand={false}
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
|
|||||||
@@ -135,6 +135,57 @@ The global `.description-with-refine textarea { padding-right: 70px }` (styles.c
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:QuickAddWorkflow 2026-06-30-00:00:
|
||||||
|
Quick-add workflow targeting sits in the action row as a compact existing-button dropdown. Use tokenized sizing/colors so the selector wraps with Save/Plan controls on narrow Board and List surfaces without introducing a separate visual hierarchy.
|
||||||
|
*/
|
||||||
|
.quick-entry-workflow-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-workflow-trigger {
|
||||||
|
max-width: calc(var(--space-xl) * 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-workflow-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-workflow-menu {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-start: calc(100% + var(--space-xs));
|
||||||
|
inset-inline-start: 0;
|
||||||
|
min-width: min(calc(var(--space-xl) * 10), calc(100vw - var(--space-lg)));
|
||||||
|
max-width: calc(100vw - var(--space-lg));
|
||||||
|
max-height: min(calc(var(--space-xl) * 10), calc(100vh - var(--space-xl) * 2));
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-workflow-option {
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.quick-entry-workflow-wrap,
|
||||||
|
.quick-entry-workflow-trigger {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-entry-workflow-menu {
|
||||||
|
min-width: min(calc(var(--space-xl) * 9), calc(100vw - var(--space-lg)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.quick-entry-model-wrap {
|
.quick-entry-model-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import "./QuickEntryBox.css";
|
import "./QuickEntryBox.css";
|
||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, getErrorMessage } from "@fusion/core";
|
||||||
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
import type { Task, Settings, TaskPriority, ResolvedWorkflowOptionalStep } from "@fusion/core";
|
||||||
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch } from "../api";
|
import type { ModelInfo, RefinementType, Agent, CreateTaskInput, DuplicateMatch, BoardWorkflowDefinition } from "../api";
|
||||||
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
|
import { checkDuplicateTasks, fetchModels, fetchSettings, refineText, getRefineErrorMessage, updateGlobalSettings, fetchAgents, uploadAttachment, fetchWorkflowOptionalSteps } from "../api";
|
||||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||||
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
import { Link, Paperclip, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp, ChevronRight, Bot, Server, Flag } from "lucide-react";
|
||||||
@@ -40,6 +40,10 @@ interface QuickEntryBoxProps {
|
|||||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||||
/** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */
|
/** Selected workflow lane for AI-assisted create actions. Omit in legacy board mode to preserve project-default inheritance. */
|
||||||
workflowId?: string | null;
|
workflowId?: string | null;
|
||||||
|
/** Real workflows available to the quick-add workflow selector. Board-only aggregate sentinels must be filtered before rendering/submission. */
|
||||||
|
workflowOptions?: BoardWorkflowDefinition[];
|
||||||
|
/** Project/default workflow id used when the parent view is aggregate or stale. */
|
||||||
|
defaultWorkflowId?: string | null;
|
||||||
/** Optional project context for API calls */
|
/** Optional project context for API calls */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
/**
|
/**
|
||||||
@@ -101,7 +105,25 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
function getRealWorkflowOptions(workflowOptions: BoardWorkflowDefinition[] | undefined): BoardWorkflowDefinition[] {
|
||||||
|
return (workflowOptions ?? []).filter((workflow) => workflow.id !== "__all_workflows__");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveQuickAddWorkflowId(
|
||||||
|
parentWorkflowId: string | null | undefined,
|
||||||
|
defaultWorkflowId: string | null | undefined,
|
||||||
|
workflowOptions: BoardWorkflowDefinition[],
|
||||||
|
): string | null | undefined {
|
||||||
|
if (parentWorkflowId === undefined) return undefined;
|
||||||
|
if (parentWorkflowId === null) return null;
|
||||||
|
if (workflowOptions.length === 0) return parentWorkflowId === "__all_workflows__" ? null : parentWorkflowId;
|
||||||
|
const validIds = new Set(workflowOptions.map((workflow) => workflow.id));
|
||||||
|
if (validIds.has(parentWorkflowId)) return parentWorkflowId;
|
||||||
|
if (defaultWorkflowId && validIds.has(defaultWorkflowId)) return defaultWorkflowId;
|
||||||
|
return workflowOptions[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels, onPlanningMode, onSubtaskBreakdown, workflowId, workflowOptions, defaultWorkflowId, projectId, autoExpand = true, defaultExpanded = true, singleLine = false, favoriteProviders: parentFavoriteProviders, favoriteModels: parentFavoriteModels, onToggleFavorite: parentToggleFavorite, onToggleModelFavorite: parentToggleModelFavorite, onOpenTask }: QuickEntryBoxProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [description, setDescription] = useState(() => {
|
const [description, setDescription] = useState(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -162,6 +184,17 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const [portalRoot] = useState<HTMLElement | null>(() =>
|
const [portalRoot] = useState<HTMLElement | null>(() =>
|
||||||
typeof document !== "undefined" ? document.body : null,
|
typeof document !== "undefined" ? document.body : null,
|
||||||
);
|
);
|
||||||
|
const realWorkflowOptions = useMemo(() => getRealWorkflowOptions(workflowOptions), [workflowOptions]);
|
||||||
|
const workflowPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previousWorkflowDefaultRef = useRef<{ workflowId: string | null | undefined; defaultWorkflowId: string | null | undefined }>({ workflowId, defaultWorkflowId });
|
||||||
|
const [showWorkflowPicker, setShowWorkflowPicker] = useState(false);
|
||||||
|
/*
|
||||||
|
FNXC:QuickAddWorkflow 2026-06-30-00:00:
|
||||||
|
Quick-add needs an independent real workflow target because the main Board selector can be on the aggregate "All workflows" read view. Resolve only against real workflow ids and keep the aggregate sentinel out of task create, Plan, Subtask, and optional-step requests.
|
||||||
|
*/
|
||||||
|
const [quickEntryWorkflowId, setQuickEntryWorkflowId] = useState<string | null | undefined>(() => (
|
||||||
|
resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, getRealWorkflowOptions(workflowOptions))
|
||||||
|
));
|
||||||
const [modelsLoading, setModelsLoading] = useState(false);
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||||
@@ -259,10 +292,38 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
FNXC:WorkflowOptionalSteps 2026-06-26-05:10:
|
FNXC:WorkflowOptionalSteps 2026-06-26-05:10:
|
||||||
Resolution MUST mirror the executor/store (explicit workflowId → project default → `builtin:coding`). The earlier `?? null` tail hid `builtin:coding`'s optional steps (browser-verification, code-review) whenever no project default workflow was configured, so operators never saw the toggles even though the unselected task runs `builtin:coding` (FN-7039). Fall back to `builtin:coding` once settings have loaded; stay `null` while settings are still loading so we don't fetch the wrong workflow then refetch.
|
Resolution MUST mirror the executor/store (explicit workflowId → project default → `builtin:coding`). The earlier `?? null` tail hid `builtin:coding`'s optional steps (browser-verification, code-review) whenever no project default workflow was configured, so operators never saw the toggles even though the unselected task runs `builtin:coding` (FN-7039). Fall back to `builtin:coding` once settings have loaded; stay `null` while settings are still loading so we don't fetch the wrong workflow then refetch.
|
||||||
*/
|
*/
|
||||||
|
const selectedQuickEntryWorkflow = typeof quickEntryWorkflowId === "string"
|
||||||
|
? realWorkflowOptions.find((option) => option.id === quickEntryWorkflowId)
|
||||||
|
: undefined;
|
||||||
|
const quickEntryWorkflowNameCounts = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const option of realWorkflowOptions) {
|
||||||
|
counts.set(option.name, (counts.get(option.name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [realWorkflowOptions]);
|
||||||
|
const showWorkflowSelector = workflowId !== undefined && realWorkflowOptions.length >= 2 && quickEntryWorkflowId !== null;
|
||||||
|
const quickEntryWorkflowLabel = selectedQuickEntryWorkflow?.name ?? t("tasks.workflow", "Workflow");
|
||||||
|
const selectedWorkflowForCreate = workflowId === undefined ? undefined : quickEntryWorkflowId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parentChanged = previousWorkflowDefaultRef.current.workflowId !== workflowId
|
||||||
|
|| previousWorkflowDefaultRef.current.defaultWorkflowId !== defaultWorkflowId;
|
||||||
|
previousWorkflowDefaultRef.current = { workflowId, defaultWorkflowId };
|
||||||
|
setQuickEntryWorkflowId((current) => {
|
||||||
|
const resolved = resolveQuickAddWorkflowId(workflowId, defaultWorkflowId, realWorkflowOptions);
|
||||||
|
if (parentChanged) return resolved;
|
||||||
|
if (typeof current === "string" && realWorkflowOptions.some((option) => option.id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
});
|
||||||
|
}, [defaultWorkflowId, realWorkflowOptions, workflowId]);
|
||||||
|
|
||||||
const effectiveOptionalWorkflowId =
|
const effectiveOptionalWorkflowId =
|
||||||
workflowId === null
|
selectedWorkflowForCreate === null
|
||||||
? null
|
? null
|
||||||
: (workflowId ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
|
: (selectedWorkflowForCreate ?? settings?.defaultWorkflowId ?? (settings ? "builtin:coding" : null));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -480,6 +541,19 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showPriorityPicker]);
|
}, [showPriorityPicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showWorkflowPicker) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (workflowPickerRef.current?.contains(target)) return;
|
||||||
|
setShowWorkflowPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [showWorkflowPicker]);
|
||||||
|
|
||||||
const resetForm = useCallback(() => {
|
const resetForm = useCallback(() => {
|
||||||
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
pendingImages.forEach((img) => URL.revokeObjectURL(img.previewUrl));
|
||||||
setPendingImages([]);
|
setPendingImages([]);
|
||||||
@@ -566,6 +640,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const createdTask = await onCreate({
|
const createdTask = await onCreate({
|
||||||
description: trimmed,
|
description: trimmed,
|
||||||
column: "triage",
|
column: "triage",
|
||||||
|
...(selectedWorkflowForCreate !== undefined ? { workflowId: selectedWorkflowForCreate } : {}),
|
||||||
dependencies: dependencies.length ? dependencies : undefined,
|
dependencies: dependencies.length ? dependencies : undefined,
|
||||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||||
modelPresetId: selectedPresetId,
|
modelPresetId: selectedPresetId,
|
||||||
@@ -612,6 +687,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
onCreate,
|
onCreate,
|
||||||
description,
|
description,
|
||||||
dependencies,
|
dependencies,
|
||||||
|
selectedWorkflowForCreate,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
hasExecutorOverride,
|
hasExecutorOverride,
|
||||||
@@ -752,6 +828,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
setPriorityPickerPosition(null);
|
setPriorityPickerPosition(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (showWorkflowPicker) {
|
||||||
|
setShowWorkflowPicker(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (showAgentPicker) {
|
if (showAgentPicker) {
|
||||||
setShowAgentPicker(false);
|
setShowAgentPicker(false);
|
||||||
setAgentPickerPosition(null);
|
setAgentPickerPosition(null);
|
||||||
@@ -786,6 +866,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
activeModelSubmenu,
|
activeModelSubmenu,
|
||||||
isRefineMenuOpen,
|
isRefineMenuOpen,
|
||||||
showPriorityPicker,
|
showPriorityPicker,
|
||||||
|
showWorkflowPicker,
|
||||||
projectId,
|
projectId,
|
||||||
setIsDisclosureExpanded,
|
setIsDisclosureExpanded,
|
||||||
duplicateMatches,
|
duplicateMatches,
|
||||||
@@ -1379,8 +1460,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (workflowId !== undefined) {
|
if (selectedWorkflowForCreate !== undefined) {
|
||||||
onPlanningMode?.(trimmed, workflowId);
|
onPlanningMode?.(trimmed, selectedWorkflowForCreate);
|
||||||
} else {
|
} else {
|
||||||
onPlanningMode?.(trimmed);
|
onPlanningMode?.(trimmed);
|
||||||
}
|
}
|
||||||
@@ -1388,7 +1469,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
|
FNXC:QuickAddPlanningPreserve 2026-06-22-00:00:
|
||||||
Opening planning mode must preserve the quick-add description and scoped draft so exiting planning without creating tasks restores the user's text. The draft is cleared only by planning-completion handlers.
|
Opening planning mode must preserve the quick-add description and scoped draft so exiting planning without creating tasks restores the user's text. The draft is cleared only by planning-completion handlers.
|
||||||
*/
|
*/
|
||||||
}, [description, onPlanningMode, workflowId, addToast, t]);
|
}, [description, onPlanningMode, selectedWorkflowForCreate, addToast, t]);
|
||||||
|
|
||||||
const handleSubtaskClick = useCallback(() => {
|
const handleSubtaskClick = useCallback(() => {
|
||||||
const trimmed = description.trim();
|
const trimmed = description.trim();
|
||||||
@@ -1396,14 +1477,14 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
addToast(t("tasks.enterDescriptionFirst", "Enter a description first"), "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (workflowId !== undefined) {
|
if (selectedWorkflowForCreate !== undefined) {
|
||||||
onSubtaskBreakdown?.(trimmed, workflowId);
|
onSubtaskBreakdown?.(trimmed, selectedWorkflowForCreate);
|
||||||
} else {
|
} else {
|
||||||
onSubtaskBreakdown?.(trimmed);
|
onSubtaskBreakdown?.(trimmed);
|
||||||
}
|
}
|
||||||
// Clear the form after triggering subtask breakdown
|
// Clear the form after triggering subtask breakdown
|
||||||
resetForm();
|
resetForm();
|
||||||
}, [description, onSubtaskBreakdown, workflowId, addToast, resetForm]);
|
}, [description, onSubtaskBreakdown, selectedWorkflowForCreate, addToast, resetForm]);
|
||||||
|
|
||||||
const handleSaveClick = useCallback(() => {
|
const handleSaveClick = useCallback(() => {
|
||||||
// Save button now creates the task (same as Enter key)
|
// Save button now creates the task (same as Enter key)
|
||||||
@@ -1571,6 +1652,72 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
touchButtonRef.current = null;
|
touchButtonRef.current = null;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{showWorkflowSelector && (
|
||||||
|
<div className="quick-entry-workflow-wrap" ref={workflowPickerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm dep-trigger quick-entry-workflow-trigger"
|
||||||
|
data-testid="quick-entry-workflow-trigger"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={showWorkflowPicker}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeps(false);
|
||||||
|
setShowAgentPicker(false);
|
||||||
|
setAgentPickerPosition(null);
|
||||||
|
setShowNodePicker(false);
|
||||||
|
setNodePickerPosition(null);
|
||||||
|
setShowPriorityPicker(false);
|
||||||
|
setPriorityPickerPosition(null);
|
||||||
|
setIsModelMenuOpen(false);
|
||||||
|
setModelMenuPosition(null);
|
||||||
|
setActiveModelSubmenu(null);
|
||||||
|
setShowWorkflowPicker((prev) => !prev);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowWorkflowPicker(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("tasks.quickEntryWorkflowTitle", "Workflow for the next task")}
|
||||||
|
>
|
||||||
|
<span className="quick-entry-workflow-label">{quickEntryWorkflowLabel}</span>
|
||||||
|
<ChevronDown size={12} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{showWorkflowPicker && (
|
||||||
|
<div className="dep-dropdown quick-entry-workflow-menu" role="listbox" data-testid="quick-entry-workflow-menu">
|
||||||
|
<div className="dep-dropdown-search-header">{t("tasks.quickEntryWorkflowHeader", "Create in workflow")}</div>
|
||||||
|
{realWorkflowOptions.map((option) => {
|
||||||
|
const duplicateName = (quickEntryWorkflowNameCounts.get(option.name) ?? 0) > 1;
|
||||||
|
const optionLabel = duplicateName
|
||||||
|
? t("tasks.quickEntryWorkflowDuplicateLabel", "{{name}} ({{id}})", { name: option.name, id: option.id })
|
||||||
|
: option.name;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={quickEntryWorkflowId === option.id}
|
||||||
|
aria-label={optionLabel}
|
||||||
|
className={`dep-dropdown-item quick-entry-workflow-option${quickEntryWorkflowId === option.id ? " selected" : ""}`}
|
||||||
|
data-testid={`quick-entry-workflow-option-${option.id}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setQuickEntryWorkflowId(option.id);
|
||||||
|
setShowWorkflowPicker(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="dep-dropdown-title">{option.name}</span>
|
||||||
|
{duplicateName ? <span className="dep-dropdown-subtitle">{option.id}</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-task-create btn-sm"
|
className="btn btn-task-create btn-sm"
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ vi.mock("../Column", () => ({
|
|||||||
doneSortMode,
|
doneSortMode,
|
||||||
onDoneSortModeChange,
|
onDoneSortModeChange,
|
||||||
workflowId,
|
workflowId,
|
||||||
|
workflowOptions,
|
||||||
|
defaultWorkflowId,
|
||||||
canDropTask,
|
canDropTask,
|
||||||
onPlanningMode,
|
onPlanningMode,
|
||||||
onSubtaskBreakdown,
|
onSubtaskBreakdown,
|
||||||
@@ -90,6 +92,8 @@ vi.mock("../Column", () => ({
|
|||||||
doneSortMode?: string;
|
doneSortMode?: string;
|
||||||
onDoneSortModeChange?: (mode: "completion-date-desc" | "task-id-desc") => void;
|
onDoneSortModeChange?: (mode: "completion-date-desc" | "task-id-desc") => void;
|
||||||
workflowId?: string;
|
workflowId?: string;
|
||||||
|
workflowOptions?: { id: string; name: string }[];
|
||||||
|
defaultWorkflowId?: string | null;
|
||||||
canDropTask?: unknown;
|
canDropTask?: unknown;
|
||||||
onPlanningMode?: unknown;
|
onPlanningMode?: unknown;
|
||||||
onSubtaskBreakdown?: unknown;
|
onSubtaskBreakdown?: unknown;
|
||||||
@@ -97,7 +101,12 @@ vi.mock("../Column", () => ({
|
|||||||
}) => {
|
}) => {
|
||||||
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
columnRenderCounts[column] = (columnRenderCounts[column] ?? 0) + 1;
|
||||||
return (
|
return (
|
||||||
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
|
<div data-testid={`column-${column}`} data-tasks={JSON.stringify(tasks)} data-workflow-badges={JSON.stringify(Object.fromEntries(taskWorkflowBadges ?? new Map()))} data-collapsed={collapsed ? "true" : "false"} data-has-quick-create={onQuickCreate ? "yes" : "no"} data-has-new-task={onNewTask ? "yes" : "no"} data-has-auto-merge-toggle={onToggleAutoMerge ? "yes" : "no"} data-has-archive-all={onArchiveAllDone ? "yes" : "no"} data-favorite-providers={JSON.stringify(favoriteProviders ?? [])} data-favorite-models={JSON.stringify(favoriteModels ?? [])} data-has-toggle-favorite={onToggleFavorite ? "yes" : "no"} data-has-toggle-model-favorite={onToggleModelFavorite ? "yes" : "no"} data-is-search-active={isSearchActive ? "true" : "false"} data-done-sort-mode={doneSortMode ?? ""} data-has-done-sort-handler={onDoneSortModeChange ? "yes" : "no"} data-workflow-id={workflowId ?? ""} data-workflow-options={JSON.stringify((workflowOptions ?? []).map((workflow) => workflow.id))} data-default-workflow-id={defaultWorkflowId ?? ""} data-column-display-name={columnDisplayName ?? ""} data-has-can-drop={canDropTask ? "yes" : "no"} data-has-planning={onPlanningMode ? "yes" : "no"} data-has-subtask={onSubtaskBreakdown ? "yes" : "no"}>
|
||||||
|
{onQuickCreate ? (
|
||||||
|
<button type="button" data-testid={`mock-quick-create-${column}`} onClick={() => void (onQuickCreate as (input: { description: string; column?: string; workflowId?: string }) => Promise<unknown>)({ description: `Create from ${column}`, column, workflowId: "wf-custom" })}>
|
||||||
|
quick-create-{column}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
|
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
|
||||||
{task.title ?? task.description ?? task.id}
|
{task.title ?? task.description ?? task.id}
|
||||||
@@ -1406,6 +1415,37 @@ describe("Board", () => {
|
|||||||
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
expect(screen.queryByTestId("workflow-switcher-edit-__all_workflows__")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it("passes workflow options and the selected workflow default to per-workflow quick-add", async () => {
|
||||||
|
enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||||
|
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })] });
|
||||||
|
|
||||||
|
await selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||||
|
|
||||||
|
const intakeColumn = screen.getByTestId("column-intake");
|
||||||
|
expect(intakeColumn).toHaveAttribute("data-default-workflow-id", CUSTOM_WORKFLOW.id);
|
||||||
|
expect(JSON.parse(intakeColumn.getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:coding", "wf-custom"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults All workflows quick-add to the default workflow and resolves selected workflow columns", async () => {
|
||||||
|
const onQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new", workflowId: "wf-custom" });
|
||||||
|
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||||
|
renderBoard({ onQuickCreate });
|
||||||
|
|
||||||
|
await selectWorkflow("__all_workflows__");
|
||||||
|
|
||||||
|
const defaultCreateColumn = screen.getByTestId("column-triage");
|
||||||
|
expect(defaultCreateColumn).toHaveAttribute("data-workflow-id", "builtin:coding");
|
||||||
|
expect(defaultCreateColumn).toHaveAttribute("data-default-workflow-id", "builtin:coding");
|
||||||
|
fireEvent.click(screen.getByTestId("mock-quick-create-triage"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
workflowId: "wf-custom",
|
||||||
|
column: "intake",
|
||||||
|
})));
|
||||||
|
expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" }));
|
||||||
|
});
|
||||||
|
|
||||||
it("falls stale and missing task workflow ids back to the default workflow", async () => {
|
it("falls stale and missing task workflow ids back to the default workflow", async () => {
|
||||||
enableFlag(
|
enableFlag(
|
||||||
{ "FN-default": "builtin:coding", "FN-stale": "wf-deleted", "FN-custom": "wf-custom" },
|
{ "FN-default": "builtin:coding", "FN-stale": "wf-deleted", "FN-custom": "wf-custom" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ListView } from "../ListView";
|
import { ListView } from "../ListView";
|
||||||
@@ -52,22 +52,36 @@ vi.mock("../QuickEntryBox", () => ({
|
|||||||
onPlanningMode,
|
onPlanningMode,
|
||||||
onSubtaskBreakdown,
|
onSubtaskBreakdown,
|
||||||
workflowId,
|
workflowId,
|
||||||
|
workflowOptions,
|
||||||
|
defaultWorkflowId,
|
||||||
}: {
|
}: {
|
||||||
onCreate?: (input: { description: string }) => Promise<unknown>;
|
onCreate?: (input: { description: string; workflowId?: string | null }) => Promise<unknown>;
|
||||||
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
|
addToast: (message: string, type?: "error" | "success" | "info" | "warning") => void;
|
||||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||||
workflowId?: string | null;
|
workflowId?: string | null;
|
||||||
|
workflowOptions?: { id: string; name: string }[];
|
||||||
|
defaultWorkflowId?: string | null;
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(
|
||||||
|
workflowId ?? defaultWorkflowId ?? workflowOptions?.[0]?.id,
|
||||||
|
);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
const [modelMenuOpen, setModelMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedWorkflowId(workflowId ?? defaultWorkflowId ?? workflowOptions?.[0]?.id);
|
||||||
|
}, [defaultWorkflowId, workflowId, workflowOptions]);
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const description = value.trim();
|
const description = value.trim();
|
||||||
if (!description || !onCreate) return;
|
if (!description || !onCreate) return;
|
||||||
try {
|
try {
|
||||||
await onCreate({ description });
|
await onCreate({
|
||||||
|
description,
|
||||||
|
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
|
||||||
|
});
|
||||||
setValue("");
|
setValue("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(err instanceof Error ? err.message : "Failed to create task", "error");
|
addToast(err instanceof Error ? err.message : "Failed to create task", "error");
|
||||||
@@ -77,8 +91,8 @@ vi.mock("../QuickEntryBox", () => ({
|
|||||||
const handoff = (callback?: (description: string, workflowId?: string | null) => void) => {
|
const handoff = (callback?: (description: string, workflowId?: string | null) => void) => {
|
||||||
const description = value.trim();
|
const description = value.trim();
|
||||||
if (!description || !callback) return;
|
if (!description || !callback) return;
|
||||||
if (workflowId !== undefined) {
|
if (selectedWorkflowId !== undefined) {
|
||||||
callback(description, workflowId);
|
callback(description, selectedWorkflowId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
callback(description);
|
callback(description);
|
||||||
@@ -121,6 +135,12 @@ vi.mock("../QuickEntryBox", () => ({
|
|||||||
<button type="button" data-testid="quick-entry-subtask" onClick={() => handoff(onSubtaskBreakdown)}>
|
<button type="button" data-testid="quick-entry-subtask" onClick={() => handoff(onSubtaskBreakdown)}>
|
||||||
Subtask
|
Subtask
|
||||||
</button>
|
</button>
|
||||||
|
{workflowOptions && workflowOptions.length > 1 ? (
|
||||||
|
<button type="button" data-testid="quick-entry-workflow-option-wf-custom" onClick={() => setSelectedWorkflowId("wf-custom")}>
|
||||||
|
Custom workflow
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<span data-testid="quick-entry-workflow-props" data-workflow-id={workflowId ?? ""} data-default-workflow-id={defaultWorkflowId ?? ""} data-workflow-options={JSON.stringify((workflowOptions ?? []).map((option) => option.id))} />
|
||||||
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
|
<button type="button" data-testid="quick-entry-save" onClick={() => void submit()}>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
@@ -3070,6 +3090,43 @@ describe("ListView Quick Entry", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes workflow options to quick-add and submits the changed selector workflow", 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: "wf-custom",
|
||||||
|
name: "Custom",
|
||||||
|
columns: [{ id: "backlog", name: "Backlog", flags: { intake: true } }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
taskWorkflowIds: {},
|
||||||
|
});
|
||||||
|
renderListView({ onQuickCreate: mockOnQuickCreate });
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-workflow-id", "builtin:default"));
|
||||||
|
expect(screen.getByTestId("quick-entry-workflow-props")).toHaveAttribute("data-default-workflow-id", "builtin:default");
|
||||||
|
expect(JSON.parse(screen.getByTestId("quick-entry-workflow-props").getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:default", "wf-custom"]);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-toggle"));
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-custom"));
|
||||||
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create on changed list workflow" } });
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-save"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockOnQuickCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
description: "Create on changed list workflow",
|
||||||
|
workflowId: "wf-custom",
|
||||||
|
column: "backlog",
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
it("passes the selected workflow id to list quick-entry Plan and Subtask handoffs", async () => {
|
it("passes the selected workflow id to list quick-entry Plan and Subtask handoffs", async () => {
|
||||||
const onPlanningMode = vi.fn();
|
const onPlanningMode = vi.fn();
|
||||||
const onSubtaskBreakdown = vi.fn();
|
const onSubtaskBreakdown = vi.fn();
|
||||||
|
|||||||
@@ -1614,6 +1614,79 @@ describe("QuickEntryBox", () => {
|
|||||||
innerWidthSpy.mockRestore();
|
innerWidthSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe("quick-add workflow selector", () => {
|
||||||
|
const workflowOptions = [
|
||||||
|
{ id: "wf-default", name: "Coding", columns: [] },
|
||||||
|
{ id: "wf-review", name: "Review", columns: [] },
|
||||||
|
{ id: "wf-review-copy", name: "Review", columns: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("defaults to the provided workflow, changes selection, and passes it to Save/Plan/Subtask", async () => {
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||||
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
|
const onPlanningMode = vi.fn();
|
||||||
|
const onSubtaskBreakdown = vi.fn();
|
||||||
|
renderQuickEntryBox({
|
||||||
|
onCreate,
|
||||||
|
onPlanningMode,
|
||||||
|
onSubtaskBreakdown,
|
||||||
|
workflowId: "wf-review",
|
||||||
|
defaultWorkflowId: "wf-default",
|
||||||
|
workflowOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Review");
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-workflow-trigger"));
|
||||||
|
expect(screen.getByLabelText("Review (wf-review-copy)")).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-default"));
|
||||||
|
expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Coding");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Create in selected workflow" } });
|
||||||
|
clickSave();
|
||||||
|
await waitFor(() => expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "wf-default" })));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Plan in selected workflow" } });
|
||||||
|
fireEvent.click(screen.getByTestId("plan-button"));
|
||||||
|
expect(onPlanningMode).toHaveBeenCalledWith("Plan in selected workflow", "wf-default");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("quick-entry-input"), { target: { value: "Subtask in selected workflow" } });
|
||||||
|
fireEvent.click(screen.getByTestId("subtask-button"));
|
||||||
|
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Subtask in selected workflow", "wf-default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repairs stale parent workflow ids to the default workflow and refetches optional steps when changed", async () => {
|
||||||
|
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([]);
|
||||||
|
renderQuickEntryBox({
|
||||||
|
workflowId: "__all_workflows__",
|
||||||
|
defaultWorkflowId: "wf-default",
|
||||||
|
workflowOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-default", TEST_PROJECT_ID));
|
||||||
|
expect(screen.getByTestId("quick-entry-workflow-trigger")).toHaveTextContent("Coding");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-workflow-trigger"));
|
||||||
|
fireEvent.click(screen.getByTestId("quick-entry-workflow-option-wf-review"));
|
||||||
|
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-review", TEST_PROJECT_ID));
|
||||||
|
expect(fetchWorkflowOptionalSteps).not.toHaveBeenCalledWith("__all_workflows__", TEST_PROJECT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the selector without real workflow choices and leaves no shell", () => {
|
||||||
|
renderQuickEntryBox({ workflowId: "wf-default", workflowOptions: [{ id: "wf-default", name: "Coding", columns: [] }] });
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("quick-entry-workflow-trigger")).toBeNull();
|
||||||
|
expect(screen.getByTestId("quick-entry-actions").querySelector(".quick-entry-workflow-wrap")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses tokenized responsive CSS for the workflow selector", () => {
|
||||||
|
const selectorRule = cssRuleBody(QUICK_ENTRY_BOX_CSS, ".quick-entry-workflow-menu");
|
||||||
|
expect(selectorRule).toContain("var(--space-");
|
||||||
|
expect(selectorRule).not.toMatch(/#[0-9a-f]{3,8}|rgb\(/i);
|
||||||
|
expect(QUICK_ENTRY_BOX_CSS).toMatch(/@media \(max-width: 768px\) \{[\s\S]*?quick-entry-workflow/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("optional workflow steps", () => {
|
describe("optional workflow steps", () => {
|
||||||
const DEFAULT_ON_STEP = {
|
const DEFAULT_ON_STEP = {
|
||||||
templateId: "browser-verification",
|
templateId: "browser-verification",
|
||||||
|
|||||||
Reference in New Issue
Block a user