Skip to content

Commit 2c31e70

Browse files
Return full project JSON from createProject and add error handling for delete/update
- `interlinearizer.createProject` now returns the full persisted project as a JSON string instead of just the UUID, so the WebView can populate `activeProject` with authoritative server data rather than reconstructing it locally - `CreateProjectModal.onProjectCreated` callback now receives the parsed `InterlinearProjectSummary` object instead of `(id, writingSystem)` pair - Add `isInterlinearProjectSummary` type guard to `SelectInterlinearProjectModal` and reuse it in the project list filter and the new create flow - Wrap `deleteProject` and `updateProjectMetadata` backend handlers in try/catch with logging and error notifications (previously unhandled rejections) - Register a no-op `interlinearizer.viewProjectInfo` backend command so the platform menu system can surface it; all logic runs in the WebView - Update tests and type declarations to match
1 parent 7e2c4c0 commit 2c31e70

9 files changed

Lines changed: 211 additions & 51 deletions

src/__tests__/components/CreateProjectModal.test.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ describe('CreateProjectModal', () => {
9999
);
100100
});
101101

102-
it('calls onClose after submitting when sendCommand returns an ID', async () => {
103-
jest.mocked(papi.commands.sendCommand).mockResolvedValue('new-project-id');
102+
it('calls onClose after submitting when sendCommand returns a project JSON', async () => {
103+
jest
104+
.mocked(papi.commands.sendCommand)
105+
.mockResolvedValue(
106+
JSON.stringify({ id: 'new-project-id', createdAt: '2026-01-01T00:00:00.000Z' }),
107+
);
104108
const onClose = jest.fn();
105109
render(<CreateProjectModal projectId={testProjectId} onClose={onClose} />);
106110

@@ -117,8 +121,14 @@ describe('CreateProjectModal', () => {
117121
expect(container.firstChild).toBeNull();
118122
});
119123

120-
it('calls onProjectCreated with the new ID and language when sendCommand returns an ID', async () => {
121-
jest.mocked(papi.commands.sendCommand).mockResolvedValue('new-project-id');
124+
it('calls onProjectCreated with the parsed project when sendCommand returns a project JSON', async () => {
125+
const persistedProject = {
126+
id: 'new-project-id',
127+
createdAt: '2026-01-01T00:00:00.000Z',
128+
sourceProjectId: testProjectId,
129+
analysisWritingSystem: 'en',
130+
};
131+
jest.mocked(papi.commands.sendCommand).mockResolvedValue(JSON.stringify(persistedProject));
122132
const onProjectCreated = jest.fn();
123133
render(
124134
<CreateProjectModal
@@ -130,7 +140,9 @@ describe('CreateProjectModal', () => {
130140

131141
await userEvent.click(screen.getByRole('button', { name: /^create$/i }));
132142

133-
expect(onProjectCreated).toHaveBeenCalledWith('new-project-id', 'en');
143+
await waitFor(() =>
144+
expect(onProjectCreated).toHaveBeenCalledWith(expect.objectContaining(persistedProject)),
145+
);
134146
});
135147

136148
it('does not call onProjectCreated or onClose when sendCommand returns undefined', async () => {

src/__tests__/interlinearizer.web-view.test.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,26 @@ jest.mock('../components/CreateProjectModal', () => ({
2626
onProjectCreated,
2727
}: {
2828
onClose: () => void;
29-
onProjectCreated?: (id: string, ws: string) => void;
29+
onProjectCreated?: (project: {
30+
id: string;
31+
createdAt: string;
32+
sourceProjectId: string;
33+
analysisWritingSystem: string;
34+
}) => void;
3035
}) => (
3136
<div>
3237
<h2>Create Interlinear Project</h2>
33-
<button type="button" onClick={() => onProjectCreated?.('new-il-id', 'en')}>
38+
<button
39+
type="button"
40+
onClick={() =>
41+
onProjectCreated?.({
42+
id: 'new-il-id',
43+
createdAt: '2026-01-01T00:00:00.000Z',
44+
sourceProjectId: 'test-project-id',
45+
analysisWritingSystem: 'en',
46+
})
47+
}
48+
>
3449
Submit
3550
</button>
3651
<button type="button" onClick={onClose}>

src/__tests__/main.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ describe('main', () => {
215215
'interlinearizer.createProject',
216216
'interlinearizer.getProjectsForSource',
217217
'interlinearizer.newProject',
218+
'interlinearizer.viewProjectInfo',
218219
'interlinearizer.updateProjectMetadata',
219220
'interlinearizer.deleteProject',
220221
]),
@@ -449,6 +450,32 @@ describe('main', () => {
449450
});
450451
});
451452

453+
describe('interlinearizer.viewProjectInfo command', () => {
454+
it('registers the interlinearizer.viewProjectInfo command', async () => {
455+
const context = createTestActivationContext();
456+
457+
await activate(context);
458+
459+
expect(__mockRegisterCommand).toHaveBeenCalledWith(
460+
'interlinearizer.viewProjectInfo',
461+
expect.any(Function),
462+
expect.any(Object),
463+
);
464+
});
465+
466+
it('resolves to undefined and triggers no side effects (handled entirely in the WebView)', async () => {
467+
const context = createTestActivationContext();
468+
await activate(context);
469+
const rawHandler = findRegisteredHandler('interlinearizer.viewProjectInfo');
470+
if (!rawHandler) throw new Error('Handler not found for interlinearizer.viewProjectInfo');
471+
472+
await expect(rawHandler()).resolves.toBeUndefined();
473+
expect(__mockOpenWebView).not.toHaveBeenCalled();
474+
expect(__mockSelectProject).not.toHaveBeenCalled();
475+
expect(__mockNotificationsSend).not.toHaveBeenCalled();
476+
});
477+
});
478+
452479
describe('interlinearizer.createProject command', () => {
453480
const mockCreateProject = jest.mocked(projectStorage.createProject);
454481
const emptyAnalysis = { segmentAnalyses: [], tokenAnalyses: [], phrases: [] };
@@ -474,7 +501,7 @@ describe('main', () => {
474501
);
475502
});
476503

477-
it('delegates to projectStorage.createProject and returns the new project ID', async () => {
504+
it('delegates to projectStorage.createProject and returns the JSON-serialized project', async () => {
478505
mockCreateProject.mockResolvedValue(stubProject);
479506
const handler = await getCreateProjectHandler();
480507

@@ -487,7 +514,7 @@ describe('main', () => {
487514
undefined,
488515
undefined,
489516
);
490-
expect(result).toBe('new-project-id');
517+
expect(result).toBe(JSON.stringify(stubProject));
491518
});
492519

493520
it('does not show a project picker dialog', async () => {
@@ -683,6 +710,21 @@ describe('main', () => {
683710

684711
expect(mockDeleteProject).toHaveBeenCalledWith(expect.anything(), 'to-delete-id');
685712
});
713+
714+
it('logs the error and sends an error notification when storage throws', async () => {
715+
mockDeleteProject.mockRejectedValue(new Error('disk full'));
716+
const handler = await getDeleteProjectHandler();
717+
718+
await handler('to-delete-id');
719+
720+
expect(__mockLogger.error).toHaveBeenCalledWith(
721+
'Interlinearizer: failed to delete project',
722+
expect.any(Error),
723+
);
724+
expect(__mockNotificationsSend).toHaveBeenCalledWith(
725+
expect.objectContaining({ severity: 'error' }),
726+
);
727+
});
686728
});
687729

688730
describe('interlinearizer.getProjectsForSource command', () => {
@@ -799,6 +841,22 @@ describe('main', () => {
799841

800842
expect(result).toBeUndefined();
801843
});
844+
845+
it('logs the error, sends an error notification, and returns undefined when storage throws', async () => {
846+
mockUpdateProjectMetadata.mockRejectedValue(new Error('disk full'));
847+
const handler = await getUpdateProjectMetadataHandler();
848+
849+
const result = await handler('proj-id', 'My Name', 'My Desc');
850+
851+
expect(result).toBeUndefined();
852+
expect(__mockLogger.error).toHaveBeenCalledWith(
853+
'Interlinearizer: failed to update project metadata',
854+
expect.any(Error),
855+
);
856+
expect(__mockNotificationsSend).toHaveBeenCalledWith(
857+
expect.objectContaining({ severity: 'error' }),
858+
);
859+
});
802860
});
803861

804862
describe('deactivate', () => {

src/components/CreateProjectModal.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import papi, { logger } from '@papi/frontend';
22
import { useLocalizedStrings } from '@papi/frontend/react';
33
import { Button } from 'platform-bible-react';
44
import { useState, useCallback, useMemo } from 'react';
5+
import {
6+
type InterlinearProjectSummary,
7+
isInterlinearProjectSummary,
8+
} from './SelectInterlinearProjectModal';
59

610
/** Localized string keys used by {@link CreateProjectModal}. */
711
const CREATE_PROJECT_MODAL_STRING_KEYS = [
@@ -24,8 +28,8 @@ const CREATE_PROJECT_MODAL_STRING_KEYS = [
2428
* @param props - Component props
2529
* @param props.projectId - Source project to create the interlinear project for
2630
* @param props.onClose - Callback invoked when the modal should be dismissed (cancel or submit)
27-
* @param props.onProjectCreated - Optional callback invoked with the new project UUID and analysis
28-
* language after successful creation, before `onClose` is called.
31+
* @param props.onProjectCreated - Optional callback invoked with the full persisted project after
32+
* successful creation, before `onClose` is called.
2933
* @returns The modal overlay with name, description, language inputs and submit/cancel buttons, or
3034
* nothing while localized strings are loading.
3135
*/
@@ -36,7 +40,7 @@ export function CreateProjectModal({
3640
}: Readonly<{
3741
projectId: string;
3842
onClose: () => void;
39-
onProjectCreated?: (interlinearProjectId: string, analysisWritingSystem: string) => void;
43+
onProjectCreated?: (project: InterlinearProjectSummary) => void;
4044
}>) {
4145
const [localizedStrings, stringsLoading] = useLocalizedStrings(
4246
useMemo(() => [...CREATE_PROJECT_MODAL_STRING_KEYS], []),
@@ -48,19 +52,23 @@ export function CreateProjectModal({
4852

4953
/**
5054
* Sends the `interlinearizer.createProject` command with the collected form values, notifies the
51-
* caller via `onProjectCreated`, then closes the modal.
55+
* caller via `onProjectCreated`, then closes the modal. Logs and shows a notification on
56+
* failure.
57+
*
58+
* @returns A promise that resolves when the command completes or the error notification is sent.
5259
*/
5360
const handleSubmit = useCallback(async () => {
5461
try {
55-
const newId = await papi.commands.sendCommand(
62+
const projectJson = await papi.commands.sendCommand(
5663
'interlinearizer.createProject',
5764
projectId,
5865
analysisLanguage,
5966
name || undefined,
6067
description || undefined,
6168
);
62-
if (!newId) return;
63-
onProjectCreated?.(newId, analysisLanguage);
69+
if (!projectJson) return;
70+
const parsed: unknown = JSON.parse(projectJson);
71+
if (isInterlinearProjectSummary(parsed)) onProjectCreated?.(parsed);
6472
onClose();
6573
} catch (e) {
6674
logger.error('Interlinearizer: failed to create project', e);

src/components/ProjectMetadataModal.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ export function ProjectMetadataModal({
8383

8484
/**
8585
* Sends the updated name, description, and analysis language to the backend, then notifies the
86-
* caller and closes the modal.
86+
* caller and closes the modal. Logs and shows a notification on failure.
87+
*
88+
* @returns A promise that resolves when the command completes or the error notification is sent.
8789
*/
8890
const handleSave = useCallback(async () => {
8991
const newName = editName || undefined;
@@ -111,7 +113,12 @@ export function ProjectMetadataModal({
111113
}
112114
}, [editName, editDescription, editLanguage, interlinearProjectId, onProjectSaved, onClose]);
113115

114-
/** Sends the delete command to the backend, then notifies the caller and closes the modal. */
116+
/**
117+
* Sends the delete command to the backend, then notifies the caller and closes the modal. Logs
118+
* and shows a notification on failure.
119+
*
120+
* @returns A promise that resolves when the command completes or the error notification is sent.
121+
*/
115122
const handleDelete = useCallback(async () => {
116123
try {
117124
await papi.commands.sendCommand('interlinearizer.deleteProject', interlinearProjectId);

src/components/SelectInterlinearProjectModal.tsx

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ export type InterlinearProjectSummary = Pick<
2121
'id' | 'createdAt' | 'sourceProjectId' | 'analysisWritingSystem' | 'name' | 'description'
2222
>;
2323

24+
/** Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON. */
25+
export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary {
26+
return (
27+
!!p &&
28+
typeof p === 'object' &&
29+
'id' in p &&
30+
typeof p.id === 'string' &&
31+
'createdAt' in p &&
32+
typeof p.createdAt === 'string' &&
33+
'sourceProjectId' in p &&
34+
typeof p.sourceProjectId === 'string' &&
35+
'analysisWritingSystem' in p &&
36+
typeof p.analysisWritingSystem === 'string'
37+
);
38+
}
39+
2440
/**
2541
* Modal that lists all existing interlinearizer projects for a source project and lets the user
2642
* select one, view its details (via the info icon), or request that a new one be created. Fires
@@ -54,18 +70,21 @@ export function SelectInterlinearProjectModal({
5470

5571
const [projects, setProjects] = useState<InterlinearProjectSummary[]>([]);
5672

57-
/** Fetches interlinear projects for `sourceProjectId` and updates the `projects` state. */
73+
/**
74+
* Fetches interlinear projects for `sourceProjectId` and updates the `projects` state. Logs and
75+
* shows a notification on failure.
76+
*
77+
* @returns A promise that resolves when the project list is loaded or the error notification is
78+
* sent.
79+
*/
5880
const loadProjects = useCallback(async () => {
5981
try {
6082
const json = await papi.commands.sendCommand(
6183
'interlinearizer.getProjectsForSource',
6284
sourceProjectId,
6385
);
6486
const parsed: unknown = JSON.parse(json);
65-
if (Array.isArray(parsed))
66-
setProjects(
67-
parsed.filter((p): p is InterlinearProjectSummary => !!p && typeof p === 'object'),
68-
);
87+
if (Array.isArray(parsed)) setProjects(parsed.filter(isInterlinearProjectSummary));
6988
} catch (e) {
7089
logger.error('Interlinearizer: failed to load projects for source', e);
7190
await papi.notifications
@@ -82,6 +101,7 @@ export function SelectInterlinearProjectModal({
82101
* Delegates to `onSelect` when the user clicks a project row.
83102
*
84103
* @param project - The project the user selected.
104+
* @returns Void
85105
*/
86106
const handleSelect = useCallback(
87107
(project: InterlinearProjectSummary) => {
@@ -94,6 +114,7 @@ export function SelectInterlinearProjectModal({
94114
* Delegates to `onViewInfo` when the user clicks a project's info icon.
95115
*
96116
* @param project - The project whose info icon was clicked.
117+
* @returns Void
97118
*/
98119
const handleViewInfo = useCallback(
99120
(project: InterlinearProjectSummary) => {

src/interlinearizer.web-view.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -305,19 +305,13 @@ globalThis.webViewComponent = function InterlinearizerWebView({
305305
/**
306306
* Records a newly created interlinear project as the active project and closes the create modal.
307307
*
308-
* @param interlinearProjectId - UUID of the newly created interlinear project.
309-
* @param analysisWritingSystem - BCP47 writing-system tag chosen during project creation.
308+
* @param project - The full persisted project returned by the create command.
310309
*/
311310
const handleProjectCreated = useCallback(
312-
(interlinearProjectId: string, analysisWritingSystem: string) => {
313-
setActiveProject({
314-
id: interlinearProjectId,
315-
createdAt: new Date().toISOString(),
316-
sourceProjectId: projectId ?? /* c8 ignore next -- projectId is never undefined here */ '',
317-
analysisWritingSystem,
318-
});
311+
(project: InterlinearProjectSummary) => {
312+
setActiveProject(project);
319313
},
320-
[projectId, setActiveProject],
314+
[setActiveProject],
321315
);
322316

323317
/**

0 commit comments

Comments
 (0)