FN-7042: stop refocusing quick task input after submit
Prevent successful quick task creation from stealing focus back to the quick-entry textarea. - Remove the post-submit focus restoration request/effect from QuickEntryBox.\n- Update desktop, mobile, duplicate-confirm, and image-upload tests to assert no automatic refocus.\n- Add a patch changeset documenting the quick-entry focus fix.\n\nFiles changed:\n .changeset/fn-7042-quick-entry-no-refocus.md | 7 +++\n .../dashboard/app/components/QuickEntryBox.tsx | 46 +------------------\n .../components/__tests__/QuickEntryBox.test.tsx | 53 ++++++++++++----------\n 3 files changed, 39 insertions(+), 67 deletions(-) Fusion-Task-Id: FN-7042 Fusion-Task-Lineage: 7c8e26a6-3a6b-463f-bfb7-8310682584f8
This commit is contained in:
7
.changeset/fn-7042-quick-entry-no-refocus.md
Normal file
7
.changeset/fn-7042-quick-entry-no-refocus.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Quick task input no longer refocuses itself after you add a task.
|
||||||
|
category: fix
|
||||||
|
dev: Removed QuickEntryBox post-submit focus restoration (FNXC:QuickEntryFocus); supersedes FN-6217/FN-6219.
|
||||||
@@ -18,7 +18,6 @@ import { ProviderIcon } from "./ProviderIcon";
|
|||||||
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
import { WorkflowOptionalStepsDropdown } from "./WorkflowOptionalStepsDropdown";
|
||||||
|
|
||||||
const STORAGE_KEY = "kb-quick-entry-text";
|
const STORAGE_KEY = "kb-quick-entry-text";
|
||||||
const MOBILE_BREAKPOINT_PX = 768;
|
|
||||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||||
|
|
||||||
interface PendingImage {
|
interface PendingImage {
|
||||||
@@ -111,7 +110,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [postSubmitFocusRequest, setPostSubmitFocusRequest] = useState(0);
|
|
||||||
// isExpanded controls textarea height styling (auto-resize)
|
// isExpanded controls textarea height styling (auto-resize)
|
||||||
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) starts collapsed so the textarea is one line, not the tall 80px variant.
|
// FNXC:QuickEntry 2026-06-22-19:25: singleLine (List view) starts collapsed so the textarea is one line, not the tall 80px variant.
|
||||||
const [isExpanded, setIsExpanded] = useState(!singleLine);
|
const [isExpanded, setIsExpanded] = useState(!singleLine);
|
||||||
@@ -122,8 +120,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
const touchButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const justResetRef = useRef(false);
|
const justResetRef = useRef(false);
|
||||||
const postSubmitFocusTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const handledPostSubmitFocusRequestRef = useRef(0);
|
|
||||||
const previousProjectIdRef = useRef(projectId);
|
const previousProjectIdRef = useRef(projectId);
|
||||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||||
const pendingImagesRef = useRef<PendingImage[]>([]);
|
const pendingImagesRef = useRef<PendingImage[]>([]);
|
||||||
@@ -386,46 +382,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}
|
}
|
||||||
}, [description, isExpanded, autoResize, singleLine]);
|
}, [description, isExpanded, autoResize, singleLine]);
|
||||||
|
|
||||||
const requestFocusAfterSuccessfulSubmit = useCallback(() => {
|
|
||||||
setPostSubmitFocusRequest((request) => request + 1);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:QuickEntryFocus 2026-06-19-16:50:
|
FNXC:QuickEntryFocus 2026-06-25-00:00:
|
||||||
Desktop users should keep typing after Enter, Save, or duplicate-confirmed task creation, while mobile users must not receive an automatic focus that opens the soft keyboard.
|
After a successful task creation, the quick-entry textarea must not re-focus itself on any surface, desktop or mobile. The user explicitly does not want focus to return to the input after adding a task, superseding FN-6217/FN-6219; keep clearing the form on submit.
|
||||||
Drive the post-submit focus from a resolved-submit state request instead of a ref-gated effect so React state ordering cannot skip the restoration when the form clears under broad jsdom load.
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
postSubmitFocusRequest === 0 ||
|
|
||||||
handledPostSubmitFocusRequestRef.current === postSubmitFocusRequest ||
|
|
||||||
isSubmitting ||
|
|
||||||
description !== "" ||
|
|
||||||
!textareaRef.current
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handledPostSubmitFocusRequestRef.current = postSubmitFocusRequest;
|
|
||||||
|
|
||||||
if (postSubmitFocusTimeoutRef.current) {
|
|
||||||
clearTimeout(postSubmitFocusTimeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
postSubmitFocusTimeoutRef.current = setTimeout(() => {
|
|
||||||
postSubmitFocusTimeoutRef.current = null;
|
|
||||||
if (typeof window !== "undefined" && window.innerWidth > MOBILE_BREAKPOINT_PX) {
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (postSubmitFocusTimeoutRef.current) {
|
|
||||||
clearTimeout(postSubmitFocusTimeoutRef.current);
|
|
||||||
postSubmitFocusTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [description, isSubmitting, postSubmitFocusRequest]);
|
|
||||||
|
|
||||||
// Clear dep search when dropdown closes
|
// Clear dep search when dropdown closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -634,7 +594,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
requestFocusAfterSuccessfulSubmit();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDescription(originalDescription);
|
setDescription(originalDescription);
|
||||||
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
|
addToast(getErrorMessage(err) || t("tasks.createFailed", "Failed to create task"), "error");
|
||||||
@@ -667,7 +626,6 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
|||||||
projectId,
|
projectId,
|
||||||
addToast,
|
addToast,
|
||||||
resetForm,
|
resetForm,
|
||||||
requestFocusAfterSuccessfulSubmit,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
|
|||||||
@@ -560,7 +560,7 @@ describe("QuickEntryBox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("post-submission focus restoration (FN-6217)", () => {
|
describe("post-submission focus behavior (FN-7042: no refocus)", () => {
|
||||||
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
|
it("does not auto-focus the quick-entry textarea on empty desktop mount", async () => {
|
||||||
mockDesktopViewport();
|
mockDesktopViewport();
|
||||||
renderQuickEntryBox({});
|
renderQuickEntryBox({});
|
||||||
@@ -602,7 +602,7 @@ describe("QuickEntryBox", () => {
|
|||||||
expect(document.activeElement).not.toBe(textarea);
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the quick-entry textarea after a successful Enter submission on desktop", async () => {
|
it("does not refocus the quick-entry textarea after a successful Enter submission on desktop", async () => {
|
||||||
mockDesktopViewport();
|
mockDesktopViewport();
|
||||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
renderQuickEntryBox({ onCreate });
|
renderQuickEntryBox({ onCreate });
|
||||||
@@ -616,11 +616,12 @@ describe("QuickEntryBox", () => {
|
|||||||
await waitForSubmitSuccessToClear(textarea);
|
await waitForSubmitSuccessToClear(textarea);
|
||||||
await flushPendingTimers();
|
await flushPendingTimers();
|
||||||
|
|
||||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
// FN-7042 reverses FN-6217/FN-6219: successful creation clears the draft but never steals focus back.
|
||||||
expect(document.activeElement).toBe(textarea);
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the quick-entry textarea after a successful Save-button submission on desktop", async () => {
|
it("does not refocus the quick-entry textarea after a successful Save-button submission on desktop", async () => {
|
||||||
mockDesktopViewport();
|
mockDesktopViewport();
|
||||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
renderQuickEntryBox({ onCreate });
|
renderQuickEntryBox({ onCreate });
|
||||||
@@ -634,11 +635,11 @@ describe("QuickEntryBox", () => {
|
|||||||
await waitForSubmitSuccessToClear(textarea);
|
await waitForSubmitSuccessToClear(textarea);
|
||||||
await flushPendingTimers();
|
await flushPendingTimers();
|
||||||
|
|
||||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
expect(document.activeElement).toBe(textarea);
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("focuses the quick-entry textarea only after duplicate-confirmed creation completes on desktop", async () => {
|
it("does not refocus the quick-entry textarea after duplicate-confirmed creation completes on desktop", async () => {
|
||||||
mockDesktopViewport();
|
mockDesktopViewport();
|
||||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
|
||||||
@@ -660,8 +661,8 @@ describe("QuickEntryBox", () => {
|
|||||||
await waitForSubmitSuccessToClear(textarea);
|
await waitForSubmitSuccessToClear(textarea);
|
||||||
await flushPendingTimers();
|
await flushPendingTimers();
|
||||||
|
|
||||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
expect(document.activeElement).toBe(textarea);
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never auto-focuses the quick-entry textarea on mobile, including after a successful submission", async () => {
|
it("never auto-focuses the quick-entry textarea on mobile, including after a successful submission", async () => {
|
||||||
@@ -1557,25 +1558,25 @@ describe("QuickEntryBox", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maintains focus after successful creation", async () => {
|
it("does not refocus after successful creation", async () => {
|
||||||
const { props } = renderQuickEntryBox({});
|
const { props } = renderQuickEntryBox({});
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
|
|
||||||
fireEvent.focus(textarea);
|
|
||||||
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
fireEvent.change(textarea, { target: { value: "Task to create" } });
|
||||||
fireEvent.keyDown(textarea, { key: "Enter" });
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(props.onCreate).toHaveBeenCalled();
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
await waitFor(() => expect(textarea.value).toBe(""));
|
||||||
|
await flushPendingTimers();
|
||||||
|
|
||||||
// Focus restoration happens after submit state clears
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
await waitFor(() => {
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
expect(document.activeElement).toBe(textarea);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not restore focus after successful creation at mobile width", async () => {
|
it("does not refocus after successful creation at mobile width", async () => {
|
||||||
const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
|
const innerWidthSpy = vi.spyOn(window, "innerWidth", "get").mockReturnValue(375);
|
||||||
const { props } = renderQuickEntryBox({});
|
const { props } = renderQuickEntryBox({});
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
const textarea = screen.getByTestId("quick-entry-input");
|
||||||
@@ -2939,12 +2940,13 @@ describe("QuickEntryBox", () => {
|
|||||||
expect(screen.queryByAltText("remove.png")).toBeNull();
|
expect(screen.queryByAltText("remove.png")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uploads each pending image after task creation", async () => {
|
it("uploads each pending image after task creation without refocusing", async () => {
|
||||||
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
const onCreate = vi.fn().mockResolvedValue(CREATED_TASK);
|
||||||
renderQuickEntryBox({ onCreate });
|
renderQuickEntryBox({ onCreate });
|
||||||
expandQuickEntry();
|
expandQuickEntry();
|
||||||
|
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
const fileInput = screen.getByTestId("quick-entry-file-input") as HTMLInputElement;
|
||||||
const fileA = new File(["a"], "a.png", { type: "image/png" });
|
const fileA = new File(["a"], "a.png", { type: "image/png" });
|
||||||
const fileB = new File(["b"], "b.png", { type: "image/png" });
|
const fileB = new File(["b"], "b.png", { type: "image/png" });
|
||||||
@@ -2960,6 +2962,8 @@ describe("QuickEntryBox", () => {
|
|||||||
|
|
||||||
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileA, TEST_PROJECT_ID);
|
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileA, TEST_PROJECT_ID);
|
||||||
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileB, TEST_PROJECT_ID);
|
expect(uploadAttachment).toHaveBeenCalledWith(CREATED_TASK.id, fileB, TEST_PROJECT_ID);
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not upload attachments when no pending images exist", async () => {
|
it("does not upload attachments when no pending images exist", async () => {
|
||||||
@@ -3043,9 +3047,10 @@ describe("QuickEntryBox", () => {
|
|||||||
expect(controls?.hasAttribute("hidden")).toBe(true);
|
expect(controls?.hasAttribute("hidden")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("after task creation with autoExpand, focus restore preserves visible controls", async () => {
|
it("after task creation with autoExpand, no refocus still preserves visible controls", async () => {
|
||||||
const { props } = renderQuickEntryBox();
|
const { props } = renderQuickEntryBox();
|
||||||
const textarea = screen.getByTestId("quick-entry-input");
|
const textarea = screen.getByTestId("quick-entry-input") as HTMLTextAreaElement;
|
||||||
|
const focusSpy = vi.spyOn(textarea, "focus");
|
||||||
const controls = document.getElementById("quick-entry-controls");
|
const controls = document.getElementById("quick-entry-controls");
|
||||||
|
|
||||||
// Type and submit without collapsing disclosure.
|
// Type and submit without collapsing disclosure.
|
||||||
@@ -3056,10 +3061,12 @@ describe("QuickEntryBox", () => {
|
|||||||
expect(props.onCreate).toHaveBeenCalled();
|
expect(props.onCreate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// After creation, focus is restored asynchronously and visible controls remain visible.
|
// FN-7042: creation no longer restores focus; visible controls should remain visible anyway.
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
|
||||||
});
|
});
|
||||||
|
expect(focusSpy).not.toHaveBeenCalled();
|
||||||
|
expect(document.activeElement).not.toBe(textarea);
|
||||||
expect(controls?.hasAttribute("hidden")).toBe(false);
|
expect(controls?.hasAttribute("hidden")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user