diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 5df44ff43..2ae62a076 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -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'; @@ -46,10 +46,9 @@ router.get( // resolve the same language; anything else renders the page in English and then swaps it. const activeLanguages = getActiveLanguages(); 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({ diff --git a/apps/web/src/__tests__/language-toggle.test.tsx b/apps/web/src/__tests__/language-toggle.test.tsx index 66819b47f..57ea40e9b 100644 --- a/apps/web/src/__tests__/language-toggle.test.tsx +++ b/apps/web/src/__tests__/language-toggle.test.tsx @@ -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'; @@ -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 ( +
+ {t({ en: 'Dashboard', es: 'Panel de control', fr: 'Tableau' })} + +
+ ); + }; + + it('should re-render an ancestor when a language is deactivated mid-session', () => { + const { rerender } = render(); + act(() => i18n.changeLanguage('es')); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Panel de control'); + + rerender(); + 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(); + act(() => i18n.changeLanguage('fr')); + + rerender(); + expect(i18n.resolvedLanguage).toBe('fr'); + expect(screen.getByTestId('ancestor-label').textContent).toBe('Tableau'); + }); }); diff --git a/apps/web/src/__tests__/reconcile-interface-language.test.ts b/apps/web/src/__tests__/reconcile-interface-language.test.ts new file mode 100644 index 000000000..1132472a3 --- /dev/null +++ b/apps/web/src/__tests__/reconcile-interface-language.test.ts @@ -0,0 +1,71 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { ActiveLanguages } from '@opendatacapture/schemas/core'; +import type { QueryClient } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Route } from '@/routes/_app/route'; +import { reconcileInterfaceLanguage } from '@/services/i18n'; +import { useAppStore } from '@/store'; + +vi.mock('@/config', () => ({ + config: { + dev: {}, + meta: { contactEmail: '', docsUrl: '', githubRepoUrl: '', licenseUrl: '' }, + setup: { apiBaseUrl: '', isGatewayEnabled: true } + } +})); + +describe('reconcileInterfaceLanguage', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should move a reader off a language the instance no longer offers', () => { + i18n.changeLanguage('es'); + reconcileInterfaceLanguage(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('en'); + }); + + it('should leave a reader on a language the instance still offers', () => { + i18n.changeLanguage('fr'); + reconcileInterfaceLanguage(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('fr'); + }); + + it('should not change the language when nothing moved, so it does not notify every translated component', () => { + const changeLanguage = vi.spyOn(i18n, 'changeLanguage'); + reconcileInterfaceLanguage(['en', 'es', 'fr']); + expect(changeLanguage).not.toHaveBeenCalled(); + }); +}); + +describe('_app beforeLoad', () => { + const runBeforeLoad = async (activeLanguages: ActiveLanguages) => { + const queryClient = { fetchQuery: vi.fn().mockResolvedValue({ activeLanguages, isSetup: true }) }; + const beforeLoad = Route.options.beforeLoad as (opts: { + context: { queryClient: Pick }; + }) => Promise; + await beforeLoad({ context: { queryClient } }); + }; + + beforeEach(() => { + i18n.changeLanguage('en'); + useAppStore.setState({ accessToken: 'token', currentUser: null }); + }); + + it('should move a stranded reader before the app renders, so every component mounts in an offered language', async () => { + i18n.changeLanguage('es'); + await runBeforeLoad(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('en'); + }); + + it('should keep a reader on a language the instance still offers', async () => { + i18n.changeLanguage('fr'); + await runBeforeLoad(['en', 'fr']); + expect(i18n.resolvedLanguage).toBe('fr'); + }); +}); diff --git a/apps/web/src/routes/_app/route.tsx b/apps/web/src/routes/_app/route.tsx index 9acfb6f3e..5b3b4b1e5 100644 --- a/apps/web/src/routes/_app/route.tsx +++ b/apps/web/src/routes/_app/route.tsx @@ -5,6 +5,7 @@ import { setupStateQueryOptions } from '@/hooks/useSetupStateQuery'; import { DisclaimerProvider } from '@/providers/DisclaimerProvider'; import { ForceClearQueryCacheProvider } from '@/providers/ForceClearQueryCacheProvider'; import { WalkthroughProvider } from '@/providers/WalkthroughProvider'; +import { reconcileInterfaceLanguage } from '@/services/i18n'; import { useAppStore } from '@/store'; export const Route = createFileRoute('/_app')({ @@ -23,6 +24,8 @@ export const Route = createFileRoute('/_app')({ if (currentUser?.mustResetPassword) { throw redirect({ to: '/auth/reset-password' }); } + // Before the tree renders, so no component has to be told after the fact. + reconcileInterfaceLanguage(setupState.activeLanguages); }, component: () => { return ( diff --git a/apps/web/src/services/i18n.ts b/apps/web/src/services/i18n.ts index 808f6e96d..0648d33cf 100644 --- a/apps/web/src/services/i18n.ts +++ b/apps/web/src/services/i18n.ts @@ -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'; @@ -49,4 +51,19 @@ i18n.init({ } }); +/** + * Move a reader off a language their instance no longer offers. + * + * 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): void => { + const language = resolveActiveLanguage(i18n.resolvedLanguage, activeLanguages); + if (language !== i18n.resolvedLanguage) { + i18n.changeLanguage(language); + } +}; + export default i18n; diff --git a/packages/react-core/src/hooks/useLanguageOptions.ts b/packages/react-core/src/hooks/useLanguageOptions.ts index 525122cb8..29b224f64 100644 --- a/packages/react-core/src/hooks/useLanguageOptions.ts +++ b/packages/react-core/src/hooks/useLanguageOptions.ts @@ -1,6 +1,7 @@ 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'; @@ -8,21 +9,23 @@ 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); }; diff --git a/packages/schemas/src/core/core.test.ts b/packages/schemas/src/core/core.test.ts index cafbee2df..a685349eb 100644 --- a/packages/schemas/src/core/core.test.ts +++ b/packages/schemas/src/core/core.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { $Json, $LicenseIdentifier, $RegexString, toInstrumentAuthoringLanguage } from './core.js'; +import { + $Json, + $LicenseIdentifier, + $RegexString, + resolveActiveLanguage, + toInstrumentAuthoringLanguage +} from './core.js'; describe('$Json', () => { it('should accept a value nesting arrays and records of JSON literals', () => { @@ -11,6 +17,20 @@ describe('$Json', () => { }); }); +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('$LicenseIdentifier', () => { it('should accept a recognized SPDX identifier', () => { expect($LicenseIdentifier.safeParse('MIT').success).toBe(true); diff --git a/packages/schemas/src/core/core.ts b/packages/schemas/src/core/core.ts index d0a2ea1ac..6e4d8b15c 100644 --- a/packages/schemas/src/core/core.ts +++ b/packages/schemas/src/core/core.ts @@ -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 diff --git a/testing/src/specs/admin-settings.spec.ts b/testing/src/specs/admin-settings.spec.ts index e74430e62..494a1269a 100644 --- a/testing/src/specs/admin-settings.spec.ts +++ b/testing/src/specs/admin-settings.spec.ts @@ -45,26 +45,56 @@ test.describe('admin settings', () => { await expect(settingsPage.defaultAssignmentDurationInput).toHaveValue(String(durationDays)); }); - test('should hide the language toggle once only one language is offered', async ({ getPageModel, page }) => { - const settingsPage = await getPageModel('/admin/settings'); - - // `activeLanguages` is one instance-wide document seeded with English and French, so the - // deactivated language is restored at the end rather than left off for the next spec. - await expect(settingsPage.activeLanguageCheckbox('en')).toBeVisible(); - await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible(); - - const deactivated = waitForSetupPatch(page); - await settingsPage.activeLanguageCheckbox('fr').click(); - expect((await deactivated).ok()).toBe(true); - - await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toHaveCount(0); - // The last remaining language cannot be turned off, so an instance always offers one. - await expect(settingsPage.activeLanguageCheckbox('en')).toBeDisabled(); - - const restored = waitForSetupPatch(page); - await settingsPage.activeLanguageCheckbox('fr').click(); - expect((await restored).ok()).toBe(true); - await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible(); + test.describe('active languages', () => { + // `activeLanguages` is one instance-wide document seeded with English and French, so these tests + // restore it at the end and must not run concurrently with each other. + test.describe.configure({ mode: 'serial' }); + + test('should hide the language toggle once only one language is offered', async ({ getPageModel, page }) => { + const settingsPage = await getPageModel('/admin/settings'); + + await expect(settingsPage.activeLanguageCheckbox('en')).toBeVisible(); + await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible(); + + const deactivated = waitForSetupPatch(page); + await settingsPage.activeLanguageCheckbox('fr').click(); + expect((await deactivated).ok()).toBe(true); + + await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toHaveCount(0); + // The last remaining language cannot be turned off, so an instance always offers one. + await expect(settingsPage.activeLanguageCheckbox('en')).toBeDisabled(); + + const restored = waitForSetupPatch(page); + await settingsPage.activeLanguageCheckbox('fr').click(); + expect((await restored).ok()).toBe(true); + await expect(page.getByTestId('sidebar').getByTestId('language-toggle')).toBeVisible(); + }); + + test('should move the sidebar to an offered language when the language being read is deactivated', async ({ + getPageModel, + page + }) => { + const settingsPage = await getPageModel('/admin/settings'); + + // 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. Spanish is restored to inactive at the end, as it is seeded. + 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'); + }); }); test('should apply the group switcher position preference immediately', async ({ getPageModel }) => {