Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/__tests__/components/InterlinearizerLoader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,20 @@ jest.mock('../../components/ProjectMetadataModal', () => ({
<button
type="button"
data-testid="metadata-modal-saved"
onClick={() => onProjectSaved({ analysisWritingSystem: 'fr' })}
onClick={() => {
onProjectSaved({ analysisWritingSystem: 'fr' });
onClose();
}}
>
Save
</button>
<button
type="button"
data-testid="metadata-modal-deleted"
onClick={() => onProjectDeleted(MOCK_PROJECT.id)}
onClick={() => {
onProjectDeleted(MOCK_PROJECT.id);
onClose();
}}
>
Delete
</button>
Expand Down
156 changes: 13 additions & 143 deletions src/components/InterlinearizerLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,29 +5,16 @@ import { TabToolbar } from 'platform-bible-react';
import type { SelectMenuItemHandler } from 'platform-bible-react';
import { isPlatformError } from 'platform-bible-utils';
import { useCallback, useMemo, useState } from 'react';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting';
import ContinuousScrollToggle from './ContinuousScrollToggle';
import { CreateProjectModal } from './CreateProjectModal';
import Interlinearizer from './Interlinearizer';
import { ProjectMetadataModal } from './ProjectMetadataModal';
import ProjectModals, { type ModalState } from './ProjectModals';
import ScriptureNavControls from './ScriptureNavControls';
import {
SelectInterlinearProjectModal,
type InterlinearProjectSummary,
} from './SelectInterlinearProjectModal';
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting';
import type { ActiveProjectState } from './SelectInterlinearProjectModal';

const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%'];

/** Which modal is currently visible. Only one can be open at a time. */
type ModalState = 'none' | 'select' | 'create' | 'metadata';

/** Fields of the active interlinear project persisted in WebView state. */
type ActiveProjectState = Pick<
InterlinearProjectSummary,
'id' | 'createdAt' | 'name' | 'description' | 'sourceProjectId' | 'analysisWritingSystem'
>;

/**
* Root component for loading the Interlinearizer. Loads book data and settings, manages modal state
* for project creation/selection/metadata, then renders error and loading states or delegates to
Expand Down Expand Up @@ -73,32 +60,11 @@ export default function InterlinearizerLoader({
* restores. Updated after creation and when the user selects an existing project from the
* picker.
*/
const [activeProject, setActiveProject, resetActiveProject] = useWebViewState<
ActiveProjectState | undefined
>('activeProject', undefined);

/**
* The project currently open in the metadata modal. Set when the user clicks the info icon in the
* select modal or triggers "View Project Info" from the menu.
*/
const [metadataProject, setMetadataProject] = useState<InterlinearProjectSummary | undefined>(
const [activeProject] = useWebViewState<ActiveProjectState | undefined>(
'activeProject',
undefined,
);

/**
* Tracks where the metadata modal was opened from so the correct modal is restored on close.
* `'select'` means it was opened via the info icon in the select modal; `'menu'` means it was
* opened via the "View Project Info" menu item.
*/
const [metadataSource, setMetadataSource] = useState<'select' | 'menu'>('menu');

/**
* Tracks where the create modal was opened from so the correct modal is restored on close.
* `'select'` means it was opened via "New Interlinear Project..." in the select modal; `'menu'`
* means it was opened directly from the top menu.
*/
const [createSource, setCreateSource] = useState<'select' | 'menu'>('menu');

/**
* Routes top-menu commands to the appropriate modal. `openSelectProjectModal` opens the select
* modal; `openNewProjectModal` opens the create modal directly; `openProjectInfoModal` opens the
Expand All @@ -111,79 +77,16 @@ export default function InterlinearizerLoader({
if (item.command === 'interlinearizer.openSelectProjectModal') {
setModal('select');
} else if (item.command === 'interlinearizer.openNewProjectModal') {
setCreateSource('menu');
setModal('create');
} else if (item.command === 'interlinearizer.openProjectInfoModal') {
if (activeProject) {
setMetadataProject(activeProject);
setMetadataSource('menu');
setModal('metadata');
}
}
},
[activeProject],
);

/**
* Opens the metadata modal for the project whose info icon was clicked in the select modal.
*
* @param project - The project to display in the metadata modal.
*/
const handleViewInfo = useCallback((project: InterlinearProjectSummary) => {
setMetadataProject(project);
setMetadataSource('select');
setModal('metadata');
}, []);

/**
* Records a newly created interlinear project as the active project.
*
* @param project - The full persisted project returned by the create command.
*/
const handleProjectCreated = useCallback(
(project: InterlinearProjectSummary) => {
setActiveProject(project);
},
[setActiveProject],
);

/** Closes the create modal, returning to the select modal if creation was initiated from there. */
const handleCreateModalClose = useCallback(() => {
setModal(createSource === 'select' ? 'select' : 'none');
}, [createSource]);

/**
* Called when the metadata modal saves changes. Updates `activeProject` state when the edited
* project is the currently active one, then returns to the appropriate modal.
*
* @param updated - The updated name, description, and analysisWritingSystem.
*/
const handleMetadataProjectSaved = useCallback(
(updated: { name?: string; description?: string; analysisWritingSystem: string }) => {
if (activeProject && metadataProject?.id === activeProject.id) {
setActiveProject({ ...activeProject, ...updated });
}
setModal(metadataSource === 'select' ? 'select' : 'none');
setMetadataProject(undefined);
},
[activeProject, metadataProject, metadataSource, setActiveProject],
);

/**
* Called when the metadata modal deletes the project. Clears `activeProject` if it was the
* deleted project, then returns to the appropriate modal.
*
* @param deletedId - UUID of the project that was deleted.
*/
const handleMetadataProjectDeleted = useCallback(
(deletedId: string) => {
if (activeProject?.id === deletedId) resetActiveProject();
setModal(metadataSource === 'select' ? 'select' : 'none');
setMetadataProject(undefined);
},
[activeProject, metadataSource, resetActiveProject],
);

/**
* Fetches the top-menu data for this WebView from the platform's menu data provider, hiding "View
* Project Info" when no interlinear project is currently active.
Expand Down Expand Up @@ -273,46 +176,13 @@ export default function InterlinearizerLoader({
/>
)}

{modal === 'select' && (
<SelectInterlinearProjectModal
sourceProjectId={projectId}
onSelect={(project) => {
setActiveProject(project);
setModal('none');
}}
onCreateNew={() => {
setCreateSource('select');
setModal('create');
}}
onClose={() => setModal('none')}
onViewInfo={handleViewInfo}
/>
)}

{modal === 'create' && (
<CreateProjectModal
projectId={projectId}
onClose={handleCreateModalClose}
onProjectCreated={handleProjectCreated}
/>
)}

{modal === 'metadata' && metadataProject && (
<ProjectMetadataModal
interlinearProjectId={metadataProject.id}
name={metadataProject.name}
description={metadataProject.description}
sourceProjectId={metadataProject.sourceProjectId}
analysisWritingSystem={metadataProject.analysisWritingSystem}
createdAt={metadataProject.createdAt}
onClose={() => {
setModal(metadataSource === 'select' ? 'select' : 'none');
setMetadataProject(undefined);
}}
onProjectSaved={handleMetadataProjectSaved}
onProjectDeleted={handleMetadataProjectDeleted}
/>
)}
<ProjectModals
activeProject={activeProject}
modal={modal}
projectId={projectId}
setModal={setModal}
useWebViewState={useWebViewState}
/>
</div>
);
}
159 changes: 159 additions & 0 deletions src/components/ProjectModals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { UseWebViewStateHook } from '@papi/core';
import { useCallback, useState } from 'react';
import { CreateProjectModal } from './CreateProjectModal';
import { ProjectMetadataModal } from './ProjectMetadataModal';
import {
type ActiveProjectState,
type InterlinearProjectSummary,
SelectInterlinearProjectModal,
} from './SelectInterlinearProjectModal';

/** Which modal is currently visible. Only one can be open at a time. */
export type ModalState = 'none' | 'select' | 'create' | 'metadata';

/**
* Component for managing project modals in the Interlinearizer. Handles state for project creation,
* selection, and metadata modals.
*
* @param props - Component props
* @param props.activeProject - The currently active interlinear project, read from WebView state by
* the parent.
* @param props.modal - Which modal is currently open
* @param props.projectId - PAPI project ID passed from the host
* @param props.setModal - Setter for which modal is open
* @param props.useWebViewState - Hook for reading and writing values persisted in the WebView's
* saved state (survives tab restores)
* @returns The currently active modal, or an empty container when no modal is open.
*/
export default function ProjectModals({
activeProject,
modal,
projectId,
setModal,
useWebViewState,
}: Readonly<{
activeProject: ActiveProjectState | undefined;
modal: ModalState;
projectId: string;
setModal: (modal: ModalState) => void;
useWebViewState: UseWebViewStateHook;
}>) {
const [, setActiveProject, resetActiveProject] = useWebViewState<ActiveProjectState | undefined>(
'activeProject',
undefined,
);

/**
* The project currently open in the metadata modal. Set when the user clicks the info icon in the
* select modal or triggers "View Project Info" from the menu.
*/
const [metadataProject, setMetadataProject] = useState<InterlinearProjectSummary | undefined>(
undefined,
);

/**
* Tracks where the metadata modal was opened from so the correct modal is restored on close.
* `'select'` means it was opened via the info icon in the select modal; `'menu'` means it was
* opened via the "View Project Info" menu item.
*/
const [metadataSourceIsSelect, setMetadataSourceIsSelect] = useState(false);

/**
* Tracks whether the create modal was opened from the select modal, so the correct modal is
* restored on close.
*/
const [createSourceIsSelect, setCreateSourceIsSelect] = useState(false);

const resolvedMetadataProject = metadataProject ?? activeProject;

/**
* Opens the metadata modal for the project whose info icon was clicked in the select modal.
*
* @param project - The project to display in the metadata modal.
*/
const handleViewInfo = useCallback(
(project: InterlinearProjectSummary) => {
setMetadataProject(project);
setMetadataSourceIsSelect(true);
setModal('metadata');
},
[setModal],
);

/**
* Called when the metadata modal saves changes. Updates `activeProject` state when the edited
* project is the currently active one.
*
* @param updated - The updated name, description, and analysisWritingSystem.
*/
const handleMetadataProjectSaved = useCallback(
(updated: { name?: string; description?: string; analysisWritingSystem: string }) => {
if (activeProject && resolvedMetadataProject?.id === activeProject.id) {
setActiveProject({ ...activeProject, ...updated });
}
},
[activeProject, resolvedMetadataProject, setActiveProject],
);

/**
* Called when the metadata modal deletes the project. Clears `activeProject` if it was the
* deleted project.
*
* @param deletedId - UUID of the project that was deleted.
*/
const handleMetadataProjectDeleted = useCallback(
(deletedId: string) => {
if (activeProject?.id === deletedId) resetActiveProject();
},
[activeProject, resetActiveProject],
);

return (
<div>
{modal === 'select' && (
<SelectInterlinearProjectModal
sourceProjectId={projectId}
onSelect={(project) => {
setActiveProject(project);
setModal('none');
}}
onCreateNew={() => {
setCreateSourceIsSelect(true);
setModal('create');
}}
onClose={() => setModal('none')}
onViewInfo={handleViewInfo}
/>
)}

{modal === 'create' && (
<CreateProjectModal
projectId={projectId}
onClose={() => {
setModal(createSourceIsSelect ? 'select' : 'none');
setCreateSourceIsSelect(false);
}}
onProjectCreated={setActiveProject}
/>
)}

{modal === 'metadata' && resolvedMetadataProject && (
<ProjectMetadataModal
interlinearProjectId={resolvedMetadataProject.id}
name={resolvedMetadataProject.name}
description={resolvedMetadataProject.description}
sourceProjectId={resolvedMetadataProject.sourceProjectId}
analysisWritingSystem={resolvedMetadataProject.analysisWritingSystem}
createdAt={resolvedMetadataProject.createdAt}
onClose={() => {
setModal(metadataSourceIsSelect ? 'select' : 'none');
setMetadataSourceIsSelect(false);
setMetadataProject(undefined);
}}
onProjectSaved={handleMetadataProjectSaved}
onProjectDeleted={handleMetadataProjectDeleted}
/>
)}
</div>
);
}
6 changes: 6 additions & 0 deletions src/components/SelectInterlinearProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export type InterlinearProjectSummary = Pick<
'id' | 'createdAt' | 'sourceProjectId' | 'analysisWritingSystem' | 'name' | 'description'
>;

/** Fields of the active interlinear project persisted in WebView state. */
export type ActiveProjectState = Pick<
InterlinearProjectSummary,
'id' | 'createdAt' | 'name' | 'description' | 'sourceProjectId' | 'analysisWritingSystem'
>;

/** Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON. */
export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary {
return (
Expand Down