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:
7
.changeset/fn-8476-coding-ideas-move.md
Normal file
7
.changeset/fn-8476-coding-ideas-move.md
Normal 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.
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user