Skip to content

Commit 16e854b

Browse files
Memoize Modal handler callbacks, add targetProjectId to modals where relevant to prevent silent deletion, docstring audit
1 parent 6be5e38 commit 16e854b

11 files changed

Lines changed: 136 additions & 41 deletions

src/__tests__/components/ProjectMetadataModal.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const testProps = {
4040
onProjectDeleted: jest.fn(),
4141
};
4242

43+
const testPropsWithTarget = {
44+
...testProps,
45+
targetProjectId: 'target-project-id',
46+
};
47+
4348
describe('ProjectMetadataModal', () => {
4449
beforeEach(() => {
4550
jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]);
@@ -97,6 +102,24 @@ describe('ProjectMetadataModal', () => {
97102
'New Name',
98103
undefined,
99104
['en'],
105+
undefined,
106+
),
107+
);
108+
});
109+
110+
it('passes targetProjectId to sendCommand when the project has a target project', async () => {
111+
render(<ProjectMetadataModal {...testPropsWithTarget} />);
112+
113+
await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
114+
115+
await waitFor(() =>
116+
expect(mockSendCommand).toHaveBeenCalledWith(
117+
'interlinearizer.updateProjectMetadata',
118+
'il-project-uuid',
119+
undefined,
120+
undefined,
121+
['en'],
122+
'target-project-id',
100123
),
101124
);
102125
});
@@ -152,6 +175,7 @@ describe('ProjectMetadataModal', () => {
152175
undefined,
153176
'New Desc',
154177
['en'],
178+
undefined,
155179
),
156180
);
157181
});
@@ -171,6 +195,7 @@ describe('ProjectMetadataModal', () => {
171195
undefined,
172196
'Replaced',
173197
['en'],
198+
undefined,
174199
),
175200
);
176201
});
@@ -189,6 +214,7 @@ describe('ProjectMetadataModal', () => {
189214
undefined,
190215
undefined,
191216
['en'],
217+
undefined,
192218
),
193219
);
194220
});
@@ -257,6 +283,7 @@ describe('ProjectMetadataModal', () => {
257283
undefined,
258284
undefined,
259285
['fr'],
286+
undefined,
260287
),
261288
);
262289
});

src/__tests__/components/SelectInterlinearProjectModal.test.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ describe('SelectInterlinearProjectModal', () => {
167167
expect(screen.getAllByRole('listitem')).toHaveLength(1);
168168
});
169169

170+
it('silently drops entries with a non-string targetProjectId field', async () => {
171+
const badTarget = { ...STUB_PROJECT, targetProjectId: 99 };
172+
mockSendCommand.mockResolvedValue(JSON.stringify([badTarget, STUB_PROJECT_2]));
173+
render(<SelectInterlinearProjectModal {...defaultProps} />);
174+
await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());
175+
expect(screen.getAllByRole('listitem')).toHaveLength(1);
176+
});
177+
170178
it('shows the empty-state message when getProjectsForSource returns a non-array', async () => {
171179
mockSendCommand.mockResolvedValue(JSON.stringify({ not: 'an array' }));
172180
render(<SelectInterlinearProjectModal {...defaultProps} />);

src/components/Interlinearizer.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,17 @@ import ContinuousView from './ContinuousView';
55
import MemoizedSegmentView from './SegmentView';
66

77
/**
8-
* Main component for the Interlinearizer. Renders a sticky toolbar and continuous view at the top,
9-
* followed by segmented views.
8+
* Main content area for the Interlinearizer. Renders an optional {@link ContinuousView} strip at the
9+
* top followed by a scrollable list of {@link MemoizedSegmentView}s for the current chapter.
1010
*
1111
* @param props - Component props
1212
* @param props.book - Book data used by the continuous view
1313
* @param props.bookSegments - Segments to render as individual verse views
1414
* @param props.continuousScroll - Whether the continuous scroll view is shown
1515
* @param props.scrRef - Current scripture reference
1616
* @param props.setScrRef - Callback to update the scripture reference
17-
* @returns The continuous-view header (when enabled) above the segment list for the active chapter.
17+
* @returns The continuous-view strip (when enabled) above the scrollable segment list for the
18+
* active chapter.
1819
*/
1920
export default function Interlinearizer({
2021
book,

src/components/InterlinearizerLoader.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { ActiveProjectState } from './SelectInterlinearProjectModal';
1616
const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%'];
1717

1818
/**
19-
* Root component for loading the Interlinearizer. Loads book data and settings, manages modal state
19+
* Root component for the Interlinearizer WebView. Loads book data and settings, manages modal state
2020
* for project creation/selection/metadata, then renders error and loading states or delegates to
2121
* {@link Interlinearizer} when data is ready.
2222
*
@@ -61,8 +61,8 @@ export default function InterlinearizerLoader({
6161

6262
/**
6363
* Persisted snapshot of the active interlinear project — kept in WebView state so it survives tab
64-
* restores. Updated after creation and when the user selects an existing project from the
65-
* picker.
64+
* restores. The setter lives in {@link ProjectModals}, which writes to the same `'activeProject'`
65+
* key; this component reads the value to decide which menu items to show.
6666
*/
6767
const [activeProject] = useWebViewState<ActiveProjectState | undefined>(
6868
'activeProject',
@@ -100,6 +100,12 @@ export default function InterlinearizerLoader({
100100
{ topMenu: undefined, includeDefaults: true, contextMenu: undefined },
101101
);
102102

103+
/**
104+
* Top-menu descriptor passed to {@link TabToolbar}. Identical to
105+
* `webViewMenuPossiblyError.topMenu` except that the `interlinearizer.openProjectInfoModal` item
106+
* is filtered out when no project is active, since that command requires an active project to act
107+
* on.
108+
*/
103109
const projectMenuData = useMemo(() => {
104110
const menu =
105111
webViewMenuPossiblyError && !isPlatformError(webViewMenuPossiblyError)

src/components/ProjectMetadataModal.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export type ProjectMetadataModalProps = Readonly<{
3535
description?: string;
3636
/** Platform.Bible project ID of the source text. */
3737
sourceProjectId: string;
38+
/** Optional Platform.Bible project ID of the target text for bilateral alignment projects. */
39+
targetProjectId?: string;
3840
/** BCP 47 tags for the analysis languages. */
3941
analysisLanguages: string[];
4042
/** ISO 8601 creation timestamp. */
@@ -64,6 +66,7 @@ export function ProjectMetadataModal({
6466
name,
6567
description,
6668
sourceProjectId,
69+
targetProjectId,
6770
analysisLanguages,
6871
createdAt,
6972
onClose,
@@ -112,6 +115,7 @@ export function ProjectMetadataModal({
112115
newName,
113116
newDescription,
114117
newLanguages,
118+
targetProjectId,
115119
);
116120
if (!updatedProjectJson) return;
117121
onProjectSaved?.({
@@ -126,7 +130,15 @@ export function ProjectMetadataModal({
126130
isSubmittingRef.current = false;
127131
setIsSubmitting(false);
128132
}
129-
}, [editName, editDescription, editLanguages, interlinearProjectId, onProjectSaved, onClose]);
133+
}, [
134+
editName,
135+
editDescription,
136+
editLanguages,
137+
interlinearProjectId,
138+
targetProjectId,
139+
onProjectSaved,
140+
onClose,
141+
]);
130142

131143
/**
132144
* Sends the delete command to the backend, then notifies the caller and closes the modal. Logs on

src/components/ProjectModals.tsx

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import {
1212
export type ModalState = 'none' | 'select' | 'create' | 'metadata';
1313

1414
/**
15-
* Component for managing project modals in the Interlinearizer. Handles state for project creation,
16-
* selection, and metadata modals.
15+
* Single mount point for all project-related dialogs. Renders at most one of
16+
* {@link SelectInterlinearProjectModal}, {@link CreateProjectModal}, or {@link ProjectMetadataModal}
17+
* based on the `modal` prop, and manages the shared WebView state for the active project.
1718
*
1819
* @param props - Component props
1920
* @param props.activeProject - The currently active interlinear project, read from WebView state by
@@ -52,9 +53,10 @@ export default function ProjectModals({
5253
);
5354

5455
/**
55-
* Tracks where the metadata modal was opened from so the correct modal is restored on close.
56-
* `'select'` means it was opened via the info icon in the select modal; `'menu'` means it was
57-
* opened via the "View Project Info" menu item.
56+
* Tracks whether the metadata modal was opened from the select modal (info icon) or from the
57+
* top-menu "View Project Info" item. `true` means opened via the select modal, so closing the
58+
* metadata modal restores the select modal; `false` means opened from the menu, so closing
59+
* dismisses to `'none'`.
5860
*/
5961
const [metadataSourceIsSelect, setMetadataSourceIsSelect] = useState(false);
6062

@@ -102,29 +104,51 @@ export default function ProjectModals({
102104
[activeProject, resetActiveProject],
103105
);
104106

107+
const handleSelectProject = useCallback(
108+
(project: InterlinearProjectSummary) => {
109+
setActiveProject(project);
110+
setModal('none');
111+
},
112+
[setActiveProject, setModal],
113+
);
114+
115+
const handleSelectCreateNew = useCallback(() => setModal('create'), [setModal]);
116+
117+
const handleSelectClose = useCallback(() => setModal('none'), [setModal]);
118+
119+
const handleCreateClose = useCallback(() => setModal('none'), [setModal]);
120+
121+
const handleProjectCreated = useCallback(
122+
(project: InterlinearProjectSummary) => {
123+
setActiveProject(project);
124+
setModal('none');
125+
},
126+
[setActiveProject, setModal],
127+
);
128+
129+
const handleMetadataClose = useCallback(() => {
130+
setModal(metadataSourceIsSelect ? 'select' : 'none');
131+
setMetadataSourceIsSelect(false);
132+
setMetadataProject(undefined);
133+
}, [metadataSourceIsSelect, setModal]);
134+
105135
return (
106136
<div>
107137
{modal === 'select' && (
108138
<SelectInterlinearProjectModal
109139
sourceProjectId={projectId}
110-
onSelect={(project) => {
111-
setActiveProject(project);
112-
setModal('none');
113-
}}
114-
onCreateNew={() => setModal('create')}
115-
onClose={() => setModal('none')}
140+
onSelect={handleSelectProject}
141+
onCreateNew={handleSelectCreateNew}
142+
onClose={handleSelectClose}
116143
onViewInfo={handleViewInfo}
117144
/>
118145
)}
119146

120147
{modal === 'create' && (
121148
<CreateProjectModal
122149
projectId={projectId}
123-
onClose={() => setModal('none')}
124-
onProjectCreated={(project) => {
125-
setActiveProject(project);
126-
setModal('none');
127-
}}
150+
onClose={handleCreateClose}
151+
onProjectCreated={handleProjectCreated}
128152
/>
129153
)}
130154

@@ -134,13 +158,10 @@ export default function ProjectModals({
134158
name={resolvedMetadataProject.name}
135159
description={resolvedMetadataProject.description}
136160
sourceProjectId={resolvedMetadataProject.sourceProjectId}
161+
targetProjectId={resolvedMetadataProject.targetProjectId}
137162
analysisLanguages={resolvedMetadataProject.analysisLanguages}
138163
createdAt={resolvedMetadataProject.createdAt}
139-
onClose={() => {
140-
setModal(metadataSourceIsSelect ? 'select' : 'none');
141-
setMetadataSourceIsSelect(false);
142-
setMetadataProject(undefined);
143-
}}
164+
onClose={handleMetadataClose}
144165
onProjectSaved={handleMetadataProjectSaved}
145166
onProjectDeleted={handleMetadataProjectDeleted}
146167
/>

src/components/SelectInterlinearProjectModal.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,25 @@ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [
1818
/** The subset of InterlinearProject fields this modal displays and returns. */
1919
export type InterlinearProjectSummary = Pick<
2020
InterlinearProject,
21-
'id' | 'createdAt' | 'sourceProjectId' | 'analysisLanguages' | 'name' | 'description'
21+
| 'id'
22+
| 'createdAt'
23+
| 'sourceProjectId'
24+
| 'targetProjectId'
25+
| 'analysisLanguages'
26+
| 'name'
27+
| 'description'
2228
>;
2329

2430
/** Fields of the active interlinear project persisted in WebView state. */
2531
export type ActiveProjectState = Pick<
2632
InterlinearProjectSummary,
27-
'id' | 'createdAt' | 'name' | 'description' | 'sourceProjectId' | 'analysisLanguages'
33+
| 'id'
34+
| 'createdAt'
35+
| 'name'
36+
| 'description'
37+
| 'sourceProjectId'
38+
| 'targetProjectId'
39+
| 'analysisLanguages'
2840
>;
2941

3042
/**
@@ -48,7 +60,8 @@ export function isInterlinearProjectSummary(p: unknown): p is InterlinearProject
4860
Array.isArray(p.analysisLanguages) &&
4961
p.analysisLanguages.every((l) => typeof l === 'string') &&
5062
(!('name' in p) || typeof p.name === 'string') &&
51-
(!('description' in p) || typeof p.description === 'string')
63+
(!('description' in p) || typeof p.description === 'string') &&
64+
(!('targetProjectId' in p) || typeof p.targetProjectId === 'string')
5265
);
5366
}
5467

src/hooks/useOptimisticBooleanSetting.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ const TIMEOUT_MS = 15_000;
77
/**
88
* Manages a boolean project setting with optimistic UI updates.
99
*
10-
* The local value is updated immediately on change and stays locked until timeout elapses, to allow
11-
* the stored setting to finish updating. If the setting fails to save, the local value will persist
12-
* rather than reverting.
10+
* The local value is updated immediately on change and stays locked for {@link TIMEOUT_MS} to allow
11+
* the stored setting to finish updating without causing a visible bounce. If the platform confirms
12+
* the new value before the timeout, the lock is released; if the platform never responds (or
13+
* responds after the timeout), the lock clears and subsequent platform updates flow through
14+
* normally.
1315
*
1416
* @param projectId - PAPI project ID
1517
* @param settingKey - A valid key for a boolean setting

src/main.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ async function openInterlinearizer(projectId?: string): Promise<string | undefin
100100
* @param webViewId - ID of an open WebView whose project to use; if omitted falls back to a picker.
101101
* @returns The WebView ID of the opened (or focused) tab, or `undefined` if the user cancels.
102102
* @throws If `papi.webViews.getOpenWebViewDefinition` rejects.
103-
* @throws Any error thrown by {@link openInterlinearizer} (dialog or WebView platform errors).
103+
* @throws Any error thrown by {@link openInterlinearizer} (dialog or WebView errors).
104104
*/
105105
async function openInterlinearizerForWebView(webViewId?: string): Promise<string | undefined> {
106106
if (!webViewId) return openInterlinearizer();
@@ -161,7 +161,7 @@ async function createInterlinearProject(
161161
* @param interlinearProjectId - UUID of the interlinearizer project to delete.
162162
* @returns A promise that resolves when the deletion (or no-op) is complete.
163163
* @throws {SyntaxError} If the project-IDs index contains invalid JSON (propagated from
164-
* {@link projectStorage.deleteProject} via {@link readIds}).
164+
* `projectStorage.deleteProject`).
165165
* @throws If `papi.storage.deleteUserData` rejects for a non-ENOENT reason, or if
166166
* `papi.storage.writeUserData` rejects when updating the index. All storage errors are logged and
167167
* shown as a notification before being re-thrown so the caller can handle failure UX.
@@ -234,8 +234,8 @@ async function updateProjectMetadata(
234234
* @returns A JSON string of `InterlinearProject[]`, or `"[]"` if none exist.
235235
* @throws {SyntaxError} If the project-IDs index or any project record contains invalid JSON.
236236
* @throws If `papi.storage.readUserData` rejects for a non-ENOENT reason (propagated from
237-
* {@link projectStorage.getProjectsForSource}). Callers can use this to distinguish a storage
238-
* outage from a legitimately empty list.
237+
* `projectStorage.getProjectsForSource`). Callers can use this to distinguish a storage outage
238+
* from a legitimately empty list.
239239
*/
240240
async function getProjectsForSource(sourceProjectId: string): Promise<string> {
241241
try {

src/services/projectStorage.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,10 @@ export async function updateProjectMetadata(
285285
* @param id - The project UUID to delete.
286286
* @throws {SyntaxError} If the `projectIds` storage value contains invalid JSON (from
287287
* {@link readIds}).
288-
* @throws If `papi.storage.deleteUserData` throws for a reason other than ENOENT (non-ENOENT errors
289-
* are rethrown).
288+
* @throws If `papi.storage.deleteUserData` throws for a reason other than ENOENT.
290289
* @throws If `papi.storage.writeUserData` rejects when updating `PROJECT_IDS_KEY`.
291-
* @throws Any error propagated from {@link readIds}, `enqueueProjectOp`, or `enqueueIndexOp`.
290+
* @throws Any error propagated from {@link readIds}, {@link enqueueProjectOp}, or
291+
* {@link enqueueIndexOp}.
292292
*/
293293
export async function deleteProject(token: ExecutionToken, id: string): Promise<void> {
294294
await enqueueProjectOp(id, async () => {

0 commit comments

Comments
 (0)