Skip to content
Open
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
9 changes: 4 additions & 5 deletions apps/gateway/src/routers/root.router.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $Language } from '@opendatacapture/schemas/core';
import { $Language, resolveActiveLanguage } from '@opendatacapture/schemas/core';
import { $InstrumentBundleContainer } from '@opendatacapture/schemas/instrument';
import { Router } from 'express';

Expand Down Expand Up @@ -45,10 +45,9 @@ router.get(
// resolve the same language; anything else renders the page in English and then swaps it.
const { activeLanguages } = assignment;
const requestedLanguage = $Language.safeParse(req.query.lang);
const language =
requestedLanguage.success && activeLanguages.includes(requestedLanguage.data)
? requestedLanguage.data
: activeLanguages[0];
const language = requestedLanguage.success
? resolveActiveLanguage(requestedLanguage.data, activeLanguages)
: activeLanguages[0];

const token = generateToken(assignment.id);
const html = res.locals.loadRoot({
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/__tests__/language-toggle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { i18n } from '@douglasneuroinformatics/libui/i18n';
import { LanguageToggle } from '@opendatacapture/react-core';
import type { ActiveLanguages } from '@opendatacapture/schemas/core';
import { cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import '@/services/i18n';
Expand Down Expand Up @@ -43,4 +44,36 @@ describe('LanguageToggle', () => {
renderToggle(['es']);
expect(i18n.resolvedLanguage).toBe('es');
});

// The sidebar renders the toggle as a descendant while translating its own strings, so it is the
// ancestor that has to re-render for the fix to be worth anything — asserting `resolvedLanguage`
// alone passed while the sidebar stayed in the deactivated language.
const Ancestor = ({ activeLanguages }: { activeLanguages: ActiveLanguages }) => {
const { t } = useTranslation();
return (
<div>
<span data-testid="ancestor-label">{t({ en: 'Dashboard', es: 'Panel de control', fr: 'Tableau' })}</span>
<LanguageToggle activeLanguages={activeLanguages} />
</div>
);
};

it('should re-render an ancestor when a language is deactivated mid-session', () => {
const { rerender } = render(<Ancestor activeLanguages={['en', 'es', 'fr']} />);
act(() => i18n.changeLanguage('es'));
expect(screen.getByTestId('ancestor-label').textContent).toBe('Panel de control');

rerender(<Ancestor activeLanguages={['en', 'fr']} />);
expect(i18n.resolvedLanguage).toBe('en');
expect(screen.getByTestId('ancestor-label').textContent).toBe('Dashboard');
});

it('should leave a reader alone when the deactivated language was not theirs', () => {
const { rerender } = render(<Ancestor activeLanguages={['en', 'es', 'fr']} />);
act(() => i18n.changeLanguage('fr'));

rerender(<Ancestor activeLanguages={['en', 'fr']} />);
expect(i18n.resolvedLanguage).toBe('fr');
expect(screen.getByTestId('ancestor-label').textContent).toBe('Tableau');
});
});
28 changes: 28 additions & 0 deletions apps/web/src/__tests__/reconcile-interface-language.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { i18n } from '@douglasneuroinformatics/libui/i18n';
import { beforeEach, describe, expect, it } from 'vitest';

import { reconcileInterfaceLanguage } from '@/services/i18n';

import '@/services/i18n';

describe('reconcileInterfaceLanguage', () => {
beforeEach(() => {
i18n.changeLanguage('en');
});

it('should move a reader off a language the instance no longer offers', () => {
i18n.changeLanguage('es');
expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(true);
expect(i18n.resolvedLanguage).toBe('en');
});

it('should leave a reader on a language the instance still offers', () => {
i18n.changeLanguage('fr');
expect(reconcileInterfaceLanguage(['en', 'fr'])).toBe(false);
expect(i18n.resolvedLanguage).toBe('fr');
});

it('should report no change when nothing moved, so it does not notify every translated component', () => {
expect(reconcileInterfaceLanguage(['en', 'es', 'fr'])).toBe(false);
});
});
3 changes: 3 additions & 0 deletions apps/web/src/routes/_app/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DisclaimerProvider } from '@/providers/DisclaimerProvider';
import { ForceClearQueryCacheProvider } from '@/providers/ForceClearQueryCacheProvider';
import { WalkthroughProvider } from '@/providers/WalkthroughProvider';
import { useAppStore } from '@/store';
import { reconcileInterfaceLanguage } from '@/services/i18n';

export const Route = createFileRoute('/_app')({
beforeLoad: async ({ context }) => {
Expand All @@ -17,6 +18,8 @@ export const Route = createFileRoute('/_app')({
if (!accessToken) {
throw redirect({ to: '/auth/login' });
}
// Before the tree renders, so no component has to be told after the fact.
reconcileInterfaceLanguage(setupState.activeLanguages);
},
component: () => {
return (
Expand Down
19 changes: 19 additions & 0 deletions apps/web/src/services/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
/* eslint-disable @typescript-eslint/no-namespace */

import { i18n } from '@douglasneuroinformatics/libui/i18n';
import { resolveActiveLanguage } from '@opendatacapture/schemas/core';
import type { ActiveLanguages } from '@opendatacapture/schemas/core';

import auth from '../translations/auth.json';
import common from '../translations/common.json';
Expand Down Expand Up @@ -49,4 +51,21 @@ i18n.init({
}
});

/**
* Move a reader off a language their instance no longer offers, and report whether it moved them.
*
* Called before the app renders rather than from a component: `changeLanguage` notifies only the
* components already subscribed, libui's `useTranslation` subscribes in an effect, and effects run
* child-first — so a correction made after mount never reaches the ancestors of whatever made it.
* The sidebar renders the language toggle, so the sidebar is what a late correction leaves behind.
*/
export const reconcileInterfaceLanguage = (activeLanguages: ActiveLanguages): boolean => {
const language = resolveActiveLanguage(i18n.resolvedLanguage, activeLanguages);
if (language === i18n.resolvedLanguage) {
return false;
}
i18n.changeLanguage(language);
return true;
};

export default i18n;
19 changes: 11 additions & 8 deletions packages/react-core/src/hooks/useLanguageOptions.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { useEffect } from 'react';

import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { resolveActiveLanguage } from '@opendatacapture/schemas/core';
import type { ActiveLanguages } from '@opendatacapture/schemas/core';

import { toLanguageToggleOptions } from '../utils/language';

/**
* The languages an instance offers, as options for libui's `LanguageToggle`.
*
* Deactivating a language would otherwise strand every user already reading in it: their strings
* still resolve, but the toggle no longer lists it, so they have no way back. Reconciling here —
* rather than where an admin flips the setting — moves whoever is affected on their next load,
* not just the admin who made the change.
* The effect covers an admin deactivating a language **during** a session: every component is
* subscribed to `languageChange` by then, so they all re-render. It cannot cover a tree that
* mounts already stranded — effects run child-first, so this fires before the ancestors rendering
* the toggle have subscribed, and they would keep the deactivated language. A host resolves that
* case before it renders (`apps/web` in the `_app` route's `beforeLoad`; `apps/gateway` picks the
* language server-side from the same set), which is why this only has to handle the live change.
*/
export const useLanguageOptions = (activeLanguages: ActiveLanguages) => {
const { changeLanguage, resolvedLanguage } = useTranslation();

const isActive = activeLanguages.includes(resolvedLanguage);
const reconciled = resolveActiveLanguage(resolvedLanguage, activeLanguages);

useEffect(() => {
if (!isActive) {
changeLanguage(activeLanguages[0]);
if (reconciled !== resolvedLanguage) {
changeLanguage(reconciled);
}
}, [isActive, activeLanguages]);
}, [reconciled, resolvedLanguage]);

return toLanguageToggleOptions(activeLanguages);
};
22 changes: 21 additions & 1 deletion packages/schemas/src/core/core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';

import { $ActiveLanguages, $LocalizedString, LANGUAGES, toInstrumentAuthoringLanguage } from './core.js';
import {
$ActiveLanguages,
$LocalizedString,
LANGUAGES,
resolveActiveLanguage,
toInstrumentAuthoringLanguage
} from './core.js';

describe('$ActiveLanguages', () => {
it.each([[['en']], [['es']], [['en', 'fr']], [['en', 'es', 'fr']]])(
Expand All @@ -27,6 +33,20 @@ describe('$ActiveLanguages', () => {
});
});

describe('resolveActiveLanguage', () => {
it('should keep a reader on their language while the instance still offers it', () => {
expect(resolveActiveLanguage('fr', ['en', 'fr'])).toBe('fr');
});

it('should move a reader off a deactivated language, which the toggle no longer offers a way out of', () => {
expect(resolveActiveLanguage('es', ['en', 'fr'])).toBe('en');
});

it('should fall back to the first offered language, so the result does not depend on click order', () => {
expect(resolveActiveLanguage('en', ['fr', 'es'])).toBe('fr');
});
});

describe('$LocalizedString', () => {
it('should accept an entry for every interface language', () => {
expect($LocalizedString.safeParse({ en: 'Hello', es: 'Hola', fr: 'Bonjour' }).success).toBe(true);
Expand Down
11 changes: 11 additions & 0 deletions packages/schemas/src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ export const $ActiveLanguages = z.tuple([$Language], $Language);
/** The languages an instance offers before an admin has chosen, and the fallback for one saved before this setting existed. */
export const DEFAULT_ACTIVE_LANGUAGES: ActiveLanguages = ['en', 'fr'];

/**
* The language a reader should end up in, given the set their instance offers. A reader on a
* language that has since been deactivated falls back to the first active one — the toggle no
* longer lists theirs, so leaving them on it strands them with no way out.
*
* This is the one place that policy is decided; both the moment it is applied — before the app
* renders, and again whenever an admin changes the set mid-session — resolve through here.
*/
export const resolveActiveLanguage = (language: Language, activeLanguages: ActiveLanguages): Language =>
activeLanguages.includes(language) ? language : activeLanguages[0];

/**
* A string authored in each of the application's languages. Every field is nullish so content
* may target a single language, and nullish rather than optional to match Prisma's
Expand Down
23 changes: 23 additions & 0 deletions testing/src/specs/admin-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ test.describe('admin settings', () => {
await settingsPage.activeLanguageCheckbox('fr').click();
expect((await restored).ok()).toBe(true);
await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible();

// Deactivating the language the reader is currently in. The sidebar is the casualty when this
// goes wrong: it renders the toggle, so it is the ancestor a correction made from the toggle
// cannot reach. `Iniciar una sesión` is a namespace string, translated on any branch.
const sidebar = page.getByTestId('sidebar');
const activated = waitForSetupPatch(page);
await settingsPage.activeLanguageCheckbox('es').click();
expect((await activated).ok()).toBe(true);

await sidebar.getByTestId('language-toggle').getByRole('button').click();
await page.getByRole('menuitem', { name: 'Español' }).click();
await expect(sidebar).toContainText('Iniciar una sesión');

const deactivatedSpanish = waitForSetupPatch(page);
await settingsPage.activeLanguageCheckbox('es').click();
expect((await deactivatedSpanish).ok()).toBe(true);

await expect(sidebar).toContainText('Start Session');
await expect(sidebar).not.toContainText('Iniciar una sesión');

// And on a fresh mount, where the correction has to already have happened before render.
await page.reload();
await expect(sidebar).toContainText('Start Session');
});

test('should apply the group switcher position preference immediately', async ({ getPageModel }) => {
Expand Down
Loading