From 47c6a1adade2e5901e63644b6c4c961b7259c1d7 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 15 Jun 2026 16:51:59 -0600 Subject: [PATCH 1/7] Add auto-saving draft with Save / Save As and Wipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing the interlinearizer no longer writes to the active project on every keystroke. Each source project now has an always-present draft (draft:{sourceProjectId}) that auto-saves every edit to papi storage, decoupled from the user's saved projects and never shown in the picker, so work is never lost. Persistence is now explicit: - Save writes the draft to the active project - Save As writes the draft to a new project, or overwrites an existing one - New starts an empty draft (a project is created only on Save As) - Open loads a project into the draft as a working copy - Wipe clears the draft — the current book or the whole thing Switching projects (New / Open) while the draft is dirty prompts to discard. The tab title shows a "●" marker while the draft has unsaved changes, toggled via updateWebViewDefinition (Platform.Bible exposes no native unsaved-tab indicator). Implementation: - DraftProject type + isDraftProject guard; getDraft/saveDraft storage with a per-source serialization queue; getDraft/saveDraft backend commands. - useDraftProject hook owns the draft (autosave, dirty tracking, and a draftVersion that remounts the editor on New/Open/Wipe). - New SaveAsProjectModal, WipeConfirm, and DiscardDraftConfirm; the New modal is repurposed to configure a draft rather than create a project. - removeBookFromAnalysis util backs the per-book wipe. Alignment links are intentionally not carried in the draft yet (no link-editing feature exists); Save preserves a target project's existing links. Deferred UX decisions are recorded in user-questions.md. 988 tests, 100% coverage, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- __mocks__/platform-bible-react.tsx | 72 ++ contributions/localizedStrings.json | 30 +- contributions/menus.json | 36 + .../components/InterlinearizerLoader.test.tsx | 922 ++++++++++-------- .../modals/CreateProjectModal.test.tsx | 266 +---- .../modals/DiscardDraftConfirm.test.tsx | 51 + .../components/modals/ProjectModals.test.tsx | 784 +++++++++------ .../modals/SaveAsProjectModal.test.tsx | 203 ++++ .../components/modals/WipeConfirm.test.tsx | 63 ++ src/__tests__/hooks/useDraftProject.test.ts | 381 ++++++++ src/__tests__/main.test.ts | 124 ++- src/__tests__/services/projectStorage.test.ts | 96 +- src/__tests__/types/type-guards.test.ts | 176 ++++ src/__tests__/utils/analysis-book.test.ts | 194 ++++ src/components/InterlinearizerLoader.tsx | 232 +++-- src/components/modals/CreateProjectModal.tsx | 105 +- src/components/modals/DiscardDraftConfirm.tsx | 58 ++ src/components/modals/ProjectModals.tsx | 323 ++++-- src/components/modals/SaveAsProjectModal.tsx | 218 +++++ src/components/modals/WipeConfirm.tsx | 70 ++ src/hooks/useDraftProject.ts | 272 ++++++ src/interlinearizer.web-view.tsx | 4 + src/main.ts | 159 ++- src/services/projectStorage.ts | 105 +- src/types/empty-factories.ts | 19 +- src/types/interlinearizer.d.ts | 108 ++ src/types/type-guards.ts | 30 +- src/utils/analysis-book.ts | 51 + user-questions.md | 31 + 29 files changed, 3960 insertions(+), 1223 deletions(-) create mode 100644 src/__tests__/components/modals/DiscardDraftConfirm.test.tsx create mode 100644 src/__tests__/components/modals/SaveAsProjectModal.test.tsx create mode 100644 src/__tests__/components/modals/WipeConfirm.test.tsx create mode 100644 src/__tests__/hooks/useDraftProject.test.ts create mode 100644 src/__tests__/types/type-guards.test.ts create mode 100644 src/__tests__/utils/analysis-book.test.ts create mode 100644 src/components/modals/DiscardDraftConfirm.tsx create mode 100644 src/components/modals/SaveAsProjectModal.tsx create mode 100644 src/components/modals/WipeConfirm.tsx create mode 100644 src/hooks/useDraftProject.ts create mode 100644 src/utils/analysis-book.ts diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 1007fc6f..cc9bf7a3 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -65,6 +65,42 @@ export const MOCK_VIEW_PROJECT_INFO_MENU_ITEM: MenuItemContainingCommand = { localizeNotes: '', }; +/** Sentinel menu item passed by the mock toolbar when the save button is clicked. */ +export const MOCK_SAVE_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_save%', + command: 'interlinearizer.save', + group: 'interlinearizer.file.actions', + order: 1, + localizeNotes: '', +}; + +/** Sentinel menu item passed by the mock toolbar when the save-as button is clicked. */ +export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_saveAs%', + command: 'interlinearizer.openSaveAsModal', + group: 'interlinearizer.file.actions', + order: 2, + localizeNotes: '', +}; + +/** Sentinel menu item passed by the mock toolbar when the wipe-book button is clicked. */ +export const MOCK_WIPE_BOOK_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_wipeBook%', + command: 'interlinearizer.wipeBook', + group: 'interlinearizer.draft.actions', + order: 1, + localizeNotes: '', +}; + +/** Sentinel menu item passed by the mock toolbar when the wipe-draft button is clicked. */ +export const MOCK_WIPE_DRAFT_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_wipeDraft%', + command: 'interlinearizer.wipeDraft', + group: 'interlinearizer.draft.actions', + order: 2, + localizeNotes: '', +}; + /** * Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests @@ -127,6 +163,42 @@ export function TabToolbar({ View project info )} + {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} {onSelectViewInfoMenuItem && ( + + + ), +})); + jest.mock('../../components/ContinuousView', () => ({ __esModule: true, default: () =>
, @@ -155,12 +186,14 @@ jest.mock('../../components/modals/ProjectModals', () => ({ /** * Minimal ProjectModals stand-in that drives modal state and active-project state through the * same `useWebViewState` hook the real component uses, so tests can assert on state transitions - * without mounting the full modal tree. + * without mounting the full modal tree. Accepts (and ignores) the draft-related props the loader + * now passes (`dirty`, `getDraftSnapshot`, `loadFromProject`, `markSynced`, `resetDraft`). * * @param modal - Current modal identifier controlling which stub panel is rendered. * @param setModal - Callback to transition to a different modal state. * @param activeProject - The currently active interlinear project, or undefined when none is * selected. + * @param defaultAnalysisLanguage - BCP 47 tag forwarded as the create modal's default language. * @param useWebViewState - Injected hook used to read and write persisted WebView state; must * support the `'activeProject'` key. * @returns A JSX element containing the stub modal panels keyed by `modal`. @@ -176,6 +209,11 @@ jest.mock('../../components/modals/ProjectModals', () => ({ setModal: (m: string) => void; activeProject: MockProject | undefined; defaultAnalysisLanguage?: string; + dirty: boolean; + getDraftSnapshot: () => DraftProject | undefined; + loadFromProject: (project: unknown) => void; + markSynced: () => void; + resetDraft: (config: unknown) => void; useWebViewState: ( key: string, def: MockProject | undefined, @@ -237,6 +275,17 @@ jest.mock('../../components/modals/ProjectModals', () => ({
)} + {modal === 'saveAs' && ( +
+ +
+ )} {modal === 'metadata' && activeProject && (
+
+ ), +})); + +jest.mock('../../../components/modals/SaveAsProjectModal', () => ({ + __esModule: true, + SaveAsProjectModal: ({ + defaultName, + onSaveNew, + onOverwrite, + onClose, + }: { + defaultName?: string; + onSaveNew: (name?: string, description?: string) => void; + onOverwrite: (p: InterlinearProjectSummary) => void; + onClose: () => void; + }) => ( +
+ + + +
+ ), +})); + +jest.mock('../../../components/modals/DiscardDraftConfirm', () => ({ + __esModule: true, + DiscardDraftConfirm: ({ + onConfirm, + onCancel, + }: { + onConfirm: () => void; + onCancel: () => void; + }) => ( +
+ +
), @@ -148,479 +229,534 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({ ), })); +/** Props accepted by {@link buildProps}, mirroring ProjectModals' own props. */ +type ModalsOverrides = Partial<{ + activeProject: InterlinearProjectSummary | undefined; + defaultAnalysisLanguage: string; + dirty: boolean; + getDraftSnapshot: () => DraftProject | undefined; + loadFromProject: jest.Mock; + markSynced: jest.Mock; + modal: ModalState; + resetDraft: jest.Mock; + setModal: jest.Mock; + useWebViewState: ReturnType; +}>; + /** - * Renders ProjectModals with sensible defaults and returns helpers for assertions. + * Builds a complete ProjectModals prop set, filling required props with sensible defaults so each + * test only specifies what it cares about. * - * @param overrides - Partial props to merge over the defaults. Supports `activeProject`, `modal` - * (defaults to `'none'`), `setModal` (defaults to a fresh `jest.fn()`), and `useWebViewState` - * (defaults to a fresh {@link makeWebViewState} instance). - * @returns An object containing `setModal` — either the caller-supplied function or the internally - * created `jest.fn()` — so callers can assert on it after interactions. + * @param overrides - Props to override. + * @returns The full prop object to spread onto ProjectModals. */ -function renderModals( - overrides: Partial<{ - activeProject: InterlinearProjectSummary | undefined; - defaultAnalysisLanguage: string; - modal: ModalState; - setModal: (m: ModalState) => void; - useWebViewState: ReturnType; - }> = {}, -) { - const setModal = overrides.setModal ?? jest.fn(); - const useWebViewState = overrides.useWebViewState ?? makeWebViewState(); - render( - , - ); - return { setModal }; +function buildProps(overrides: ModalsOverrides = {}) { + return { + activeProject: overrides.activeProject, + defaultAnalysisLanguage: overrides.defaultAnalysisLanguage, + dirty: overrides.dirty ?? false, + getDraftSnapshot: overrides.getDraftSnapshot ?? (() => MOCK_DRAFT), + loadFromProject: overrides.loadFromProject ?? jest.fn(), + markSynced: overrides.markSynced ?? jest.fn(), + modal: overrides.modal ?? 'none', + projectId: 'source-proj', + resetDraft: overrides.resetDraft ?? jest.fn(), + setModal: overrides.setModal ?? jest.fn(), + useWebViewState: overrides.useWebViewState ?? makeWebViewState(), + }; } describe('ProjectModals', () => { + beforeEach(() => { + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + jest.mocked(papi.commands.sendCommand).mockResolvedValue(undefined); + }); + describe('modal visibility', () => { it('renders nothing when modal is none', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.querySelector('[data-testid]')).toBeNull(); }); it('renders the select modal when modal is select', () => { - renderModals({ modal: 'select' }); + render(); expect(screen.getByTestId('select-modal')).toBeInTheDocument(); }); it('renders the create modal when modal is create', () => { - renderModals({ modal: 'create' }); + render(); expect(screen.getByTestId('create-modal')).toBeInTheDocument(); }); + it('renders the save-as modal (prefilled from the draft) when modal is saveAs', () => { + render(); + const modal = screen.getByTestId('saveas-modal'); + expect(modal).toBeInTheDocument(); + expect(modal).toHaveAttribute('data-default-name', 'Suggested Name'); + }); + it('renders the metadata modal when modal is metadata and activeProject is set', () => { - renderModals({ modal: 'metadata', activeProject: MOCK_PROJECT }); + render(); expect(screen.getByTestId('metadata-modal')).toBeInTheDocument(); }); it('renders nothing when modal is metadata but no active or metadata project', () => { const { container } = render( - , + , ); expect(container.querySelector('[data-testid="metadata-modal"]')).toBeNull(); }); + + it('forwards defaultAnalysisLanguage to the create modal', () => { + render(); + expect(screen.getByTestId('create-modal')).toHaveAttribute('data-default-lang', 'es'); + }); }); - describe('select modal', () => { - it('calls setModal with none when select modal close is clicked', async () => { - const { setModal } = renderModals({ modal: 'select' }); - await userEvent.click(screen.getByTestId('select-close')); + describe('open (select) flow', () => { + it('loads the chosen project into the draft and closes when not dirty', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)); + const loadFromProject = jest.fn(); + const setModal = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.getProject', + 'proj-1', + ), + ); + expect(loadFromProject).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + analysis: emptyAnalysis(), + }); expect(setModal).toHaveBeenCalledWith('none'); }); - it('sets the active project and calls setModal with none when a project is selected', async () => { - const state = makeWebViewState(); - const { setModal } = renderModals({ modal: 'select', useWebViewState: state }); + it('carries the target project id into the draft for a bilateral project', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT_WITH_TARGET)); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => + expect(loadFromProject).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + targetProjectId: 'target-proj', + analysis: emptyAnalysis(), + }), + ); + }); + + it('notifies and does not load when getProject returns a non-project shape', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify({ not: 'a project' })); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + + it('notifies and does not load when getProject returns a project without valid analysis', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify({ ...MOCK_PROJECT, analysis: { bad: true } })); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + + it('notifies when getProject returns nothing (project missing)', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(undefined); + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + + it('logs and notifies when getProject rejects', async () => { + jest.mocked(papi.commands.sendCommand).mockRejectedValueOnce(new Error('network')); + const loadFromProject = jest.fn(); + render(); + await userEvent.click(screen.getByTestId('select-select')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + + it('calls setModal with none when the select modal is closed', async () => { + const setModal = jest.fn(); + render(); + await userEvent.click(screen.getByTestId('select-close')); expect(setModal).toHaveBeenCalledWith('none'); }); it('calls setModal with create when create new is clicked', async () => { - const { setModal } = renderModals({ modal: 'select' }); + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('select-create-new')); expect(setModal).toHaveBeenCalledWith('create'); }); - it('opens metadata modal for the chosen project when view info is clicked', async () => { - const { setModal } = renderModals({ modal: 'select' }); + it('opens the metadata modal for the chosen project when view info is clicked', async () => { + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('select-view-info')); expect(setModal).toHaveBeenCalledWith('metadata'); }); }); - describe('create modal', () => { - it('forwards defaultAnalysisLanguage to CreateProjectModal', () => { - renderModals({ modal: 'create', defaultAnalysisLanguage: 'es' }); - expect(screen.getByTestId('create-modal')).toHaveAttribute('data-default-lang', 'es'); + describe('new (create) flow', () => { + it('resets the draft and closes when not dirty', async () => { + const resetDraft = jest.fn(); + const setModal = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('create-submit')); + + expect(resetDraft).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + name: 'New', + description: 'Desc', + }); + expect(setModal).toHaveBeenCalledWith('none'); }); - it('calls setModal with none when create modal is closed without a select source', async () => { - const { setModal } = renderModals({ modal: 'create' }); + it('calls setModal with none when the create modal closes without a select source', async () => { + const setModal = jest.fn(); + render(); await userEvent.click(screen.getByTestId('create-close')); expect(setModal).toHaveBeenCalledWith('none'); }); - it('calls setModal with select on close when opened from the select modal', async () => { + it('returns to the select modal when the create modal was opened from it', async () => { const setModal = jest.fn(); - const state = makeWebViewState(); - + const useWebViewState = makeWebViewState(); const { rerender } = render( - , + , ); await userEvent.click(screen.getByTestId('select-create-new')); - rerender( - , - ); + rerender(); setModal.mockClear(); await userEvent.click(screen.getByTestId('create-close')); expect(setModal).toHaveBeenCalledWith('select'); }); + }); - it('resets createSourceIsSelect after closing from select source', async () => { - const setModal = jest.fn(); - const state = makeWebViewState(); + describe('discard confirmation (dirty draft)', () => { + it('confirms before opening a project when the draft is dirty', async () => { + jest + .mocked(papi.commands.sendCommand) + .mockResolvedValueOnce(JSON.stringify(MOCK_FULL_PROJECT)); + const loadFromProject = jest.fn(); + render(); - const { rerender } = render( - , - ); - await userEvent.click(screen.getByTestId('select-create-new')); - rerender( - , + await userEvent.click(screen.getByTestId('select-select')); + // The discard confirm replaces the select modal; the project is not opened yet. + expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); + expect(screen.queryByTestId('select-modal')).toBeNull(); + expect(loadFromProject).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByTestId('discard-confirm')); + await waitFor(() => expect(loadFromProject).toHaveBeenCalled()); + }); + + it('cancels the discard confirm and returns to the select modal', async () => { + const loadFromProject = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('select-select')); + await userEvent.click(screen.getByTestId('discard-cancel')); + + expect(screen.queryByTestId('discard-modal')).toBeNull(); + expect(screen.getByTestId('select-modal')).toBeInTheDocument(); + expect(loadFromProject).not.toHaveBeenCalled(); + }); + + it('confirms before starting a new draft when the draft is dirty', async () => { + const resetDraft = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('create-submit')); + expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); + expect(resetDraft).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByTestId('discard-confirm')); + expect(resetDraft).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + name: 'New', + description: 'Desc', + }); + }); + }); + + describe('save as flow', () => { + it('creates a new project, writes the analysis, marks synced, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const markSynced = jest.fn(); + const setModal = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.createProject', + 'source-proj', + ['en'], + undefined, + 'NewName', + 'NewDesc', + ), ); - await userEvent.click(screen.getByTestId('create-close')); - // After close, setModal returns to 'select'. If we re-render with create again, - // closing this time should go to 'none' (select source was reset). - setModal.mockClear(); - rerender( - , + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), ); - await userEvent.click(screen.getByTestId('create-close')); + expect(markSynced).toHaveBeenCalledTimes(1); expect(setModal).toHaveBeenCalledWith('none'); }); - it('sets the active project and calls setModal with none when a project is created', async () => { - const state = makeWebViewState(); - const { setModal } = renderModals({ modal: 'create', useWebViewState: state }); - await userEvent.click(screen.getByTestId('create-created')); - expect(setModal).toHaveBeenCalledWith('none'); + it('notifies and does not mark synced when create returns a non-project shape', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); + const markSynced = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => expect(papi.notifications.send).toHaveBeenCalledTimes(1)); + expect(markSynced).not.toHaveBeenCalled(); }); - }); - describe('metadata modal', () => { - it('calls setModal with none when metadata modal closes without a select source', async () => { - const { setModal } = renderModals({ modal: 'metadata', activeProject: MOCK_PROJECT }); - await userEvent.click(screen.getByTestId('metadata-close')); - expect(setModal).toHaveBeenCalledWith('none'); + it('does not crash when saving a new project rejects', async () => { + jest.mocked(papi.commands.sendCommand).mockRejectedValueOnce(new Error('boom')); + const markSynced = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('saveas-new')); + + await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalled()); + expect(markSynced).not.toHaveBeenCalled(); }); - it('calls setModal with select on close when metadataSourceIsSelect is true', async () => { + it('overwrites an existing project, marks synced, and closes', async () => { + const markSynced = jest.fn(); const setModal = jest.fn(); - const state = makeWebViewState(); + render(); - const { rerender } = render( - , - ); - await userEvent.click(screen.getByTestId('select-view-info')); - rerender( - , + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + await waitFor(() => + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.saveAnalysis', + 'proj-1', + JSON.stringify(emptyAnalysis()), + ), ); - setModal.mockClear(); - await userEvent.click(screen.getByTestId('metadata-close')); - expect(setModal).toHaveBeenCalledWith('select'); + expect(markSynced).toHaveBeenCalledTimes(1); + expect(setModal).toHaveBeenCalledWith('none'); + }); + + it('does not crash when overwriting rejects', async () => { + jest.mocked(papi.commands.sendCommand).mockRejectedValueOnce(new Error('boom')); + const markSynced = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('saveas-overwrite')); + + await waitFor(() => expect(papi.commands.sendCommand).toHaveBeenCalled()); + expect(markSynced).not.toHaveBeenCalled(); }); - it('resets metadataSourceIsSelect after closing from select source', async () => { + it('calls setModal with none when the save-as modal is closed', async () => { const setModal = jest.fn(); - const state = makeWebViewState(); + render(); + await userEvent.click(screen.getByTestId('saveas-close')); + expect(setModal).toHaveBeenCalledWith('none'); + }); + }); - const { rerender } = render( - , - ); - await userEvent.click(screen.getByTestId('select-view-info')); - rerender( - , - ); - await userEvent.click(screen.getByTestId('metadata-close')); - // After close, setModal returns to 'select'. If we re-render with metadata again, - // closing this time should go to 'none' (select source was reset). - setModal.mockClear(); - rerender( + describe('metadata flow', () => { + it('calls setModal with none when the metadata modal closes without a select source', async () => { + const setModal = jest.fn(); + render( , ); await userEvent.click(screen.getByTestId('metadata-close')); expect(setModal).toHaveBeenCalledWith('none'); }); - it('updates activeProject when the saved project matches the active project', async () => { - const state = makeWebViewState(); - // Set active project first via select modal + it('returns to the select modal on close when opened via view info', async () => { const setModal = jest.fn(); + const useWebViewState = makeWebViewState(); const { rerender } = render( - , + , ); - await userEvent.click(screen.getByTestId('select-select')); + await userEvent.click(screen.getByTestId('select-view-info')); + rerender(); + setModal.mockClear(); + await userEvent.click(screen.getByTestId('metadata-close')); + expect(setModal).toHaveBeenCalledWith('select'); + }); - // Re-render with metadata open and activeProject as MOCK_PROJECT - rerender( + it('updates the active project when the saved project is the active one', async () => { + const setModal = jest.fn(); + const useWebViewState = makeWebViewState(); + render( , ); await userEvent.click(screen.getByTestId('metadata-save')); - // Saving should call setModal with none and update the active project (no error) expect(setModal).toHaveBeenCalledWith('none'); }); - it('does not update activeProject when the saved project does not match the active project', async () => { - const state = makeWebViewState(); + it('does not update the active project when the saved project is a different one', async () => { const setModal = jest.fn(); - - // Open select, choose project 1 as active, then view info for project 2 + const useWebViewState = makeWebViewState(); const { rerender } = render( , ); - await userEvent.click(screen.getByTestId('select-select')); await userEvent.click(screen.getByTestId('select-view-info-2')); - rerender( , ); - // Saving project 2 — onProjectSaved is called but activeProject (proj-1) ≠ metadataProject (proj-2) + setModal.mockClear(); await userEvent.click(screen.getByTestId('metadata-save')); - expect(setModal).toHaveBeenCalledWith('none'); + // Opened via the select modal's info icon, so closing returns to the select modal. + expect(setModal).toHaveBeenCalledWith('select'); }); - it('does not update activeProject when there is no active project on save', async () => { + it('does not update the active project when there is none on save', async () => { const setModal = jest.fn(); - const state = makeWebViewState(); - + const useWebViewState = makeWebViewState(); const { rerender } = render( - , + , ); await userEvent.click(screen.getByTestId('select-view-info')); - rerender( - , - ); + rerender(); setModal.mockClear(); await userEvent.click(screen.getByTestId('metadata-save')); - // Metadata was opened from select, so close returns to select (not none) expect(setModal).toHaveBeenCalledWith('select'); }); - it('resets the active project when the deleted project matches the active project', async () => { - const state = makeWebViewState(); + it('resets the active project when the deleted project is the active one', async () => { const setModal = jest.fn(); - - // Set MOCK_PROJECT as active via select, then open metadata - const { rerender } = render( + const useWebViewState = makeWebViewState(); + render( , - ); - await userEvent.click(screen.getByTestId('select-select')); - - rerender( - , ); await userEvent.click(screen.getByTestId('metadata-delete')); expect(setModal).toHaveBeenCalledWith('none'); }); - it('does not reset the active project when the deleted project does not match', async () => { - const state = makeWebViewState(); + it('does not reset the active project when a different project is deleted', async () => { const setModal = jest.fn(); - + const useWebViewState = makeWebViewState(); const { rerender } = render( , ); await userEvent.click(screen.getByTestId('select-view-info-2')); - rerender( , ); setModal.mockClear(); - // Delete proj-2, but active is proj-1 — should not reset active project await userEvent.click(screen.getByTestId('metadata-delete-2')); - // Opened from select, so close returns to select expect(setModal).toHaveBeenCalledWith('select'); }); - it('does not reset the active project when there is no active project on delete', async () => { - const state = makeWebViewState(); + it('does not reset the active project when there is none on delete', async () => { const setModal = jest.fn(); - + const useWebViewState = makeWebViewState(); const { rerender } = render( - , + , ); await userEvent.click(screen.getByTestId('select-view-info')); - - rerender( - , - ); + rerender(); setModal.mockClear(); await userEvent.click(screen.getByTestId('metadata-delete')); - // Opened from select, so close returns to select expect(setModal).toHaveBeenCalledWith('select'); }); - it('uses metadataProject over activeProject when both are set', async () => { + it('prefers the metadata project over the active project when both are set', async () => { const setModal = jest.fn(); - const state = makeWebViewState(); - - // Open select, view info for proj-2 (sets metadataProject=proj-2) + const useWebViewState = makeWebViewState(); const { rerender } = render( - , + , ); await userEvent.click(screen.getByTestId('select-view-info-2')); - rerender( , ); - // Metadata modal is visible — it uses metadataProject (proj-2), not activeProject (proj-1) expect(screen.getByTestId('metadata-modal')).toBeInTheDocument(); }); }); diff --git a/src/__tests__/components/modals/SaveAsProjectModal.test.tsx b/src/__tests__/components/modals/SaveAsProjectModal.test.tsx new file mode 100644 index 00000000..95424548 --- /dev/null +++ b/src/__tests__/components/modals/SaveAsProjectModal.test.tsx @@ -0,0 +1,203 @@ +/** @file Unit tests for SaveAsProjectModal. */ +/// +/// + +import { render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import papi, { logger } from '@papi/frontend'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { SaveAsProjectModal } from '../../../components/modals/SaveAsProjectModal'; +import type { InterlinearProjectSummary } from '../../../types/interlinear-project-summary'; + +const mockSendCommand = jest.mocked(papi.commands.sendCommand); + +const LOCALIZED: Record = { + '%interlinearizer_modal_saveAs_title%': 'Save As', + '%interlinearizer_modal_saveAs_new_section%': 'Save as new project', + '%interlinearizer_modal_create_name_label%': 'Name', + '%interlinearizer_modal_create_name_placeholder%': 'e.g. Greek NT glossing', + '%interlinearizer_modal_create_description_label%': 'Description', + '%interlinearizer_modal_create_description_placeholder%': 'e.g. Token-level English glosses', + '%interlinearizer_modal_saveAs_save_new%': 'Save as new', + '%interlinearizer_modal_saveAs_existing_section%': 'Overwrite an existing project', + '%interlinearizer_modal_saveAs_none%': 'No existing projects for this source.', + '%interlinearizer_modal_saveAs_overwrite%': 'Overwrite', + '%interlinearizer_modal_saveAs_overwrite_confirm_body%': 'Overwrite this project with the draft?', + '%interlinearizer_modal_saveAs_overwrite_confirm_ok%': 'Overwrite', + '%interlinearizer_modal_saveAs_overwrite_confirm_cancel%': 'Keep project', + '%interlinearizer_modal_saveAs_cancel%': 'Cancel', + '%interlinearizer_modal_select_name_unnamed%': 'Unnamed', +}; + +const STUB_PROJECT: InterlinearProjectSummary = { + id: 'proj-uuid', + createdAt: '2026-01-15T10:30:00.000Z', + sourceProjectId: 'src-proj', + analysisLanguages: ['en'], +}; + +const STUB_PROJECT_2: InterlinearProjectSummary = { + id: 'proj-uuid-2', + createdAt: '2026-02-01T08:00:00.000Z', + sourceProjectId: 'src-proj', + analysisLanguages: ['fr'], + name: 'French glosses', +}; + +const defaultProps = { + sourceProjectId: 'src-proj', + onSaveNew: jest.fn(), + onOverwrite: jest.fn(), + onClose: jest.fn(), +}; + +describe('SaveAsProjectModal', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + mockSendCommand.mockResolvedValue('[]'); + jest.mocked(papi.notifications.send).mockResolvedValue('mock-notification-id'); + }); + + it('loads existing projects for the source on mount', async () => { + render(); + + await waitFor(() => + expect(mockSendCommand).toHaveBeenCalledWith( + 'interlinearizer.getProjectsForSource', + 'src-proj', + ), + ); + }); + + it('prefills the name and description fields from the defaults', async () => { + render( + , + ); + + expect(screen.getByLabelText('Name')).toHaveValue('Draft name'); + expect(screen.getByLabelText('Description')).toHaveValue('Draft description'); + }); + + it('calls onSaveNew with the trimmed name and description', async () => { + const onSaveNew = jest.fn(); + render(); + + await userEvent.type(screen.getByLabelText('Name'), ' My Project '); + await userEvent.type(screen.getByLabelText('Description'), ' My Description '); + await userEvent.click(screen.getByTestId('save-as-new')); + + expect(onSaveNew).toHaveBeenCalledWith('My Project', 'My Description'); + }); + + it('calls onSaveNew with undefined when the name and description fields are blank', async () => { + const onSaveNew = jest.fn(); + render(); + + await userEvent.type(screen.getByLabelText('Name'), ' '); + await userEvent.click(screen.getByTestId('save-as-new')); + + expect(onSaveNew).toHaveBeenCalledWith(undefined, undefined); + }); + + it('shows the empty-list message when getProjectsForSource returns no projects', async () => { + mockSendCommand.mockResolvedValue('[]'); + render(); + + await waitFor(() => + expect(screen.getByText('No existing projects for this source.')).toBeInTheDocument(), + ); + }); + + it('shows the inline overwrite confirm and calls onOverwrite with the chosen project', async () => { + const onOverwrite = jest.fn(); + mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, STUB_PROJECT_2])); + render(); + + await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument()); + + // Each row has its own Overwrite button; click the one in the French glosses row. + const frenchRow = screen.getByText('French glosses').closest('li'); + if (!frenchRow) throw new Error('expected the French glosses row to be present'); + const { getByRole } = within(frenchRow); + await userEvent.click(getByRole('button', { name: 'Overwrite' })); + + expect(screen.getByText('Overwrite this project with the draft?')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('save-as-overwrite-confirm')); + + expect(onOverwrite).toHaveBeenCalledWith(STUB_PROJECT_2); + }); + + it('hides the inline overwrite confirm when its Cancel button is clicked', async () => { + mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT])); + render(); + + await waitFor(() => expect(screen.getByText('Unnamed')).toBeInTheDocument()); + + const row = screen.getByText('Unnamed').closest('li'); + if (!row) throw new Error('expected the project row to be present'); + await userEvent.click(within(row).getByRole('button', { name: 'Overwrite' })); + expect(screen.getByText('Overwrite this project with the draft?')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Keep project' })); + + expect(screen.queryByText('Overwrite this project with the draft?')).not.toBeInTheDocument(); + }); + + it('logs and notifies when loading the project list rejects', async () => { + const loadError = new Error('network error'); + mockSendCommand.mockRejectedValue(loadError); + render(); + + await waitFor(() => + expect(logger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to load projects for Save As', + loadError, + ), + ); + expect(papi.notifications.send).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ); + }); + + it('calls onClose when the Cancel button is clicked', async () => { + const onClose = jest.fn(); + render(); + + // The Cancel button is disabled until the initial load resolves. + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await waitFor(() => expect(cancelButton).not.toBeDisabled()); + + await userEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('ignores a project-list response that arrives after a newer load has started', async () => { + let resolveFirst: (v: string) => void = () => {}; + mockSendCommand + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(JSON.stringify([STUB_PROJECT_2])); + + const { rerender } = render(); + + // Start a second load by changing sourceProjectId before the first resolves. + rerender(); + await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument()); + + // Now deliver the stale first response — it must not replace the current list. + resolveFirst(JSON.stringify([STUB_PROJECT])); + + await waitFor(() => expect(screen.queryByText('Unnamed')).not.toBeInTheDocument()); + expect(screen.getByText('French glosses')).toBeInTheDocument(); + }); +}); diff --git a/src/__tests__/components/modals/WipeConfirm.test.tsx b/src/__tests__/components/modals/WipeConfirm.test.tsx new file mode 100644 index 00000000..bdf26a9b --- /dev/null +++ b/src/__tests__/components/modals/WipeConfirm.test.tsx @@ -0,0 +1,63 @@ +/** @file Unit tests for WipeConfirm. */ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { WipeConfirm } from '../../../components/modals/WipeConfirm'; + +const LOCALIZED: Record = { + '%interlinearizer_confirm_wipe_book_title%': 'Wipe this book?', + '%interlinearizer_confirm_wipe_book_body%': 'This removes the analysis from the current book.', + '%interlinearizer_confirm_wipe_draft_title%': 'Wipe the entire draft?', + '%interlinearizer_confirm_wipe_draft_body%': 'This removes the analysis from the whole draft.', + '%interlinearizer_confirm_wipe_ok%': 'Wipe', + '%interlinearizer_confirm_wipe_cancel%': 'Cancel', +}; + +const defaultProps = { + scope: 'book' as const, + onConfirm: jest.fn(), + onCancel: jest.fn(), +}; + +describe('WipeConfirm', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + }); + + it('renders the book title and body when scope is "book"', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Wipe this book?' })).toBeInTheDocument(); + expect( + screen.getByText('This removes the analysis from the current book.'), + ).toBeInTheDocument(); + }); + + it('renders the draft title and body when scope is "all"', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Wipe the entire draft?' })).toBeInTheDocument(); + expect(screen.getByText('This removes the analysis from the whole draft.')).toBeInTheDocument(); + }); + + it('calls onConfirm when the wipe-confirm button is clicked', async () => { + const onConfirm = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('wipe-confirm')); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when the Cancel button is clicked', async () => { + const onCancel = jest.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts new file mode 100644 index 00000000..4c111875 --- /dev/null +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -0,0 +1,381 @@ +/** @file Unit tests for useDraftProject hook. */ +/// + +import papi, { logger } from '@papi/frontend'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { DraftProject, TextAnalysis } from 'interlinearizer'; +import useDraftProject from '../../hooks/useDraftProject'; +import { emptyAnalysis } from '../../types/empty-factories'; + +const SOURCE_PROJECT_ID = 'source-project-1'; +const PLATFORM_LANGUAGE = 'en'; + +/** Handle to the mocked PAPI sendCommand so tests can assert on / override its calls. */ +const mockSendCommand = jest.mocked(papi.commands.sendCommand); + +/** + * Builds a `DraftProject` for seeding the `getDraft` mock, overriding any fields needed by a test. + * + * @param overrides - Partial fields to merge over the baseline draft. + * @returns A fresh `DraftProject` with the overrides applied. + */ +function makeDraft(overrides: Partial = {}): DraftProject { + return { + sourceProjectId: SOURCE_PROJECT_ID, + analysisLanguages: ['fr'], + analysis: emptyAnalysis(), + dirty: false, + ...overrides, + }; +} + +/** + * Builds a `TextAnalysis` carrying a single token analysis so tests can prove a specific analysis + * object round-trips through the draft. + * + * @param id - Identifier for the lone token analysis, used to distinguish instances. + * @returns A `TextAnalysis` containing one token analysis with the given id. + */ +function analysisWithToken(id: string): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: [{ id, surfaceText: 'word' }], + tokenAnalysisLinks: [ + { analysisId: id, status: 'approved', token: { tokenRef: 'GEN 1:1:0', surfaceText: 'word' } }, + ], + }; +} + +/** + * Points the `getDraft` command at a resolved JSON draft while leaving `saveDraft` resolving void. + * All other commands resolve undefined so an unexpected call never rejects. + * + * @param draft - The draft the `getDraft` command should return (JSON-serialized). + */ +function mockGetDraftResolves(draft: DraftProject): void { + mockSendCommand.mockImplementation((...args: Parameters) => { + if (args[0] === 'interlinearizer.getDraft') return Promise.resolve(JSON.stringify(draft)); + return Promise.resolve(undefined); + }); +} + +/** + * Renders the hook and waits for the initial `getDraft` load to settle so `draft` is populated and + * `isDraftLoading` is false before a test exercises a callback. + * + * @returns The renderHook result handle, post-load. + */ +async function renderLoaded() { + const view = renderHook(() => useDraftProject(SOURCE_PROJECT_ID, PLATFORM_LANGUAGE)); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + return view; +} + +/** + * Returns the JSON payload of the most recent `saveDraft` call, parsed back into a `DraftProject`. + * + * @returns The persisted draft from the latest `saveDraft` invocation. + * @throws If no `saveDraft` call has been made, or its second argument was not a string. + */ +function lastSavedDraft(): DraftProject { + const saveCalls = mockSendCommand.mock.calls.filter( + (call) => call[0] === 'interlinearizer.saveDraft', + ); + const lastCall = saveCalls[saveCalls.length - 1]; + if (!lastCall) throw new Error('expected at least one saveDraft call'); + const json = lastCall[2]; + if (typeof json !== 'string') throw new Error('expected saveDraft JSON argument to be a string'); + return JSON.parse(json); +} + +describe('useDraftProject', () => { + beforeEach(() => { + jest.mocked(logger.error).mockImplementation(() => {}); + mockGetDraftResolves(makeDraft()); + }); + + it('loads the stored draft on mount and clears the loading flag', async () => { + const stored = makeDraft({ analysis: analysisWithToken('tok-load') }); + mockGetDraftResolves(stored); + + const { result } = await renderLoaded(); + + expect(result.current.draft?.analysis.tokenAnalyses[0].id).toBe('tok-load'); + expect(result.current.isDraftLoading).toBe(false); + expect(result.current.dirty).toBe(false); + }); + + it('requests the draft for the given source project id', async () => { + await renderLoaded(); + + expect(mockSendCommand).toHaveBeenCalledWith('interlinearizer.getDraft', SOURCE_PROJECT_ID); + }); + + it('seeds the platform language in memory when the stored draft has no analysis languages', async () => { + mockGetDraftResolves(makeDraft({ analysisLanguages: [] })); + + const { result } = await renderLoaded(); + + expect(result.current.draft?.analysisLanguages).toEqual([PLATFORM_LANGUAGE]); + }); + + it('keeps the stored analysis languages when the draft already has some', async () => { + mockGetDraftResolves(makeDraft({ analysisLanguages: ['fr', 'de'] })); + + const { result } = await renderLoaded(); + + expect(result.current.draft?.analysisLanguages).toEqual(['fr', 'de']); + }); + + it('falls back to an empty draft and logs when getDraft rejects', async () => { + const failure = new Error('storage down'); + mockSendCommand.mockImplementation((...args: Parameters) => { + if (args[0] === 'interlinearizer.getDraft') return Promise.reject(failure); + return Promise.resolve(undefined); + }); + + const { result } = await renderLoaded(); + + // emptyDraft has no analysis languages, so the seeding branch fills in the platform language. + expect(result.current.draft?.analysisLanguages).toEqual([PLATFORM_LANGUAGE]); + expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]); + expect(result.current.dirty).toBe(false); + expect(jest.mocked(logger.error)).toHaveBeenCalledWith( + 'Interlinearizer: failed to load draft', + failure, + ); + }); + + describe('autosaveAnalysis', () => { + it('stores the edited analysis, marks the draft dirty, and persists with dirty:true', async () => { + const { result } = await renderLoaded(); + + const edited = analysisWithToken('tok-autosave'); + act(() => { + result.current.autosaveAnalysis(edited); + }); + + expect(result.current.dirty).toBe(true); + expect(result.current.draft?.analysis.tokenAnalyses[0].id).toBe('tok-autosave'); + const saved = lastSavedDraft(); + expect(saved.dirty).toBe(true); + expect(saved.analysis.tokenAnalyses[0].id).toBe('tok-autosave'); + }); + + it('does not error or re-render when called again while already dirty', async () => { + const { result } = await renderLoaded(); + + act(() => { + result.current.autosaveAnalysis(analysisWithToken('first')); + }); + act(() => { + result.current.autosaveAnalysis(analysisWithToken('second')); + }); + + expect(result.current.dirty).toBe(true); + // The second autosave does not bump draftVersion and dirty was already true, so it does not + // re-render: the rendered `draft` still reflects the first edit while the live ref holds the + // second. The second edit is persisted and visible through the synchronous snapshot. + expect(result.current.draft?.analysis.tokenAnalyses[0].id).toBe('first'); + expect(result.current.getDraftSnapshot()?.analysis.tokenAnalyses[0].id).toBe('second'); + expect(lastSavedDraft().analysis.tokenAnalyses[0].id).toBe('second'); + }); + }); + + describe('resetDraft', () => { + it('resets to an empty analysis, clears dirty, bumps the version, and persists', async () => { + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.resetDraft({ analysisLanguages: ['es'] }); + }); + + expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]); + expect(result.current.draft?.analysisLanguages).toEqual(['es']); + expect(result.current.dirty).toBe(false); + expect(result.current.draftVersion).toBe(versionBefore + 1); + const saved = lastSavedDraft(); + expect(saved.dirty).toBe(false); + expect(saved.analysisLanguages).toEqual(['es']); + }); + + it('carries name, description, and target project id onto the reset draft', async () => { + const { result } = await renderLoaded(); + + act(() => { + result.current.resetDraft({ + analysisLanguages: ['es'], + targetProjectId: 'target-1', + name: 'My Draft', + description: 'A description', + }); + }); + + expect(result.current.draft?.targetProjectId).toBe('target-1'); + expect(result.current.draft?.suggestedName).toBe('My Draft'); + expect(result.current.draft?.suggestedDescription).toBe('A description'); + }); + + it('seeds the platform language when the New config supplies no analysis languages', async () => { + const { result } = await renderLoaded(); + + act(() => { + result.current.resetDraft({ analysisLanguages: [] }); + }); + + expect(result.current.draft?.analysisLanguages).toEqual([PLATFORM_LANGUAGE]); + expect(result.current.draft?.targetProjectId).toBeUndefined(); + expect(result.current.draft?.suggestedName).toBeUndefined(); + expect(result.current.draft?.suggestedDescription).toBeUndefined(); + }); + }); + + describe('loadFromProject', () => { + it('copies analysis, analysis languages, and target, clears dirty, and bumps the version', async () => { + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + const projectAnalysis = analysisWithToken('tok-open'); + act(() => { + result.current.loadFromProject({ + analysis: projectAnalysis, + analysisLanguages: ['de'], + targetProjectId: 'target-9', + }); + }); + + expect(result.current.draft?.analysis.tokenAnalyses[0].id).toBe('tok-open'); + expect(result.current.draft?.analysisLanguages).toEqual(['de']); + expect(result.current.draft?.targetProjectId).toBe('target-9'); + expect(result.current.dirty).toBe(false); + expect(result.current.draftVersion).toBe(versionBefore + 1); + expect(lastSavedDraft().dirty).toBe(false); + }); + + it('omits the target project id when the opened project has none', async () => { + const { result } = await renderLoaded(); + + act(() => { + result.current.loadFromProject({ + analysis: emptyAnalysis(), + analysisLanguages: ['de'], + }); + }); + + expect(result.current.draft?.targetProjectId).toBeUndefined(); + }); + }); + + describe('wipeBook', () => { + it('removes the book, marks the draft dirty, bumps the version, and persists', async () => { + const genToken = analysisWithToken('gen-tok'); + // A MAT token that should survive wiping GEN. + const mixed: TextAnalysis = { + ...genToken, + tokenAnalyses: [...genToken.tokenAnalyses, { id: 'mat-tok', surfaceText: 'word' }], + tokenAnalysisLinks: [ + ...genToken.tokenAnalysisLinks, + { + analysisId: 'mat-tok', + status: 'approved', + token: { tokenRef: 'MAT 1:1:0', surfaceText: 'word' }, + }, + ], + }; + mockGetDraftResolves(makeDraft({ analysis: mixed })); + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.wipeBook('GEN'); + }); + + const ids = result.current.draft?.analysis.tokenAnalyses.map((a) => a.id); + expect(ids).toEqual(['mat-tok']); + expect(result.current.dirty).toBe(true); + expect(result.current.draftVersion).toBe(versionBefore + 1); + expect(lastSavedDraft().dirty).toBe(true); + }); + }); + + describe('wipeAll', () => { + it('clears the analysis entirely, marks the draft dirty, bumps the version, and persists', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-wipe-all') })); + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.wipeAll(); + }); + + expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]); + expect(result.current.dirty).toBe(true); + expect(result.current.draftVersion).toBe(versionBefore + 1); + expect(lastSavedDraft().dirty).toBe(true); + }); + }); + + describe('markSynced', () => { + it('clears dirty and persists the synced draft without bumping the version', async () => { + const { result } = await renderLoaded(); + // Make the draft dirty first so the transition to synced is observable. + act(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-sync')); + }); + expect(result.current.dirty).toBe(true); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.markSynced(); + }); + + expect(result.current.dirty).toBe(false); + expect(result.current.draftVersion).toBe(versionBefore); + expect(lastSavedDraft().dirty).toBe(false); + }); + }); + + describe('getDraftSnapshot', () => { + it('returns the latest draft synchronously after an autosave', async () => { + const { result } = await renderLoaded(); + + const edited = analysisWithToken('tok-snapshot'); + act(() => { + result.current.autosaveAnalysis(edited); + }); + + const snapshot = result.current.getDraftSnapshot(); + expect(snapshot?.analysis.tokenAnalyses[0].id).toBe('tok-snapshot'); + expect(snapshot?.dirty).toBe(true); + }); + }); + + it('does not update state or throw when unmounted before getDraft resolves', async () => { + let resolveGetDraft: (json: string) => void = () => {}; + const deferred = new Promise((resolve) => { + resolveGetDraft = resolve; + }); + mockSendCommand.mockImplementation((...args: Parameters) => { + if (args[0] === 'interlinearizer.getDraft') return deferred; + return Promise.resolve(undefined); + }); + + const { result, unmount } = renderHook(() => + useDraftProject(SOURCE_PROJECT_ID, PLATFORM_LANGUAGE), + ); + expect(result.current.isDraftLoading).toBe(true); + + unmount(); + + // Resolve after unmount: the canceled guard must skip the ref/state publish. + await act(async () => { + resolveGetDraft(JSON.stringify(makeDraft())); + await deferred; + }); + + // State stayed at its last mounted value (still loading) and no error was logged. + expect(result.current.isDraftLoading).toBe(true); + expect(jest.mocked(logger.error)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index 8eab3912..2c348b65 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -6,7 +6,7 @@ import papiBackendMock from '@papi/backend'; import { activate, deactivate } from '@main'; import type { InterlinearizerOpenOptions } from '@main'; import * as projectStorage from '../services/projectStorage'; -import { emptyAnalysis } from '../types/empty-factories'; +import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { createTestActivationContext, makeStubProject } from './test-helpers'; jest.mock('../services/projectStorage'); @@ -174,6 +174,16 @@ const getSaveAnalysisHandler = () => 'interlinearizer.saveAnalysis', ); +/** Activates the extension and returns the `interlinearizer.getDraft` handler. */ +const getGetDraftHandler = () => + activateAndGetHandler<(sourceProjectId: string) => Promise>('interlinearizer.getDraft'); + +/** Activates the extension and returns the `interlinearizer.saveDraft` handler. */ +const getSaveDraftHandler = () => + activateAndGetHandler<(sourceProjectId: string, draftJson: string) => Promise>( + 'interlinearizer.saveDraft', + ); + /** * Retrieves the callback passed to onDidOpenWebView during the most recent activate() call. * @@ -246,6 +256,8 @@ describe('main', () => { 'interlinearizer.getProject', 'interlinearizer.saveAnalysis', 'interlinearizer.getProjectsForSource', + 'interlinearizer.getDraft', + 'interlinearizer.saveDraft', 'interlinearizer.openSelectProjectModal', 'interlinearizer.openNewProjectModal', 'interlinearizer.openProjectInfoModal', @@ -264,7 +276,7 @@ describe('main', () => { await activate(context); - expect(context.registrations.unsubscribers.size).toBe(18); + expect(context.registrations.unsubscribers.size).toBe(24); }); it('logs activation start and finish', async () => { @@ -936,6 +948,114 @@ describe('main', () => { }); }); + describe('interlinearizer.getDraft command', () => { + const mockGetDraft = jest.mocked(projectStorage.getDraft); + const stubDraft = emptyDraft('src-project'); + + it('registers the interlinearizer.getDraft command', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.getDraft', + expect.any(Function), + expect.any(Object), + ); + }); + + it('delegates to projectStorage.getDraft and returns the JSON-serialized draft', async () => { + mockGetDraft.mockResolvedValue(stubDraft); + const handler = await getGetDraftHandler(); + + const result = await handler('src-project'); + + expect(mockGetDraft).toHaveBeenCalledWith(expect.anything(), 'src-project'); + expect(result).toBe(JSON.stringify(stubDraft)); + }); + + it('logs the error and rethrows when storage throws', async () => { + mockGetDraft.mockRejectedValue(new Error('disk full')); + const handler = await getGetDraftHandler(); + + await expect(handler('src-project')).rejects.toThrow('disk full'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to get draft', + expect.any(Error), + ); + }); + }); + + describe('interlinearizer.saveDraft command', () => { + const mockSaveDraft = jest.mocked(projectStorage.saveDraft); + const stubDraft = { ...emptyDraft('src-project'), analysisLanguages: ['en'], dirty: true }; + + it('registers the interlinearizer.saveDraft command', async () => { + const context = createTestActivationContext(); + + await activate(context); + + expect(__mockRegisterCommand).toHaveBeenCalledWith( + 'interlinearizer.saveDraft', + expect.any(Function), + expect.any(Object), + ); + }); + + it('parses the JSON, validates it, and delegates to projectStorage.saveDraft', async () => { + mockSaveDraft.mockResolvedValue(undefined); + const handler = await getSaveDraftHandler(); + + await handler('src-project', JSON.stringify(stubDraft)); + + expect(mockSaveDraft).toHaveBeenCalledWith(expect.anything(), 'src-project', stubDraft); + }); + + it('logs the error, sends an error notification, and rethrows when storage throws', async () => { + mockSaveDraft.mockRejectedValue(new Error('disk full')); + const handler = await getSaveDraftHandler(); + + await expect(handler('src-project', JSON.stringify(stubDraft))).rejects.toThrow('disk full'); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft', + expect.any(Error), + ); + expect(__mockNotificationsSend).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ); + }); + + it('logs the error, sends an error notification, and rethrows when draftJson is not valid JSON', async () => { + const handler = await getSaveDraftHandler(); + + await expect(handler('src-project', 'not-json')).rejects.toThrow(SyntaxError); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft', + expect.any(SyntaxError), + ); + expect(__mockNotificationsSend).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ); + expect(mockSaveDraft).not.toHaveBeenCalled(); + }); + + it('logs the error, sends an error notification, and rethrows when draftJson does not conform to DraftProject', async () => { + const handler = await getSaveDraftHandler(); + + await expect( + handler('src-project', JSON.stringify({ sourceProjectId: 'x' })), + ).rejects.toThrow(TypeError); + expect(__mockLogger.error).toHaveBeenCalledWith( + 'Interlinearizer: failed to save draft', + expect.any(TypeError), + ); + expect(__mockNotificationsSend).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'error' }), + ); + expect(mockSaveDraft).not.toHaveBeenCalled(); + }); + }); + describe('deactivate', () => { it('returns true to indicate successful deactivation', async () => { const result = await deactivate(); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index fda292ff..becbebf5 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -5,14 +5,16 @@ import papiBackendMock from '@papi/backend'; import { createProject, deleteProject, + getDraft, getProject, getProjectsForSource, listProjects, resetQueuesForTesting, + saveDraft, updateAnalysis, updateProjectMetadata, } from '../../services/projectStorage'; -import { emptyAnalysis } from '../../types/empty-factories'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { createTestActivationContext, makeStubProject } from '../test-helpers'; /** @@ -558,4 +560,96 @@ describe('projectStorage', () => { ); }); }); + + describe('getDraft', () => { + it('returns the parsed stored draft read from the draft key', async () => { + const stored = { ...emptyDraft('src-proj'), analysisLanguages: ['fr'], dirty: true }; + __mockReadUserData.mockResolvedValue(JSON.stringify(stored)); + + const result = await getDraft(token, 'src-proj'); + + expect(result).toEqual(stored); + expect(__mockReadUserData).toHaveBeenCalledWith(token, 'draft:src-proj'); + }); + + it('returns a fresh empty draft when no draft has been written (ENOENT)', async () => { + __mockReadUserData.mockRejectedValue(enoentError()); + + const result = await getDraft(token, 'src-proj'); + + expect(result).toEqual(emptyDraft('src-proj')); + }); + + it('does not write to storage when returning a fresh empty draft', async () => { + __mockReadUserData.mockRejectedValue(enoentError()); + + await getDraft(token, 'src-proj'); + + expect(__mockWriteUserData).not.toHaveBeenCalled(); + }); + + it('rethrows a non-ENOENT error from storage', async () => { + __mockReadUserData.mockRejectedValue(new Error('permission denied')); + + await expect(getDraft(token, 'src-proj')).rejects.toThrow('permission denied'); + }); + + it('propagates a JSON parse error when the stored draft is corrupt', async () => { + __mockReadUserData.mockResolvedValue('not valid json'); + + await expect(getDraft(token, 'src-proj')).rejects.toThrow(SyntaxError); + }); + }); + + describe('saveDraft', () => { + it('writes the draft JSON under the draft key', async () => { + const draft = { ...emptyDraft('src-proj'), analysisLanguages: ['en'], dirty: true }; + + await saveDraft(token, 'src-proj', draft); + + expect(__mockWriteUserData).toHaveBeenCalledWith( + token, + 'draft:src-proj', + JSON.stringify(draft), + ); + }); + + it('never writes the projectIds index key', async () => { + await saveDraft(token, 'src-proj', emptyDraft('src-proj')); + + expect(__mockWriteUserData).not.toHaveBeenCalledWith(token, 'projectIds', expect.anything()); + }); + + it('serializes concurrent writes to the same source so they resolve in order', async () => { + const order: string[] = []; + let resolveFirstWrite!: () => void; + const firstWriteGate = new Promise((resolve) => { + resolveFirstWrite = resolve; + }); + + let writeCallCount = 0; + __mockWriteUserData.mockImplementation((): Promise => { + writeCallCount += 1; + if (writeCallCount === 1) return firstWriteGate; + return Promise.resolve(); + }); + + const first = saveDraft(token, 'src-proj', { ...emptyDraft('src-proj'), dirty: false }).then( + () => order.push('first'), + ); + const second = saveDraft(token, 'src-proj', { ...emptyDraft('src-proj'), dirty: true }).then( + () => order.push('second'), + ); + + // The second write must not begin until the first settles: only one write has been issued. + await Promise.resolve(); + expect(writeCallCount).toBe(1); + + resolveFirstWrite(); + await Promise.all([first, second]); + + expect(order).toEqual(['first', 'second']); + expect(writeCallCount).toBe(2); + }); + }); }); diff --git a/src/__tests__/types/type-guards.test.ts b/src/__tests__/types/type-guards.test.ts new file mode 100644 index 00000000..33d947c5 --- /dev/null +++ b/src/__tests__/types/type-guards.test.ts @@ -0,0 +1,176 @@ +/** @file Unit tests for the `isDraftProject` type guard in type-guards.ts. */ +/// + +import { isDraftProject } from '../../types/type-guards'; +import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; + +describe('isDraftProject', () => { + /** + * Builds a valid `DraftProject` object as the accept baseline. Each reject test starts from this + * shape and breaks exactly one field so the failure is attributable to that branch. + * + * @returns A fresh, structurally valid `DraftProject`. + */ + function validDraft(): unknown { + return emptyDraft('src-project'); + } + + it('accepts a valid DraftProject', () => { + expect(isDraftProject(validDraft())).toBe(true); + }); + + it('accepts a fully-populated DraftProject with all optional fields', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: ['en', 'fr'], + targetProjectId: 'tgt-project', + suggestedName: 'My Draft', + suggestedDescription: 'A description', + analysis: emptyAnalysis(), + dirty: true, + }; + + expect(isDraftProject(draft)).toBe(true); + }); + + it('rejects a non-object value', () => { + expect(isDraftProject('not an object')).toBe(false); + }); + + it('rejects null', () => { + // `JSON.parse('null')` yields a real null payload (typed unknown) without a bare null literal, + // which the no-null lint rule forbids; this mirrors parsing a stored draft that is literally null. + expect(isDraftProject(JSON.parse('null'))).toBe(false); + }); + + it('rejects an object missing sourceProjectId', () => { + const draft: unknown = { + analysisLanguages: [], + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects a non-string sourceProjectId', () => { + const draft: unknown = { + sourceProjectId: 42, + analysisLanguages: [], + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects an object missing analysisLanguages', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects analysisLanguages that is not an array', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: 'en', + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects analysisLanguages containing a non-string element', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: ['en', 7], + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects an object missing dirty', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + analysis: emptyAnalysis(), + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects a non-boolean dirty', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + analysis: emptyAnalysis(), + dirty: 'yes', + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects a wrong-typed targetProjectId', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + targetProjectId: 99, + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects a wrong-typed suggestedName', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + suggestedName: 123, + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects a wrong-typed suggestedDescription', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + suggestedDescription: false, + analysis: emptyAnalysis(), + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects an object missing analysis', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); + + it('rejects an object whose analysis fails isTextAnalysis', () => { + const draft: unknown = { + sourceProjectId: 'src-project', + analysisLanguages: [], + analysis: { segmentAnalyses: [] }, + dirty: false, + }; + + expect(isDraftProject(draft)).toBe(false); + }); +}); diff --git a/src/__tests__/utils/analysis-book.test.ts b/src/__tests__/utils/analysis-book.test.ts new file mode 100644 index 00000000..e3e29ac1 --- /dev/null +++ b/src/__tests__/utils/analysis-book.test.ts @@ -0,0 +1,194 @@ +/** @file Unit tests for utils/analysis-book.ts. */ +/// + +import type { + SegmentAnalysis, + SegmentAnalysisLink, + TextAnalysis, + TokenAnalysis, + TokenAnalysisLink, +} from 'interlinearizer'; +import { bookOfRef, removeBookFromAnalysis } from '../../utils/analysis-book'; +import { makePhraseLink } from '../test-helpers'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Creates a minimal `TokenAnalysis` payload record fixture. + * + * @param id - Analysis id. + * @param surfaceText - Surface text; defaults to `id` when omitted. + * @returns A `TokenAnalysis` with the given id. + */ +function mkTokenAnalysis(id: string, surfaceText = id): TokenAnalysis { + return { id, surfaceText }; +} + +/** + * Creates a `TokenAnalysisLink` joining a token ref to an analysis id. + * + * @param analysisId - The `TokenAnalysis.id` this link points at. + * @param tokenRef - The token ref the analysis is attached to. + * @returns An approved `TokenAnalysisLink`. + */ +function mkTokenLink(analysisId: string, tokenRef: string): TokenAnalysisLink { + return { analysisId, status: 'approved', token: { tokenRef, surfaceText: tokenRef } }; +} + +/** + * Creates a minimal `SegmentAnalysis` payload record fixture. + * + * @param id - Analysis id. + * @param surfaceText - Surface text; defaults to `id` when omitted. + * @returns A `SegmentAnalysis` with the given id. + */ +function mkSegmentAnalysis(id: string, surfaceText = id): SegmentAnalysis { + return { id, surfaceText }; +} + +/** + * Creates a `SegmentAnalysisLink` joining a segment id to an analysis id. + * + * @param analysisId - The `SegmentAnalysis.id` this link points at. + * @param segmentId - The segment id the analysis is attached to. + * @returns An approved `SegmentAnalysisLink`. + */ +function mkSegmentLink(analysisId: string, segmentId: string): SegmentAnalysisLink { + return { analysisId, status: 'approved', segmentId }; +} + +// --------------------------------------------------------------------------- +// bookOfRef +// --------------------------------------------------------------------------- + +describe('bookOfRef', () => { + it('extracts the book code from a token ref with a char offset', () => { + expect(bookOfRef('GEN 1:1:0')).toBe('GEN'); + }); + + it('extracts the book code from a verse-level segment id', () => { + expect(bookOfRef('GEN 1:1')).toBe('GEN'); + }); + + it('extracts a numeric-prefixed book code', () => { + expect(bookOfRef('1JN 2:3:5')).toBe('1JN'); + }); + + it('returns the whole string when it contains no space', () => { + expect(bookOfRef('GEN')).toBe('GEN'); + }); +}); + +// --------------------------------------------------------------------------- +// removeBookFromAnalysis +// --------------------------------------------------------------------------- + +describe('removeBookFromAnalysis', () => { + /** + * Builds a `TextAnalysis` spanning two books (GEN and EXO) with: + * + * - A GEN token analysis + link and an EXO token analysis + link, + * - A GEN segment analysis + link and an EXO segment analysis + link, + * - An EXO-only phrase (should survive) and a cross-book GEN+EXO phrase (should be removed), + * - An orphan token analysis (`tok-orphan`) referenced only by a GEN link, so removing GEN leaves + * the payload unreferenced and it must be dropped by orphan cleanup. + * + * @returns A populated `TextAnalysis` fixture. + */ + function makeTwoBookAnalysis(): TextAnalysis { + return { + tokenAnalyses: [ + mkTokenAnalysis('tok-gen'), + mkTokenAnalysis('tok-exo'), + mkTokenAnalysis('tok-orphan'), + ], + tokenAnalysisLinks: [ + mkTokenLink('tok-gen', 'GEN 1:1:0'), + mkTokenLink('tok-exo', 'EXO 2:2:0'), + // Only link referencing tok-orphan is a GEN link → removing GEN orphans the payload. + mkTokenLink('tok-orphan', 'GEN 3:3:0'), + ], + segmentAnalyses: [mkSegmentAnalysis('seg-gen'), mkSegmentAnalysis('seg-exo')], + segmentAnalysisLinks: [ + mkSegmentLink('seg-gen', 'GEN 1:1'), + mkSegmentLink('seg-exo', 'EXO 2:2'), + ], + phraseAnalyses: [mkTokenAnalysis('ph-exo'), mkTokenAnalysis('ph-cross')], + phraseAnalysisLinks: [ + // Entirely within EXO → survives. + makePhraseLink('ph-exo', ['EXO 2:2:0', 'EXO 2:2:3']), + // Cross-book: an EXO token AND a GEN token → removed when wiping GEN. + makePhraseLink('ph-cross', ['EXO 4:4:0', 'GEN 5:5:0']), + ], + }; + } + + it('drops GEN token links and keeps EXO token links', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.tokenAnalysisLinks.map((l) => l.token.tokenRef)).toEqual(['EXO 2:2:0']); + }); + + it('drops the GEN token analysis payload and keeps the EXO one', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.tokenAnalyses.map((a) => a.id)).toEqual(['tok-exo']); + }); + + it('drops an analysis left unreferenced after its only link is removed (orphan cleanup)', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + // tok-orphan was only referenced by a GEN link, so it must not survive. + expect(result.tokenAnalyses.map((a) => a.id)).not.toContain('tok-orphan'); + }); + + it('drops GEN segment links and keeps EXO segment links', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.segmentAnalysisLinks.map((l) => l.segmentId)).toEqual(['EXO 2:2']); + }); + + it('drops the GEN segment analysis payload and keeps the EXO one', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.segmentAnalyses.map((a) => a.id)).toEqual(['seg-exo']); + }); + + it('removes a cross-book phrase whose token list contains a GEN token', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.phraseAnalysisLinks.map((l) => l.analysisId)).toEqual(['ph-exo']); + }); + + it('drops the cross-book phrase analysis payload and keeps the EXO-only one', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + expect(result.phraseAnalyses.map((a) => a.id)).toEqual(['ph-exo']); + }); + + it('keeps a wholly-EXO phrase when removing GEN', () => { + const result = removeBookFromAnalysis(makeTwoBookAnalysis(), 'GEN'); + const survivor = result.phraseAnalysisLinks.find((l) => l.analysisId === 'ph-exo'); + expect(survivor?.tokens.map((t) => t.tokenRef)).toEqual(['EXO 2:2:0', 'EXO 2:2:3']); + }); + + it('does not mutate the input analysis object', () => { + const input = makeTwoBookAnalysis(); + const snapshot = JSON.parse(JSON.stringify(input)); + removeBookFromAnalysis(input, 'GEN'); + expect(input).toEqual(snapshot); + }); + + it('returns a new object and new array references, not the originals', () => { + const input = makeTwoBookAnalysis(); + const result = removeBookFromAnalysis(input, 'GEN'); + expect(result).not.toBe(input); + expect(result.tokenAnalysisLinks).not.toBe(input.tokenAnalysisLinks); + expect(result.segmentAnalysisLinks).not.toBe(input.segmentAnalysisLinks); + expect(result.phraseAnalysisLinks).not.toBe(input.phraseAnalysisLinks); + }); + + it('returns an empty analysis unchanged in value when no record matches the book code', () => { + const input = makeTwoBookAnalysis(); + const result = removeBookFromAnalysis(input, 'LEV'); + // Nothing belongs to LEV, so every record survives. + expect(result.tokenAnalyses.map((a) => a.id)).toEqual(['tok-gen', 'tok-exo', 'tok-orphan']); + expect(result.segmentAnalyses.map((a) => a.id)).toEqual(['seg-gen', 'seg-exo']); + expect(result.phraseAnalyses.map((a) => a.id)).toEqual(['ph-exo', 'ph-cross']); + }); +}); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 395ac8fa..416f8d1e 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -1,11 +1,15 @@ -import type { UseWebViewScrollGroupScrRefHook, UseWebViewStateHook } from '@papi/core'; +import type { + UseWebViewScrollGroupScrRefHook, + UseWebViewStateHook, + WebViewProps, +} from '@papi/core'; import papi, { logger } from '@papi/frontend'; import { useData, useSetting } from '@papi/frontend/react'; -import type { InterlinearProject, TextAnalysis } from 'interlinearizer'; import { TabToolbar } from 'platform-bible-react'; import type { SelectMenuItemHandler } from 'platform-bible-react'; import { isPlatformError } from 'platform-bible-utils'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import useDraftProject from '../hooks/useDraftProject'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; @@ -13,10 +17,14 @@ import Interlinearizer from './Interlinearizer'; import ViewOptionsDropdown from './controls/ViewOptionsDropdown'; import type { PhraseMode } from '../types/phrase-mode'; import ProjectModals, { type ModalState } from './modals/ProjectModals'; +import { WipeConfirm } from './modals/WipeConfirm'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; +/** Host-injected callback to update this WebView's definition (used to toggle the tab title). */ +type UpdateWebViewDefinition = WebViewProps['updateWebViewDefinition']; + /** * WebView menu holding only the platform defaults. Used both as the `useData` default while the * provider's menu is loading and as the fallback when it returns an error. @@ -27,6 +35,16 @@ const DEFAULT_WEB_VIEW_MENU = { contextMenu: undefined, }; +/** + * Base tab title for the Interlinearizer WebView. PAPI exposes no native unsaved-changes indicator, + * so {@link UNSAVED_TAB_MARKER} is appended to this via `updateWebViewDefinition` while the draft + * has unsaved changes. + */ +const BASE_TAB_TITLE = 'Interlinearizer'; + +/** Glyph appended to the tab title while the draft has unsaved changes. */ +const UNSAVED_TAB_MARKER = ' ●'; + /** * Root component for the Interlinearizer WebView. Mounts the {@link InterlinearNavProvider} so the * loader and the whole {@link Interlinearizer} subtree read and write navigation through one source @@ -38,20 +56,28 @@ const DEFAULT_WEB_VIEW_MENU = { * reference and its setter * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted * by the PAPI host + * @param props.updateWebViewDefinition - Host-injected callback to update this WebView's + * definition; used to toggle the tab's unsaved-changes title marker * @returns The nav provider wrapping {@link InterlinearizerLoaderInner} */ export default function InterlinearizerLoader({ projectId, useWebViewScrollGroupScrRef, useWebViewState, + updateWebViewDefinition, }: Readonly<{ projectId: string; useWebViewScrollGroupScrRef: UseWebViewScrollGroupScrRefHook; useWebViewState: UseWebViewStateHook; + updateWebViewDefinition: UpdateWebViewDefinition; }>) { return ( - + ); } @@ -66,15 +92,19 @@ export default function InterlinearizerLoader({ * @param props.projectId - PAPI project ID passed from the host * @param props.useWebViewState - Hook for reading and writing typed WebView-scoped state persisted * by the PAPI host + * @param props.updateWebViewDefinition - Host-injected callback used to toggle the tab's + * unsaved-changes title marker * @returns The toolbar and either an error/loading state or the fully rendered * {@link Interlinearizer} */ function InterlinearizerLoaderInner({ projectId, useWebViewState, + updateWebViewDefinition, }: Readonly<{ projectId: string; useWebViewState: UseWebViewStateHook; + updateWebViewDefinition: UpdateWebViewDefinition; }>) { const { rawScrRef, @@ -104,97 +134,35 @@ function InterlinearizerLoaderInner({ undefined, ); - /** - * BCP 47 tag used for reading and writing gloss values. Prefers the active project's first - * configured analysis language; falls back to the platform UI language when no project is - * active. - */ - const analysisLanguage = activeProject?.analysisLanguages[0] ?? platformLanguage; - - /** - * `TextAnalysis` loaded from storage for the currently active interlinear project, or `undefined` - * when no project is active or the load is still in flight. Passed to {@link Interlinearizer} as - * `initialAnalysis` to seed the Redux store on mount. - */ - const [activeProjectAnalysis, setActiveProjectAnalysis] = useState( - undefined, - ); + // The always-present draft is the runtime source of truth for the analysis being edited. Edits + // auto-save here (not to the active project); Save / Save As copy the draft into a project. + const { + isDraftLoading, + draft, + draftVersion, + dirty, + autosaveAnalysis, + resetDraft, + loadFromProject, + getDraftSnapshot, + markSynced, + wipeBook, + wipeAll, + } = useDraftProject(projectId, platformLanguage); /** - * `true` while the `interlinearizer.getProject` command is in flight for the current - * `activeProject`. Blocks rendering {@link Interlinearizer} until the analysis is ready so the - * store is never seeded with stale data from a previous project. + * BCP 47 tag used for reading and writing gloss values. Prefers the draft's first configured + * analysis language; falls back to the platform UI language for a brand-new source. */ - const [isAnalysisLoading, setIsAnalysisLoading] = useState(false); + const analysisLanguage = draft?.analysisLanguages[0] ?? platformLanguage; + // Reflect the draft's unsaved-changes state in the tab title. PAPI has no native dirty indicator, + // so we append a marker to the title via the host-injected `updateWebViewDefinition`. useEffect(() => { - if (!activeProject) { - setActiveProjectAnalysis(undefined); - setIsAnalysisLoading(false); - return; - } - - let canceled = false; - setIsAnalysisLoading(true); - - /** - * Fetches the stored `TextAnalysis` for the active project and updates component state. - * - * Writes `activeProjectAnalysis` on success (or `undefined` when the project record is absent) - * and clears `isAnalysisLoading` in the `finally` block. Both state updates are suppressed when - * `canceled` is `true` (i.e. the effect was cleaned up before the fetch completed). - * - * @returns Promise that resolves to void once state has been updated or the update has been - * suppressed due to cancellation. - * @throws Never — errors are caught internally and logged; `activeProjectAnalysis` is set to - * `undefined` on failure. - */ - const loadAnalysis = async () => { - try { - const json = await papi.commands.sendCommand( - 'interlinearizer.getProject', - activeProject.id, - ); - if (canceled) return; - if (json) { - const project: InterlinearProject = JSON.parse(json); - setActiveProjectAnalysis(project.analysis); - } else { - setActiveProjectAnalysis(undefined); - } - } catch (e) { - if (!canceled) { - logger.error('Interlinearizer: failed to load project analysis', e); - setActiveProjectAnalysis(undefined); - } - } finally { - if (!canceled) setIsAnalysisLoading(false); - } - }; - - loadAnalysis(); - - return () => { - canceled = true; - }; - }, [activeProject?.id]); // eslint-disable-line react-hooks/exhaustive-deps - - /** - * Persists an updated analysis to the backend after each gloss write. No-ops when no project is - * active. Errors are logged but not surfaced — the backend already sends an error notification. - * - * @param analysis - The updated `TextAnalysis` to persist. - * @returns Void — the underlying command is fire-and-forget; errors are caught and logged. - */ - const handleSaveAnalysis = useCallback( - (analysis: TextAnalysis) => { - if (!activeProject) return; - papi.commands - .sendCommand('interlinearizer.saveAnalysis', activeProject.id, JSON.stringify(analysis)) - .catch((e) => logger.error('Interlinearizer: failed to save analysis', e)); - }, - [activeProject], - ); + updateWebViewDefinition({ + title: dirty ? `${BASE_TAB_TITLE}${UNSAVED_TAB_MARKER}` : BASE_TAB_TITLE, + }); + }, [dirty, updateWebViewDefinition]); const { isLoading: isContinuousScrollLoading, @@ -254,7 +222,7 @@ function InterlinearizerLoaderInner({ // Treating this window as loading swaps the old view for the Loading… curtain immediately, so // nothing of either book shows until the new one has mounted and fades in. const isCrossBookSwap = !!book && scrRef.book !== book.bookRef; - const showLoading = isLoading || isAnalysisLoading || isSettingLoading || isCrossBookSwap; + const showLoading = isLoading || isDraftLoading || isSettingLoading || isCrossBookSwap; const isLoaded = !hasError && !showLoading && !!book; // Abort any in-flight cross-book fade when the new book fails to load, so the error is revealed @@ -265,18 +233,61 @@ function InterlinearizerLoaderInner({ const [modal, setModal] = useState('none'); + /** Which destructive wipe confirmation is showing, or `undefined` when none. */ + const [wipeConfirm, setWipeConfirm] = useState<'book' | 'all' | undefined>(undefined); + const [phraseMode, setPhraseMode] = useState({ kind: 'view' }); - // Reset phraseMode when the active project changes so stale edit/confirm-unlink state from a - // previous project is never passed to the newly mounted Interlinearizer. + // Reset phraseMode whenever the draft is replaced wholesale (New / Open / Wipe) so stale + // edit/confirm-unlink state is never passed to the newly mounted Interlinearizer. useEffect(() => { setPhraseMode({ kind: 'view' }); - }, [activeProject?.id]); + }, [draftVersion]); /** - * Routes top-menu commands to the appropriate modal. `openSelectProjectModal` opens the select - * modal; `openNewProjectModal` opens the create modal directly; `openProjectInfoModal` opens the - * metadata modal for the currently active project. + * Saves the current draft to the active project. When no project is active there is nothing to + * save to yet, so it opens Save As instead. Errors are logged; the backend surfaces the + * notification. + * + * @returns A promise that resolves once the save completes or Save As is opened. + */ + const handleSave = useCallback(async () => { + if (!activeProject) { + setModal('saveAs'); + return; + } + const snapshot = getDraftSnapshot(); + /* v8 ignore next -- save is only reachable once the editor (and draft) have loaded */ + if (!snapshot) return; + try { + await papi.commands.sendCommand( + 'interlinearizer.saveAnalysis', + activeProject.id, + JSON.stringify(snapshot.analysis), + ); + markSynced(); + } catch (e) { + logger.error('Interlinearizer: failed to save draft to project', e); + } + }, [activeProject, getDraftSnapshot, markSynced, setModal]); + + /** Performs the confirmed wipe (current book or whole draft) and dismisses the confirmation. */ + const handleWipeConfirm = useCallback(() => { + if (wipeConfirm === 'book') { + /* v8 ignore next -- wipe-book is only offered once a book is loaded */ + if (book) wipeBook(book.bookRef); + } else { + wipeAll(); + } + setWipeConfirm(undefined); + }, [wipeConfirm, book, wipeBook, wipeAll]); + + /** Dismisses the wipe confirmation, leaving the draft untouched. */ + const handleWipeCancel = useCallback(() => setWipeConfirm(undefined), []); + + /** + * Routes top-menu commands to the appropriate action. The project commands open their modals; the + * file commands save (or open Save As); the draft commands open a wipe confirmation. * * @param item - The menu item that was activated. */ @@ -290,9 +301,17 @@ function InterlinearizerLoaderInner({ if (activeProject) { setModal('metadata'); } + } else if (item.command === 'interlinearizer.save') { + handleSave(); + } else if (item.command === 'interlinearizer.openSaveAsModal') { + setModal('saveAs'); + } else if (item.command === 'interlinearizer.wipeBook') { + setWipeConfirm('book'); + } else if (item.command === 'interlinearizer.wipeDraft') { + setWipeConfirm('all'); } }, - [activeProject], + [activeProject, handleSave], ); /** @@ -403,13 +422,13 @@ function InterlinearizerLoaderInner({ ) : ( + + {wipeConfirm && ( + + )} ); } diff --git a/src/components/modals/CreateProjectModal.tsx b/src/components/modals/CreateProjectModal.tsx index 0fee76d0..3e33d864 100644 --- a/src/components/modals/CreateProjectModal.tsx +++ b/src/components/modals/CreateProjectModal.tsx @@ -1,9 +1,6 @@ -import papi, { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; import { Button } from 'platform-bible-react'; -import { useState, useCallback, useRef } from 'react'; -import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; -import { isInterlinearProjectSummary } from '../../types/type-guards'; +import { useState, useCallback } from 'react'; /** Localized string keys used by {@link CreateProjectModal}. */ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [ @@ -18,97 +15,67 @@ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [ '%interlinearizer_modal_create_cancel%', ]; +/** Configuration collected by {@link CreateProjectModal} for a new draft. */ +export type CreateDraftConfig = { + /** + * BCP 47 analysis language tags parsed from the language field (never empty; falls back to + * `und`). + */ + analysisLanguages: string[]; + /** Trimmed name, or `undefined` when the field was left blank. */ + name?: string; + /** Trimmed description, or `undefined` when the field was left blank. */ + description?: string; +}; + /** - * Modal dialog that collects project name, description, and analysis language tag before creating a - * new interlinear project. Submitting sends the `interlinearizer.createProject` command with the - * known source project ID and the entered values. + * Modal dialog that collects the configuration for a new draft — name, description, and analysis + * language(s) — then hands it back via {@link onCreateDraft}. No project is persisted here: "New" + * resets the working draft to an empty baseline, and a project is only materialized later via Save + * As. The typed name/description are retained on the draft to prefill that Save As dialog. * * @param props - Component props - * @param props.projectId - Source project to create the interlinear project for * @param props.defaultAnalysisLanguage - BCP 47 tag pre-populated in the analysis language field; * caller should pass the platform UI language so the user sees a sensible starting value. * Defaults to `'und'` when absent. - * @param props.onClose - Callback invoked when the modal should be dismissed (cancel or submit) - * @param props.onProjectCreated - Optional callback invoked with the full persisted project after - * successful creation, before `onClose` is called. - * @returns The modal overlay with name, description, language inputs and submit/cancel buttons, or - * nothing while localized strings are loading. + * @param props.onClose - Callback invoked when the modal should be dismissed (cancel). + * @param props.onCreateDraft - Callback invoked with the collected configuration on submit. + * @returns The modal overlay with name, description, and language inputs, or nothing while + * localized strings are loading. */ export function CreateProjectModal({ - projectId, defaultAnalysisLanguage, onClose, - onProjectCreated, + onCreateDraft, }: Readonly<{ - projectId: string; /** BCP 47 tag pre-populated in the analysis language field; defaults to `'und'` when absent. */ defaultAnalysisLanguage?: string; onClose: () => void; - onProjectCreated?: (project: InterlinearProjectSummary) => void; + onCreateDraft: (config: CreateDraftConfig) => void; }>) { const [localizedStrings, stringsLoading] = useLocalizedStrings(CREATE_PROJECT_MODAL_STRING_KEYS); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [analysisLanguages, setAnalysisLanguages] = useState(defaultAnalysisLanguage ?? 'und'); - const [isSubmitting, setIsSubmitting] = useState(false); - const isSubmittingRef = useRef(false); /** - * Sends the `interlinearizer.createProject` command with the collected form values, then notifies - * the caller via `onProjectCreated` and closes the modal. Shows a user-visible error notification - * if the response cannot be parsed (SyntaxError); for other errors, logs and defers to the - * backend command handler to surface the notification. - * - * The analysis-languages input is interpreted as a comma-separated list of BCP 47 tags; entries - * are trimmed and empty entries dropped. Falls back to `['und']` when the user clears the field. - * - * @returns A promise that resolves when the command completes or the error is handled. + * Parses the analysis-languages input (comma-separated BCP 47 tags; entries trimmed and empties + * dropped; falls back to `['und']` when cleared) and hands the configuration to + * {@link onCreateDraft}. */ - const handleSubmit = useCallback(async () => { - /* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */ - if (isSubmittingRef.current) return; - isSubmittingRef.current = true; - setIsSubmitting(true); + const handleSubmit = useCallback(() => { const parsedLanguages = analysisLanguages .split(',') .map((t) => t.trim()) .filter((t) => t.length > 0); const normalizedAnalysisLanguages = parsedLanguages.length > 0 ? parsedLanguages : ['und']; - try { - const projectJson = await papi.commands.sendCommand( - 'interlinearizer.createProject', - projectId, - normalizedAnalysisLanguages, - undefined, - name.trim() || undefined, - description.trim() || undefined, - ); - const parsed: unknown = JSON.parse(projectJson); - if (!isInterlinearProjectSummary(parsed)) { - await papi.notifications.send({ - message: '%interlinearizer_error_create_project_failed%', - severity: 'error', - }); - return; - } - onProjectCreated?.(parsed); - onClose(); - } catch (e) { - if (e instanceof SyntaxError) { - logger.error('Interlinearizer: failed to parse create project response', e); - await papi.notifications.send({ - message: '%interlinearizer_error_create_project_failed%', - severity: 'error', - }); - return; - } - logger.error('Interlinearizer: failed to create project', e); - } finally { - isSubmittingRef.current = false; - setIsSubmitting(false); - } - }, [projectId, analysisLanguages, name, description, onClose, onProjectCreated]); + onCreateDraft({ + analysisLanguages: normalizedAnalysisLanguages, + name: name.trim() || undefined, + description: description.trim() || undefined, + }); + }, [analysisLanguages, name, description, onCreateDraft]); /* v8 ignore next */ if (stringsLoading) return undefined; @@ -155,10 +122,10 @@ export function CreateProjectModal({ placeholder={localizedStrings['%interlinearizer_modal_create_language_placeholder%']} />
- -
diff --git a/src/components/modals/DiscardDraftConfirm.tsx b/src/components/modals/DiscardDraftConfirm.tsx new file mode 100644 index 00000000..7d03b50b --- /dev/null +++ b/src/components/modals/DiscardDraftConfirm.tsx @@ -0,0 +1,58 @@ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button } from 'platform-bible-react'; + +/** Localized string keys used by {@link DiscardDraftConfirm}. */ +const DISCARD_DRAFT_CONFIRM_STRING_KEYS: `%${string}%`[] = [ + '%interlinearizer_confirm_discard_title%', + '%interlinearizer_confirm_discard_body%', + '%interlinearizer_confirm_discard_ok%', + '%interlinearizer_confirm_discard_cancel%', +]; + +/** + * Confirmation dialog shown before an action that would replace the current draft (New or Open) + * when the draft has unsaved changes. Confirming discards the draft's unsaved work; canceling + * returns to the previous dialog. + * + * @param props - Component props + * @param props.onConfirm - Called when the user accepts discarding the draft's unsaved changes. + * @param props.onCancel - Called when the user backs out, leaving the draft untouched. + * @returns The confirmation overlay, or nothing while localized strings are loading. + */ +export function DiscardDraftConfirm({ + onConfirm, + onCancel, +}: Readonly<{ + onConfirm: () => void; + onCancel: () => void; +}>) { + const [localizedStrings, stringsLoading] = useLocalizedStrings(DISCARD_DRAFT_CONFIRM_STRING_KEYS); + + /* v8 ignore next */ if (stringsLoading) return undefined; + + return ( +
+ +

+ {localizedStrings['%interlinearizer_confirm_discard_title%']} +

+

+ {localizedStrings['%interlinearizer_confirm_discard_body%']} +

+
+ + +
+
+
+ ); +} diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index e2de73b5..e5239b7a 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -1,42 +1,77 @@ import type { UseWebViewStateHook } from '@papi/core'; +import papi, { logger } from '@papi/frontend'; +import type { DraftProject } from 'interlinearizer'; import { useCallback, useState } from 'react'; +import type { NewDraftConfig, OpenableProject } from '../../hooks/useDraftProject'; import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; -import { CreateProjectModal } from './CreateProjectModal'; +import { isInterlinearProjectSummary, isTextAnalysis } from '../../types/type-guards'; +import { CreateProjectModal, type CreateDraftConfig } from './CreateProjectModal'; +import { DiscardDraftConfirm } from './DiscardDraftConfirm'; import { ProjectMetadataModal } from './ProjectMetadataModal'; +import { SaveAsProjectModal } from './SaveAsProjectModal'; import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal'; /** Which project-related modal is currently open; `'none'` means no modal is visible. */ -export type ModalState = 'none' | 'select' | 'create' | 'metadata'; +export type ModalState = 'none' | 'select' | 'create' | 'metadata' | 'saveAs'; + +/** + * A draft-replacing action deferred behind the unsaved-changes confirmation: either starting a new + * empty draft or opening an existing project into the draft. + */ +type PendingReplace = + | { kind: 'new'; config: NewDraftConfig } + | { kind: 'open'; project: InterlinearProjectSummary }; /** * Single mount point for all project-related dialogs. Renders at most one of - * {@link SelectInterlinearProjectModal}, {@link CreateProjectModal}, or {@link ProjectMetadataModal} - * based on the `modal` prop, and manages the shared WebView state for the active project. + * {@link SelectInterlinearProjectModal}, {@link CreateProjectModal}, {@link ProjectMetadataModal}, + * {@link SaveAsProjectModal}, or the {@link DiscardDraftConfirm} guard; manages the shared WebView + * state for the active project; and routes New / Open / Save As through the draft (rather than + * persisting projects directly on every edit). * * @param props - Component props - * @param props.activeProject - The currently active interlinear project, read from WebView state by - * the parent. + * @param props.activeProject - The currently active interlinear project (the Save target), read + * from WebView state by the parent. * @param props.defaultAnalysisLanguage - BCP 47 tag forwarded to {@link CreateProjectModal} as the * initial value of the analysis language field; should be the platform UI language. - * @param props.modal - Which modal is currently open - * @param props.projectId - PAPI project ID passed from the host - * @param props.setModal - Setter for which modal is open + * @param props.dirty - Whether the draft has unsaved changes; when true, New / Open are gated + * behind the discard confirmation. + * @param props.getDraftSnapshot - Returns the latest draft envelope (analysis + config) to persist + * on Save As. + * @param props.loadFromProject - Loads a project's analysis + config into the draft (the "Open" + * flow). + * @param props.markSynced - Marks the draft as saved (clears `dirty`) after a successful Save As. + * @param props.modal - Which modal is currently open. + * @param props.projectId - PAPI source project ID passed from the host. + * @param props.resetDraft - Resets the draft to an empty baseline with the given config (the "New" + * flow). + * @param props.setModal - Setter for which modal is open. * @param props.useWebViewState - Hook for reading and writing values persisted in the WebView's - * saved state (survives tab restores) - * @returns The currently active modal, or an empty container when no modal is open. + * saved state (survives tab restores). + * @returns The currently active modal/confirmation, or an empty container when none is open. */ export default function ProjectModals({ activeProject, defaultAnalysisLanguage, + dirty, + getDraftSnapshot, + loadFromProject, + markSynced, modal, projectId, + resetDraft, setModal, useWebViewState, }: Readonly<{ activeProject: InterlinearProjectSummary | undefined; defaultAnalysisLanguage?: string; + dirty: boolean; + getDraftSnapshot: () => DraftProject | undefined; + loadFromProject: (project: OpenableProject) => void; + markSynced: () => void; modal: ModalState; projectId: string; + resetDraft: (config: NewDraftConfig) => void; setModal: (modal: ModalState) => void; useWebViewState: UseWebViewStateHook; }>) { @@ -68,6 +103,9 @@ export default function ProjectModals({ */ const [metadataSourceIsSelect, setMetadataSourceIsSelect] = useState(false); + /** A draft-replacing action awaiting confirmation because the draft has unsaved changes. */ + const [pendingReplace, setPendingReplace] = useState(undefined); + const resolvedMetadataProject = metadataProject ?? activeProject; /** @@ -113,19 +151,178 @@ export default function ProjectModals({ ); /** - * Called when the user selects a project in the select modal. Persists it as the active project - * and dismisses the modal. + * Loads the given project into the draft as a working copy and makes it the Save target. Fetches + * the full project (with analysis) via `interlinearizer.getProject`, validates it, then seeds the + * draft and dismisses the modal. Logs and notifies on failure, leaving the draft untouched. + * + * @param project - The project summary the user chose to open. + * @returns A promise that resolves once the draft is loaded or the failure has been handled. + */ + const openProject = useCallback( + async (project: InterlinearProjectSummary) => { + try { + const json = await papi.commands.sendCommand('interlinearizer.getProject', project.id); + const parsed: unknown = json ? JSON.parse(json) : undefined; + const analysis = + parsed && typeof parsed === 'object' && 'analysis' in parsed + ? parsed.analysis + : undefined; + if (!isInterlinearProjectSummary(parsed) || !isTextAnalysis(analysis)) { + await papi.notifications + .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) + .catch(() => {}); + return; + } + loadFromProject({ + analysisLanguages: parsed.analysisLanguages, + ...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }), + analysis, + }); + setActiveProject(project); + setModal('none'); + } catch (e) { + logger.error('Interlinearizer: failed to open project into draft', e); + await papi.notifications + .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) + .catch(() => {}); + } + }, + [loadFromProject, setActiveProject, setModal], + ); + + /** + * Resets the draft to a fresh empty baseline with the chosen config and clears the Save target so + * the next Save behaves as Save As. + * + * @param config - The configuration collected by the New dialog. + */ + const startNewDraft = useCallback( + (config: CreateDraftConfig) => { + resetDraft(config); + resetActiveProject(); + setCreateSourceIsSelect(false); + setModal('none'); + }, + [resetDraft, resetActiveProject, setModal], + ); + + /** + * Called when the user selects a project in the select modal. Opens it immediately, or defers + * behind the unsaved-changes confirmation when the draft is dirty. * * @param project - The project the user selected. */ const handleSelectProject = useCallback( (project: InterlinearProjectSummary) => { - setActiveProject(project); - setModal('none'); + if (dirty) setPendingReplace({ kind: 'open', project }); + else openProject(project); + }, + [dirty, openProject], + ); + + /** + * Called when the New dialog is submitted. Starts the new draft immediately, or defers behind the + * unsaved-changes confirmation when the draft is dirty. + * + * @param config - The configuration collected by the New dialog. + */ + const handleCreateDraft = useCallback( + (config: CreateDraftConfig) => { + if (dirty) setPendingReplace({ kind: 'new', config }); + else startNewDraft(config); + }, + [dirty, startNewDraft], + ); + + /** Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. */ + const handleConfirmReplace = useCallback(() => { + /* v8 ignore next -- the confirm only renders while a pending action exists */ + if (!pendingReplace) return; + if (pendingReplace.kind === 'open') openProject(pendingReplace.project); + else startNewDraft(pendingReplace.config); + setPendingReplace(undefined); + }, [pendingReplace, openProject, startNewDraft]); + + /** Cancels the deferred action, returning to the underlying modal with the draft untouched. */ + const handleCancelReplace = useCallback(() => setPendingReplace(undefined), []); + + /** + * Saves the current draft as a brand-new project: creates the project with the draft's languages + * / target, writes the draft's analysis into it, then makes it the active Save target and clears + * the dirty flag. Backend commands surface their own error notifications; here we only log. + * + * @param name - Trimmed project name, or `undefined`. + * @param description - Trimmed project description, or `undefined`. + * @returns A promise that resolves once the save completes or the failure has been handled. + */ + const handleSaveAsNew = useCallback( + async (name?: string, description?: string) => { + const snapshot = getDraftSnapshot(); + /* v8 ignore next -- the Save As modal is only open once the draft has loaded */ + if (!snapshot) return; + try { + const createdJson = await papi.commands.sendCommand( + 'interlinearizer.createProject', + projectId, + snapshot.analysisLanguages, + snapshot.targetProjectId, + name, + description, + ); + const created: unknown = JSON.parse(createdJson); + if (!isInterlinearProjectSummary(created)) { + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); + return; + } + await papi.commands.sendCommand( + 'interlinearizer.saveAnalysis', + created.id, + JSON.stringify(snapshot.analysis), + ); + setActiveProject(created); + markSynced(); + setModal('none'); + } catch (e) { + logger.error('Interlinearizer: failed to save draft as new project', e); + } }, - [setActiveProject, setModal], + [getDraftSnapshot, projectId, setActiveProject, markSynced, setModal], ); + /** + * Overwrites an existing project with the current draft: writes the draft's analysis into the + * chosen project, makes it the active Save target, and clears the dirty flag. The backend + * surfaces its own error notification; here we only log. + * + * @param project - The existing project to overwrite. + * @returns A promise that resolves once the overwrite completes or the failure has been handled. + */ + const handleOverwrite = useCallback( + async (project: InterlinearProjectSummary) => { + const snapshot = getDraftSnapshot(); + /* v8 ignore next -- the Save As modal is only open once the draft has loaded */ + if (!snapshot) return; + try { + await papi.commands.sendCommand( + 'interlinearizer.saveAnalysis', + project.id, + JSON.stringify(snapshot.analysis), + ); + setActiveProject(project); + markSynced(); + setModal('none'); + } catch (e) { + logger.error('Interlinearizer: failed to overwrite project with draft', e); + } + }, + [getDraftSnapshot, setActiveProject, markSynced, setModal], + ); + + /** Called when the user dismisses the Save As modal without saving. */ + const handleSaveAsClose = useCallback(() => setModal('none'), [setModal]); + /** * Called when the user clicks "Create new" in the select modal. Switches to the create modal and * records that the create modal was opened from the select modal. @@ -139,7 +336,7 @@ export default function ProjectModals({ const handleSelectClose = useCallback(() => setModal('none'), [setModal]); /** - * Called when the user dismisses the create modal without saving. Returns to the select modal + * Called when the user dismisses the create modal without submitting. Returns to the select modal * when it was opened from there; otherwise dismisses to `'none'`. */ const handleCreateClose = useCallback(() => { @@ -147,20 +344,6 @@ export default function ProjectModals({ setCreateSourceIsSelect(false); }, [createSourceIsSelect, setModal]); - /** - * Called when the create modal successfully creates a project. Persists it as the active project - * and dismisses the modal. - * - * @param project - The newly created project. - */ - const handleProjectCreated = useCallback( - (project: InterlinearProjectSummary) => { - setActiveProject(project); - setModal('none'); - }, - [setActiveProject, setModal], - ); - /** * Called when the metadata modal is dismissed. Returns to the select modal when it was opened * from there; otherwise dismisses to `'none'`. @@ -171,40 +354,58 @@ export default function ProjectModals({ setMetadataProject(undefined); }, [metadataSourceIsSelect, setModal]); + const draftSnapshot = getDraftSnapshot(); + return (
- {modal === 'select' && ( - - )} + {pendingReplace ? ( + + ) : ( + <> + {modal === 'select' && ( + + )} - {modal === 'create' && ( - - )} + {modal === 'create' && ( + + )} + + {modal === 'saveAs' && ( + + )} - {modal === 'metadata' && resolvedMetadataProject && ( - + {modal === 'metadata' && resolvedMetadataProject && ( + + )} + )}
); diff --git a/src/components/modals/SaveAsProjectModal.tsx b/src/components/modals/SaveAsProjectModal.tsx new file mode 100644 index 00000000..5dfe2eb2 --- /dev/null +++ b/src/components/modals/SaveAsProjectModal.tsx @@ -0,0 +1,218 @@ +import papi, { logger } from '@papi/frontend'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button } from 'platform-bible-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; +import { isInterlinearProjectSummary } from '../../types/type-guards'; + +/** Localized string keys used by {@link SaveAsProjectModal}. */ +const SAVE_AS_MODAL_STRING_KEYS: `%${string}%`[] = [ + '%interlinearizer_modal_saveAs_title%', + '%interlinearizer_modal_saveAs_new_section%', + '%interlinearizer_modal_create_name_label%', + '%interlinearizer_modal_create_name_placeholder%', + '%interlinearizer_modal_create_description_label%', + '%interlinearizer_modal_create_description_placeholder%', + '%interlinearizer_modal_saveAs_save_new%', + '%interlinearizer_modal_saveAs_existing_section%', + '%interlinearizer_modal_saveAs_none%', + '%interlinearizer_modal_saveAs_overwrite%', + '%interlinearizer_modal_saveAs_overwrite_confirm_body%', + '%interlinearizer_modal_saveAs_overwrite_confirm_ok%', + '%interlinearizer_modal_saveAs_overwrite_confirm_cancel%', + '%interlinearizer_modal_saveAs_cancel%', + '%interlinearizer_modal_select_name_unnamed%', +]; + +/** + * Save As dialog. Lets the user save the current draft either to a brand-new project (name + + * description) or by overwriting an existing project for this source (with an inline confirm). This + * component is presentational: it collects the choice and delegates the actual persistence to the + * caller via {@link onSaveNew} / {@link onOverwrite}. + * + * @param props - Component props + * @param props.sourceProjectId - Source project whose existing interlinear projects to list as + * overwrite targets. + * @param props.defaultName - Name prefilled into the new-project field (the draft's suggested + * name). + * @param props.defaultDescription - Description prefilled into the new-project field. + * @param props.onSaveNew - Called with the trimmed name/description to save the draft as a new + * project. + * @param props.onOverwrite - Called with the chosen existing project to overwrite it with the + * draft. + * @param props.onClose - Called when the user dismisses the dialog without saving. + * @returns The Save As overlay, or nothing while localized strings are loading. + */ +export function SaveAsProjectModal({ + sourceProjectId, + defaultName, + defaultDescription, + onSaveNew, + onOverwrite, + onClose, +}: Readonly<{ + sourceProjectId: string; + defaultName?: string; + defaultDescription?: string; + onSaveNew: (name?: string, description?: string) => void; + onOverwrite: (project: InterlinearProjectSummary) => void; + onClose: () => void; +}>) { + const [localizedStrings, stringsLoading] = useLocalizedStrings(SAVE_AS_MODAL_STRING_KEYS); + + const [name, setName] = useState(defaultName ?? ''); + const [description, setDescription] = useState(defaultDescription ?? ''); + const [projects, setProjects] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + /** The existing project pending an overwrite confirmation, or `undefined`. */ + const [confirmOverwrite, setConfirmOverwrite] = useState( + undefined, + ); + + /** Incremented each time a load starts; lets an in-flight response detect it has been superseded. */ + const loadGenRef = useRef(0); + + /** + * Loads existing interlinear projects for `sourceProjectId` to populate the overwrite list. Logs + * and notifies on failure; ignores a response superseded by a newer load. + * + * @returns A promise that resolves when the list is loaded or the failure has been handled. + */ + const loadProjects = useCallback(async () => { + loadGenRef.current += 1; + const gen = loadGenRef.current; + setIsLoading(true); + setProjects([]); + try { + const json = await papi.commands.sendCommand( + 'interlinearizer.getProjectsForSource', + sourceProjectId, + ); + if (gen !== loadGenRef.current) return; + const parsed: unknown = JSON.parse(json); + /* v8 ignore next 2 -- backend always returns a JSON array; defensive guard */ + if (!Array.isArray(parsed)) + throw new TypeError('getProjectsForSource did not return an array'); + setProjects(parsed.filter(isInterlinearProjectSummary)); + } catch (e) { + logger.error('Interlinearizer: failed to load projects for Save As', e); + await papi.notifications + .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) + .catch(() => {}); + } finally { + if (gen === loadGenRef.current) setIsLoading(false); + } + }, [sourceProjectId]); + + useEffect(() => { + loadProjects(); + }, [loadProjects]); + + /** Saves the draft as a new project with the trimmed name/description (blank fields → undefined). */ + const handleSaveNew = useCallback(() => { + onSaveNew(name.trim() || undefined, description.trim() || undefined); + }, [name, description, onSaveNew]); + + /* v8 ignore next */ if (stringsLoading) return undefined; + + return ( +
+ +

+ {localizedStrings['%interlinearizer_modal_saveAs_title%']} +

+ +

+ {localizedStrings['%interlinearizer_modal_saveAs_new_section%']} +

+ + setName(e.target.value)} + placeholder={localizedStrings['%interlinearizer_modal_create_name_placeholder%']} + /> + +