feat(FN-5136): lock quick entry submit before duplicate check

Adds duplicate-submission guard to the Quick Entry box, locking the submit action before the duplicate check resolves and preventing concurrent or double submissions from creating duplicate tasks. Includes tests covering the lock/unlock flow and duplicate-skip behavior.

Fusion-Task-Id: FN-5136
This commit is contained in:
Fusion (runfusion.ai)
2026-05-19 07:29:16 -07:00
committed by gsxdsm
parent 4955ccf6ea
commit 7652bbf29e
3 changed files with 153 additions and 4 deletions

View File

@@ -164,6 +164,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
const [nodeId, setNodeId] = useState<string | undefined>(undefined);
const [duplicateMatches, setDuplicateMatches] = useState<DuplicateMatch[] | null>(null);
const submitInFlightRef = useRef(false);
const { nodes } = useNodes();
// AI Refinement state
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
@@ -506,7 +507,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
const originalDescription = description;
setIsSubmitting(true);
setDescription("");
try {
const createdTask = await onCreate({
@@ -548,6 +548,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
setDescription(originalDescription);
addToast(getErrorMessage(err) || "Failed to create task", "error");
} finally {
submitInFlightRef.current = false;
setIsSubmitting(false);
}
}, [
@@ -577,21 +578,40 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
]);
const handleSubmit = useCallback(async () => {
const trimmed = description.trim();
if (!trimmed || isSubmitting || !onCreate) return;
if (submitInFlightRef.current) {
return;
}
submitInFlightRef.current = true;
setIsSubmitting(true);
const trimmed = description.trim();
if (!trimmed || !onCreate) {
submitInFlightRef.current = false;
setIsSubmitting(false);
return;
}
let releaseLockOnExit = true;
try {
const matches = await checkDuplicateTasks({ description: trimmed }, projectId);
if (matches.length > 0) {
setDuplicateMatches(matches);
releaseLockOnExit = false;
return;
}
} catch (_error) {
addToast("Duplicate check failed; creating task anyway.", "error");
}
releaseLockOnExit = false;
await submitCreateTask(trimmed);
}, [description, isSubmitting, onCreate, projectId, submitCreateTask, addToast]);
if (releaseLockOnExit) {
submitInFlightRef.current = false;
setIsSubmitting(false);
}
}, [description, onCreate, projectId, submitCreateTask, addToast]);
const handleDuplicateOpen = useCallback((taskId: string) => {
if (onOpenTask) {
@@ -607,14 +627,22 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
const matches = duplicateMatches;
if (!trimmed || !matches || matches.length === 0) {
setDuplicateMatches(null);
submitInFlightRef.current = false;
setIsSubmitting(false);
return;
}
setDuplicateMatches(null);
// Keep optimistic submit lock active while duplicate-confirmed creation is in flight.
submitInFlightRef.current = true;
setIsSubmitting(true);
await submitCreateTask(trimmed, { acknowledgedDuplicates: matches.map((match) => match.id) });
}, [description, duplicateMatches, submitCreateTask]);
const handleDuplicateCancel = useCallback(() => {
setDuplicateMatches(null);
submitInFlightRef.current = false;
setIsSubmitting(false);
}, []);
const handleKeyDown = useCallback(
@@ -627,6 +655,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
}
// Enter without Shift submits
e.preventDefault();
if (duplicateMatches || submitInFlightRef.current) {
return;
}
handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
@@ -695,6 +726,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
showPriorityPicker,
projectId,
setIsDisclosureExpanded,
duplicateMatches,
],
);

View File

@@ -3441,6 +3441,118 @@ describe("QuickEntryBox", () => {
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
expect(addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
});
it("FN-5136: ignores rapid Enter presses while duplicate check is pending", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
let resolveDup!: (matches: Array<{ id: string; title: string; description: string; column: string; score: number }>) => void;
const dupPromise = new Promise<Array<{ id: string; title: string; description: string; column: string; score: number }>>((resolve) => {
resolveDup = resolve;
});
vi.mocked(checkDuplicateTasks).mockReturnValueOnce(dupPromise as any);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "pending enter lock" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
resolveDup([]);
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
});
it("FN-5136: disables Save and ignores rapid Save clicks while duplicate check is pending", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
let resolveDup!: (matches: Array<{ id: string; title: string; description: string; column: string; score: number }>) => void;
const dupPromise = new Promise<Array<{ id: string; title: string; description: string; column: string; score: number }>>((resolve) => {
resolveDup = resolve;
});
vi.mocked(checkDuplicateTasks).mockReturnValueOnce(dupPromise as any);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "pending save lock" } });
expandQuickEntry();
const saveButton = screen.getByTestId("quick-entry-save");
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.queryByTestId("quick-entry-save")).toBeNull();
expect(input).toBeDisabled();
});
fireEvent.click(input);
await act(async () => {
resolveDup([]);
});
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
});
it("FN-5136: blocks Enter then Save mixed submit while in flight", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
let resolveDup!: (matches: Array<{ id: string; title: string; description: string; column: string; score: number }>) => void;
const dupPromise = new Promise<Array<{ id: string; title: string; description: string; column: string; score: number }>>((resolve) => {
resolveDup = resolve;
});
vi.mocked(checkDuplicateTasks).mockReturnValueOnce(dupPromise as any);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "mixed submit lock" } });
expandQuickEntry();
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
await act(async () => {
resolveDup([]);
});
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
});
it("FN-5136: keeps Save/Enter locked while duplicate modal is open and unlocks on cancel", async () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-321", title: "Duplicate", description: "desc", column: "todo", score: 0.9 },
]);
renderQuickEntryBox({ onCreate });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "duplicate candidate" } });
expandQuickEntry();
fireEvent.keyDown(input, { key: "Enter" });
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
expect(screen.queryByTestId("quick-entry-save")).toBeNull();
expect(input).toBeDisabled();
fireEvent.keyDown(input, { key: "Enter" });
expect(onCreate).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => {
const saveButton = screen.getByTestId("quick-entry-save");
expect(saveButton).not.toBeDisabled();
});
});
it("FN-5136: duplicate-check rejection falls through once and stays single-submit", async () => {
let resolveCreate!: () => void;
const onCreate = vi.fn().mockImplementation(() => new Promise<void>((resolve) => { resolveCreate = resolve; }));
const addToast = vi.fn();
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("boom"));
renderQuickEntryBox({ onCreate, addToast });
const input = screen.getByTestId("quick-entry-input");
fireEvent.change(input, { target: { value: "fall through only once" } });
fireEvent.keyDown(input, { key: "Enter" });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1));
expect(addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
resolveCreate();
await waitFor(() => expect(input).not.toBeDisabled());
});
});
});