From fa87f3efa40b924a8da38eb62e3fa0516ffdfb5f Mon Sep 17 00:00:00 2001 From: Saagar Patel Date: Tue, 21 Apr 2026 21:20:20 +0200 Subject: [PATCH] refactor(components): extract useDraftLifecycle hook Consolidate the six orchestration useEffects that remained in the DraftTab shell (workspace-catalog refresh on mount, similar-response suggestion debounce, savedDraftId->loadAlternatives, Cmd-1/2/3 panel-density keyboard listener, initialDraft replay, loadTemplates on mount) into a dedicated hook. Shell drops from 1451 to 1393 LOC and gains seven renderHook tests exercising each effect plus the keyboard shortcut's editable-target + conversation-mode guards. --- src/components/Draft/DraftTab.tsx | 86 +++---------- .../Draft/useDraftLifecycle.test.ts | 119 ++++++++++++++++++ src/components/Draft/useDraftLifecycle.ts | 102 +++++++++++++++ 3 files changed, 235 insertions(+), 72 deletions(-) create mode 100644 src/components/Draft/useDraftLifecycle.test.ts create mode 100644 src/components/Draft/useDraftLifecycle.ts diff --git a/src/components/Draft/DraftTab.tsx b/src/components/Draft/DraftTab.tsx index 3469d2c..f6fd95c 100644 --- a/src/components/Draft/DraftTab.tsx +++ b/src/components/Draft/DraftTab.tsx @@ -1,7 +1,6 @@ import { useState, useCallback, - useEffect, forwardRef, useImperativeHandle, useMemo, @@ -15,6 +14,7 @@ import { useDraftChecklist } from "./useDraftChecklist"; import { useDraftFirstResponse } from "./useDraftFirstResponse"; import { useDraftGeneration } from "./useDraftGeneration"; import { useDraftIntake } from "./useDraftIntake"; +import { useDraftLifecycle } from "./useDraftLifecycle"; import { useDraftPersistence } from "./useDraftPersistence"; import { useGuidedRunbook } from "./useGuidedRunbook"; import { useResponseActions } from "./useResponseActions"; @@ -177,19 +177,6 @@ function createWorkspaceRunbookScopeKey(): string { return `workspace:${createWorkspaceScopeSeed()}`; } -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - const tag = target.tagName.toLowerCase(); - return ( - tag === "input" || - tag === "textarea" || - tag === "select" || - target.isContentEditable - ); -} - export const DraftTab = forwardRef( function DraftTab( { initialDraft, onNavigateToSource, revampModeEnabled = false }, @@ -448,10 +435,6 @@ export const DraftTab = forwardRef( }, }); - useEffect(() => { - void refreshWorkspaceCatalog(); - }, [refreshWorkspaceCatalog]); - const { caseIntake, setCaseIntake, @@ -571,21 +554,6 @@ export const DraftTab = forwardRef( setSuggestionsDismissed(true); }, []); - // Find similar saved responses when input changes - useEffect(() => { - if (input.trim().length >= 10) { - setSuggestionsDismissed(false); - findSimilar(input); - } - }, [input, findSimilar]); - - // Load alternatives when draft is loaded/saved - useEffect(() => { - if (savedDraftId) { - loadAlternatives(savedDraftId); - } - }, [savedDraftId, loadAlternatives]); - const handleClear = useCallback(() => { setInput(""); setOcrText(null); @@ -702,34 +670,6 @@ export const DraftTab = forwardRef( [modelLoaded, responseLength, generateStreaming, clearStreamingText], ); - useEffect(() => { - if (viewMode !== "panels") { - return; - } - const handleKeydown = (event: KeyboardEvent) => { - if (!event.metaKey || event.altKey || event.ctrlKey) { - return; - } - if (isEditableTarget(event.target)) { - return; - } - - if (event.key === "1") { - event.preventDefault(); - handlePanelDensityModeChange("balanced"); - } else if (event.key === "2") { - event.preventDefault(); - handlePanelDensityModeChange("focus-intake"); - } else if (event.key === "3") { - event.preventDefault(); - handlePanelDensityModeChange("focus-response"); - } - }; - - window.addEventListener("keydown", handleKeydown); - return () => window.removeEventListener("keydown", handleKeydown); - }, [viewMode, handlePanelDensityModeChange]); - const buildDiagnosisJson = useCallback(() => { const completedIds = Object.keys(checklistCompleted).filter( (id) => checklistCompleted[id], @@ -1146,17 +1086,19 @@ export const DraftTab = forwardRef( onShowError: showError, }); - // Load initial draft if provided - useEffect(() => { - if (initialDraft) { - handleLoadDraft(initialDraft); - } - }, [initialDraft, handleLoadDraft]); - - // Load templates on mount - useEffect(() => { - loadTemplates(); - }, [loadTemplates]); + useDraftLifecycle({ + initialDraft, + viewMode, + input, + savedDraftId, + refreshWorkspaceCatalog, + findSimilar, + loadAlternatives, + loadTemplates, + handleLoadDraft, + onPanelDensityModeChange: handlePanelDensityModeChange, + setSuggestionsDismissed, + }); // Expose functions to parent via ref useImperativeHandle( diff --git a/src/components/Draft/useDraftLifecycle.test.ts b/src/components/Draft/useDraftLifecycle.test.ts new file mode 100644 index 0000000..6473ae9 --- /dev/null +++ b/src/components/Draft/useDraftLifecycle.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { fireEvent, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useDraftLifecycle } from "./useDraftLifecycle"; + +type HookOptions = Parameters[0]; + +function makeOptions(overrides: Partial = {}): HookOptions { + return { + initialDraft: null, + viewMode: "panels", + input: "", + savedDraftId: null, + refreshWorkspaceCatalog: vi.fn().mockResolvedValue(undefined), + findSimilar: vi.fn().mockResolvedValue(undefined), + loadAlternatives: vi.fn().mockResolvedValue(undefined), + loadTemplates: vi.fn().mockResolvedValue(undefined), + handleLoadDraft: vi.fn(), + onPanelDensityModeChange: vi.fn(), + setSuggestionsDismissed: vi.fn(), + ...overrides, + }; +} + +describe("useDraftLifecycle", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("refreshes catalog and loads templates on mount", () => { + const options = makeOptions(); + renderHook(() => useDraftLifecycle(options)); + + expect(options.refreshWorkspaceCatalog).toHaveBeenCalledTimes(1); + expect(options.loadTemplates).toHaveBeenCalledTimes(1); + }); + + it("finds similar saved responses once input has 10+ characters", () => { + const options = makeOptions({ input: "short" }); + const { rerender } = renderHook( + (props: HookOptions) => useDraftLifecycle(props), + { initialProps: options }, + ); + expect(options.findSimilar).not.toHaveBeenCalled(); + + const updated = makeOptions({ + ...options, + input: "longer than ten characters", + }); + rerender(updated); + + expect(updated.findSimilar).toHaveBeenCalledWith( + "longer than ten characters", + ); + expect(updated.setSuggestionsDismissed).toHaveBeenCalledWith(false); + }); + + it("loads alternatives when a savedDraftId appears", () => { + const options = makeOptions(); + const { rerender } = renderHook( + (props: HookOptions) => useDraftLifecycle(props), + { initialProps: options }, + ); + expect(options.loadAlternatives).not.toHaveBeenCalled(); + + const updated = makeOptions({ ...options, savedDraftId: "draft-1" }); + rerender(updated); + + expect(updated.loadAlternatives).toHaveBeenCalledWith("draft-1"); + }); + + it("fires initialDraft load exactly once when the prop is provided", () => { + const initialDraft = { id: "d-1" } as HookOptions["initialDraft"]; + const options = makeOptions({ initialDraft }); + renderHook(() => useDraftLifecycle(options)); + + expect(options.handleLoadDraft).toHaveBeenCalledWith(initialDraft); + }); + + it("maps Cmd-1/2/3 to the three panel density modes in panels view", () => { + const options = makeOptions({ viewMode: "panels" }); + renderHook(() => useDraftLifecycle(options)); + + fireEvent.keyDown(window, { key: "1", metaKey: true }); + expect(options.onPanelDensityModeChange).toHaveBeenCalledWith("balanced"); + + fireEvent.keyDown(window, { key: "2", metaKey: true }); + expect(options.onPanelDensityModeChange).toHaveBeenCalledWith( + "focus-intake", + ); + + fireEvent.keyDown(window, { key: "3", metaKey: true }); + expect(options.onPanelDensityModeChange).toHaveBeenCalledWith( + "focus-response", + ); + }); + + it("ignores keyboard shortcuts when viewMode is conversation", () => { + const options = makeOptions({ viewMode: "conversation" }); + renderHook(() => useDraftLifecycle(options)); + + fireEvent.keyDown(window, { key: "1", metaKey: true }); + expect(options.onPanelDensityModeChange).not.toHaveBeenCalled(); + }); + + it("ignores keyboard shortcuts when focus is in an editable target", () => { + const options = makeOptions({ viewMode: "panels" }); + renderHook(() => useDraftLifecycle(options)); + + const input = document.createElement("input"); + document.body.appendChild(input); + input.focus(); + + fireEvent.keyDown(input, { key: "1", metaKey: true, bubbles: true }); + expect(options.onPanelDensityModeChange).not.toHaveBeenCalled(); + + document.body.removeChild(input); + }); +}); diff --git a/src/components/Draft/useDraftLifecycle.ts b/src/components/Draft/useDraftLifecycle.ts new file mode 100644 index 0000000..bc745ba --- /dev/null +++ b/src/components/Draft/useDraftLifecycle.ts @@ -0,0 +1,102 @@ +import { useEffect } from "react"; +import type { SavedDraft } from "../../types/workspace"; + +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName.toLowerCase(); + return ( + tag === "input" || + tag === "textarea" || + tag === "select" || + target.isContentEditable + ); +} + +type PanelDensityMode = "balanced" | "focus-intake" | "focus-response"; +type ViewMode = "panels" | "conversation"; + +interface UseDraftLifecycleOptions { + initialDraft?: SavedDraft | null; + viewMode: ViewMode; + input: string; + savedDraftId: string | null; + + refreshWorkspaceCatalog: () => Promise; + findSimilar: (query: string) => Promise | void; + loadAlternatives: (draftId: string) => Promise | void; + loadTemplates: () => Promise | void; + handleLoadDraft: (draft: SavedDraft) => void; + onPanelDensityModeChange: (mode: PanelDensityMode) => void; + setSuggestionsDismissed: (value: boolean) => void; +} + +export function useDraftLifecycle({ + initialDraft, + viewMode, + input, + savedDraftId, + refreshWorkspaceCatalog, + findSimilar, + loadAlternatives, + loadTemplates, + handleLoadDraft, + onPanelDensityModeChange, + setSuggestionsDismissed, +}: UseDraftLifecycleOptions) { + useEffect(() => { + void refreshWorkspaceCatalog(); + }, [refreshWorkspaceCatalog]); + + useEffect(() => { + if (input.trim().length >= 10) { + setSuggestionsDismissed(false); + void findSimilar(input); + } + }, [input, findSimilar, setSuggestionsDismissed]); + + useEffect(() => { + if (savedDraftId) { + void loadAlternatives(savedDraftId); + } + }, [savedDraftId, loadAlternatives]); + + useEffect(() => { + if (viewMode !== "panels") { + return; + } + const handleKeydown = (event: KeyboardEvent) => { + if (!event.metaKey || event.altKey || event.ctrlKey) { + return; + } + if (isEditableTarget(event.target)) { + return; + } + + if (event.key === "1") { + event.preventDefault(); + onPanelDensityModeChange("balanced"); + } else if (event.key === "2") { + event.preventDefault(); + onPanelDensityModeChange("focus-intake"); + } else if (event.key === "3") { + event.preventDefault(); + onPanelDensityModeChange("focus-response"); + } + }; + + window.addEventListener("keydown", handleKeydown); + return () => window.removeEventListener("keydown", handleKeydown); + }, [viewMode, onPanelDensityModeChange]); + + useEffect(() => { + if (initialDraft) { + handleLoadDraft(initialDraft); + } + }, [initialDraft, handleLoadDraft]); + + useEffect(() => { + void loadTemplates(); + }, [loadTemplates]); +}