FN-8476: restore Ideas detail move action

Restore Coding (Ideas) task-detail moves to Todo while preserving supplied workflow field definitions.

- Resolve selected workflow columns independently for detail move actions
- Use workflow-specific primary action labels and add desktop/mobile regression coverage
- Add a patch changeset for the restored detail action

Files changed:
 .changeset/fn-8476-coding-ideas-move.md            |  7 ++
 .../dashboard/app/components/TaskDetailModal.tsx   | 85 +++++++++++++-------
 .../TaskDetailModal.custom-fields.test.tsx         |  9 ++-
 .../__tests__/TaskDetailModal.rendering.test.tsx   | 91 ++++++++++++++++++++++
 4 files changed, 160 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-8476

Fusion-Task-Lineage: 37975987-91e4-47f2-9fa4-57b2074391f9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 00:43:04 -07:00
parent 93b693c639
commit 8ab4241afd
4 changed files with 160 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore the Coding Ideas detail action to move parked ideas to Todo.
category: fix
dev: Detail workflow move targets now resolve independently from supplied custom-field definitions.

View File

@@ -78,6 +78,7 @@ import { getRelativeTimeBucket } from "../utils/relativeTimeAgo";
import { isReviewBudgetExhaustedApproval } from "../utils/reviewBudgetApproval";
import { ACTIVE_STATUSES, resolveEffectiveExecutor, resolveEffectivePlanning, resolveEffectiveValidator, type ModelSelection } from "./effective-model-resolution";
import { TaskContextMenu, buildTaskActionMenuModel, getTaskPrAutomationLabel } from "./TaskContextMenu";
import type { TaskContextMenuColumnFlags, TaskContextMenuColumnMetadata } from "./TaskContextMenu";
import { FLOATING_WINDOW_GEOMETRY_CHANGE_EVENT } from "./FloatingWindow";
import { useFileBrowser } from "../context/FileBrowserContext";
import type { DetailTaskInitialActionRequest } from "../hooks/useModalManager";
@@ -384,9 +385,9 @@ export interface TaskDetailModalProps {
/** Project setting: true restores Chat-first tab order/default; false or missing uses Activity-first. */
taskDetailChatFirst?: boolean;
/** Pre-resolved workflow field defs for this task's workflow (U13/KTD-14).
* When provided (e.g. threaded from a Board that already holds the payload)
* the modal skips its own board-workflows fetch entirely. Falls back to the
* self-fetch when absent (e.g. modal opened from non-board contexts). */
* When provided, these remain authoritative for custom-field rendering.
* Move metadata still resolves independently because field definitions do not
* identify the selected workflow's ordered columns. */
workflowFieldDefs?: WorkflowFieldDefinition[] | null;
}
@@ -502,13 +503,27 @@ function normalizeTaskPriorityValue(priority: Task["priority"]): TaskPriority {
: DEFAULT_TASK_PRIORITY;
}
function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, taskId: string): { id: string; name: string; icon?: string; fields: WorkflowFieldDefinition[] | null } | null {
interface TaskWorkflowMetadata {
id: string;
name: string;
icon?: string;
fields: WorkflowFieldDefinition[] | null;
moveColumns: TaskContextMenuColumnMetadata[];
currentColumnFlags?: TaskContextMenuColumnFlags;
}
function resolveTaskWorkflowMetadata(payload: BoardWorkflowsPayload, task: Pick<Task, "id" | "column">): TaskWorkflowMetadata | null {
if (payload.flagEnabled !== true) return null;
const workflowId = payload.taskWorkflowIds[taskId] ?? payload.defaultWorkflowId;
const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId;
const workflow = payload.workflows.find((candidate) => candidate.id === workflowId);
const name = workflow?.name?.trim();
if (!workflow || !name) return null;
return { id: workflow.id, name, icon: workflow.icon, fields: workflow.fields ?? null };
const moveColumns = workflow.columns
.filter((column) => column.flags.hiddenFromBoard !== true)
.map((column) => ({ id: column.id as ColumnId, label: column.name, flags: column.flags }));
const currentColumnFlags = moveColumns.find((column) => column.id === task.column)?.flags;
return { id: workflow.id, name, icon: workflow.icon, fields: workflow.fields ?? null, moveColumns, currentColumnFlags };
}
function normalizeExecutionModeValue(executionMode: Task["executionMode"]): "standard" | "fast" {
@@ -931,13 +946,18 @@ export function TaskDetailContent({
/*
FNXC:WorkflowBadges 2026-06-29-00:00:
Task details need a stable workflow-name badge because aggregate Board cards can mix tasks from multiple workflows. Resolve the badge name and custom field definitions from the same board-workflows payload so detail headers do not issue duplicate workflow-metadata fetches.
FNXC:CodingIdeasWorkflow 2026-07-21-00:00:
Coding (Ideas) intake cards need selected-workflow columns to derive their truthful
move target through TaskContextMenu. Callers may supply field definitions, but fields
cannot encode ordered columns, so resolve move metadata independently without replacing
the caller-owned field definitions.
*/
const [taskWorkflowBadge, setTaskWorkflowBadge] = useState<{ id: string; name: string; icon?: string } | null>(null);
const [workflowMoveMetadata, setWorkflowMoveMetadata] = useState<Pick<TaskWorkflowMetadata, "moveColumns" | "currentColumnFlags"> | null>(null);
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
// from the board-workflows payload; absent when the workflow declares none,
// in which case the fields section renders nothing (today's UI byte-identical).
// When `workflowFieldDefsProp` is provided by the caller (e.g. the Board
// already holds the payload) we skip the self-fetch entirely.
const [customFieldDefs, setCustomFieldDefs] = useState<WorkflowFieldDefinition[] | null>(
workflowFieldDefsProp !== undefined ? (workflowFieldDefsProp ?? null) : null,
);
@@ -949,40 +969,45 @@ export function TaskDetailContent({
setCustomFieldValues(task.customFields ?? {});
}, [task.id, task.customFields]);
// Resolve this task's workflow field definitions and display name once per task. Skipped when
// the caller supplies `workflowFieldDefs` directly (Board context). Best-effort:
// a failed fetch (or flag-OFF empty payload) leaves defs/name null → no section/badge.
// Resolve selected-workflow display and move metadata once per task. A supplied fields prop
// avoids a duplicate field lookup, but cannot replace this ordered-column lookup.
useEffect(() => {
if (workflowFieldDefsProp !== undefined) {
// Prop-driven path: keep in sync if the prop changes (task switch etc.).
setCustomFieldDefs(workflowFieldDefsProp ?? null);
setTaskWorkflowBadge(null);
return;
}
/*
FNXC:WorkflowBadges 2026-06-29-16:48:
Mounted task-detail hosts can swap from one task to another (List split-pane, right dock, floating windows). Clear the previous workflow badge before the shared board-workflows lookup resolves so aggregate-board context never shows a stale cross-workflow label.
Mounted task-detail hosts can swap from one task to another (List split-pane, right dock, floating windows). Clear the previous workflow badge and move metadata before the shared board-workflows lookup resolves so aggregate-board context never shows stale cross-workflow labels or targets.
*/
setCustomFieldDefs(null);
if (workflowFieldDefsProp !== undefined) {
setCustomFieldDefs(workflowFieldDefsProp ?? null);
} else {
setCustomFieldDefs(null);
}
setTaskWorkflowBadge(null);
setWorkflowMoveMetadata(null);
let cancelled = false;
void fetchBoardWorkflows(projectId)
.then((payload) => {
if (cancelled) return;
const metadata = resolveTaskWorkflowMetadata(payload, task.id);
setCustomFieldDefs(metadata?.fields ?? null);
const metadata = resolveTaskWorkflowMetadata(payload, task);
if (workflowFieldDefsProp === undefined) {
setCustomFieldDefs(metadata?.fields ?? null);
}
setTaskWorkflowBadge(metadata ? { id: metadata.id, name: metadata.name, icon: metadata.icon } : null);
setWorkflowMoveMetadata(metadata ? {
moveColumns: metadata.moveColumns,
currentColumnFlags: metadata.currentColumnFlags,
} : null);
})
.catch(() => {
if (!cancelled) {
setCustomFieldDefs(null);
if (workflowFieldDefsProp === undefined) setCustomFieldDefs(null);
setTaskWorkflowBadge(null);
setWorkflowMoveMetadata(null);
}
});
return () => {
cancelled = true;
};
}, [task.id, projectId, workflowFieldDefsProp]);
}, [task, projectId, workflowFieldDefsProp]);
/*
FNXC:PlannerOversight 2026-07-04-17:00:
@@ -3516,6 +3541,8 @@ export function TaskDetailContent({
task,
t,
columnLabel,
currentColumnFlags: workflowMoveMetadata?.currentColumnFlags,
workflowMoveColumns: workflowMoveMetadata?.moveColumns,
canRetryTask,
hasDuplicateHandler: Boolean(onDuplicateTask),
hasRetryHandler: Boolean(onRetryTask),
@@ -3540,6 +3567,7 @@ export function TaskDetailContent({
task,
t,
columnLabel,
workflowMoveMetadata,
canRetryTask,
onDuplicateTask,
onRetryTask,
@@ -3561,7 +3589,8 @@ export function TaskDetailContent({
handleCheckPrStatus,
handleBypassReview,
]);
const primaryMoveTransition = taskActionMenuModel.moveTransitions[0]?.column;
const primaryMoveAction = taskActionMenuModel.moveTransitions[0];
const primaryMoveTransition = primaryMoveAction?.column;
const secondaryMoveTransitions = taskActionMenuModel.moveTransitions.slice(1);
const hasSecondaryMoveOptions = secondaryMoveTransitions.length > 0;
const reviewAction = taskActionMenuModel.reviewAction;
@@ -6334,12 +6363,12 @@ export function TaskDetailContent({
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition}
aria-label={primaryMoveTransition ? t("taskDetail.move.moveTo", "Move to {{column}}", { column: columnLabel(primaryMoveTransition) }) : undefined}
aria-label={primaryMoveAction?.primaryLabel}
aria-haspopup={hasSecondaryMoveOptions ? "menu" : undefined}
aria-expanded={hasSecondaryMoveOptions ? showMoveMenu : undefined}
>
<span className="detail-move-btn__label">
{t("taskDetail.move.moveTo", "Move to {{column}}", { column: primaryMoveTransition ? columnLabel(primaryMoveTransition) : "" })}
{primaryMoveAction?.primaryLabel ?? t("taskDetail.move.moveTo", "Move to {{column}}", { column: "" })}
</span>
{hasSecondaryMoveOptions && (
<span className="detail-move-btn__arrow" aria-hidden="true">
@@ -6381,12 +6410,12 @@ export function TaskDetailContent({
onClick={handleMoveButtonClick}
onKeyDown={handleMoveButtonKeyDown}
disabled={!primaryMoveTransition}
aria-label={primaryMoveTransition ? t("taskDetail.move.moveTo", "Move to {{column}}", { column: columnLabel(primaryMoveTransition) }) : undefined}
aria-label={primaryMoveAction?.primaryLabel}
aria-haspopup={hasSecondaryMoveOptions ? "menu" : undefined}
aria-expanded={hasSecondaryMoveOptions ? showMoveMenu : undefined}
>
<span className="detail-move-btn__label">
{t("taskDetail.move.moveTo", "Move to {{column}}", { column: primaryMoveTransition ? columnLabel(primaryMoveTransition) : "" })}
{primaryMoveAction?.primaryLabel ?? t("taskDetail.move.moveTo", "Move to {{column}}", { column: "" })}
</span>
{hasSecondaryMoveOptions && (
<span className="detail-move-btn__arrow" aria-hidden="true">

View File

@@ -69,7 +69,7 @@ describe("TaskDetailModal custom fields (U13/KTD-14)", () => {
expect((screen.getByLabelText("Owner") as HTMLInputElement).value).toBe("alice");
});
it("uses workflowFieldDefs prop directly and skips the board-workflows fetch", async () => {
it("uses workflowFieldDefs prop directly while resolving independent move metadata", async () => {
const fetchSpy = vi.spyOn(dashboardApi, "fetchBoardWorkflows");
const defs: WorkflowFieldDefinition[] = [
{ id: "owner", name: "Owner", type: "string", render: { placement: "detail" } },
@@ -90,8 +90,8 @@ describe("TaskDetailModal custom fields (U13/KTD-14)", () => {
);
await waitFor(() => expect(screen.getByTestId("task-fields-section")).toBeTruthy());
expect((screen.getByLabelText("Owner") as HTMLInputElement).value).toBe("bob");
// The fetch must NOT have been triggered since the prop was provided.
expect(fetchSpy).not.toHaveBeenCalled();
// Supplied fields remain authoritative; the one fetch resolves move columns independently.
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
});
it("renders no fields section when workflowFieldDefs prop is an empty array", async () => {
@@ -113,6 +113,7 @@ describe("TaskDetailModal custom fields (U13/KTD-14)", () => {
// Give React a tick to settle; no section should appear.
await new Promise((r) => setTimeout(r, 50));
expect(screen.queryByTestId("task-fields-section")).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
// Empty supplied fields still avoid replacement; the lookup is only for move metadata.
await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1));
});
});

View File

@@ -2851,4 +2851,95 @@ describe("TaskDetailModal", () => {
});
});
describe("Coding (Ideas) workflow detail moves", () => {
const ideasWorkflowPayload = {
flagEnabled: true,
defaultWorkflowId: "builtin:coding-ideas",
workflows: [{
id: "builtin:coding-ideas",
name: "Coding (Ideas)",
columns: [
{ id: "ideas", name: "Ideas", flags: { intake: true } },
{ id: "todo", name: "Todo", flags: {} },
],
}],
taskWorkflowIds: { "FN-ideas": "builtin:coding-ideas" },
};
const ideasTask = () => makeTask({ id: "FN-ideas", column: "ideas", title: "Parked coding idea" });
const setViewport = (width: number) => {
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
window.dispatchEvent(new Event("resize"));
};
beforeEach(() => {
vi.mocked(dashboardApi.fetchBoardWorkflows).mockReset();
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValue(ideasWorkflowPayload);
});
afterEach(() => {
setViewport(1024);
});
async function expectIdeasMove(renderDetail: () => void, onMoveTask: ReturnType<typeof vi.fn>) {
renderDetail();
const moveButton = await screen.findByRole("button", { name: "Move to Todo" });
expect(moveButton).toBeEnabled();
await userEvent.click(moveButton);
expect(onMoveTask).toHaveBeenCalledWith("FN-ideas", "todo", undefined);
}
it("moves an Ideas task to Todo from the modal at desktop width", async () => {
setViewport(1024);
const onMoveTask = vi.fn(async () => ideasTask());
await expectIdeasMove(() => {
render(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, onMoveTask);
});
it("moves an Ideas task to Todo from the modal at 375px", async () => {
setViewport(375);
const onMoveTask = vi.fn(async () => ideasTask());
await expectIdeasMove(() => {
render(<TaskDetailModal initialTab="definition" task={ideasTask()} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, onMoveTask);
});
it("moves an Ideas task to Todo from embedded detail at desktop width", async () => {
setViewport(1024);
const onMoveTask = vi.fn(async () => ideasTask());
await expectIdeasMove(() => {
render(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, onMoveTask);
});
it("moves an Ideas task to Todo from embedded detail at 375px", async () => {
setViewport(375);
const onMoveTask = vi.fn(async () => ideasTask());
await expectIdeasMove(() => {
render(<TaskDetailContent embedded initialTab="definition" task={ideasTask()} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
}, onMoveTask);
});
it("keeps supplied fields while independently resolving and clearing Ideas move metadata", async () => {
const onMoveTask = vi.fn(async () => ideasTask());
const workflowFieldDefs = [{ id: "owner", name: "Owner", type: "string" as const, render: { placement: "detail" as const } }];
const { rerender } = render(
<TaskDetailModal initialTab="definition" task={ideasTask()} workflowFieldDefs={workflowFieldDefs} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />,
);
expect(await screen.findByLabelText("Owner")).toBeInTheDocument();
const moveButton = await screen.findByRole("button", { name: "Move to Todo" });
await userEvent.click(moveButton);
expect(onMoveTask).toHaveBeenCalledWith("FN-ideas", "todo", undefined);
expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(1);
vi.mocked(dashboardApi.fetchBoardWorkflows).mockResolvedValueOnce({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} });
rerender(
<TaskDetailModal initialTab="definition" task={makeTask({ id: "FN-unresolved", column: "ideas" })} workflowFieldDefs={workflowFieldDefs} onClose={noop} onMoveTask={onMoveTask} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />,
);
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("button", { name: "Move to Todo" })).toBeNull();
});
});
});