Add guarded task creation directly to completed-task recommendation notices. - Render live recommendation details and task actions in mailbox detail and conversation surfaces. - Prevent duplicate creates, expose existing task links, and support unavailable and retry states. - Cover desktop and mobile surfaces, update notice copy and documentation, and add a release changeset. Files changed: .../fn-9104-mailbox-recommendation-create.md | 7 ++ docs/dashboard-guide.md | 2 +- .../__tests__/task-recommendation-notice.test.ts | 7 ++ packages/core/src/task-recommendation-notice.ts | 9 +- packages/dashboard/app/components/MailboxModal.tsx | 3 + .../app/components/MailboxTaskRecommendations.css | 9 ++ .../app/components/MailboxTaskRecommendations.tsx | 127 +++++++++++++++++++++ packages/dashboard/app/components/MailboxView.tsx | 3 + .../MailboxTaskRecommendations.surfaces.test.tsx | 77 +++++++++++++ .../__tests__/MailboxTaskRecommendations.test.tsx | 72 ++++++++++++ 10 files changed, 311 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-9104 Fusion-Task-Lineage: 94f53bfe-f57f-42ee-970e-56658129c1e0 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
73 lines
4.3 KiB
TypeScript
73 lines
4.3 KiB
TypeScript
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import type { MessageMetadata } from "@fusion/core";
|
|
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
import { createTaskFromRecommendation, fetchTaskDetail } from "../../api";
|
|
import { MailboxTaskRecommendations } from "../MailboxTaskRecommendations";
|
|
|
|
vi.mock("../../api", () => ({ createTaskFromRecommendation: vi.fn(), fetchTaskDetail: vi.fn() }));
|
|
|
|
const metadata: MessageMetadata = { kind: "task-recommendation-notice", taskId: "FN-9100", recommendationIds: ["recommendation-1"] };
|
|
const detail = { id: "FN-9100", recommendations: [{ id: "recommendation-1", title: "Follow up", description: "Finish the optional work.", category: "feature" }] };
|
|
|
|
describe("MailboxTaskRecommendations", () => {
|
|
beforeEach(() => vi.resetAllMocks());
|
|
|
|
it("renders nothing for non-notices, missing parents, and empty recommendation ids", () => {
|
|
for (const candidate of [{}, { kind: "task-recommendation-notice", recommendationIds: ["recommendation-1"] }, { kind: "task-recommendation-notice", taskId: "FN-9100", recommendationIds: [] }]) {
|
|
const { container, unmount } = render(<MailboxTaskRecommendations metadata={candidate} />);
|
|
expect(container).toBeEmptyDOMElement();
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it("keeps a failed parent lookup inert", async () => {
|
|
vi.mocked(fetchTaskDetail).mockRejectedValue(new Error("not found"));
|
|
render(<MailboxTaskRecommendations metadata={metadata} />);
|
|
await waitFor(() => expect(screen.getByTestId("mailbox-task-recommendations-unavailable")).toBeInTheDocument());
|
|
expect(screen.queryByRole("button", { name: "Create task" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("creates once and replaces the action with the linked task", async () => {
|
|
const onOpenTask = vi.fn();
|
|
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
|
|
vi.mocked(createTaskFromRecommendation).mockResolvedValue({ task: { id: "FN-9101" }, parent: detail } as never);
|
|
render(<MailboxTaskRecommendations metadata={metadata} projectId="project-1" onOpenTask={onOpenTask} />);
|
|
await screen.findByRole("button", { name: "Create task" });
|
|
fireEvent.click(screen.getByRole("button", { name: "Create task" }));
|
|
await waitFor(() => expect(screen.getByRole("button", { name: "View task FN-9101" })).toBeInTheDocument());
|
|
expect(createTaskFromRecommendation).toHaveBeenCalledWith("FN-9100", "recommendation-1", "project-1");
|
|
fireEvent.click(screen.getByRole("button", { name: "View task FN-9101" }));
|
|
expect(onOpenTask).toHaveBeenCalledWith("FN-9101");
|
|
});
|
|
|
|
it("shows existing links without a duplicate Create action", async () => {
|
|
vi.mocked(fetchTaskDetail).mockResolvedValue({ ...detail, recommendations: [{ ...detail.recommendations[0], createdTaskId: "FN-9101" }] } as never);
|
|
render(<MailboxTaskRecommendations metadata={metadata} />);
|
|
expect(await screen.findByRole("button", { name: "View task FN-9101" })).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Create task" })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("guards rapid duplicate clicks", async () => {
|
|
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
|
|
let resolveCreate!: (value: never) => void;
|
|
vi.mocked(createTaskFromRecommendation).mockImplementation(() => new Promise((resolve) => { resolveCreate = resolve; }));
|
|
render(<MailboxTaskRecommendations metadata={metadata} />);
|
|
await screen.findByRole("button", { name: "Create task" });
|
|
const button = screen.getByRole("button", { name: "Create task" });
|
|
fireEvent.click(button);
|
|
fireEvent.click(button);
|
|
expect(createTaskFromRecommendation).toHaveBeenCalledTimes(1);
|
|
resolveCreate({ task: { id: "FN-9101" }, parent: detail } as never);
|
|
await screen.findByRole("button", { name: "View task FN-9101" });
|
|
});
|
|
|
|
it("offers a retry after a rejected creation", async () => {
|
|
vi.mocked(fetchTaskDetail).mockResolvedValue(detail as never);
|
|
vi.mocked(createTaskFromRecommendation).mockRejectedValue(new Error("conflict"));
|
|
render(<MailboxTaskRecommendations metadata={metadata} />);
|
|
fireEvent.click(await screen.findByRole("button", { name: "Create task" }));
|
|
expect(await screen.findByRole("button", { name: "Retry creating task" })).toBeInTheDocument();
|
|
expect(screen.getByText("Could not create task. Try again.")).toBeInTheDocument();
|
|
});
|
|
});
|