feat(FN-5060): add InlineCreateCard duplicate warning flow
This commit is contained in:
committed by
gsxdsm
parent
73202b7eb0
commit
f73ffd6386
@@ -5,11 +5,12 @@ import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp, Bot, Max
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type Task, type TaskCreateInput, type TaskPriority, type Settings } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents } from "../api";
|
||||
import type { ModelInfo, Agent, NodeInfo } from "../api";
|
||||
import { checkDuplicateTasks, fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings, fetchAgents, DuplicateCandidatesError } from "../api";
|
||||
import type { ModelInfo, Agent, NodeInfo, DuplicateMatch } from "../api";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { ModelSelectionModal } from "./ModelSelectionModal";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { DuplicateWarningModal } from "./DuplicateWarningModal";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
import { getScopedItem, removeScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
@@ -118,6 +119,8 @@ export function InlineCreateCard({
|
||||
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
|
||||
// Track textarea focus for expand button visibility
|
||||
const [isDescriptionFocused, setIsDescriptionFocused] = useState(false);
|
||||
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
|
||||
const [pendingSubmit, setPendingSubmit] = useState<TaskCreateInput | null>(null);
|
||||
const justResetRef = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
@@ -330,24 +333,10 @@ export function InlineCreateCard({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!description.trim() || submitting) return;
|
||||
const submitTask = useCallback(async (input: TaskCreateInput) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const task = await onSubmit({
|
||||
description: description.trim(),
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||
modelPresetId: selectedPresetId,
|
||||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
});
|
||||
const task = await onSubmit(input);
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (pendingImages.length > 0) {
|
||||
@@ -397,31 +386,79 @@ export function InlineCreateCard({
|
||||
removeScopedItem(STORAGE_KEY, projectId);
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
if (err instanceof DuplicateCandidatesError && err.matches.length > 0) {
|
||||
setDuplicateMatches(err.matches);
|
||||
addToast(`Linked existing ${err.matches[0]?.id ?? "task"}`, "success");
|
||||
} else {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
description,
|
||||
dependencies,
|
||||
selectedAgentId,
|
||||
hasExecutorOverride,
|
||||
executorProvider,
|
||||
executorModelId,
|
||||
hasValidatorOverride,
|
||||
validatorProvider,
|
||||
validatorModelId,
|
||||
browserVerification,
|
||||
priority,
|
||||
submitting,
|
||||
pendingImages,
|
||||
onSubmit,
|
||||
addToast,
|
||||
projectId,
|
||||
selectedPresetId,
|
||||
nodeId,
|
||||
]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!description.trim() || submitting) return;
|
||||
|
||||
const input: TaskCreateInput = {
|
||||
description: description.trim(),
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||
modelPresetId: selectedPresetId,
|
||||
modelProvider: hasExecutorOverride ? executorProvider : undefined,
|
||||
modelId: hasExecutorOverride ? executorModelId : undefined,
|
||||
validatorModelProvider: hasValidatorOverride ? validatorProvider : undefined,
|
||||
validatorModelId: hasValidatorOverride ? validatorModelId : undefined,
|
||||
enabledWorkflowSteps: browserVerification ? ["browser-verification"] : undefined,
|
||||
priority,
|
||||
nodeId,
|
||||
};
|
||||
|
||||
try {
|
||||
const matches = await checkDuplicateTasks({ description: description.trim() }, projectId);
|
||||
if (matches.length > 0) {
|
||||
setDuplicateMatches(matches);
|
||||
setPendingSubmit(input);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
addToast("Duplicate check failed; creating task anyway.", "error");
|
||||
}
|
||||
|
||||
await submitTask(input);
|
||||
}, [description, submitting, dependencies, selectedAgentId, selectedPresetId, hasExecutorOverride, executorProvider, executorModelId, hasValidatorOverride, validatorProvider, validatorModelId, browserVerification, priority, nodeId, projectId, addToast, submitTask]);
|
||||
|
||||
const handleDuplicateProceed = useCallback(async () => {
|
||||
const matches = duplicateMatches;
|
||||
const input = pendingSubmit;
|
||||
setDuplicateMatches(null);
|
||||
setPendingSubmit(null);
|
||||
if (!matches || !input || matches.length === 0) return;
|
||||
await submitTask({
|
||||
...input,
|
||||
acknowledgedDuplicates: matches.map((match) => match.id),
|
||||
});
|
||||
}, [duplicateMatches, pendingSubmit, submitTask]);
|
||||
|
||||
const handleDuplicateCancel = useCallback(() => {
|
||||
setDuplicateMatches(null);
|
||||
setPendingSubmit(null);
|
||||
}, []);
|
||||
|
||||
const handleDuplicateOpen = useCallback((taskId: string) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.hash = `#/tasks/${taskId}`;
|
||||
}
|
||||
setDuplicateMatches(null);
|
||||
setPendingSubmit(null);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
@@ -1112,6 +1149,15 @@ export function InlineCreateCard({
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
{duplicateMatches && duplicateMatches.length > 0 ? (
|
||||
<DuplicateWarningModal
|
||||
matches={duplicateMatches}
|
||||
onProceed={handleDuplicateProceed}
|
||||
onCancel={handleDuplicateCancel}
|
||||
onOpen={handleDuplicateOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
import { fetchModels, fetchSettings, fetchAgents } from "../../api";
|
||||
import { fetchModels, fetchSettings, fetchAgents, checkDuplicateTasks } from "../../api";
|
||||
import { useNodes } from "../../hooks/useNodes";
|
||||
import type { ModelInfo } from "../../api";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
@@ -117,6 +117,14 @@ vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
|
||||
DuplicateCandidatesError: class DuplicateCandidatesError extends Error {
|
||||
matches: unknown[];
|
||||
constructor(matches: unknown[]) {
|
||||
super("duplicate_candidates");
|
||||
this.matches = matches;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
const TEST_PROJECT_ID = "proj-123";
|
||||
@@ -203,6 +211,7 @@ beforeEach(() => {
|
||||
unregister: vi.fn(),
|
||||
healthCheck: vi.fn(),
|
||||
});
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
@@ -311,6 +320,48 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard duplicate warning", () => {
|
||||
it("shows duplicate modal and blocks submit when duplicate check finds matches", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([{ id: "FN-002", title: "dup", description: "dup", column: "todo", score: 1 }]);
|
||||
|
||||
renderCard([], { onSubmit });
|
||||
expandCard();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "duplicate" } });
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
expect(await screen.findByText(/Possible duplicates/i)).toBeTruthy();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds with acknowledgedDuplicates from duplicate modal", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
|
||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([{ id: "FN-002", title: "dup", description: "dup", column: "todo", score: 1 }]);
|
||||
|
||||
renderCard([], { onSubmit });
|
||||
expandCard();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "duplicate" } });
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
const proceed = await screen.findByRole("button", { name: /Create anyway/i });
|
||||
fireEvent.click(proceed);
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ acknowledgedDuplicates: ["FN-002"] })));
|
||||
});
|
||||
|
||||
it("fails open when duplicate check throws", async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue({ id: "FN-001" } as Task);
|
||||
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
renderCard([], { onSubmit });
|
||||
expandCard();
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "new task" } });
|
||||
fireEvent.click(screen.getByTestId("save-button"));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
describe("InlineCreateCard model selector", () => {
|
||||
it("clicking Models button opens the ModelSelectionModal", () => {
|
||||
renderCard();
|
||||
|
||||
Reference in New Issue
Block a user