diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index ff3bb598f..c80081630 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -107,10 +107,10 @@ export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = { localizeNotes: '', }; -/** Sentinel menu item passed by the mock toolbar when the wipe button is clicked. */ -export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = { - label: '%interlinearizer_wipe%', - command: 'interlinearizer.wipe', +/** Sentinel menu item passed by the mock toolbar when the discard-changes button is clicked. */ +export const MOCK_DISCARD_CHANGES_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_discardChanges%', + command: 'interlinearizer.openDiscardChangesModal', group: 'interlinearizer.draftActions', order: 1, localizeNotes: '', @@ -259,10 +259,10 @@ export function TabToolbar({ {onSelectProjectMenuItem && ( )} {onSelectProjectMenuItem && ( diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index fed9517e5..69ef81f93 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -13,7 +13,7 @@ "%interlinearizer_openProjectInfoModal%": "View Project Info…", "%interlinearizer_save%": "Save", "%interlinearizer_saveAs%": "Save As…", - "%interlinearizer_wipe%": "Wipe…", + "%interlinearizer_discardChanges%": "Discard Unsaved Changes…", "%interlinearizer_openLexiconChooser%": "Connect a Lexicon…", "%interlinearizer_error_openLexiconChooser_failed%": "The lexicon chooser could not be opened.", @@ -32,10 +32,10 @@ "%interlinearizer_redone_catalogDeleteChecked%": "Redid deleting {count} analyses ({usageCount} uses).", "%interlinearizer_undone_catalogClearGlossChecked%": "Undid clearing the gloss on {count} analyses.", "%interlinearizer_redone_catalogClearGlossChecked%": "Redid clearing the gloss on {count} analyses.", - "%interlinearizer_undone_wipeBook%": "Undid wiping {book}.", - "%interlinearizer_redone_wipeBook%": "Redid wiping {book}.", - "%interlinearizer_undone_wipeAll%": "Undid wiping the whole draft.", - "%interlinearizer_redone_wipeAll%": "Redid wiping the whole draft.", + "%interlinearizer_undone_discardBook%": "Undid discarding unsaved changes to {book}.", + "%interlinearizer_redone_discardBook%": "Redid discarding unsaved changes to {book}.", + "%interlinearizer_undone_discardAll%": "Undid discarding all unsaved changes.", + "%interlinearizer_redone_discardAll%": "Redid discarding all unsaved changes.", "%interlinearizer_menu_column_view%": "View", "%interlinearizer_openAnalysisCatalog%": "Analysis Catalog", @@ -299,14 +299,14 @@ "%interlinearizer_modal_saveAs_save_active_clean%": "No changes to save.", "%interlinearizer_modal_saveAs_cancel%": "Cancel", - "%interlinearizer_wipe_modal_title%": "Wipe draft analysis", - "%interlinearizer_wipe_modal_prompt%": "Choose how much of the draft's analysis to remove. Save afterward to persist the change to a project.", - "%interlinearizer_wipe_modal_scope_book%": "Current book", - "%interlinearizer_wipe_modal_scope_book_description%": "Removes all analysis for the current book from the draft.", - "%interlinearizer_wipe_modal_scope_all%": "Entire draft", - "%interlinearizer_wipe_modal_scope_all_description%": "Removes all analysis from the draft.", - "%interlinearizer_wipe_modal_confirm%": "Wipe", - "%interlinearizer_wipe_modal_cancel%": "Cancel", + "%interlinearizer_discardChanges_modal_title%": "Discard unsaved changes", + "%interlinearizer_discardChanges_modal_prompt%": "Choose how much of the unsaved analyses to discard.", + "%interlinearizer_discardChanges_modal_scope_book%": "Current book", + "%interlinearizer_discardChanges_modal_scope_book_description%": "Discard all unsaved analyses for the current book.", + "%interlinearizer_discardChanges_modal_scope_all%": "Entire draft", + "%interlinearizer_discardChanges_modal_scope_all_description%": "Discard all unsaved analyses.", + "%interlinearizer_discardChanges_modal_confirm%": "Discard", + "%interlinearizer_discardChanges_modal_cancel%": "Cancel", "%interlinearizer_bookNotInProject_title%": "Book not in this project", "%interlinearizer_bookNotInProject_description%": "This book is not part of this project yet. You can add it with {buttonLabel}.", @@ -324,6 +324,7 @@ "%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.", "%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again.", "%interlinearizer_error_save_analysis_failed%": "Could not save your analysis. Please try again.", + "%interlinearizer_error_discardChanges_failed%": "The saved project could not be read, so no changes were discarded.", "%interlinearizer_error_save_project_missing%": "The project you were saving to no longer exists. Use Save As to save your work to another project.", "%interlinearizer_info_save_in_progress%": "A save is in progress. Try again when it finishes.", "%interlinearizer_status_saving%": "Saving…", diff --git a/contributions/menus.json b/contributions/menus.json index 7ddc48ba1..a83fe679f 100644 --- a/contributions/menus.json +++ b/contributions/menus.json @@ -96,11 +96,11 @@ "command": "interlinearizer.openSaveAsModal" }, { - "label": "%interlinearizer_wipe%", - "localizeNotes": "Interlinearizer top menu > Open the wipe dialog to remove the current book's or the whole draft's analysis", + "label": "%interlinearizer_discardChanges%", + "localizeNotes": "Interlinearizer top menu > Open the dialog that discards the unsaved analysis of the current book or the whole draft", "group": "interlinearizer.draftActions", "order": 1, - "command": "interlinearizer.wipe" + "command": "interlinearizer.openDiscardChangesModal" }, { "label": "%interlinearizer_openLexiconChooser%", diff --git a/e2e-tests/README.md b/e2e-tests/README.md index c32218321..a3b70ab86 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -42,7 +42,7 @@ Feature tests run with `npm run test:e2e:cdp`. That command launches a fresh, is - **Import from `cdp.fixture`, never `app.fixture`.** The CDP config already serializes execution (`workers: 1`), so tests never race each other on the shared instance. - **The instance is only ever used with the WEB project.** This is an operating assumption, not something tests verify: `ensureInterlinearizerOpenOnWeb()` trusts an existing Interlinearizer tab and only picks WEB when opening fresh. Don't point `start:cdp` at other projects. - **Mutating tests operate on the dedicated "E2E Test Project", never on a developer's own projects.** `ensureE2eProjectActive()` opens it (creating it on first use) at the start of each mutating test. Because the draft is the single per-source working buffer, replacing it could destroy unsaved developer work — so when the draft is dirty and the active project is _not_ the e2e project, the helper first saves the draft into a new `e2e-rescued-work-` project. Rescue projects are backups, not junk: delete them manually once recovered. Dirty state left while the e2e project is active is treated as leftover test data and discarded. -- **Self-establish every precondition.** A mutating test's setup composes these steps as needed: `ensureInterlinearizerOpenOnWeb()`, `ensureE2eProjectActive()`, `navigateToScriptureRef()` (the scroll-group reference could be anywhere), and `wipeDraft()`. They stay as separate calls rather than one wrapper on purpose — tests pick the subset they need (e.g. the draft-persistence reopen deliberately runs only open + navigate, skipping the project reset and wipe that would destroy the state it is verifying). +- **Self-establish every precondition.** A mutating test's setup composes these steps as needed: `ensureInterlinearizerOpenOnWeb()`, `ensureE2eProjectActive()`, `navigateToScriptureRef()` (the scroll-group reference could be anywhere), and `discardAllChanges()`. They stay as separate calls rather than one wrapper on purpose — tests pick the subset they need (e.g. the draft-persistence reopen deliberately runs only open + navigate, skipping the project reset and discard that would destroy the state it is verifying). - **Reset at the start, tidy at the end.** Correctness rests on the start-of-test sequence, which self-heals whatever a failed run left behind (leftover modals, stray project pickers, another test's dirty draft); see the JSDocs on `ensureInterlinearizerOpenOnWeb()` and `ensureE2eProjectActive()` for the mechanism. This self-healing is also what lets the CDP config safely `retries` in CI. Mutating tests additionally end with `ensureE2eProjectActive(page, { rescueDirtyDraft: false })` to discard their own leftovers — a courtesy to the next run, not something correctness depends on. - **Use unique per-run values** (e.g. `` `e2e-gloss-${Date.now()}` ``) for anything written into the draft, so a stale leftover can never satisfy an assertion. - **Drive only the visible UI.** No JSON-RPC/WebSocket calls to set up or assert state (the rpc.discover readiness polls in the shared helpers are the one sanctioned exception). diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index 06dc7d101..6cea92727 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -1695,28 +1695,32 @@ export async function ensureE2eProjectActive( } /** - * Wipe the entire draft's analysis via the visible UI (Project menu → Wipe… → "Entire draft" → - * Wipe). Standard reset step for mutating feature tests on the shared CDP instance: run it at the - * START of a test (never at the end) so a previously failed run self-heals instead of poisoning the - * next one. + * Discard the entire draft's unsaved changes via the visible UI (Project menu → Discard Unsaved + * Changes… → "Entire draft" → Discard). With the e2e project active, the discard empties the draft, + * because no test saves to that project. Standard reset step for mutating feature tests on the + * shared CDP instance: run it at the START of a test (never at the end) so a previously failed run + * self-heals instead of poisoning the next one. * - * @throws If the wipe dialog does not open or does not close after confirming. + * @throws If the discard dialog does not open or does not close after confirming. */ -export async function wipeDraft(page: Page): Promise { +export async function discardAllChanges(page: Page): Promise { const frame = await openInterlinearizerProjectMenu(page); - await frame.getByRole('menuitem', { name: /Wipe/i }).first().click(); + await frame + .getByRole('menuitem', { name: /Discard Unsaved Changes/i }) + .first() + .click(); - const wipeDialogTitle = frame.getByTestId('wipe-modal-title'); - await expect(wipeDialogTitle).toBeVisible({ timeout: 5_000 }); - const scopeAll = frame.getByTestId('wipe-scope-all'); + const dialogTitle = frame.getByTestId('discard-changes-modal-title'); + await expect(dialogTitle).toBeVisible({ timeout: 5_000 }); + const scopeAll = frame.getByTestId('discard-changes-scope-all'); // `force`: on a slow/software-rendered CI display the just-opened modal overlay hasn't won the // hit-test yet, so a normal click is intercepted by the iframe's `#root` for a few frames and // times out. We've asserted the modal is open and target an element inside it by a unique test id, // so skipping the hit-test check is safe. await expect(scopeAll).toBeEnabled({ timeout: 5_000 }); await scopeAll.check({ force: true }); - await frame.getByTestId('wipe-confirm').click({ force: true }); - await expect(wipeDialogTitle).not.toBeVisible({ timeout: 10_000 }); + await frame.getByTestId('discard-changes-confirm').click({ force: true }); + await expect(dialogTitle).not.toBeVisible({ timeout: 10_000 }); } /** diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts index f05435ee8..bbf9d83a0 100644 --- a/e2e-tests/global-setup-cdp.ts +++ b/e2e-tests/global-setup-cdp.ts @@ -75,8 +75,8 @@ const RENDERER_SETTLE_TIMEOUT = process.env.CI ? 180_000 : 120_000; * 4. Records the PID and user-data dir for {@link globalTeardownCdp}. * * The isolated user-data dir means the run never touches a developer's real profile; the feature - * tests self-establish every precondition (they create the E2E project, navigate, and wipe the - * draft at the start of each test), so a fresh profile is sufficient. + * tests self-establish every precondition (they create the E2E project, navigate, and discard the + * draft's changes at the start of each test), so a fresh profile is sufficient. * * If the CDP port is already in use, a warm instance is assumed (a developer's own `npm run * start:cdp`) and setup is a no-op: no app is launched and nothing is recorded for teardown, so the diff --git a/e2e-tests/tests/features/draft-persistence.spec.ts b/e2e-tests/tests/features/draft-persistence.spec.ts index 8e2b8c1db..fca4e5fc2 100644 --- a/e2e-tests/tests/features/draft-persistence.spec.ts +++ b/e2e-tests/tests/features/draft-persistence.spec.ts @@ -6,7 +6,7 @@ import { getInterlinearizerFrame, navigateToScriptureRef, waitForAppAndInterlinearizerReady, - wipeDraft, + discardAllChanges, } from '../../fixtures/helpers'; test.describe('Draft persistence', () => { @@ -21,7 +21,7 @@ test.describe('Draft persistence', () => { await ensureInterlinearizerOpenOnWeb(mainPage); await ensureE2eProjectActive(mainPage); await navigateToScriptureRef(mainPage, 'GEN 1:1'); - await wipeDraft(mainPage); + await discardAllChanges(mainPage); const frame = getInterlinearizerFrame(mainPage); const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first(); diff --git a/e2e-tests/tests/features/gloss-roundtrip.spec.ts b/e2e-tests/tests/features/gloss-roundtrip.spec.ts index d866289c8..e018220e5 100644 --- a/e2e-tests/tests/features/gloss-roundtrip.spec.ts +++ b/e2e-tests/tests/features/gloss-roundtrip.spec.ts @@ -5,7 +5,7 @@ import { getInterlinearizerFrame, navigateToScriptureRef, waitForAppAndInterlinearizerReady, - wipeDraft, + discardAllChanges, } from '../../fixtures/helpers'; test.describe('Gloss round-trip', () => { @@ -19,14 +19,14 @@ test.describe('Gloss round-trip', () => { await ensureInterlinearizerOpenOnWeb(mainPage); await ensureE2eProjectActive(mainPage); await navigateToScriptureRef(mainPage, 'GEN 1:1'); - await wipeDraft(mainPage); + await discardAllChanges(mainPage); const frame = getInterlinearizerFrame(mainPage); // WEB Genesis 1:1: "In the beginning, God created the heavens and the earth." const glossInput = frame.getByLabel('Gloss for beginning', { exact: true }).first(); await expect(glossInput).toBeVisible({ timeout: 30_000 }); - // The wipe just cleared all analysis, so the field must start empty. + // The discard just emptied the draft, so the field must start empty. await expect(glossInput).toHaveValue(''); // Unique per run so a leftover value from a previous run can never false-pass. diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index 392af0afb..60a512958 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -179,29 +179,43 @@ jest.mock('../../components/controls/ScriptureNavControls', () => ({ ), })); -jest.mock('../../components/modals/WipeModal', () => ({ +jest.mock('../../components/modals/DiscardChangesModal', () => ({ __esModule: true, /** - * Minimal WipeModal stand-in exposing per-scope confirm buttons and cancel so tests can drive the - * loader's wipe handlers without the real dialog's localization or scope-picker UI. + * Minimal DiscardChangesModal stand-in exposing per-scope confirm buttons and cancel so tests can + * drive the loader's discard handlers without the real dialog's localization or scope-picker UI. */ - WipeModal: ({ + DiscardChangesModal: ({ hasActiveBook, + isSubmitting, onConfirm, onCancel, }: { hasActiveBook: boolean; + isSubmitting: boolean; onConfirm: (scope: 'book' | 'all') => void; onCancel: () => void; }) => ( -
- - -
@@ -1769,18 +1783,18 @@ describe('InterlinearizerLoader', () => { expect(screen.queryByTestId('pt9-import-banner')).not.toBeInTheDocument(); }); - it('silences Save, Save As, and Wipe while an import is open', async () => { + it('silences Save, Save As, and Discard Unsaved Changes while an import is open', async () => { mockImportCommands(); await renderImportView(); mockSendCommand.mockClear(); await userEvent.click(screen.getByTestId('tab-toolbar-save')); await userEvent.click(screen.getByTestId('tab-toolbar-save-as')); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); expect(mockSendCommand).not.toHaveBeenCalled(); expect(screen.queryByTestId('save-as-modal')).not.toBeInTheDocument(); - expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(); }); /** @@ -4188,17 +4202,17 @@ describe('InterlinearizerLoader', () => { return () => act(async () => finishSave(JSON.stringify(STUB_ACTIVE_PROJECT))); } - it('shows the save running, and holds New, Save, Save As, and Wipe while it does', async () => { + it('shows the save running, and holds New, Save, Save As, and Discard Unsaved Changes while it does', async () => { await startHeldSave(); expect(screen.getByTestId('saving-indicator')).toBeInTheDocument(); await userEvent.click(screen.getByTestId('tab-toolbar-new-project')); await userEvent.click(screen.getByTestId('tab-toolbar-save-as')); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); await userEvent.click(screen.getByTestId('tab-toolbar-save')); expect(screen.getByTestId('project-modals')).toHaveAttribute('data-modal', 'none'); - expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(); // Each held command says why it did nothing; the save itself was sent once. expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledTimes(4); expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ @@ -4216,8 +4230,8 @@ describe('InterlinearizerLoader', () => { await finishSave(); await waitFor(() => expect(screen.queryByTestId('saving-indicator')).not.toBeInTheDocument()); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - expect(screen.getByTestId('wipe-modal-panel')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + expect(screen.getByTestId('discard-changes-modal-panel')).toBeInTheDocument(); expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); }); @@ -4483,7 +4497,7 @@ describe('InterlinearizerLoader', () => { ); }); - it('keeps the source text across a wipe that replaces the draft', async () => { + it('keeps the source text across a discard that replaces the draft', async () => { let readerMounts = 0; const text = makeSourceText({ status: 'ready' }); jest.mocked(useSourceTextReader).mockImplementation(() => { @@ -4500,8 +4514,8 @@ describe('InterlinearizerLoader', () => { await userEvent.click(screen.getByTestId('tab-toolbar-concordance')); const viewMounts = interlinearizerMountCount; - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - await userEvent.click(screen.getByTestId('wipe-confirm-all')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId('discard-changes-confirm-all')); expect(interlinearizerMountCount).toBeGreaterThan(viewMounts); expect(readerMounts).toBe(1); @@ -4862,16 +4876,24 @@ describe('InterlinearizerLoader', () => { }); }); - describe('wipe command', () => { - it('opens the wipe dialog with the active-book flag set when a book is loaded', async () => { + describe('discard changes command', () => { + /** The draft the last `saveDraft` wrote, if any. */ + function lastSavedDraft(): DraftProject | undefined { + const json = mockSendCommand.mock.calls.findLast( + ([c]) => c === 'interlinearizer.saveDraft', + )?.[2]; + return typeof json === 'string' ? JSON.parse(json) : undefined; + } + + it('opens the discard dialog with the active-book flag set when a book is loaded', async () => { await act(async () => { renderLoader(); }); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); - // The dialog must appear before anything is wiped, with the loaded book reflected. - expect(screen.getByTestId('wipe-modal-panel')).toHaveAttribute( + // The dialog must appear before anything is discarded, with the loaded book reflected. + expect(screen.getByTestId('discard-changes-modal-panel')).toHaveAttribute( 'data-has-active-book', 'true', ); @@ -4880,52 +4902,157 @@ describe('InterlinearizerLoader', () => { ).toHaveLength(0); }); - it('wipes the current book through the draft after confirming the book scope', async () => { + it('discards the current book through the draft after confirming the book scope', async () => { await act(async () => { renderLoader(); }); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - await userEvent.click(screen.getByTestId('wipe-confirm-book')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId('discard-changes-confirm-book')); - // Confirming a book wipe replaces the draft (saveDraft) and dismisses the dialog. + // Confirming a book discard replaces the draft (saveDraft) and dismisses the dialog. expect( mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft').length, ).toBeGreaterThan(0); - expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(); }); - it('wipes the whole draft through the draft after confirming the all scope', async () => { + it('empties the whole draft after confirming the all scope with no active project', async () => { await act(async () => { renderLoader(); }); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - await userEvent.click(screen.getByTestId('wipe-confirm-all')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId('discard-changes-confirm-all')); + + expect(lastSavedDraft()?.analysis).toEqual(emptyAnalysis()); + expect(lastSavedDraft()?.dirty).toBe(false); + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(); + }); + + describe('with an active project', () => { + const saved = analysisApprovingAt('GEN 1:1:0', 'saved'); + + /** Serves an unsaved draft, and answers `getProject` for the active project with `read`. */ + function mockStoredProject( + read: () => Promise = async () => + JSON.stringify({ + ...STUB_ACTIVE_PROJECT, + updatedAt: '2026-01-02T00:00:00Z', + analysis: saved, + }), + ): void { + mockSendCommand.mockImplementation(async (...args) => { + if (args[0] === 'interlinearizer.getProject') return read(); + return JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'unsaved'), + dirty: true, + }); + }); + } + + /** Renders the loader with {@link STUB_ACTIVE_PROJECT} active and confirms `scope`. */ + async function discard(scope: 'book' | 'all'): Promise { + await act(async () => { + renderLoader({ + useWebViewState: makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT }), + }); + }); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId(`discard-changes-confirm-${scope}`)); + } + + it("restores the project's saved analysis when discarding the entire draft", async () => { + mockStoredProject(); + + await discard('all'); + + await waitFor(() => expect(lastSavedDraft()?.analysis).toEqual(saved)); + }); + + it("restores the current book from the project's saved analysis", async () => { + mockStoredProject(); + + await discard('book'); + + await waitFor(() => expect(lastSavedDraft()?.analysis).toEqual(saved)); + }); + + it('notifies and leaves the draft untouched when the project cannot be read', async () => { + mockStoredProject(async () => { + throw new Error('storage offline'); + }); + + await discard('all'); + + await waitFor(() => + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_error_discardChanges_failed%', + severity: 'error', + }), + ); + expect(lastSavedDraft()).toBeUndefined(); + }); + + it('keeps the dialog open and submitting while the project is read', async () => { + mockStoredProject(() => new Promise(() => {})); + + await discard('all'); + + expect(screen.getByTestId('discard-changes-modal-panel')).toHaveAttribute( + 'data-is-submitting', + 'true', + ); + }); + + it('dismisses the dialog once the restored content is applied', async () => { + mockStoredProject(); + + await discard('all'); + + await waitFor(() => + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(), + ); + }); + + it('dismisses the dialog when the project cannot be read', async () => { + mockStoredProject(async () => { + throw new Error('storage offline'); + }); + + await discard('all'); + + await waitFor(() => + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(), + ); + }); + + it('notifies and leaves the draft untouched when the project no longer exists', async () => { + mockStoredProject(async () => undefined); + + await discard('all'); - const wiped: DraftProject | undefined = (() => { - const call = [...mockSendCommand.mock.calls] - .reverse() - .find(([c]) => c === 'interlinearizer.saveDraft'); - const json = call?.[2]; - return typeof json === 'string' ? JSON.parse(json) : undefined; - })(); - expect(wiped?.analysis).toEqual(emptyAnalysis()); - // Wiping the whole draft is treated as a clean baseline, so it persists not-dirty. - expect(wiped?.dirty).toBe(false); - expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + await waitFor(() => + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith({ + message: '%interlinearizer_error_discardChanges_failed%', + severity: 'error', + }), + ); + expect(lastSavedDraft()).toBeUndefined(); + }); }); - it('leaves the draft untouched when the wipe dialog is canceled', async () => { + it('leaves the draft untouched when the discard dialog is canceled', async () => { await act(async () => { renderLoader(); }); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); mockSendCommand.mockClear(); - await userEvent.click(screen.getByTestId('wipe-modal-cancel')); + await userEvent.click(screen.getByTestId('discard-changes-modal-cancel')); - expect(screen.queryByTestId('wipe-modal-panel')).not.toBeInTheDocument(); + expect(screen.queryByTestId('discard-changes-modal-panel')).not.toBeInTheDocument(); expect( mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft'), ).toHaveLength(0); @@ -5069,7 +5196,7 @@ describe('InterlinearizerLoader', () => { }); }); - it('resets phraseMode to view when the draft is replaced (wipe)', async () => { + it('resets phraseMode to view when the draft is replaced (discard)', async () => { await act(async () => { renderLoader(); }); @@ -5088,8 +5215,8 @@ describe('InterlinearizerLoader', () => { expect(capturedInterlinearizerProps?.phraseMode.kind).toBe('edit'); // Wiping the whole draft bumps draftVersion, which the loader watches to reset phraseMode. - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - await userEvent.click(screen.getByTestId('wipe-confirm-all')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId('discard-changes-confirm-all')); expect(capturedInterlinearizerProps?.phraseMode).toEqual({ kind: 'view' }); }); @@ -5395,18 +5522,18 @@ describe('analysis store lifetime', () => { }); it('rebuilds the store when the draft is replaced wholesale', async () => { - // The store's seed is not reactive, so a replacement (New / Open / Wipe) reseeds by remounting - // the provider. Hoisting it above the book key must not cost that: a wiped draft whose store + // The store's seed is not reactive, so a replacement (New / Open / Discard) reseeds by remounting + // the provider. Hoisting it above the book key must not cost that: a discarded draft whose store // kept the old analysis would show glosses the draft no longer contains. await act(async () => renderLoader()); const storeBefore = probeStore; act(() => probeWriteGloss?.('GEN 1:1:0', 'In', 'beginning')); await act(async () => { - screen.getByTestId('tab-toolbar-wipe').click(); + screen.getByTestId('tab-toolbar-discard-changes').click(); }); await act(async () => { - screen.getByTestId('wipe-confirm-all').click(); + screen.getByTestId('discard-changes-confirm-all').click(); }); expect(probeStore).not.toBe(storeBefore); @@ -5495,8 +5622,8 @@ describe('whole-text re-anchoring', () => { await renderWithApprovalAt('EXO 1:1:3'); const { readKey } = readerArgs(); - await userEvent.click(screen.getByTestId('tab-toolbar-wipe')); - await userEvent.click(screen.getByTestId('wipe-confirm-book')); + await userEvent.click(screen.getByTestId('tab-toolbar-discard-changes')); + await userEvent.click(screen.getByTestId('discard-changes-confirm-book')); expect(readerArgs().readKey).not.toBe(readKey); }); @@ -5701,31 +5828,31 @@ describe('undo and redo', () => { }); describe('announcing a step with no one place', () => { - /** Renders the loader, glosses a word, and wipes the whole draft. */ - async function renderAndWipe(): Promise { + /** Renders the loader, glosses a word, and discards the whole draft. */ + async function renderAndDiscard(): Promise { await renderAndGloss(); await act(async () => { - screen.getByTestId('tab-toolbar-wipe').click(); + screen.getByTestId('tab-toolbar-discard-changes').click(); }); await act(async () => { - screen.getByTestId('wipe-confirm-all').click(); + screen.getByTestId('discard-changes-confirm-all').click(); }); } - it('announces an undone wipe', async () => { - await renderAndWipe(); + it('announces an undone discard', async () => { + await renderAndDiscard(); await act(async () => { fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); }); expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( - expect.objectContaining({ message: '%interlinearizer_undone_wipeAll%' }), + expect.objectContaining({ message: '%interlinearizer_undone_discardAll%' }), ); }); - it('announces a redone wipe', async () => { - await renderAndWipe(); + it('announces a redone discard', async () => { + await renderAndDiscard(); act(() => { fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); }); @@ -5735,20 +5862,20 @@ describe('undo and redo', () => { }); expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( - expect.objectContaining({ message: '%interlinearizer_redone_wipeAll%' }), + expect.objectContaining({ message: '%interlinearizer_redone_discardAll%' }), ); }); - describe('naming the book of an undone book wipe', () => { - /** Renders the loader, glosses a word in GEN, wipes GEN, and undoes the wipe. */ - async function wipeBookAndUndo(): Promise { + describe('naming the book of an undone book discard', () => { + /** Renders the loader, glosses a word in GEN, discards GEN, and undoes the discard. */ + async function discardBookAndUndo(): Promise { jest.mocked(useLocalizedStrings).mockImplementation((keys: readonly string[]) => [ Object.fromEntries( keys.map((k) => [ k, { - '%interlinearizer_undone_wipeBook%': 'Undid wiping {book}.', - '%interlinearizer_redone_wipeBook%': 'Redid wiping {book}.', + '%interlinearizer_undone_discardBook%': 'Undid wiping {book}.', + '%interlinearizer_redone_discardBook%': 'Redid wiping {book}.', }[k] ?? k, ]), ), @@ -5756,10 +5883,10 @@ describe('undo and redo', () => { ]); await renderAndGloss(); await act(async () => { - screen.getByTestId('tab-toolbar-wipe').click(); + screen.getByTestId('tab-toolbar-discard-changes').click(); }); await act(async () => { - screen.getByTestId('wipe-confirm-book').click(); + screen.getByTestId('discard-changes-confirm-book').click(); }); await act(async () => { fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); @@ -5773,7 +5900,7 @@ describe('undo and redo', () => { localizeKey === '%LocalizedId.GEN%' ? 'Mwanzo' : localizeKey, ); - await wipeBookAndUndo(); + await discardBookAndUndo(); expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( expect.objectContaining({ message: 'Undid wiping Mwanzo.' }), @@ -5785,7 +5912,7 @@ describe('undo and redo', () => { .mocked(papi.localization.getLocalizedString) .mockImplementation(async ({ localizeKey }) => localizeKey); - await wipeBookAndUndo(); + await discardBookAndUndo(); expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( expect.objectContaining({ message: 'Undid wiping Genesis.' }), @@ -5798,7 +5925,7 @@ describe('undo and redo', () => { jest .mocked(papi.localization.getLocalizedString) .mockImplementation(() => new Promise(() => {})); - await wipeBookAndUndo(); + await discardBookAndUndo(); await act(async () => { jest.advanceTimersByTime(BOOK_NAME_TIMEOUT_MS); @@ -5824,7 +5951,7 @@ describe('undo and redo', () => { ) .mockResolvedValue('Mwanzo'); - await wipeBookAndUndo(); + await discardBookAndUndo(); await act(async () => { fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); }); @@ -6205,13 +6332,13 @@ describe('undo and redo', () => { }); }); - it('brings wiped glosses back on Ctrl+Z', async () => { + it('brings discarded glosses back on Ctrl+Z', async () => { await renderAndGloss(); await act(async () => { - screen.getByTestId('tab-toolbar-wipe').click(); + screen.getByTestId('tab-toolbar-discard-changes').click(); }); await act(async () => { - screen.getByTestId('wipe-confirm-all').click(); + screen.getByTestId('discard-changes-confirm-all').click(); }); act(() => { diff --git a/src/__tests__/components/modals/DiscardChangesModal.test.tsx b/src/__tests__/components/modals/DiscardChangesModal.test.tsx new file mode 100644 index 000000000..75a54d05a --- /dev/null +++ b/src/__tests__/components/modals/DiscardChangesModal.test.tsx @@ -0,0 +1,125 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useLocalizedStrings } from '@papi/frontend/react'; +import { DiscardChangesModal } from '../../../components/modals/DiscardChangesModal'; + +const LOCALIZED: Record = { + '%interlinearizer_discardChanges_modal_title%': 'Discard unsaved changes', + '%interlinearizer_discardChanges_modal_prompt%': 'Choose how much to discard.', + '%interlinearizer_discardChanges_modal_scope_book%': 'Current book', + '%interlinearizer_discardChanges_modal_scope_book_description%': 'Discards the current book.', + '%interlinearizer_discardChanges_modal_scope_all%': 'Entire draft', + '%interlinearizer_discardChanges_modal_scope_all_description%': 'Discards the whole draft.', + '%interlinearizer_discardChanges_modal_confirm%': 'Discard', + '%interlinearizer_discardChanges_modal_cancel%': 'Cancel', +}; + +const defaultProps = { + hasActiveBook: true, + onConfirm: jest.fn(), + onCancel: jest.fn(), +}; + +describe('DiscardChangesModal', () => { + beforeEach(() => { + jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); + }); + + it('renders the title, prompt, and both scope options', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Discard unsaved changes' })).toBeInTheDocument(); + expect(screen.getByText('Choose how much to discard.')).toBeInTheDocument(); + expect(screen.getByText('Current book')).toBeInTheDocument(); + expect(screen.getByText('Discards the current book.')).toBeInTheDocument(); + expect(screen.getByText('Entire draft')).toBeInTheDocument(); + expect(screen.getByText('Discards the whole draft.')).toBeInTheDocument(); + }); + + it('defaults to the current-book scope when a book is loaded and confirms with "book"', async () => { + const onConfirm = jest.fn(); + render(); + + expect(screen.getByTestId('discard-changes-scope-book')).toBeChecked(); + expect(screen.getByTestId('discard-changes-scope-all')).not.toBeChecked(); + + await userEvent.click(screen.getByTestId('discard-changes-confirm')); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onConfirm).toHaveBeenCalledWith('book'); + }); + + it('confirms with "all" when the entire-draft scope is selected', async () => { + const onConfirm = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('discard-changes-scope-all')); + expect(screen.getByTestId('discard-changes-scope-all')).toBeChecked(); + + await userEvent.click(screen.getByTestId('discard-changes-confirm')); + + expect(onConfirm).toHaveBeenCalledWith('all'); + }); + + it('lets the user switch back to the current-book scope after picking the whole draft', async () => { + const onConfirm = jest.fn(); + render(); + + await userEvent.click(screen.getByTestId('discard-changes-scope-all')); + await userEvent.click(screen.getByTestId('discard-changes-scope-book')); + expect(screen.getByTestId('discard-changes-scope-book')).toBeChecked(); + + await userEvent.click(screen.getByTestId('discard-changes-confirm')); + + expect(onConfirm).toHaveBeenCalledWith('book'); + }); + + it('disables the current-book scope and defaults to the whole draft when no book is loaded', async () => { + const onConfirm = jest.fn(); + render(); + + expect(screen.getByTestId('discard-changes-scope-book')).toBeDisabled(); + expect(screen.getByTestId('discard-changes-scope-all')).toBeChecked(); + + await userEvent.click(screen.getByTestId('discard-changes-confirm')); + + expect(onConfirm).toHaveBeenCalledWith('all'); + }); + + it('disables both buttons while submitting', () => { + render(); + + expect(screen.getByTestId('discard-changes-confirm')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('calls onCancel when Escape is pressed', async () => { + const onCancel = jest.fn(); + render(); + + await userEvent.keyboard('{Escape}'); + + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('ignores Escape while submitting', async () => { + const onCancel = jest.fn(); + render(); + + await userEvent.keyboard('{Escape}'); + + expect(onCancel).not.toHaveBeenCalled(); + }); + + 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__/components/modals/WipeModal.test.tsx b/src/__tests__/components/modals/WipeModal.test.tsx deleted file mode 100644 index d547430fb..000000000 --- a/src/__tests__/components/modals/WipeModal.test.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/// -/// - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useLocalizedStrings } from '@papi/frontend/react'; -import { WipeModal } from '../../../components/modals/WipeModal'; - -const LOCALIZED: Record = { - '%interlinearizer_wipe_modal_title%': 'Wipe draft analysis', - '%interlinearizer_wipe_modal_prompt%': 'Choose how much of the draft to remove.', - '%interlinearizer_wipe_modal_scope_book%': 'Current book', - '%interlinearizer_wipe_modal_scope_book_description%': 'Removes the current book.', - '%interlinearizer_wipe_modal_scope_all%': 'Entire draft', - '%interlinearizer_wipe_modal_scope_all_description%': 'Removes the whole draft.', - '%interlinearizer_wipe_modal_confirm%': 'Wipe', - '%interlinearizer_wipe_modal_cancel%': 'Cancel', -}; - -const defaultProps = { - hasActiveBook: true, - onConfirm: jest.fn(), - onCancel: jest.fn(), -}; - -describe('WipeModal', () => { - beforeEach(() => { - jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); - }); - - it('renders the title, prompt, and both scope options', () => { - render(); - - expect(screen.getByRole('heading', { name: 'Wipe draft analysis' })).toBeInTheDocument(); - expect(screen.getByText('Choose how much of the draft to remove.')).toBeInTheDocument(); - expect(screen.getByText('Current book')).toBeInTheDocument(); - expect(screen.getByText('Removes the current book.')).toBeInTheDocument(); - expect(screen.getByText('Entire draft')).toBeInTheDocument(); - expect(screen.getByText('Removes the whole draft.')).toBeInTheDocument(); - }); - - it('defaults to the current-book scope when a book is loaded and confirms with "book"', async () => { - const onConfirm = jest.fn(); - render(); - - expect(screen.getByTestId('wipe-scope-book')).toBeChecked(); - expect(screen.getByTestId('wipe-scope-all')).not.toBeChecked(); - - await userEvent.click(screen.getByTestId('wipe-confirm')); - - expect(onConfirm).toHaveBeenCalledTimes(1); - expect(onConfirm).toHaveBeenCalledWith('book'); - }); - - it('confirms with "all" when the entire-draft scope is selected', async () => { - const onConfirm = jest.fn(); - render(); - - await userEvent.click(screen.getByTestId('wipe-scope-all')); - expect(screen.getByTestId('wipe-scope-all')).toBeChecked(); - - await userEvent.click(screen.getByTestId('wipe-confirm')); - - expect(onConfirm).toHaveBeenCalledWith('all'); - }); - - it('lets the user switch back to the current-book scope after picking the whole draft', async () => { - const onConfirm = jest.fn(); - render(); - - await userEvent.click(screen.getByTestId('wipe-scope-all')); - await userEvent.click(screen.getByTestId('wipe-scope-book')); - expect(screen.getByTestId('wipe-scope-book')).toBeChecked(); - - await userEvent.click(screen.getByTestId('wipe-confirm')); - - expect(onConfirm).toHaveBeenCalledWith('book'); - }); - - it('disables the current-book scope and defaults to the whole draft when no book is loaded', async () => { - const onConfirm = jest.fn(); - render(); - - expect(screen.getByTestId('wipe-scope-book')).toBeDisabled(); - expect(screen.getByTestId('wipe-scope-all')).toBeChecked(); - - await userEvent.click(screen.getByTestId('wipe-confirm')); - - expect(onConfirm).toHaveBeenCalledWith('all'); - }); - - 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 index ef70e8f75..0d28a8a5f 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -3,7 +3,7 @@ import papi, { logger } from '@papi/frontend'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { DraftProject, TextAnalysis } from 'interlinearizer'; -import { FIXTURE_STAMPS } from '../test-helpers'; +import { FIXTURE_STAMPS, makeTokenLink } from '../test-helpers'; import useDraftProject, { type DraftContent, type StepSummary, @@ -432,7 +432,7 @@ describe('useDraftProject', () => { }); act(() => { - result.current.wipeAll(); + result.current.discardAll(); }); let landed: boolean | undefined; await act(async () => { @@ -778,114 +778,269 @@ describe('useDraftProject', () => { }); }); - 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, - { ...FIXTURE_STAMPS, id: 'mat-tok', surfaceText: 'word' }, - ], - tokenAnalysisLinks: [ - ...genToken.tokenAnalysisLinks, - { - id: 'tl-2', - ...FIXTURE_STAMPS, - analysisId: 'mat-tok', - status: 'approved', - token: { tokenRef: 'MAT 1:1:0', surfaceText: 'word' }, - }, - ], + describe('discardBook', () => { + /** Builds an analysis that glosses each token ref with its own payload, `analysisId`. */ + function analysisOf(...glosses: [analysisId: string, tokenRef: string][]): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: glosses.map(([id]) => ({ ...FIXTURE_STAMPS, id, surfaceText: 'word' })), + tokenAnalysisLinks: glosses.map(([analysisId, tokenRef]) => + makeTokenLink(analysisId, tokenRef, { surfaceText: 'word' }), + ), }; - mockGetDraftResolves(makeDraft({ analysis: mixed })); + } + + /** Lists each token link in `analysis` as `tokenRef=analysisId`, sorted. */ + function glossesIn(analysis: TextAnalysis | undefined): string[] { + return (analysis?.tokenAnalysisLinks ?? []) + .map((link) => `${link.token.tokenRef}=${link.analysisId}`) + .sort(); + } + + it("restores the book's analysis from the saved project", async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisOf(['gen-unsaved', 'GEN 1:1:0']) })); const { result } = await renderLoaded(); - const versionBefore = result.current.draftVersion; + const saved = analysisOf(['gen-saved', 'GEN 1:1:0']); act(() => { - result.current.wipeBook('GEN'); + result.current.discardBook('GEN', { analysis: saved, segmentation: undefined }); }); - 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); + expect(glossesIn(result.current.draft?.analysis)).toEqual(['GEN 1:1:0=gen-saved']); + expect(glossesIn(lastSavedDraft().analysis)).toEqual(['GEN 1:1:0=gen-saved']); }); - it('drops the wiped book’s custom boundaries while keeping other books’', async () => { + it("keeps other books' unsaved analysis", async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisOf(['gen-unsaved', 'GEN 1:1:0'], ['mat-unsaved', 'MAT 1:1:0']), + }), + ); + const { result } = await renderLoaded(); + const saved = analysisOf(['gen-saved', 'GEN 1:1:0'], ['mat-saved', 'MAT 1:1:0']); + + act(() => { + result.current.discardBook('GEN', { analysis: saved, segmentation: undefined }); + }); + + expect(glossesIn(result.current.draft?.analysis)).toEqual([ + 'GEN 1:1:0=gen-saved', + 'MAT 1:1:0=mat-unsaved', + ]); + }); + + it("restores the book's boundaries from the saved project, keeping other books'", async () => { mockGetDraftResolves( makeDraft({ segmentation: { removedVerseStarts: ['GEN 1:2:0', 'MAT 3:4:0'], addedStarts: [] }, }), ); const { result } = await renderLoaded(); + const savedStart = { tokenRef: 'GEN 1:3:5', surfaceText: 'word' }; act(() => { - result.current.wipeBook('GEN'); + result.current.discardBook('GEN', { + analysis: emptyAnalysis(), + segmentation: { + removedVerseStarts: ['GEN 1:5:0', 'MAT 9:9:0'], + addedStarts: [savedStart], + }, + }); }); - const expected = { removedVerseStarts: ['MAT 3:4:0'], addedStarts: [] }; + const expected = { + removedVerseStarts: ['MAT 3:4:0', 'GEN 1:5:0'], + addedStarts: [savedStart], + }; expect(result.current.draft?.segmentation).toEqual(expected); expect(lastSavedDraft().segmentation).toEqual(expected); }); - it('clears the segmentation field entirely when only the wiped book had boundaries', async () => { + it('leaves a clean draft clean', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisOf(['gen-saved', 'GEN 1:1:0']) })); + const { result } = await renderLoaded(); + + act(() => { + result.current.discardBook('GEN', { + analysis: analysisOf(['gen-saved', 'GEN 1:1:0']), + segmentation: undefined, + }); + }); + + expect(result.current.dirty).toBe(false); + expect(lastSavedDraft().dirty).toBe(false); + }); + + it('leaves an unsaved draft unsaved', async () => { mockGetDraftResolves( makeDraft({ - segmentation: { - removedVerseStarts: ['GEN 1:2:0'], - addedStarts: [{ tokenRef: 'GEN 1:3:5', surfaceText: 'word' }], - }, + analysis: analysisOf(['gen-unsaved', 'GEN 1:1:0'], ['mat-unsaved', 'MAT 1:1:0']), + dirty: true, }), ); const { result } = await renderLoaded(); act(() => { - result.current.wipeBook('GEN'); + result.current.discardBook('GEN', { + analysis: analysisOf(['gen-saved', 'GEN 1:1:0']), + segmentation: undefined, + }); }); - expect(result.current.draft?.segmentation).toBeUndefined(); - expect(lastSavedDraft().segmentation).toBeUndefined(); + expect(result.current.dirty).toBe(true); + expect(lastSavedDraft().dirty).toBe(true); + }); + + describe('with no saved project', () => { + it('removes the book, bumps the version, and persists', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisOf(['gen-unsaved', 'GEN 1:1:0'], ['mat-unsaved', 'MAT 1:1:0']), + }), + ); + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.discardBook('GEN'); + }); + + expect(glossesIn(result.current.draft?.analysis)).toEqual(['MAT 1:1:0=mat-unsaved']); + expect(result.current.draftVersion).toBe(versionBefore + 1); + expect(glossesIn(lastSavedDraft().analysis)).toEqual(['MAT 1:1:0=mat-unsaved']); + }); + + it('marks a clean draft unsaved', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisOf(['gen-unsaved', 'GEN 1:1:0'], ['mat-unsaved', 'MAT 1:1:0']), + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.discardBook('GEN'); + }); + + expect(result.current.dirty).toBe(true); + expect(lastSavedDraft().dirty).toBe(true); + }); + + it('drops the book’s custom boundaries while keeping other books’', async () => { + mockGetDraftResolves( + makeDraft({ + segmentation: { removedVerseStarts: ['GEN 1:2:0', 'MAT 3:4:0'], addedStarts: [] }, + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.discardBook('GEN'); + }); + + const expected = { removedVerseStarts: ['MAT 3:4:0'], addedStarts: [] }; + expect(result.current.draft?.segmentation).toEqual(expected); + expect(lastSavedDraft().segmentation).toEqual(expected); + }); + + it('clears the segmentation field entirely when only that book had boundaries', async () => { + mockGetDraftResolves( + makeDraft({ + segmentation: { + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [{ tokenRef: 'GEN 1:3:5', surfaceText: 'word' }], + }, + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.discardBook('GEN'); + }); + + expect(result.current.draft?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); }); }); - describe('wipeAll', () => { - it('clears the analysis entirely, clears dirty (clean baseline), bumps the version, and persists', async () => { - // Start from a dirty draft so the transition to clean is observable. - mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-wipe-all'), dirty: true })); + describe('discardAll', () => { + it('restores the saved analysis and boundaries, clean, and persists', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisWithToken('tok-unsaved'), + dirty: true, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }), + ); const { result } = await renderLoaded(); - expect(result.current.dirty).toBe(true); const versionBefore = result.current.draftVersion; + const saved: DraftContent = { + analysis: analysisWithToken('tok-saved'), + segmentation: { removedVerseStarts: ['MAT 3:4:0'], addedStarts: [] }, + }; act(() => { - result.current.wipeAll(); + result.current.discardAll(saved); }); - expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]); + expect(result.current.draft?.analysis).toEqual(saved.analysis); + expect(result.current.draft?.segmentation).toEqual(saved.segmentation); expect(result.current.dirty).toBe(false); expect(result.current.draftVersion).toBe(versionBefore + 1); - expect(lastSavedDraft().dirty).toBe(false); + expect(lastSavedDraft()).toMatchObject({ ...saved, dirty: false }); }); - it('clears any custom segment boundaries as part of the clean baseline', async () => { + it('drops the boundaries when the saved project has none', async () => { mockGetDraftResolves( - makeDraft({ - analysis: analysisWithToken('tok-wipe-all'), - dirty: true, - segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, - }), + makeDraft({ segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] } }), ); const { result } = await renderLoaded(); act(() => { - result.current.wipeAll(); + result.current.discardAll({ analysis: emptyAnalysis(), segmentation: undefined }); }); expect(result.current.draft?.segmentation).toBeUndefined(); expect(lastSavedDraft().segmentation).toBeUndefined(); }); + + describe('with no saved project', () => { + it('empties the analysis, clean, bumps the version, and persists', async () => { + mockGetDraftResolves( + makeDraft({ analysis: analysisWithToken('tok-unsaved'), dirty: true }), + ); + const { result } = await renderLoaded(); + const versionBefore = result.current.draftVersion; + + act(() => { + result.current.discardAll(); + }); + + expect(result.current.draft?.analysis.tokenAnalyses).toEqual([]); + expect(result.current.dirty).toBe(false); + expect(result.current.draftVersion).toBe(versionBefore + 1); + expect(lastSavedDraft().dirty).toBe(false); + }); + + it('clears any custom segment boundaries', async () => { + mockGetDraftResolves( + makeDraft({ + analysis: analysisWithToken('tok-unsaved'), + dirty: true, + segmentation: { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + }), + ); + const { result } = await renderLoaded(); + + act(() => { + result.current.discardAll(); + }); + + expect(result.current.draft?.segmentation).toBeUndefined(); + expect(lastSavedDraft().segmentation).toBeUndefined(); + }); + }); }); describe('marking the draft synced after a save', () => { @@ -1026,11 +1181,11 @@ describe('useDraftProject', () => { .map((call) => call[3]); } - it('says nothing of starting over with an edit or a wipe', async () => { + it('says nothing of starting over with an edit or a discard', async () => { const { result } = await renderLoaded(); - act(() => result.current.wipeBook('JHN')); - act(() => result.current.wipeAll()); + act(() => result.current.discardBook('JHN')); + act(() => result.current.discardAll()); expect(startedOverPerWrite()).toEqual([undefined, undefined]); }); @@ -1057,7 +1212,7 @@ describe('useDraftProject', () => { const { result } = await renderLoaded(); act(() => result.current.newDraft({ analysisLanguages: ['en'] })); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); expect(startedOverPerWrite()).toEqual([true, true]); }); @@ -1071,7 +1226,7 @@ describe('useDraftProject', () => { view.rerender({ source: 'source-project-2' }); await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); - act(() => view.result.current.wipeAll()); + act(() => view.result.current.discardAll()); expect(startedOverPerWrite().at(-1)).toBeUndefined(); }); @@ -1281,13 +1436,13 @@ describe('useDraftProject', () => { expect(result.current.canUndo).toBe(false); }); - describe('wipes', () => { - it('undoes a book wipe', async () => { + describe('discards', () => { + it('undoes a book discard', async () => { const loaded = analysisWithToken('tok-loaded'); mockGetDraftResolves(makeDraft({ analysis: loaded })); const { result } = await renderLoaded(); - act(() => result.current.wipeBook('GEN')); + act(() => result.current.discardBook('GEN')); act(() => { result.current.undo(); }); @@ -1295,12 +1450,12 @@ describe('useDraftProject', () => { expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); }); - it('undoes a whole-draft wipe', async () => { + it('undoes a whole-draft discard', async () => { const loaded = analysisWithToken('tok-loaded'); mockGetDraftResolves(makeDraft({ analysis: loaded })); const { result } = await renderLoaded(); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); @@ -1308,13 +1463,32 @@ describe('useDraftProject', () => { expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); }); - it('keeps the edits made before a wipe undoable', async () => { + it('undoes a discard that restored the saved project back to the unsaved edit', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + const edited = analysisWithToken('tok-edited'); + + act(() => result.current.autosaveAnalysis(edited)); + act(() => + result.current.discardAll({ + analysis: analysisWithToken('tok-saved'), + segmentation: undefined, + }), + ); + act(() => { + result.current.undo(); + }); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(edited); + }); + + it('keeps the edits made before a discard undoable', async () => { const loaded = analysisWithToken('tok-loaded'); mockGetDraftResolves(makeDraft({ analysis: loaded })); const { result } = await renderLoaded(); act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); @@ -1325,11 +1499,11 @@ describe('useDraftProject', () => { expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); }); - it('is clean after undoing a whole-draft wipe of a clean draft', async () => { + it('is clean after undoing a whole-draft discard of a clean draft', async () => { mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); const { result } = await renderLoaded(); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); @@ -1337,12 +1511,12 @@ describe('useDraftProject', () => { expect(result.current.dirty).toBe(false); }); - it('is dirty after undoing a whole-draft wipe back to an unsaved edit', async () => { + it('is dirty after undoing a whole-draft discard back to an unsaved edit', async () => { mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); const { result } = await renderLoaded(); act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); @@ -1350,12 +1524,12 @@ describe('useDraftProject', () => { expect(result.current.dirty).toBe(true); }); - it('is clean once undo passes back through a wipe to the loaded content', async () => { + it('is clean once undo passes back through a discard to the loaded content', async () => { mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); const { result } = await renderLoaded(); act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); @@ -1366,11 +1540,11 @@ describe('useDraftProject', () => { expect(result.current.dirty).toBe(false); }); - it('is dirty after undoing back to a wipe made before the last save', async () => { + it('is dirty after undoing back to a discard made before the last save', async () => { const { result } = await renderLoaded(); const saved = analysisWithToken('tok-saved'); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => result.current.autosaveAnalysis(saved)); await saveNow(result); act(() => { @@ -1380,11 +1554,11 @@ describe('useDraftProject', () => { expect(result.current.dirty).toBe(true); }); - it('is clean again after redoing a whole-draft wipe', async () => { + it('is clean again after redoing a whole-draft discard', async () => { mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); const { result } = await renderLoaded(); - act(() => result.current.wipeAll()); + act(() => result.current.discardAll()); act(() => { result.current.undo(); }); diff --git a/src/__tests__/services/projectStorage.test.ts b/src/__tests__/services/projectStorage.test.ts index 1030b3c67..a0aba4b1c 100644 --- a/src/__tests__/services/projectStorage.test.ts +++ b/src/__tests__/services/projectStorage.test.ts @@ -1517,7 +1517,7 @@ describe('projectStorage', () => { expect(storedProject(stored, 'proj-id')).toEqual(project); }); - it('keeps refusing after Wipe All, since the damaged book is still stored', async () => { + it('keeps refusing after discarding all changes, since the damaged book is still stored', async () => { const { stored, project, loaded } = await damagedJhn(); await saveDraft(token, 'src-proj', { ...loaded, analysis: emptyAnalysis() }); diff --git a/src/__tests__/utils/analysis-book.test.ts b/src/__tests__/utils/analysis-book.test.ts index eba0be78a..ca6dedfbc 100644 --- a/src/__tests__/utils/analysis-book.test.ts +++ b/src/__tests__/utils/analysis-book.test.ts @@ -12,8 +12,11 @@ import { mergeAnalyses, removeBookFromAnalysis, removeBookFromSegmentation, + restoreBookInAnalysis, + restoreBookInSegmentation, splitAnalysisByBook, } from '../../utils/analysis-book'; +import { emptyAnalysis } from '../../types/empty-factories'; import { makePhraseLink, makeTokenLink, FIXTURE_STAMPS } from '../test-helpers'; /** @@ -483,3 +486,104 @@ describe('removeBookFromSegmentation', () => { expect(input).toEqual(snapshot); }); }); + +describe('restoreBookInAnalysis', () => { + /** Builds an analysis that links each token ref to the payload `analysisId`, one payload per id. */ + function analysisOf(...links: [analysisId: string, tokenRef: string][]): TextAnalysis { + const ids = [...new Set(links.map(([id]) => id))]; + return { + ...emptyAnalysis(), + tokenAnalyses: ids.map((id) => mkTokenAnalysis(id)), + tokenAnalysisLinks: links.map(([id, tokenRef]) => makeTokenLink(id, tokenRef)), + }; + } + + /** Lists each token link in `analysis` as `tokenRef=analysisId`, sorted. */ + function linksIn(analysis: TextAnalysis): string[] { + return analysis.tokenAnalysisLinks.map((l) => `${l.token.tokenRef}=${l.analysisId}`).sort(); + } + + it("replaces the book's records with those the source holds for it", () => { + const result = restoreBookInAnalysis( + analysisOf(['gen-draft', 'GEN 1:1:0']), + analysisOf(['gen-saved', 'GEN 1:1:0'], ['exo-saved', 'EXO 2:2:0']), + 'GEN', + ); + + expect(linksIn(result)).toEqual(['GEN 1:1:0=gen-saved']); + expect(result.tokenAnalyses.map((a) => a.id)).toEqual(['gen-saved']); + }); + + it("keeps other books' records", () => { + const result = restoreBookInAnalysis( + analysisOf(['gen-draft', 'GEN 1:1:0'], ['exo-draft', 'EXO 2:2:0']), + analysisOf(['gen-saved', 'GEN 1:1:0']), + 'GEN', + ); + + expect(linksIn(result)).toEqual(['EXO 2:2:0=exo-draft', 'GEN 1:1:0=gen-saved']); + }); + + it("keeps the analysis's version of a payload the book shares with another book", () => { + const draft = analysisOf(['shared', 'GEN 1:1:0'], ['shared', 'EXO 2:2:0']); + draft.tokenAnalyses = [mkTokenAnalysis('shared', 'edited')]; + const saved = analysisOf(['shared', 'GEN 1:1:0'], ['shared', 'EXO 2:2:0']); + + const result = restoreBookInAnalysis(draft, saved, 'GEN'); + + expect(result.tokenAnalyses).toEqual([mkTokenAnalysis('shared', 'edited')]); + }); + + it('removes the book when the source holds nothing for it', () => { + const result = restoreBookInAnalysis( + analysisOf(['gen-draft', 'GEN 1:1:0'], ['exo-draft', 'EXO 2:2:0']), + analysisOf(['exo-saved', 'EXO 2:2:0']), + 'GEN', + ); + + expect(linksIn(result)).toEqual(['EXO 2:2:0=exo-draft']); + }); + + it('removes the book when there is no source', () => { + const result = restoreBookInAnalysis(analysisOf(['gen-draft', 'GEN 1:1:0']), undefined, 'GEN'); + + expect(result).toEqual(emptyAnalysis()); + }); +}); + +describe('restoreBookInSegmentation', () => { + it("replaces the book's anchors with those the source holds for it", () => { + const savedStart = { tokenRef: 'GEN 1:3:5', surfaceText: 'word' }; + + const result = restoreBookInSegmentation( + { removedVerseStarts: ['GEN 1:2:0', 'EXO 3:4:0'], addedStarts: [] }, + { removedVerseStarts: ['GEN 1:5:0', 'EXO 9:9:0'], addedStarts: [savedStart] }, + 'GEN', + ); + + expect(result).toEqual({ + removedVerseStarts: ['EXO 3:4:0', 'GEN 1:5:0'], + addedStarts: [savedStart], + }); + }); + + it('returns undefined when no anchor remains', () => { + const result = restoreBookInSegmentation( + { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + { removedVerseStarts: ['EXO 9:9:0'], addedStarts: [] }, + 'GEN', + ); + + expect(result).toBeUndefined(); + }); + + it("keeps other books' anchors when there is no source", () => { + const result = restoreBookInSegmentation( + { removedVerseStarts: ['GEN 1:2:0', 'EXO 3:4:0'], addedStarts: [] }, + undefined, + 'GEN', + ); + + expect(result).toEqual({ removedVerseStarts: ['EXO 3:4:0'], addedStarts: [] }); + }); +}); diff --git a/src/__tests__/utils/read-openable-project.test.ts b/src/__tests__/utils/read-openable-project.test.ts new file mode 100644 index 000000000..515af9125 --- /dev/null +++ b/src/__tests__/utils/read-openable-project.test.ts @@ -0,0 +1,79 @@ +/// + +import papi from '@papi/frontend'; +import { emptyAnalysis } from '../../types/empty-factories'; +import { readOpenableProject } from '../../utils/read-openable-project'; +import { makeStubProject } from '../test-helpers'; + +const mockSendCommand = jest.mocked(papi.commands.sendCommand); + +/** Has `getProject` reply with `project` serialized. */ +function mockStoredProject(project: unknown): void { + mockSendCommand.mockResolvedValue(JSON.stringify(project)); +} + +describe('readOpenableProject', () => { + it('reads the project the id names', async () => { + mockStoredProject(makeStubProject('proj-1')); + + await readOpenableProject('proj-1'); + + expect(mockSendCommand).toHaveBeenCalledWith('interlinearizer.getProject', 'proj-1'); + }); + + it("returns the project's analysis and languages", async () => { + const stored = makeStubProject(); + mockStoredProject(stored); + + await expect(readOpenableProject(stored.id)).resolves.toEqual({ + analysisLanguages: stored.analysisLanguages, + analysis: stored.analysis, + }); + }); + + it("returns the project's alignment target and boundaries when it stores them", async () => { + const segmentation = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + mockStoredProject({ ...makeStubProject(), targetProjectId: 'target-1', segmentation }); + + await expect(readOpenableProject('proj-id')).resolves.toMatchObject({ + targetProjectId: 'target-1', + segmentation, + }); + }); + + it('returns undefined when the project is missing', async () => { + mockSendCommand.mockResolvedValue(undefined); + + await expect(readOpenableProject('proj-id')).resolves.toBeUndefined(); + }); + + it('returns undefined when the reply is not a project', async () => { + mockStoredProject({ analysis: emptyAnalysis() }); + + await expect(readOpenableProject('proj-id')).resolves.toBeUndefined(); + }); + + it('returns undefined when the project holds no analysis', async () => { + mockStoredProject({ ...makeStubProject(), analysis: undefined }); + + await expect(readOpenableProject('proj-id')).resolves.toBeUndefined(); + }); + + it('returns undefined when the project holds an invalid analysis', async () => { + mockStoredProject({ ...makeStubProject(), analysis: { tokenAnalyses: 'oops' } }); + + await expect(readOpenableProject('proj-id')).resolves.toBeUndefined(); + }); + + it('returns undefined when the project holds invalid boundaries', async () => { + mockStoredProject({ ...makeStubProject(), segmentation: { removedVerseStarts: 'oops' } }); + + await expect(readOpenableProject('proj-id')).resolves.toBeUndefined(); + }); + + it('rejects when the command rejects', async () => { + mockSendCommand.mockRejectedValue(new Error('storage offline')); + + await expect(readOpenableProject('proj-id')).rejects.toThrow('storage offline'); + }); +}); diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index f4c99f031..0269dba57 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -36,7 +36,7 @@ import { resegmentBook } from 'parsers/papi/resegmentBook'; import useLatestRef from '../hooks/useLatestRef'; import useUndoRedoKeys from '../hooks/useUndoRedoKeys'; import useWholeTextReanchor from '../hooks/useWholeTextReanchor'; -import useDraftProject, { type EditStep } from '../hooks/useDraftProject'; +import useDraftProject, { type DraftContent, type EditStep } from '../hooks/useDraftProject'; import { formatTemplate } from '../utils/format-template'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useLexiconRegistry from '../hooks/useLexiconRegistry'; @@ -57,6 +57,7 @@ import { import { perfTrack } from '../utils/perf-marks'; import { reanchorDraftToBook } from '../utils/reanchor-draft'; import { isInterlinearProjectSummary, isTextAnalysis, isWordToken } from '../types/type-guards'; +import { readOpenableProject } from '../utils/read-openable-project'; import { isPt9ImportReport, isPt9UnreadableFileList } from '../converters/pt9'; import { toProjectSummary } from '../types/interlinear-project-summary'; import useSubmitGuard from '../hooks/useSubmitGuard'; @@ -82,7 +83,7 @@ import { Pt9ImportModal, type Pt9ImportModalPhase } from './modals/Pt9ImportModa import { Pt9CheckingModal, Pt9ConvertPromptModal } from './modals/Pt9ConvertPromptModal'; import { Pt9ReadOnlyIntroModal } from './modals/Pt9ReadOnlyIntroModal'; import { usePt9ImportProbe } from '../hooks/usePt9ImportAvailability'; -import { WipeModal, type WipeScope } from './modals/WipeModal'; +import { DiscardChangesModal, type DiscardScope } from './modals/DiscardChangesModal'; import ScriptureNavControls from './controls/ScriptureNavControls'; import { InterlinearNavProvider, useInterlinearNav, type FadePhase } from './InterlinearNavContext'; import { RECENTER_FADE_TRANSITION_STYLE } from './recenter-fade'; @@ -283,10 +284,10 @@ const STRING_KEYS = [ '%interlinearizer_redone_catalogDeleteChecked%', '%interlinearizer_undone_catalogClearGlossChecked%', '%interlinearizer_redone_catalogClearGlossChecked%', - '%interlinearizer_undone_wipeBook%', - '%interlinearizer_redone_wipeBook%', - '%interlinearizer_undone_wipeAll%', - '%interlinearizer_redone_wipeAll%', + '%interlinearizer_undone_discardBook%', + '%interlinearizer_redone_discardBook%', + '%interlinearizer_undone_discardAll%', + '%interlinearizer_redone_discardAll%', '%interlinearizer_error_load_book_heading%', '%interlinearizer_error_pt9Import_load_failed%', '%interlinearizer_loading%', @@ -496,8 +497,8 @@ function InterlinearizerLoaderInner({ getDraftSnapshot, isSaving, commitDraft, - wipeBook, - wipeAll, + discardBook, + discardAll, reanchorBook, asOneStep, subscribeToAnalysisReplacements, @@ -703,11 +704,11 @@ function InterlinearizerLoaderInner({ * * `draft.segmentation` is read fresh from the ref-held draft at recompute time; the deps are the * two version counters that cover every path that can change it — `segmentationVersion` for - * boundary edits, `draftVersion` for wholesale replacements (New / Open / Wipe). The draft object - * itself is deliberately NOT a dep: gloss auto-saves replace its identity on every write without - * touching the boundaries, so keying on it would re-run the full re-segmentation after every - * gloss edit. `isDraftLoading` covers the one replacement that bumps neither counter: the initial - * draft load. + * boundary edits, `draftVersion` for wholesale replacements (New / Open / Discard). The draft + * object itself is deliberately NOT a dep: gloss auto-saves replace its identity on every write + * without touching the boundaries, so keying on it would re-run the full re-segmentation after + * every gloss edit. `isDraftLoading` covers the one replacement that bumps neither counter: the + * initial draft load. */ const segmentation = useMemo( () => (verseBook ? reanchorSegmentation(verseBook, draft?.segmentation) : draft?.segmentation), @@ -964,13 +965,13 @@ function InterlinearizerLoaderInner({ */ const modalRef = useLatestRef(modal); - /** Whether the destructive wipe dialog (book / whole-draft scope picker) is open. */ - const [wipeModalOpen, setWipeModalOpen] = useState(false); + /** Whether the discard-changes dialog (book / whole-draft scope picker) is open. */ + const [discardChangesModalOpen, setDiscardChangesModalOpen] = useState(false); const [phraseMode, setPhraseMode] = useState(VIEW_PHRASE_MODE); - // Reset phraseMode whenever the draft is replaced wholesale (New / Open / Wipe), and whenever the - // view crosses between the draft and an import, so stale edit/confirm-unlink state is never + // Reset phraseMode whenever the draft is replaced wholesale (New / Open / Discard), and whenever + // the view crosses between the draft and an import, so stale edit/confirm-unlink state is never // passed to the newly mounted Interlinearizer. An import opens without touching the draft or its // version, and a mode carried into that read-only view renders its edit-target affordances. // Crossing into the import is all this covers; the render below pins the import view's mode @@ -1416,26 +1417,52 @@ function InterlinearizerLoaderInner({ } }, [activeProject, commitDraft, projectId, setActiveProject, setModal]); + /** Guards a confirmed discard until it ends, disabling the discard dialog meanwhile. */ + const { isSubmitting: isDiscarding, runGuarded: runDiscardGuarded } = useSubmitGuard(); + /** - * Performs the confirmed wipe for the chosen scope (current book or whole draft) and dismisses - * the dialog. + * Resets the chosen scope to the active project's stored content, or empties it when no project + * is active. When the stored content can't be read, it notifies the user and leaves the draft + * untouched. Either way, the dialog closes once the discard ends. */ - const handleWipeConfirm = useCallback( - (scope: WipeScope) => { - if (scope === 'book') { - /* v8 ignore next -- defensive: the dialog disables the book scope unless a book is loaded */ - if (book) wipeBook(book.bookRef); - } - // Match 'all' explicitly rather than via an else, so a future wipe scope can't fall through to - // the destructive whole-draft wipe. - if (scope === 'all') wipeAll(); - setWipeModalOpen(false); - }, - [book, wipeBook, wipeAll], + const handleDiscardChangesConfirm = useCallback( + (scope: DiscardScope) => + runDiscardGuarded(async () => { + try { + const bookCode = book?.bookRef; + let saved: DraftContent | undefined; + if (activeProject) { + const project = await readOpenableProject(activeProject.id).catch((e) => { + logger.error('Interlinearizer: failed to read the project to discard changes', e); + return undefined; + }); + if (!project) { + await papi.notifications + .send({ + message: '%interlinearizer_error_discardChanges_failed%', + severity: 'error', + }) + .catch(() => {}); + return; + } + saved = { analysis: project.analysis, segmentation: project.segmentation }; + } + if (scope === 'book') { + /* v8 ignore next -- defensive: the dialog disables the book scope unless a book is loaded */ + if (bookCode) discardBook(bookCode, saved); + } + // Match 'all' explicitly rather than via an else, so a future scope can't fall through to + // the whole-draft discard. + if (scope === 'all') discardAll(saved); + } finally { + setDiscardChangesModalOpen(false); + } + }), + [activeProject, book, discardBook, discardAll, runDiscardGuarded], ); - /** Dismisses the wipe dialog, leaving the draft untouched. */ - const handleWipeCancel = useCallback(() => setWipeModalOpen(false), []); + /** Dismisses the discard dialog, leaving the draft untouched. */ + const handleDiscardChangesCancel = useCallback(() => setDiscardChangesModalOpen(false), []); const handleSidePanelClose = useCallback(() => setSidePanel('closed'), [setSidePanel]); const handleShowCatalog = useCallback(() => setSidePanel('catalog'), [setSidePanel]); @@ -1587,7 +1614,7 @@ function InterlinearizerLoaderInner({ /** * Routes top-menu commands to the appropriate action. The project commands open their modals; the - * file commands save (or open Save As); the draft command opens the wipe dialog. + * file commands save (or open Save As); the draft command opens the discard-changes dialog. */ const menuCommandHandler = useCallback( (item) => { @@ -1598,7 +1625,7 @@ function InterlinearizerLoaderInner({ isImportView && (item.command === 'interlinearizer.save' || item.command === 'interlinearizer.openSaveAsModal' || - item.command === 'interlinearizer.wipe'); + item.command === 'interlinearizer.openDiscardChangesModal'); if (draftCommandsInert) return; // A save copies the draft as it stood when it began, so nothing may replace or save the draft // until it ends. The toolbar shows the save running; the notification says why the command @@ -1609,7 +1636,7 @@ function InterlinearizerLoaderInner({ item.command === 'interlinearizer.openNewProjectModal' || item.command === 'interlinearizer.save' || item.command === 'interlinearizer.openSaveAsModal' || - item.command === 'interlinearizer.wipe'); + item.command === 'interlinearizer.openDiscardChangesModal'); if (heldWhileSaving) { papi.notifications .send({ message: '%interlinearizer_info_save_in_progress%', severity: 'info' }) @@ -1628,8 +1655,8 @@ function InterlinearizerLoaderInner({ handleSave(); } else if (item.command === 'interlinearizer.openSaveAsModal') { setModal('saveAs'); - } else if (item.command === 'interlinearizer.wipe') { - setWipeModalOpen(true); + } else if (item.command === 'interlinearizer.openDiscardChangesModal') { + setDiscardChangesModalOpen(true); } else if (item.command === 'interlinearizer.openAnalysisCatalog') { setSidePanel('catalog'); } else if (item.command === 'interlinearizer.openConcordance') { @@ -1850,7 +1877,7 @@ function InterlinearizerLoaderInner({ } else { // The store's lifetime is the draft's, not the loaded book's - it holds every book. Keyed on // the draft version because the seed is not reactive, so a wholesale replacement (New / Open / - // Wipe) reseeds by remounting. Wrapping the loading and error branches too keeps it alive + // Discard) reseeds by remounting. Wrapping the loading and error branches too keeps it alive // across the gap while the next book's USJ is in flight. viewArea = ( )} - {wipeModalOpen && ( - )} diff --git a/src/components/modals/DiscardChangesModal.tsx b/src/components/modals/DiscardChangesModal.tsx new file mode 100644 index 000000000..4b97852bd --- /dev/null +++ b/src/components/modals/DiscardChangesModal.tsx @@ -0,0 +1,117 @@ +import { useLocalizedStrings } from '@papi/frontend/react'; +import { Button, Label, RadioGroup, RadioGroupItem } from 'platform-bible-react'; +import { useState } from 'react'; +import { ModalShell } from './ModalShell'; + +/** The portion of the draft a discard covers: the current book only, or the entire draft. */ +export type DiscardScope = 'book' | 'all'; + +/** Localized string keys requested for this modal's rendered text. */ +const DISCARD_CHANGES_MODAL_STRING_KEYS: `%${string}%`[] = [ + '%interlinearizer_discardChanges_modal_title%', + '%interlinearizer_discardChanges_modal_prompt%', + '%interlinearizer_discardChanges_modal_scope_book%', + '%interlinearizer_discardChanges_modal_scope_book_description%', + '%interlinearizer_discardChanges_modal_scope_all%', + '%interlinearizer_discardChanges_modal_scope_all_description%', + '%interlinearizer_discardChanges_modal_confirm%', + '%interlinearizer_discardChanges_modal_cancel%', +]; + +/** + * Dialog that asks how much of the draft's unsaved analysis to discard: the current book's or the + * entire draft's. It only collects the scope; the caller does the discarding. + * + * @param props.hasActiveBook - Whether a book is currently loaded. When `false`, the "current book" + * option is disabled and the selection defaults to the whole draft. + * @param props.isSubmitting - Whether the caller is still carrying out the confirmed discard. When + * `true`, the buttons are disabled and the dialog can't be dismissed. + * @param props.onConfirm - Called with the chosen {@link DiscardScope} when the user confirms. + * @param props.onCancel - Called when the user backs out, leaving the draft untouched. + * @returns The dialog, or nothing while localized strings are loading. + */ +export function DiscardChangesModal({ + hasActiveBook, + isSubmitting = false, + onConfirm, + onCancel, +}: Readonly<{ + hasActiveBook: boolean; + isSubmitting?: boolean; + onConfirm: (scope: DiscardScope) => void; + onCancel: () => void; +}>) { + const [localizedStrings, stringsLoading] = useLocalizedStrings(DISCARD_CHANGES_MODAL_STRING_KEYS); + + // Default to the narrower per-book discard when a book is loaded; otherwise the whole draft is + // the only actionable choice. + const [scope, setScope] = useState(hasActiveBook ? 'book' : 'all'); + + /* v8 ignore next */ if (stringsLoading) return undefined; + + return ( + +

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

+ + setScope(value === 'book' ? 'book' : 'all')} + value={scope} + > +
+
+ + +
+ + {localizedStrings['%interlinearizer_discardChanges_modal_scope_book_description%']} + +
+ +
+
+ + +
+ + {localizedStrings['%interlinearizer_discardChanges_modal_scope_all_description%']} + +
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index 19d9652e4..e8b3526a3 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -6,12 +6,8 @@ import type { DraftCopyResult, NewDraftConfig, OpenableProject } from '../../hoo import useSubmitGuard from '../../hooks/useSubmitGuard'; import { toProjectSummary } from '../../types/interlinear-project-summary'; import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; -import { - isInterlinearProjectSummary, - isSavedAsProject, - isSegmentationDelta, - isTextAnalysis, -} from '../../types/type-guards'; +import { isInterlinearProjectSummary, isSavedAsProject } from '../../types/type-guards'; +import { readOpenableProject } from '../../utils/read-openable-project'; import { CreateProjectModal, type CreateDraftConfig } from './CreateProjectModal'; import { DiscardDraftConfirm } from './DiscardDraftConfirm'; import { ProjectMetadataModal } from './ProjectMetadataModal'; @@ -214,33 +210,15 @@ export default function ProjectModals({ 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; - const segmentation = - parsed && typeof parsed === 'object' && 'segmentation' in parsed - ? parsed.segmentation - : undefined; - if ( - !isInterlinearProjectSummary(parsed) || - !isTextAnalysis(analysis) || - (segmentation !== undefined && !isSegmentationDelta(segmentation)) - ) { + const openable = await readOpenableProject(project.id); + if (!openable) { await papi.notifications .send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' }) .catch(() => {}); return; } - loadFromProject({ - analysisLanguages: parsed.analysisLanguages, - ...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }), - ...(segmentation !== undefined && { segmentation }), - analysis, - }); + loadFromProject(openable); setActiveProject(project); setModal('none'); } catch (e) { diff --git a/src/components/modals/WipeModal.tsx b/src/components/modals/WipeModal.tsx deleted file mode 100644 index da4d2c26a..000000000 --- a/src/components/modals/WipeModal.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useLocalizedStrings } from '@papi/frontend/react'; -import { Button, Label, RadioGroup, RadioGroupItem } from 'platform-bible-react'; -import { useState } from 'react'; -import { ModalShell } from './ModalShell'; - -/** The portion of the draft a wipe removes: the current book only, or the entire draft. */ -export type WipeScope = 'book' | 'all'; - -/** Localized string keys requested for this modal's rendered text. */ -const WIPE_MODAL_STRING_KEYS: `%${string}%`[] = [ - '%interlinearizer_wipe_modal_title%', - '%interlinearizer_wipe_modal_prompt%', - '%interlinearizer_wipe_modal_scope_book%', - '%interlinearizer_wipe_modal_scope_book_description%', - '%interlinearizer_wipe_modal_scope_all%', - '%interlinearizer_wipe_modal_scope_all_description%', - '%interlinearizer_wipe_modal_confirm%', - '%interlinearizer_wipe_modal_cancel%', -]; - -/** - * Destructive draft-wipe dialog. Lets the user choose whether to remove the analysis for just the - * current book or for the entire draft, then confirm. Confirming removes the chosen analysis from - * the draft (which auto-saves); the user can then Save to persist the change to a project. This - * component is presentational — it collects the scope and hands it back via {@link onConfirm}; the - * caller performs the actual wipe. - * - * @param props.hasActiveBook - Whether a book is currently loaded. When `false`, the "current book" - * option is disabled and the selection defaults to the whole draft, since there is no book to - * wipe. - * @param props.onConfirm - Called with the chosen {@link WipeScope} when the user confirms. - * @param props.onCancel - Called when the user backs out, leaving the draft untouched. - * @returns The wipe dialog overlay, or nothing while localized strings are loading. - */ -export function WipeModal({ - hasActiveBook, - onConfirm, - onCancel, -}: Readonly<{ - hasActiveBook: boolean; - onConfirm: (scope: WipeScope) => void; - onCancel: () => void; -}>) { - const [localizedStrings, stringsLoading] = useLocalizedStrings(WIPE_MODAL_STRING_KEYS); - - // Default to the safer, more common per-book wipe when a book is loaded; otherwise the whole - // draft is the only actionable choice. - const [scope, setScope] = useState(hasActiveBook ? 'book' : 'all'); - - /* v8 ignore next */ if (stringsLoading) return undefined; - - return ( - -

- {localizedStrings['%interlinearizer_wipe_modal_prompt%']} -

- - setScope(value === 'book' ? 'book' : 'all')} - value={scope} - > -
-
- - -
- - {localizedStrings['%interlinearizer_wipe_modal_scope_book_description%']} - -
- -
-
- - -
- - {localizedStrings['%interlinearizer_wipe_modal_scope_all_description%']} - -
-
- -
- - -
-
- ); -} diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index e38579374..2c06f4420 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -8,7 +8,7 @@ import type { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../types/model-version'; -import { removeBookFromAnalysis, removeBookFromSegmentation } from '../utils/analysis-book'; +import { restoreBookInAnalysis, restoreBookInSegmentation } from '../utils/analysis-book'; import { DebouncedWriter } from '../utils/debounced-writer'; import { perfTime, perfTrack } from '../utils/perf-marks'; import { isEmptyDelta } from '../utils/segmentation'; @@ -116,8 +116,8 @@ export type StepSummary = Readonly< /** How many analyses had their gloss cleared. */ count: number; } - | { kind: 'wipeBook'; book: string } - | { kind: 'wipeAll' } + | { kind: 'discardBook'; book: string } + | { kind: 'discardAll' } >; /** An undo step, as recorded. */ @@ -159,9 +159,9 @@ export type UseDraftProjectResult = { */ draft: DraftProject | undefined; /** - * Monotonic counter bumped on every wholesale analysis replacement (New / Open / Wipe). Include - * it in the editor's React `key` so the analysis store reseeds from the new draft; per-edit - * auto-saves deliberately do not bump it, so editing never remounts the editor. + * Monotonic counter bumped on every wholesale analysis replacement (New / Open / Discard). + * Include it in the editor's React `key` so the analysis store reseeds from the new draft; + * per-edit auto-saves deliberately do not bump it, so editing never remounts the editor. */ draftVersion: number; /** @@ -186,7 +186,7 @@ export type UseDraftProjectResult = { getDraftSnapshot: () => DraftProject | undefined; /** * True while {@link UseDraftProjectResult.commitDraft} runs. Wholesale replacements (New / Open / - * Wipe) must be held off while it is, so the draft being saved stays the one the save copies. + * Discard) must be held off while it is, so the draft being saved stays the one the save copies. */ isSaving: boolean; /** @@ -226,13 +226,18 @@ export type UseDraftProjectResult = { * first edit. The caller is responsible for immediately persisting the project to the backend. */ newDraft: (config: NewDraftConfig) => void; - /** Removes one book's analysis from the draft, by 3-letter book code, and marks it dirty. */ - wipeBook: (bookCode: string) => void; /** - * Clears the draft's analysis entirely and marks it **not** dirty — a wiped draft is treated as a - * clean baseline, so the unsaved-changes indicator clears. The active project is left untouched. + * Discards one book's unsaved changes, by 3-letter book code: resets its analysis and boundaries + * to those in `saved`, or removes them when `saved` is absent. With `saved`, the draft stays as + * dirty as it was. Without `saved`, the draft is marked dirty. */ - wipeAll: () => void; + discardBook: (bookCode: string, saved?: DraftContent) => void; + /** + * Discards the draft's unsaved changes: resets its analysis and boundaries to those in `saved`, + * or empties them when `saved` is absent. Marks the draft clean. Keeps its languages and + * alignment target. + */ + discardAll: (saved?: DraftContent) => void; canUndo: boolean; canRedo: boolean; /** @@ -280,7 +285,7 @@ export type UseDraftProjectResult = { /** * Owns the always-present, auto-saved draft for one source project. Loads the draft on mount, seeds * a gloss language when none is stored, and exposes callbacks to auto-save edits, to undo and redo - * them, and to replace the draft wholesale (New / Open / Wipe). + * them, and to replace the draft wholesale (New / Open / Discard). * * The full draft lives in a ref — the synchronous source of truth for persistence and Save — while * a small amount of state (`isDraftLoading`, `draftVersion`, `dirty`) drives re-renders, so @@ -302,7 +307,7 @@ export default function useDraftProject( const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); // The history states that read clean: the one the draft last started from or was saved at, and - // any whole-draft wipe since. + // any discard since that left it clean. const cleanStatesRef = useRef(new Set()); const replacementListenersRef = useRef(new Set<(analysis: TextAnalysis) => void>()); const historyRevisionRef = useRef(0); @@ -545,49 +550,49 @@ export default function useDraftProject( [applyReplacement, sourceProjectId], ); - const wipeBook = useCallback( - (bookCode: string) => { + const discardBook = useCallback( + (bookCode: string, saved?: DraftContent) => { const { current } = draftRef; - /* v8 ignore next -- wipe is only reachable from the mounted editor */ + /* v8 ignore next -- discarding is only reachable from the mounted editor */ if (!current) return; - // Drop the book's custom segment boundaries alongside its analysis: the anchors are working - // state keyed by book, so keeping them would re-apply the wiped book's merges/splits on - // reload. Clear the field when nothing remains for any other book. - const segmentation = removeBookFromSegmentation(current.segmentation, bookCode); - const next: DraftProject = { - ...current, - analysis: removeBookFromAnalysis(current.analysis, bookCode), - dirty: true, + const restored: DraftContent = { + analysis: restoreBookInAnalysis(current.analysis, saved?.analysis, bookCode), + segmentation: restoreBookInSegmentation( + current.segmentation, + saved?.segmentation, + bookCode, + ), }; - if (segmentation !== undefined) next.segmentation = segmentation; - else delete next.segmentation; + // Other books may still differ from the saved project. + // Without one, nothing they hold is saved. applyReplacement( - next, + withContent(current, restored, saved ? current.dirty : true), recordStep(historyRef.current, contentOf(current), { - summary: { kind: 'wipeBook', book: bookCode }, + summary: { kind: 'discardBook', book: bookCode }, }), ); }, [applyReplacement], ); - const wipeAll = useCallback(() => { - const { current } = draftRef; - /* v8 ignore next -- wipe is only reachable from the mounted editor */ - if (!current) return; + const discardAll = useCallback( + (saved?: DraftContent) => { + const { current } = draftRef; + /* v8 ignore next -- discarding is only reachable from the mounted editor */ + if (!current) return; - // Wiping the whole draft is treated as a clean baseline (dirty: false) so the user is not nagged - // to save an empty draft. The active project is left untouched, so a subsequent Save still - // targets it. Custom segment boundaries are working state, so a whole-draft wipe clears them too. - // (Per-book wipe stays dirty, as it is a partial edit the user will usually want to save.) - const next: DraftProject = { ...current, analysis: emptyAnalysis(), dirty: false }; - delete next.segmentation; - applyReplacement( - next, - recordStep(historyRef.current, contentOf(current), { summary: { kind: 'wipeAll' } }), - ); - }, [applyReplacement]); + applyReplacement( + withContent( + current, + saved ?? { analysis: emptyAnalysis(), segmentation: undefined }, + false, + ), + recordStep(historyRef.current, contentOf(current), { summary: { kind: 'discardAll' } }), + ); + }, + [applyReplacement], + ); /** * Marks the draft synced once `saved` has been copied into a project, unless an edit landed @@ -737,8 +742,8 @@ export default function useDraftProject( autosaveSegmentation, loadFromProject, newDraft, - wipeBook, - wipeAll, + discardBook, + discardAll, canUndo, canRedo, getHistoryRevision, diff --git a/src/hooks/useLostBoundaryDismissal.ts b/src/hooks/useLostBoundaryDismissal.ts index f217b3177..d08339acd 100644 --- a/src/hooks/useLostBoundaryDismissal.ts +++ b/src/hooks/useLostBoundaryDismissal.ts @@ -11,7 +11,7 @@ type LostBoundaryDismissalOptions = { segmentation: SegmentationDelta | undefined; /** Bumped by every boundary edit. */ segmentationVersion: number; - /** Bumped by every wholesale draft replacement (New / Open / Wipe). */ + /** Bumped by every wholesale draft replacement (New / Open / Discard). */ draftVersion: number; /** Whether the initial draft load is still outstanding, which bumps neither counter. */ isDraftLoading: boolean; diff --git a/src/hooks/useRecordDismissal.ts b/src/hooks/useRecordDismissal.ts index f71872deb..97d4d74d1 100644 --- a/src/hooks/useRecordDismissal.ts +++ b/src/hooks/useRecordDismissal.ts @@ -6,7 +6,7 @@ type RecordDismissalOptions = { records: readonly string[]; /** The book `records` were found in, `undefined` while no book's records are observable. */ observedBookRef: string | undefined; - /** Bumped by every wholesale draft replacement (New / Open / Wipe). */ + /** Bumped by every wholesale draft replacement (New / Open / Discard). */ draftVersion: number; /** The WebView-state key the dismissal is stored under, one per banner. */ stateKey: string; diff --git a/src/hooks/useStaleAnalysisDismissal.ts b/src/hooks/useStaleAnalysisDismissal.ts index 405f4a0f8..00a6a503c 100644 --- a/src/hooks/useStaleAnalysisDismissal.ts +++ b/src/hooks/useStaleAnalysisDismissal.ts @@ -6,7 +6,7 @@ import useRecordDismissal from './useRecordDismissal'; type StaleAnalysisDismissalOptions = { /** The loaded book's stale analyses, `undefined` while no book's are known. */ report: StaleAnalysesReport | undefined; - /** Bumped by every wholesale draft replacement (New / Open / Wipe). */ + /** Bumped by every wholesale draft replacement (New / Open / Discard). */ draftVersion: number; /** Scopes the dismissal to one tab. */ useWebViewState: UseWebViewStateHook; diff --git a/src/main.ts b/src/main.ts index 0db89f7aa..ce60c9efc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -683,9 +683,10 @@ async function getTextReadStatus(sourceProjectId: string): Promise { /** * Persists the draft working buffer for a source project. Called from the WebView after every edit - * (auto-save) and whenever the draft is reset (New), opened from a project, or wiped. Analysis for - * a book whose stored analysis could not be read is left out of the write. The user is told which - * books once, and again only after the draft is reloaded or a write has left none out. + * (auto-save) and whenever the draft is reset (New), opened from a project, or has its changes + * discarded. Analysis for a book whose stored analysis could not be read is left out of the write. + * The user is told which books once, and again only after the draft is reloaded or a write has left + * none out. * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. @@ -1927,14 +1928,14 @@ export async function activate(context: ExecutionActivationContext): Promise {}, { method: { summary: - "Open the wipe dialog to remove the current book's or the whole draft's analysis (handled in the WebView)", + 'Open the dialog that discards the unsaved analysis of the current book or the whole draft (handled in the WebView)', params: [], result: { name: 'return value', summary: 'void', schema: { type: 'null' } }, }, @@ -1986,7 +1987,7 @@ export async function activate(context: ExecutionActivationContext): Promise Promise; /** - * Opens the wipe dialog in the Interlinearizer WebView, where the user chooses to remove the - * currently viewed book's analysis or the entire draft's analysis and confirms. The backend + * Opens the discard-changes dialog in the Interlinearizer WebView, where the user chooses to + * discard the unsaved analysis of the currently viewed book or of the entire draft. The backend * registers this command to make it visible to the platform menu system; all logic executes in * the WebView. */ - 'interlinearizer.wipe': () => Promise; + 'interlinearizer.openDiscardChangesModal': () => Promise; /** * Opens the analysis catalog panel in the Interlinearizer WebView, listing every analysis the @@ -315,9 +315,9 @@ declare module 'papi-shared-types' { /** * Persists the draft working buffer for the given source project. Called from the WebView after - * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or - * wiped. Analysis for a book whose stored analysis could not be read is left out of the write, - * and the user is told which books. + * every edit (auto-save) and whenever the draft is reset (New), opened from a project, or has + * its changes discarded. Analysis for a book whose stored analysis could not be read is left + * out of the write, and the user is told which books. * * @param sourceProjectId - Platform.Bible source project ID whose draft to write. * @param draftJson - JSON-stringified `DraftProject` to persist. diff --git a/src/utils/analysis-book.ts b/src/utils/analysis-book.ts index 1d69678be..6c9af024d 100644 --- a/src/utils/analysis-book.ts +++ b/src/utils/analysis-book.ts @@ -124,8 +124,8 @@ function collectPayloads( * * A record is dropped when the token or segment it is attached to belongs to the book, and a phrase * when its token run does — a run lies within one book, so its first token decides for the whole. A - * payload the wipe leaves unreferenced is dropped with it, so no orphans remain; one that no link - * referenced beforehand belongs to no book and survives. + * payload that the removal leaves unreferenced is dropped with it, so no orphans remain; one that + * no link referenced beforehand belongs to no book and survives. */ export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string): TextAnalysis { const tokenAnalysisLinks = analysis.tokenAnalysisLinks.filter( @@ -164,10 +164,24 @@ export function removeBookFromAnalysis(analysis: TextAnalysis, bookCode: string) } /** - * Returns a copy of the delta with every boundary anchor belonging to the book removed, so wiping a - * book also drops its custom segment boundaries. The result is `undefined` when the delta is absent - * or the removal empties it, so segmentation collapses back to the default rather than persisting - * empty arrays. + * Returns a copy of the analysis, unmutated, with the book's records replaced by those that + * `source` holds for it, if any. A payload that the book shares with another book keeps the + * analysis's version. + */ +export function restoreBookInAnalysis( + analysis: TextAnalysis, + source: TextAnalysis | undefined, + bookCode: string, +): TextAnalysis { + const remaining = removeBookFromAnalysis(analysis, bookCode); + const restored = source && splitAnalysisByBook(source).get(bookCode); + return restored ? mergeAnalyses([remaining, restored]) : remaining; +} + +/** + * Returns a copy of the delta with every boundary anchor belonging to the book removed. The result + * is `undefined` when the delta is absent or the removal empties it, so segmentation collapses back + * to the default rather than persisting empty arrays. */ export function removeBookFromSegmentation( delta: SegmentationDelta | undefined, @@ -179,3 +193,25 @@ export function removeBookFromSegmentation( if (removedVerseStarts.length === 0 && addedStarts.length === 0) return undefined; return { removedVerseStarts, addedStarts }; } + +/** + * Returns a copy of the delta with the book's boundary anchors replaced by those that `source` + * holds for it, if any, or `undefined` when no anchor remains. + */ +export function restoreBookInSegmentation( + delta: SegmentationDelta | undefined, + source: SegmentationDelta | undefined, + bookCode: string, +): SegmentationDelta | undefined { + const kept = removeBookFromSegmentation(delta, bookCode); + const removedVerseStarts = [ + ...(kept?.removedVerseStarts ?? []), + ...(source?.removedVerseStarts.filter((ref) => bookOfRef(ref) === bookCode) ?? []), + ]; + const addedStarts = [ + ...(kept?.addedStarts ?? []), + ...(source?.addedStarts.filter((start) => bookOfRef(start.tokenRef) === bookCode) ?? []), + ]; + if (removedVerseStarts.length === 0 && addedStarts.length === 0) return undefined; + return { removedVerseStarts, addedStarts }; +} diff --git a/src/utils/read-openable-project.ts b/src/utils/read-openable-project.ts new file mode 100644 index 000000000..0dc11ca02 --- /dev/null +++ b/src/utils/read-openable-project.ts @@ -0,0 +1,30 @@ +import papi from '@papi/frontend'; +import type { OpenableProject } from '../hooks/useDraftProject'; +import { + isInterlinearProjectSummary, + isSegmentationDelta, + isTextAnalysis, +} from '../types/type-guards'; + +/** + * Reads the stored interlinear project that `projectId` names, with its analysis and any stored + * segment boundaries. + * + * @returns The project, or `undefined` when it is missing or malformed. + * @throws If the `interlinearizer.getProject` command rejects, or its reply is not JSON. + */ +export async function readOpenableProject(projectId: string): Promise { + const json = await papi.commands.sendCommand('interlinearizer.getProject', projectId); + const parsed: unknown = json ? JSON.parse(json) : undefined; + if (!isInterlinearProjectSummary(parsed)) return undefined; + const analysis = 'analysis' in parsed ? parsed.analysis : undefined; + const segmentation = 'segmentation' in parsed ? parsed.segmentation : undefined; + if (!isTextAnalysis(analysis)) return undefined; + if (segmentation !== undefined && !isSegmentationDelta(segmentation)) return undefined; + return { + analysisLanguages: parsed.analysisLanguages, + ...(parsed.targetProjectId !== undefined && { targetProjectId: parsed.targetProjectId }), + ...(segmentation !== undefined && { segmentation }), + analysis, + }; +} diff --git a/user-questions.md b/user-questions.md index 4827f4caf..51070c73e 100644 --- a/user-questions.md +++ b/user-questions.md @@ -12,16 +12,17 @@ Explicit questions and/or clear list of options. Use multiple questions as neede List of relevant GitHub issues/prs/commits. -## Draft project, Save / Save As, and Wipe +## Draft project, Save / Save As, and Discard Unsaved Changes The Interlinearizer now keeps an always-present **draft** per source project that auto-saves every edit (so work is never lost), decoupled from the user's saved projects. Editing no longer writes to a project automatically; instead the user explicitly **Save**s (writes the draft to the active project) or **Save As**es (new project, or overwrite an existing one). **New** starts an empty draft (a project is only created on Save As), and **Open** loads a project into the draft as a working copy. Users can -**Wipe** the draft via a single **Wipe…** dialog where they choose the scope — the whole draft or just -the current book. The tab title shows a `●` marker while the draft has unsaved changes (Platform.Bible -exposes no native "unsaved" tab indicator). +discard the draft's unsaved changes via a single **Discard Unsaved Changes…** dialog where they choose +the scope — the whole draft or just the current book. Discarding returns that scope to the active +project's saved analysis, or empties it when no project is active. The tab title shows a `●` marker +while the draft has unsaved changes (Platform.Bible exposes no native "unsaved" tab indicator). Decisions made during development that we'd like reviewed: @@ -33,9 +34,10 @@ Decisions made during development that we'd like reviewed: changes shows a two-button confirm (Discard / Cancel). Should it instead offer a three-way choice ("Save As first" / Discard / Cancel) so the in-progress draft can be kept? -3. **"Wipe book" scope.** The Wipe dialog's "Current book" option targets the book currently in view. - A future option — a picker of the books that have draft analysis — is the likely direction but is - not yet confirmed; "current book only" is a deliberate interim choice to keep this PR scoped. +3. **"Current book" discard scope.** The Discard Unsaved Changes dialog's "Current book" option + targets the book currently in view. A future option — a picker of the books that have draft + analysis — is the likely direction but is not yet confirmed; "current book only" is a deliberate + interim choice to keep this PR scoped. 4. **Unsaved indicator + Save feedback.** The unsaved state is shown as a `●` appended to the tab title. Options: a different glyph, swapping the tab icon to a "modified" badge, and/or whether a @@ -57,13 +59,7 @@ Decisions made during development that we'd like reviewed: blur — only the indicator is eager). Is reflecting in-progress typing as "unsaved" the right behavior, or should the marker wait until an edit is actually committed? -8. **Wipe and the unsaved indicator.** Wiping the **entire draft** is treated as a clean baseline: it - clears the `●` marker (the empty draft is not flagged as unsaved) while keeping the active project - as the Save target, so a subsequent Save still writes the (now empty) draft to it. Wiping the - **current book** stays flagged as unsaved, since it is a partial edit the user will usually want to - save. Is this split right, or should both wipes behave the same? - -9. **Save As → Overwrite and the target's metadata.** "Save As → Overwrite an existing project" +8. **Save As → Overwrite and the target's metadata.** "Save As → Overwrite an existing project" replaces that project's analysis with the draft's. The draft's _config_ (analysis languages and alignment target) can differ from the chosen project's — e.g. you Open project A (languages `[en]`), then Save As → Overwrite project B (languages `[fr]`). We currently push the draft's @@ -80,14 +76,13 @@ Decisions made during development that we'd like reviewed: - Should the Overwrite confirmation surface when the draft's languages differ from the target's, so the user is aware their language tags are about to change? -10. **Single "Wipe…" menu item with a scope picker.** The two former menu items ("Wipe Current Book…" - and "Wipe Draft…") were collapsed into one **Wipe…** item that opens a dialog where the user picks - the scope — current book or entire draft — then confirms. The dialog defaults to "Current book" - (the less destructive option) and disables that option when no book is loaded. Alternative: keep - two separate menu items (each a single click, no scope step). Current choice: one menu item plus a - scope-picker dialog. +9. **Single "Discard Unsaved Changes…" menu item with a scope picker.** One **Discard Unsaved + Changes…** item opens a dialog where the user picks the scope — current book or entire draft — + then confirms. The dialog defaults to "Current book" (the less destructive option) and disables + that option when no book is loaded. Alternative: two separate menu items (each a single click, no + scope step). Current choice: one menu item plus a scope-picker dialog. -11. **In-progress typing guards a project swap.** Switching projects (New / Open) shows the discard +10. **In-progress typing guards a project swap.** Switching projects (New / Open) shows the discard confirm (item 2) whenever the draft has unsaved changes. This now also fires when a gloss field holds **uncommitted** text — the same eager signal that lights the `●` marker (item 7) — not only after that text has committed on blur. Previously the guard checked committed changes only, so