Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.recommendations.test.tsx
gsxdsm b181f20859 FN-8848: show Recommendations for completed tasks
Keep the completed-task Recommendations destination available when no records exist.

- Render a localized empty state without task-creation controls.
- Preserve Recommendations selection until a task leaves the completed column.
- Cover empty states and shared detail entry points with dashboard tests.
- Document the behavior and add a patch changeset.

Files changed:
 .changeset/fn-8848-recommendations-empty-state.md  |  7 +++
 docs/dashboard-guide.md                            |  2 +-
 .../dashboard/app/components/TaskDetailModal.tsx   | 15 ++---
 .../app/components/TaskRecommendationsTab.css      |  5 ++
 .../app/components/TaskRecommendationsTab.tsx      |  5 +-
 .../TaskDetailModal.recommendations.test.tsx       | 21 +++++++
 .../__tests__/TaskDetailModal.summary-tab.test.tsx | 73 +++++++++++++++++++++-
 packages/i18n/locales/en/app.json                  |  1 +
 packages/i18n/locales/es/app.json                  |  1 +
 packages/i18n/locales/fr/app.json                  |  1 +
 packages/i18n/locales/ko/app.json                  |  1 +
 packages/i18n/locales/zh-CN/app.json               |  1 +
 packages/i18n/locales/zh-TW/app.json               |  1 +
 packages/i18n/src/resources.d.ts                   |  1 +
 14 files changed, 125 insertions(+), 10 deletions(-)

Fusion-Task-Id: FN-8848

Fusion-Task-Lineage: e200dfba-8113-4ede-a117-f94d221a8e7d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-08 19:03:14 -07:00

133 lines
6.4 KiB
TypeScript

import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TaskRecommendationsTab } from "../TaskRecommendationsTab";
import type { Task } from "@fusion/core";
const { createTaskFromRecommendation } = vi.hoisted(() => ({ createTaskFromRecommendation: vi.fn() }));
vi.mock("../../api", () => ({ createTaskFromRecommendation }));
const task: Task = {
id: "FN-8829",
description: "Completed task",
column: "done",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-08-08T00:00:00.000Z",
updatedAt: "2026-08-08T00:00:00.000Z",
recommendations: [{
id: "rec-1",
title: "Add focused coverage",
description: "Add a separate regression test for a related workflow.",
category: "improvement",
}],
};
describe("TaskRecommendationsTab", () => {
it("renders one accessible empty message with no action for undefined or empty recommendations", () => {
const view = render(<TaskRecommendationsTab task={{ ...task, recommendations: undefined }} projectId="project-a" />);
expect(screen.getByText("No recommendations were produced for this task.")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(createTaskFromRecommendation).not.toHaveBeenCalled();
view.rerender(<TaskRecommendationsTab task={{ ...task, recommendations: [] }} projectId="project-a" />);
expect(screen.getByText("No recommendations were produced for this task.")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
expect(createTaskFromRecommendation).not.toHaveBeenCalled();
});
it("renders exactly one create action and converges to Created after success", async () => {
const parent = {
...task,
recommendations: [{ ...task.recommendations![0], createdTaskId: "FN-8830" }],
};
const onTaskReconciled = vi.fn();
createTaskFromRecommendation.mockResolvedValue({ task: { id: "FN-8830" }, parent });
render(<TaskRecommendationsTab task={task} projectId="project-a" onTaskReconciled={onTaskReconciled} />);
const button = screen.getByRole("button", { name: "Create task" });
fireEvent.click(button);
expect(button).toBeDisabled();
expect(await screen.findByText("Created FN-8830")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Create|Retry/ })).not.toBeInTheDocument();
expect(createTaskFromRecommendation).toHaveBeenCalledWith("FN-8829", "rec-1", "project-a");
expect(onTaskReconciled).toHaveBeenCalledWith(parent);
});
it("does not render an action for an already-created recommendation", () => {
render(<TaskRecommendationsTab task={{ ...task, recommendations: [{ ...task.recommendations![0], createdTaskId: "FN-8830" }] }} projectId="project-a" />);
expect(screen.getByText("Created FN-8830")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Create|Retry/ })).not.toBeInTheDocument();
expect(screen.queryByText("No recommendations were produced for this task.")).not.toBeInTheDocument();
});
it("clears recommendation action state when retained detail switches tasks", async () => {
createTaskFromRecommendation.mockResolvedValue({
task: { id: "FN-8830" },
parent: { ...task, recommendations: [{ ...task.recommendations![0], createdTaskId: "FN-8830" }] },
});
const { rerender } = render(<TaskRecommendationsTab task={task} projectId="project-a" />);
fireEvent.click(screen.getByRole("button", { name: "Create task" }));
await screen.findByText("Created FN-8830");
rerender(<TaskRecommendationsTab task={{ ...task, id: "FN-8831" }} projectId="project-a" />);
expect(await screen.findByRole("button", { name: "Create task" })).toBeEnabled();
await waitFor(() => expect(screen.queryByText("Created FN-8830")).not.toBeInTheDocument());
});
it("issues one request for rapid clicks and leaves a retry action after an intake conflict", async () => {
createTaskFromRecommendation.mockReset();
let rejectRequest: ((error: Error) => void) | undefined;
createTaskFromRecommendation.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectRequest = reject; }));
render(<TaskRecommendationsTab task={task} projectId="project-a" />);
const button = screen.getByRole("button", { name: "Create task" });
fireEvent.click(button);
fireEvent.click(button);
expect(createTaskFromRecommendation).toHaveBeenCalledTimes(1);
rejectRequest?.(new Error("duplicate_candidates"));
expect(await screen.findByRole("button", { name: "Retry creating task" })).toBeEnabled();
});
it("keeps concurrent recommendation actions isolated", async () => {
createTaskFromRecommendation.mockReset();
let resolveFirst: ((value: { task: { id: string }; parent: Task }) => void) | undefined;
let resolveSecond: ((value: { task: { id: string }; parent: Task }) => void) | undefined;
createTaskFromRecommendation
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }))
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve; }));
const secondRecommendation = {
...task.recommendations![0],
id: "rec-2",
title: "Add a separate feature",
};
const multiRecommendationTask = { ...task, recommendations: [task.recommendations![0], secondRecommendation] };
render(<TaskRecommendationsTab task={multiRecommendationTask} projectId="project-a" />);
const buttons = screen.getAllByRole("button", { name: "Create task" });
fireEvent.click(buttons[0]!);
fireEvent.click(buttons[1]!);
expect(createTaskFromRecommendation).toHaveBeenCalledTimes(2);
expect(screen.getAllByRole("button", { name: "Creating…" })).toHaveLength(2);
resolveFirst?.({
task: { id: "FN-8830" },
parent: { ...multiRecommendationTask, recommendations: [{ ...task.recommendations![0], createdTaskId: "FN-8830" }, secondRecommendation] },
});
expect(await screen.findByText("Created FN-8830")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Creating…" })).toBeDisabled();
resolveSecond?.({
task: { id: "FN-8831" },
parent: { ...multiRecommendationTask, recommendations: [task.recommendations![0], { ...secondRecommendation, createdTaskId: "FN-8831" }] },
});
expect(await screen.findByText("Created FN-8831")).toBeInTheDocument();
});
});