Skip to content

Commit 343d1bc

Browse files
Seed analysis language from project and platform defaults
- analysisLanguage now prefers the active project's first configured language over the platform UI language - platformLanguage is forwarded to ProjectModals → CreateProjectModal so new projects start with a sensible default in the language field - makeWebViewState accepts an optional seed map so tests can pre-populate state slots without type assertions - Clear focusedTokenRef in Interlinearizer when the book changes to prevent stale token references across book switches - Added tests covering each routing path for analysisLanguage resolution
1 parent 5348b58 commit 343d1bc

8 files changed

Lines changed: 128 additions & 20 deletions

src/__tests__/components/CreateProjectModal.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,29 @@ describe('CreateProjectModal', () => {
5454
);
5555
});
5656

57+
it('pre-populates the language field from defaultAnalysisLanguage and submits it', async () => {
58+
render(
59+
<CreateProjectModal
60+
projectId={testProjectId}
61+
defaultAnalysisLanguage="fr"
62+
onClose={() => {}}
63+
/>,
64+
);
65+
66+
await userEvent.click(screen.getByRole('button', { name: /^create$/i }));
67+
68+
await waitFor(() =>
69+
expect(papi.commands.sendCommand).toHaveBeenCalledWith(
70+
'interlinearizer.createProject',
71+
testProjectId,
72+
['fr'],
73+
undefined,
74+
undefined,
75+
undefined,
76+
),
77+
);
78+
});
79+
5780
it('sends the createProject command with entered name and description when submitted', async () => {
5881
render(<CreateProjectModal projectId={testProjectId} onClose={() => {}} />);
5982

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,13 @@ jest.mock('../../components/ProjectModals', () => ({
104104
modal,
105105
setModal,
106106
activeProject,
107+
defaultAnalysisLanguage,
107108
useWebViewState,
108109
}: {
109110
modal: string;
110111
setModal: (m: string) => void;
111112
activeProject: MockProject | undefined;
113+
defaultAnalysisLanguage?: string;
112114
useWebViewState: (
113115
key: string,
114116
def: MockProject | undefined,
@@ -117,7 +119,11 @@ jest.mock('../../components/ProjectModals', () => ({
117119
}) {
118120
const [, setActiveProject] = useWebViewState('activeProject', undefined);
119121
return (
120-
<div data-testid="project-modals" data-modal={modal}>
122+
<div
123+
data-testid="project-modals"
124+
data-modal={modal}
125+
data-default-lang={defaultAnalysisLanguage}
126+
>
121127
{modal === 'select' && (
122128
<div data-testid="select-modal">
123129
<button
@@ -428,7 +434,34 @@ describe('InterlinearizerLoader', () => {
428434
expect(screen.getByTestId('interlinearizer')).toBeInTheDocument();
429435
});
430436

431-
it('passes the first interfaceLanguage tag to Interlinearizer as analysisLanguage', () => {
437+
it('passes the first analysisLanguages tag from the active project as analysisLanguage', () => {
438+
const state = makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT });
439+
render(
440+
<InterlinearizerLoader
441+
projectId={testProjectId}
442+
useWebViewScrollGroupScrRef={makeScrollGroupHook()}
443+
useWebViewState={state}
444+
/>,
445+
);
446+
447+
expect(capturedInterlinearizerProps?.analysisLanguage).toBe('en');
448+
});
449+
450+
it('prefers the project analysisLanguage over the platform interface language', () => {
451+
mockSettings('simple', ['fr']);
452+
const state = makeWebViewState({ activeProject: STUB_ACTIVE_PROJECT });
453+
render(
454+
<InterlinearizerLoader
455+
projectId={testProjectId}
456+
useWebViewScrollGroupScrRef={makeScrollGroupHook()}
457+
useWebViewState={state}
458+
/>,
459+
);
460+
461+
expect(capturedInterlinearizerProps?.analysisLanguage).toBe('en');
462+
});
463+
464+
it('falls back to the first interfaceLanguage tag when no project is active', () => {
432465
mockSettings('simple', ['fr', 'en']);
433466
render(
434467
<InterlinearizerLoader
@@ -441,7 +474,20 @@ describe('InterlinearizerLoader', () => {
441474
expect(capturedInterlinearizerProps?.analysisLanguage).toBe('fr');
442475
});
443476

444-
it('passes "und" to Interlinearizer as analysisLanguage when interfaceLanguage is empty', () => {
477+
it('passes the platform language to ProjectModals as defaultAnalysisLanguage', () => {
478+
mockSettings('simple', ['de']);
479+
render(
480+
<InterlinearizerLoader
481+
projectId={testProjectId}
482+
useWebViewScrollGroupScrRef={makeScrollGroupHook()}
483+
useWebViewState={makeWebViewState()}
484+
/>,
485+
);
486+
487+
expect(screen.getByTestId('project-modals')).toHaveAttribute('data-default-lang', 'de');
488+
});
489+
490+
it('falls back to "und" as analysisLanguage when no project is active and interfaceLanguage is empty', () => {
445491
render(
446492
<InterlinearizerLoader
447493
projectId={testProjectId}

src/__tests__/components/ProjectModals.test.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,15 @@ jest.mock('../../components/SelectInterlinearProjectModal', () => ({
6969
jest.mock('../../components/CreateProjectModal', () => ({
7070
__esModule: true,
7171
CreateProjectModal: ({
72+
defaultAnalysisLanguage,
7273
onClose,
7374
onProjectCreated,
7475
}: {
76+
defaultAnalysisLanguage?: string;
7577
onClose: () => void;
7678
onProjectCreated: (p: InterlinearProjectSummary) => void;
7779
}) => (
78-
<div data-testid="create-modal">
80+
<div data-testid="create-modal" data-default-lang={defaultAnalysisLanguage}>
7981
<button type="button" data-testid="create-close" onClick={onClose}>
8082
Close
8183
</button>
@@ -158,6 +160,7 @@ jest.mock('../../components/ProjectMetadataModal', () => ({
158160
function renderModals(
159161
overrides: Partial<{
160162
activeProject: InterlinearProjectSummary | undefined;
163+
defaultAnalysisLanguage: string;
161164
modal: ModalState;
162165
setModal: (m: ModalState) => void;
163166
useWebViewState: ReturnType<typeof makeWebViewState>;
@@ -168,6 +171,7 @@ function renderModals(
168171
render(
169172
<ProjectModals
170173
activeProject={overrides.activeProject}
174+
defaultAnalysisLanguage={overrides.defaultAnalysisLanguage}
171175
modal={overrides.modal ?? 'none'}
172176
projectId="source-proj"
173177
setModal={setModal}
@@ -249,6 +253,11 @@ describe('ProjectModals', () => {
249253
});
250254

251255
describe('create modal', () => {
256+
it('forwards defaultAnalysisLanguage to CreateProjectModal', () => {
257+
renderModals({ modal: 'create', defaultAnalysisLanguage: 'es' });
258+
expect(screen.getByTestId('create-modal')).toHaveAttribute('data-default-lang', 'es');
259+
});
260+
252261
it('calls setModal with none when create modal is closed without a select source', async () => {
253262
const { setModal } = renderModals({ modal: 'create' });
254263
await userEvent.click(screen.getByTestId('create-close'));

src/__tests__/test-helpers.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,21 @@ type StateSlot<T> = { get: () => T; set: (v: T) => void };
2828
* Returns a `useWebViewState` hook stub that stores values in typed per-key closures so state
2929
* persists across re-renders within the same test without requiring any type assertions.
3030
*
31+
* @param seed - Optional map of key → initial value. When a key is present in `seed` the slot is
32+
* pre-populated with that value instead of using the hook's `defaultValue` argument.
3133
* @returns A hook function with the signature `(key, defaultValue) => [value, setter, reset]` where
32-
* `value` is the current stored value for `key` (initially `defaultValue`), `setter` updates it,
33-
* and `reset` removes the slot so the next call re-initializes from `defaultValue`.
34+
* `value` is the current stored value for `key` (initially `defaultValue` or the seeded value),
35+
* `setter` updates it, and `reset` removes the slot so the next call re-initializes from
36+
* `defaultValue`.
3437
*/
35-
export function makeWebViewState() {
38+
export function makeWebViewState(seed: Record<string, unknown> = {}) {
3639
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3740
const slots = new Map<string, StateSlot<any>>();
3841
return <T>(key: string, defaultValue: T): [T, (v: T) => void, () => void] => {
3942
let slot: StateSlot<T> | undefined = slots.get(key);
4043
if (slot === undefined) {
41-
let stored = defaultValue;
44+
// eslint-disable-next-line no-type-assertion/no-type-assertion
45+
let stored: T = Object.hasOwn(seed, key) ? (seed[key] as T) : defaultValue;
4246
slot = {
4347
get: () => stored,
4448
set: (v) => {

src/components/CreateProjectModal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [
2525
*
2626
* @param props - Component props
2727
* @param props.projectId - Source project to create the interlinear project for
28+
* @param props.defaultAnalysisLanguage - BCP 47 tag pre-populated in the analysis language field;
29+
* caller should pass the platform UI language so the user sees a sensible starting value.
30+
* Defaults to `'und'` when absent.
2831
* @param props.onClose - Callback invoked when the modal should be dismissed (cancel or submit)
2932
* @param props.onProjectCreated - Optional callback invoked with the full persisted project after
3033
* successful creation, before `onClose` is called.
@@ -33,18 +36,21 @@ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [
3336
*/
3437
export function CreateProjectModal({
3538
projectId,
39+
defaultAnalysisLanguage,
3640
onClose,
3741
onProjectCreated,
3842
}: Readonly<{
3943
projectId: string;
44+
/** BCP 47 tag pre-populated in the analysis language field; defaults to `'und'` when absent. */
45+
defaultAnalysisLanguage?: string;
4046
onClose: () => void;
4147
onProjectCreated?: (project: InterlinearProjectSummary) => void;
4248
}>) {
4349
const [localizedStrings, stringsLoading] = useLocalizedStrings(CREATE_PROJECT_MODAL_STRING_KEYS);
4450

4551
const [name, setName] = useState('');
4652
const [description, setDescription] = useState('');
47-
const [analysisLanguages, setAnalysisLanguages] = useState('und');
53+
const [analysisLanguages, setAnalysisLanguages] = useState(defaultAnalysisLanguage ?? 'und');
4854
const [isSubmitting, setIsSubmitting] = useState(false);
4955
const isSubmittingRef = useRef(false);
5056

src/components/Interlinearizer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ function InterlinearizerInner({
5151
}: Omit<InterlinearizerProps, 'initialAnalysis' | 'analysisLanguage' | 'onSaveAnalysis'>) {
5252
const [focusedTokenRef, setFocusedTokenRef] = useState<string | undefined>(undefined);
5353

54+
// Clear stale focused token when the book changes so focusedTokenRef never refers to a token
55+
// in a different book.
56+
useEffect(() => {
57+
setFocusedTokenRef(undefined);
58+
}, [book]);
59+
5460
/** All word tokens in book order — index into this array is the phrase index. */
5561
const wordTokens = useMemo(
5662
() => book.segments.flatMap((seg) => seg.tokens).filter((token) => token.type === 'word'),

src/components/InterlinearizerLoader.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,28 @@ export default function InterlinearizerLoader({
4444
const [interfaceMode] = useSetting('platform.interfaceMode', 'simple');
4545
const [interfaceLanguages] = useSetting('platform.interfaceLanguage', ['und']);
4646
/* v8 ignore next 3 -- useSetting never returns PlatformError for this key in practice */
47-
const analysisLanguage = isPlatformError(interfaceLanguages)
47+
const platformLanguage = isPlatformError(interfaceLanguages)
4848
? 'und'
4949
: interfaceLanguages[0] || 'und';
5050

51+
/**
52+
* Persisted snapshot of the active interlinear project — kept in WebView state so it survives tab
53+
* restores. The setter lives in {@link ProjectModals}, which writes to the same `'activeProject'`
54+
* key; this component reads the value to decide which menu items to show and which analysis
55+
* language to use.
56+
*/
57+
const [activeProject] = useWebViewState<InterlinearProjectSummary | undefined>(
58+
'activeProject',
59+
undefined,
60+
);
61+
62+
/**
63+
* BCP 47 tag used for reading and writing gloss values. Prefers the active project's first
64+
* configured analysis language; falls back to the platform UI language when no project is
65+
* active.
66+
*/
67+
const analysisLanguage = activeProject?.analysisLanguages[0] ?? platformLanguage;
68+
5169
const {
5270
isLoading: isSettingLoading,
5371
onChange: handleContinuousScrollChange,
@@ -65,16 +83,6 @@ export default function InterlinearizerLoader({
6583

6684
const [modal, setModal] = useState<ModalState>('none');
6785

68-
/**
69-
* Persisted snapshot of the active interlinear project — kept in WebView state so it survives tab
70-
* restores. The setter lives in {@link ProjectModals}, which writes to the same `'activeProject'`
71-
* key; this component reads the value to decide which menu items to show.
72-
*/
73-
const [activeProject] = useWebViewState<InterlinearProjectSummary | undefined>(
74-
'activeProject',
75-
undefined,
76-
);
77-
7886
/**
7987
* Routes top-menu commands to the appropriate modal. `openSelectProjectModal` opens the select
8088
* modal; `openNewProjectModal` opens the create modal directly; `openProjectInfoModal` opens the
@@ -198,6 +206,7 @@ export default function InterlinearizerLoader({
198206

199207
<ProjectModals
200208
activeProject={activeProject}
209+
defaultAnalysisLanguage={platformLanguage}
201210
modal={modal}
202211
projectId={projectId}
203212
setModal={setModal}

src/components/ProjectModals.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type ModalState = 'none' | 'select' | 'create' | 'metadata';
1616
* @param props - Component props
1717
* @param props.activeProject - The currently active interlinear project, read from WebView state by
1818
* the parent.
19+
* @param props.defaultAnalysisLanguage - BCP 47 tag forwarded to {@link CreateProjectModal} as the
20+
* initial value of the analysis language field; should be the platform UI language.
1921
* @param props.modal - Which modal is currently open
2022
* @param props.projectId - PAPI project ID passed from the host
2123
* @param props.setModal - Setter for which modal is open
@@ -25,12 +27,14 @@ export type ModalState = 'none' | 'select' | 'create' | 'metadata';
2527
*/
2628
export default function ProjectModals({
2729
activeProject,
30+
defaultAnalysisLanguage,
2831
modal,
2932
projectId,
3033
setModal,
3134
useWebViewState,
3235
}: Readonly<{
3336
activeProject: InterlinearProjectSummary | undefined;
37+
defaultAnalysisLanguage?: string;
3438
modal: ModalState;
3539
projectId: string;
3640
setModal: (modal: ModalState) => void;
@@ -182,6 +186,7 @@ export default function ProjectModals({
182186
{modal === 'create' && (
183187
<CreateProjectModal
184188
projectId={projectId}
189+
defaultAnalysisLanguage={defaultAnalysisLanguage}
185190
onClose={handleCreateClose}
186191
onProjectCreated={handleProjectCreated}
187192
/>

0 commit comments

Comments
 (0)