diff --git a/src/__tests__/components/modals/ProjectModals.test.tsx b/src/__tests__/components/modals/ProjectModals.test.tsx index 8f168782..c29896e0 100644 --- a/src/__tests__/components/modals/ProjectModals.test.tsx +++ b/src/__tests__/components/modals/ProjectModals.test.tsx @@ -292,6 +292,25 @@ function makeWebViewStateWithResetSpy(resetActiveProject: () => void) { ]; } +/** + * Builds a `useWebViewState` stub with spies on both the setter and the reset for the + * `'activeProject'` key, so tests can assert which path was taken after a create attempt. + * + * @param setActiveProject - Spy invoked when the `'activeProject'` slot is set. + * @param resetActiveProject - Spy invoked when the `'activeProject'` slot is reset. + * @returns A `useWebViewState`-shaped hook stub. + */ +function makeWebViewStateWithActiveProjectSpies( + setActiveProject: jest.Mock, + resetActiveProject: jest.Mock, +) { + return (key: string, defaultValue: T): [T, (v: T) => void, () => void] => [ + defaultValue, + key === 'activeProject' ? setActiveProject : () => {}, + key === 'activeProject' ? resetActiveProject : () => {}, + ]; +} + describe('ProjectModals', () => { beforeEach(() => { jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); @@ -452,9 +471,11 @@ describe('ProjectModals', () => { }); describe('new (create) flow', () => { - it('starts an empty draft, clears the active project, and closes when not dirty', async () => { + it('seeds the draft, calls createProject on the backend, and closes', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); const newDraft = jest.fn(); const setModal = jest.fn(); + const setActiveProject = jest.fn(); const resetActiveProject = jest.fn(); render( { modal: 'create', newDraft, setModal, - useWebViewState: makeWebViewStateWithResetSpy(resetActiveProject), + useWebViewState: makeWebViewStateWithActiveProjectSpies( + setActiveProject, + resetActiveProject, + ), })} />, ); await userEvent.click(screen.getByTestId('create-submit')); - // New starts a draft locally (carrying the typed name/description for the Save As prefill) and - // clears the active project so Save routes to Save As; no backend project is created. + await waitFor(() => expect(setActiveProject).toHaveBeenCalledWith(MOCK_PROJECT)); expect(newDraft).toHaveBeenCalledWith({ analysisLanguages: ['en'], suggestedName: 'New', suggestedDescription: 'Desc', }); - expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( + expect(papi.commands.sendCommand).toHaveBeenCalledWith( 'interlinearizer.createProject', - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', ); - expect(resetActiveProject).toHaveBeenCalledTimes(1); + expect(resetActiveProject).not.toHaveBeenCalled(); expect(setModal).toHaveBeenCalledWith('none'); }); + it('falls back to resetActiveProject and keeps modal open when backend project creation fails', async () => { + // Default mock returns undefined; JSON.parse(undefined) throws into the catch block. + const newDraft = jest.fn(); + const setModal = jest.fn(); + const resetActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + + await waitFor(() => expect(resetActiveProject).toHaveBeenCalledTimes(1)); + expect(newDraft).toHaveBeenCalledWith({ + analysisLanguages: ['en'], + suggestedName: 'New', + suggestedDescription: 'Desc', + }); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.createProject', + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', + ); + expect(setModal).not.toHaveBeenCalledWith('none'); + }); + + it('notifies and falls back to resetActiveProject and keeps modal open when backend returns a non-project shape', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify({ bad: true })); + const setModal = jest.fn(); + const resetActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + + await waitFor(() => expect(resetActiveProject).toHaveBeenCalledTimes(1)); + expect(papi.notifications.send).toHaveBeenCalledWith({ + message: '%interlinearizer_error_create_project_failed%', + severity: 'error', + }); + expect(setModal).not.toHaveBeenCalledWith('none'); + }); + it('calls setModal with none when the create modal closes without a select source', async () => { const setModal = jest.fn(); render(); @@ -540,14 +622,22 @@ describe('ProjectModals', () => { expect(loadFromProject).not.toHaveBeenCalled(); }); - it('confirms before starting a new draft when the draft is dirty', async () => { + it('confirms before creating a project when the draft is dirty', async () => { const newDraft = jest.fn(); render(); await userEvent.click(screen.getByTestId('create-submit')); expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); - // The new draft must not start until the user confirms discarding the current one. + // Neither draft nor project creation should start until the user confirms discarding. expect(newDraft).not.toHaveBeenCalled(); + expect(papi.commands.sendCommand).not.toHaveBeenCalledWith( + 'interlinearizer.createProject', + expect.anything(), + expect.anything(), + undefined, + expect.anything(), + expect.anything(), + ); await userEvent.click(screen.getByTestId('discard-confirm')); await waitFor(() => @@ -557,6 +647,37 @@ describe('ProjectModals', () => { suggestedDescription: 'Desc', }), ); + expect(papi.commands.sendCommand).toHaveBeenCalledWith( + 'interlinearizer.createProject', + 'source-proj', + ['en'], + undefined, + 'New', + 'Desc', + ); + }); + + it('creates the project and closes after confirming the discard on a dirty draft', async () => { + jest.mocked(papi.commands.sendCommand).mockResolvedValueOnce(JSON.stringify(MOCK_PROJECT)); + const setModal = jest.fn(); + const setActiveProject = jest.fn(); + render( + , + ); + + await userEvent.click(screen.getByTestId('create-submit')); + expect(screen.getByTestId('discard-modal')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('discard-confirm')); + await waitFor(() => expect(setModal).toHaveBeenCalledWith('none')); + expect(setActiveProject).toHaveBeenCalledWith(MOCK_PROJECT); }); it('disables the discard-confirm button while an open is in flight', async () => { diff --git a/src/components/modals/ProjectModals.tsx b/src/components/modals/ProjectModals.tsx index dce22496..3096d68d 100644 --- a/src/components/modals/ProjectModals.tsx +++ b/src/components/modals/ProjectModals.tsx @@ -15,8 +15,9 @@ import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal'; export type ModalState = 'none' | 'select' | 'create' | 'metadata' | 'saveAs'; /** - * A draft-replacing action deferred behind the unsaved-changes confirmation: either starting a new - * empty draft or opening an existing project into the draft. + * A draft-replacing action deferred behind the unsaved-changes confirmation: either creating and + * persisting a new project (then seeding the draft from it), or opening an existing project into + * the draft. */ type PendingReplace = | { kind: 'new'; config: CreateDraftConfig } @@ -27,8 +28,10 @@ type PendingReplace = * {@link SelectInterlinearProjectModal}, {@link CreateProjectModal}, {@link ProjectMetadataModal}, or * {@link SaveAsProjectModal}, with the {@link DiscardDraftConfirm} guard overlaid on top when a * draft-replacing action is pending (so canceling returns to the underlying modal with its state - * intact); manages the shared WebView state for the active project; and routes New / Open / Save As - * through the draft (rather than persisting projects directly on every edit). + * intact); manages the shared WebView state for the active project; and routes project lifecycle + * actions through the draft: New creates and persists a project immediately (so it appears in + * Select right away) and seeds the draft from it; Open loads an existing project into the draft; + * Save / Save As write the draft's analysis back to the active project or create a new one. * * @param props - Component props * @param props.activeProject - The currently active interlinear project (the Save target), read @@ -41,8 +44,9 @@ type PendingReplace = * on Save As. * @param props.loadFromProject - Loads a project's analysis + config into the draft (the "Open" * flow). - * @param props.newDraft - Starts a fresh, empty draft (the "New" flow); no backend project is - * created until Save As. + * @param props.newDraft - Seeds the in-memory draft with empty analysis and the given config; + * called by {@link createAndPersistProject} before the backend round-trip so the editor is ready + * regardless of whether persistence succeeds. * @param props.markSynced - Marks the draft as saved (clears `dirty`) after a successful Save As, * given the analysis that was persisted; a no-op if an edit landed during the save. * @param props.modal - Which modal is currently open. @@ -108,6 +112,12 @@ export default function ProjectModals({ /** A draft-replacing action awaiting confirmation because the draft has unsaved changes. */ const [pendingReplace, setPendingReplace] = useState(undefined); + /** + * Whether the project-creation round-trip is in flight. Used to disable the Create / Cancel + * buttons in the create modal while the backend persists the new project. + */ + const [isCreating, setIsCreating] = useState(false); + /** * Whether the discard-and-replace confirmation flow is in flight (either opening an existing * project or starting a new draft); disables DiscardDraftConfirm buttons to prevent races. @@ -200,25 +210,61 @@ export default function ProjectModals({ ); /** - * Starts a fresh, empty draft with the given config — the "New" flow. Seeds the draft (carrying - * the typed name/description as the Save As prefill), clears the active project so there is no - * Save target yet — Save routes to Save As until the draft is saved — and dismisses the modal. No - * backend project is created until the user explicitly saves. + * Creates a new project in storage with the given config and an empty analysis, then seeds the + * draft from it so Save targets the newly created project. This is the "New" flow: the project is + * persisted immediately so it shows up in "Select Interlinear Project" right away. + * + * `newDraft` is called synchronously before the backend round-trip so the editor is ready + * immediately regardless of whether persistence succeeds. This is safe: when dirty is `false` (no + * discard confirmation shown), any data in the draft is either already committed to the active + * project or the draft was empty — nothing is lost. When dirty is `true` the + * {@link DiscardDraftConfirm} dialog has already obtained explicit user consent to discard. + * + * The `interlinearizer.createProject` command sends its own error notification before rethrowing, + * so the catch block only needs to log — callers do not need to send a second notification. This + * matches {@link handleSaveAsNew}, which uses the same command and follows the same pattern. * * @param config - The configuration collected by the New dialog. + * @returns `true` if the project was created and persisted successfully; `false` otherwise. + * Callers are responsible for closing the modal on success and keeping it open on failure so + * the user can retry without re-entering their inputs. */ - const startNewDraft = useCallback( - (config: CreateDraftConfig) => { + const createAndPersistProject = useCallback( + async (config: CreateDraftConfig): Promise => { newDraft({ analysisLanguages: config.analysisLanguages, ...(config.name !== undefined && { suggestedName: config.name }), ...(config.description !== undefined && { suggestedDescription: config.description }), }); - resetActiveProject(); - setCreateSourceIsSelect(false); - setModal('none'); + let created: InterlinearProjectSummary | undefined; + try { + const createdJson = await papi.commands.sendCommand( + 'interlinearizer.createProject', + projectId, + config.analysisLanguages, + undefined, + config.name, + config.description, + ); + const parsed: unknown = JSON.parse(createdJson); + if (isInterlinearProjectSummary(parsed)) { + created = parsed; + } else { + await papi.notifications + .send({ message: '%interlinearizer_error_create_project_failed%', severity: 'error' }) + .catch(() => {}); + } + } catch (e) { + logger.error('Interlinearizer: failed to create project from New dialog', e); + } + if (created) { + setActiveProject(created); + } else { + resetActiveProject(); + } + return created !== undefined; }, - [newDraft, resetActiveProject, setModal], + [newDraft, projectId, resetActiveProject, setActiveProject], ); /** @@ -236,24 +282,39 @@ export default function ProjectModals({ ); /** - * Called when the New dialog is submitted. Starts the new draft immediately, or defers behind the - * unsaved-changes confirmation when the draft is dirty. Starting a draft is synchronous (no - * backend round-trip), so no in-flight re-entry guard is needed. + * Called when the New dialog is submitted. Creates and persists the project immediately, or + * defers behind the unsaved-changes confirmation when the draft is dirty. Disables the modal + * buttons via `isCreating` during the backend round-trip. Closes on success; stays open on + * failure so the user can retry without re-entering their inputs. * * @param config - The configuration collected by the New dialog. */ const handleCreateDraft = useCallback( - (config: CreateDraftConfig) => { + async (config: CreateDraftConfig) => { if (dirty) { setPendingReplace({ kind: 'new', config }); return; } - startNewDraft(config); + setIsCreating(true); + try { + const success = await createAndPersistProject(config); + if (success) { + setCreateSourceIsSelect(false); + setModal('none'); + } + } finally { + setIsCreating(false); + } }, - [dirty, startNewDraft], + [createAndPersistProject, dirty, setCreateSourceIsSelect, setModal], ); - /** Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. */ + /** + * Confirms the deferred draft-replacing action after the user accepts losing unsaved changes. For + * a deferred Open, delegates entirely to {@link openProject}. For a deferred New, closes on + * success; on failure the discard confirm is dismissed but the underlying create modal stays + * visible so the user can retry. + */ const handleConfirmReplace = useCallback(async () => { /* v8 ignore next -- the confirm only renders while a pending action exists */ if (!pendingReplace) return; @@ -266,13 +327,29 @@ export default function ProjectModals({ if (pendingReplace.kind === 'open') { await openProject(pendingReplace.project); } else { - startNewDraft(pendingReplace.config); + setIsCreating(true); + try { + const success = await createAndPersistProject(pendingReplace.config); + if (success) { + setCreateSourceIsSelect(false); + setModal('none'); + } + } finally { + setIsCreating(false); + } } } finally { setIsReplacing(false); setPendingReplace(undefined); } - }, [isReplacing, openProject, pendingReplace, startNewDraft]); + }, [ + createAndPersistProject, + isReplacing, + openProject, + pendingReplace, + setCreateSourceIsSelect, + setModal, + ]); /** Cancels the deferred action, returning to the underlying modal with the draft untouched. */ const handleCancelReplace = useCallback(() => setPendingReplace(undefined), []); @@ -426,6 +503,7 @@ export default function ProjectModals({ {modal === 'create' && ( diff --git a/src/hooks/useDraftProject.ts b/src/hooks/useDraftProject.ts index 3e0f9bc0..0bfd289b 100644 --- a/src/hooks/useDraftProject.ts +++ b/src/hooks/useDraftProject.ts @@ -68,9 +68,9 @@ export type UseDraftProjectResult = { loadFromProject: (project: OpenableProject) => void; /** * Starts a fresh, empty draft for the current source — the "New" flow. Seeds the chosen analysis - * languages and retains the typed name/description as `suggestedName`/`suggestedDescription` to - * prefill Save As; no backend project is created until the user explicitly saves. The new draft - * is clean (`dirty: false`), so the unsaved-changes indicator stays clear until the first edit. + * languages and retains the typed name/description as `suggestedName`/`suggestedDescription`. The + * new draft is clean (`dirty: false`), so the unsaved-changes indicator stays clear until the + * first edit. The caller is responsible for immediately persisting the project to the backend. * * @param config - The languages and optional suggested name/description from the New dialog. */