Skip to content

Commit ca16719

Browse files
authored
Merge pull request #61 from saagpatel/codex/refactor/wave5-7-draft-lifecycle
refactor(components): extract useDraftLifecycle hook
2 parents 9333ec8 + fa87f3e commit ca16719

3 files changed

Lines changed: 235 additions & 72 deletions

File tree

src/components/Draft/DraftTab.tsx

Lines changed: 14 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
useState,
33
useCallback,
4-
useEffect,
54
forwardRef,
65
useImperativeHandle,
76
useMemo,
@@ -15,6 +14,7 @@ import { useDraftChecklist } from "./useDraftChecklist";
1514
import { useDraftFirstResponse } from "./useDraftFirstResponse";
1615
import { useDraftGeneration } from "./useDraftGeneration";
1716
import { useDraftIntake } from "./useDraftIntake";
17+
import { useDraftLifecycle } from "./useDraftLifecycle";
1818
import { useDraftPersistence } from "./useDraftPersistence";
1919
import { useGuidedRunbook } from "./useGuidedRunbook";
2020
import { useResponseActions } from "./useResponseActions";
@@ -177,19 +177,6 @@ function createWorkspaceRunbookScopeKey(): string {
177177
return `workspace:${createWorkspaceScopeSeed()}`;
178178
}
179179

180-
function isEditableTarget(target: EventTarget | null): boolean {
181-
if (!(target instanceof HTMLElement)) {
182-
return false;
183-
}
184-
const tag = target.tagName.toLowerCase();
185-
return (
186-
tag === "input" ||
187-
tag === "textarea" ||
188-
tag === "select" ||
189-
target.isContentEditable
190-
);
191-
}
192-
193180
export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
194181
function DraftTab(
195182
{ initialDraft, onNavigateToSource, revampModeEnabled = false },
@@ -448,10 +435,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
448435
},
449436
});
450437

451-
useEffect(() => {
452-
void refreshWorkspaceCatalog();
453-
}, [refreshWorkspaceCatalog]);
454-
455438
const {
456439
caseIntake,
457440
setCaseIntake,
@@ -571,21 +554,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
571554
setSuggestionsDismissed(true);
572555
}, []);
573556

574-
// Find similar saved responses when input changes
575-
useEffect(() => {
576-
if (input.trim().length >= 10) {
577-
setSuggestionsDismissed(false);
578-
findSimilar(input);
579-
}
580-
}, [input, findSimilar]);
581-
582-
// Load alternatives when draft is loaded/saved
583-
useEffect(() => {
584-
if (savedDraftId) {
585-
loadAlternatives(savedDraftId);
586-
}
587-
}, [savedDraftId, loadAlternatives]);
588-
589557
const handleClear = useCallback(() => {
590558
setInput("");
591559
setOcrText(null);
@@ -702,34 +670,6 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
702670
[modelLoaded, responseLength, generateStreaming, clearStreamingText],
703671
);
704672

705-
useEffect(() => {
706-
if (viewMode !== "panels") {
707-
return;
708-
}
709-
const handleKeydown = (event: KeyboardEvent) => {
710-
if (!event.metaKey || event.altKey || event.ctrlKey) {
711-
return;
712-
}
713-
if (isEditableTarget(event.target)) {
714-
return;
715-
}
716-
717-
if (event.key === "1") {
718-
event.preventDefault();
719-
handlePanelDensityModeChange("balanced");
720-
} else if (event.key === "2") {
721-
event.preventDefault();
722-
handlePanelDensityModeChange("focus-intake");
723-
} else if (event.key === "3") {
724-
event.preventDefault();
725-
handlePanelDensityModeChange("focus-response");
726-
}
727-
};
728-
729-
window.addEventListener("keydown", handleKeydown);
730-
return () => window.removeEventListener("keydown", handleKeydown);
731-
}, [viewMode, handlePanelDensityModeChange]);
732-
733673
const buildDiagnosisJson = useCallback(() => {
734674
const completedIds = Object.keys(checklistCompleted).filter(
735675
(id) => checklistCompleted[id],
@@ -1146,17 +1086,19 @@ export const DraftTab = forwardRef<DraftTabHandle, DraftTabProps>(
11461086
onShowError: showError,
11471087
});
11481088

1149-
// Load initial draft if provided
1150-
useEffect(() => {
1151-
if (initialDraft) {
1152-
handleLoadDraft(initialDraft);
1153-
}
1154-
}, [initialDraft, handleLoadDraft]);
1155-
1156-
// Load templates on mount
1157-
useEffect(() => {
1158-
loadTemplates();
1159-
}, [loadTemplates]);
1089+
useDraftLifecycle({
1090+
initialDraft,
1091+
viewMode,
1092+
input,
1093+
savedDraftId,
1094+
refreshWorkspaceCatalog,
1095+
findSimilar,
1096+
loadAlternatives,
1097+
loadTemplates,
1098+
handleLoadDraft,
1099+
onPanelDensityModeChange: handlePanelDensityModeChange,
1100+
setSuggestionsDismissed,
1101+
});
11601102

11611103
// Expose functions to parent via ref
11621104
useImperativeHandle(
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// @vitest-environment jsdom
2+
import { fireEvent, renderHook } from "@testing-library/react";
3+
import { afterEach, describe, expect, it, vi } from "vitest";
4+
import { useDraftLifecycle } from "./useDraftLifecycle";
5+
6+
type HookOptions = Parameters<typeof useDraftLifecycle>[0];
7+
8+
function makeOptions(overrides: Partial<HookOptions> = {}): HookOptions {
9+
return {
10+
initialDraft: null,
11+
viewMode: "panels",
12+
input: "",
13+
savedDraftId: null,
14+
refreshWorkspaceCatalog: vi.fn().mockResolvedValue(undefined),
15+
findSimilar: vi.fn().mockResolvedValue(undefined),
16+
loadAlternatives: vi.fn().mockResolvedValue(undefined),
17+
loadTemplates: vi.fn().mockResolvedValue(undefined),
18+
handleLoadDraft: vi.fn(),
19+
onPanelDensityModeChange: vi.fn(),
20+
setSuggestionsDismissed: vi.fn(),
21+
...overrides,
22+
};
23+
}
24+
25+
describe("useDraftLifecycle", () => {
26+
afterEach(() => {
27+
vi.restoreAllMocks();
28+
});
29+
30+
it("refreshes catalog and loads templates on mount", () => {
31+
const options = makeOptions();
32+
renderHook(() => useDraftLifecycle(options));
33+
34+
expect(options.refreshWorkspaceCatalog).toHaveBeenCalledTimes(1);
35+
expect(options.loadTemplates).toHaveBeenCalledTimes(1);
36+
});
37+
38+
it("finds similar saved responses once input has 10+ characters", () => {
39+
const options = makeOptions({ input: "short" });
40+
const { rerender } = renderHook(
41+
(props: HookOptions) => useDraftLifecycle(props),
42+
{ initialProps: options },
43+
);
44+
expect(options.findSimilar).not.toHaveBeenCalled();
45+
46+
const updated = makeOptions({
47+
...options,
48+
input: "longer than ten characters",
49+
});
50+
rerender(updated);
51+
52+
expect(updated.findSimilar).toHaveBeenCalledWith(
53+
"longer than ten characters",
54+
);
55+
expect(updated.setSuggestionsDismissed).toHaveBeenCalledWith(false);
56+
});
57+
58+
it("loads alternatives when a savedDraftId appears", () => {
59+
const options = makeOptions();
60+
const { rerender } = renderHook(
61+
(props: HookOptions) => useDraftLifecycle(props),
62+
{ initialProps: options },
63+
);
64+
expect(options.loadAlternatives).not.toHaveBeenCalled();
65+
66+
const updated = makeOptions({ ...options, savedDraftId: "draft-1" });
67+
rerender(updated);
68+
69+
expect(updated.loadAlternatives).toHaveBeenCalledWith("draft-1");
70+
});
71+
72+
it("fires initialDraft load exactly once when the prop is provided", () => {
73+
const initialDraft = { id: "d-1" } as HookOptions["initialDraft"];
74+
const options = makeOptions({ initialDraft });
75+
renderHook(() => useDraftLifecycle(options));
76+
77+
expect(options.handleLoadDraft).toHaveBeenCalledWith(initialDraft);
78+
});
79+
80+
it("maps Cmd-1/2/3 to the three panel density modes in panels view", () => {
81+
const options = makeOptions({ viewMode: "panels" });
82+
renderHook(() => useDraftLifecycle(options));
83+
84+
fireEvent.keyDown(window, { key: "1", metaKey: true });
85+
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith("balanced");
86+
87+
fireEvent.keyDown(window, { key: "2", metaKey: true });
88+
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith(
89+
"focus-intake",
90+
);
91+
92+
fireEvent.keyDown(window, { key: "3", metaKey: true });
93+
expect(options.onPanelDensityModeChange).toHaveBeenCalledWith(
94+
"focus-response",
95+
);
96+
});
97+
98+
it("ignores keyboard shortcuts when viewMode is conversation", () => {
99+
const options = makeOptions({ viewMode: "conversation" });
100+
renderHook(() => useDraftLifecycle(options));
101+
102+
fireEvent.keyDown(window, { key: "1", metaKey: true });
103+
expect(options.onPanelDensityModeChange).not.toHaveBeenCalled();
104+
});
105+
106+
it("ignores keyboard shortcuts when focus is in an editable target", () => {
107+
const options = makeOptions({ viewMode: "panels" });
108+
renderHook(() => useDraftLifecycle(options));
109+
110+
const input = document.createElement("input");
111+
document.body.appendChild(input);
112+
input.focus();
113+
114+
fireEvent.keyDown(input, { key: "1", metaKey: true, bubbles: true });
115+
expect(options.onPanelDensityModeChange).not.toHaveBeenCalled();
116+
117+
document.body.removeChild(input);
118+
});
119+
});
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useEffect } from "react";
2+
import type { SavedDraft } from "../../types/workspace";
3+
4+
function isEditableTarget(target: EventTarget | null): boolean {
5+
if (!(target instanceof HTMLElement)) {
6+
return false;
7+
}
8+
const tag = target.tagName.toLowerCase();
9+
return (
10+
tag === "input" ||
11+
tag === "textarea" ||
12+
tag === "select" ||
13+
target.isContentEditable
14+
);
15+
}
16+
17+
type PanelDensityMode = "balanced" | "focus-intake" | "focus-response";
18+
type ViewMode = "panels" | "conversation";
19+
20+
interface UseDraftLifecycleOptions {
21+
initialDraft?: SavedDraft | null;
22+
viewMode: ViewMode;
23+
input: string;
24+
savedDraftId: string | null;
25+
26+
refreshWorkspaceCatalog: () => Promise<unknown>;
27+
findSimilar: (query: string) => Promise<unknown> | void;
28+
loadAlternatives: (draftId: string) => Promise<unknown> | void;
29+
loadTemplates: () => Promise<unknown> | void;
30+
handleLoadDraft: (draft: SavedDraft) => void;
31+
onPanelDensityModeChange: (mode: PanelDensityMode) => void;
32+
setSuggestionsDismissed: (value: boolean) => void;
33+
}
34+
35+
export function useDraftLifecycle({
36+
initialDraft,
37+
viewMode,
38+
input,
39+
savedDraftId,
40+
refreshWorkspaceCatalog,
41+
findSimilar,
42+
loadAlternatives,
43+
loadTemplates,
44+
handleLoadDraft,
45+
onPanelDensityModeChange,
46+
setSuggestionsDismissed,
47+
}: UseDraftLifecycleOptions) {
48+
useEffect(() => {
49+
void refreshWorkspaceCatalog();
50+
}, [refreshWorkspaceCatalog]);
51+
52+
useEffect(() => {
53+
if (input.trim().length >= 10) {
54+
setSuggestionsDismissed(false);
55+
void findSimilar(input);
56+
}
57+
}, [input, findSimilar, setSuggestionsDismissed]);
58+
59+
useEffect(() => {
60+
if (savedDraftId) {
61+
void loadAlternatives(savedDraftId);
62+
}
63+
}, [savedDraftId, loadAlternatives]);
64+
65+
useEffect(() => {
66+
if (viewMode !== "panels") {
67+
return;
68+
}
69+
const handleKeydown = (event: KeyboardEvent) => {
70+
if (!event.metaKey || event.altKey || event.ctrlKey) {
71+
return;
72+
}
73+
if (isEditableTarget(event.target)) {
74+
return;
75+
}
76+
77+
if (event.key === "1") {
78+
event.preventDefault();
79+
onPanelDensityModeChange("balanced");
80+
} else if (event.key === "2") {
81+
event.preventDefault();
82+
onPanelDensityModeChange("focus-intake");
83+
} else if (event.key === "3") {
84+
event.preventDefault();
85+
onPanelDensityModeChange("focus-response");
86+
}
87+
};
88+
89+
window.addEventListener("keydown", handleKeydown);
90+
return () => window.removeEventListener("keydown", handleKeydown);
91+
}, [viewMode, onPanelDensityModeChange]);
92+
93+
useEffect(() => {
94+
if (initialDraft) {
95+
handleLoadDraft(initialDraft);
96+
}
97+
}, [initialDraft, handleLoadDraft]);
98+
99+
useEffect(() => {
100+
void loadTemplates();
101+
}, [loadTemplates]);
102+
}

0 commit comments

Comments
 (0)