diff --git a/apps/gateway/src/lib/setup-state.ts b/apps/gateway/src/lib/setup-state.ts index cf7d3a71b..cdf6cb818 100644 --- a/apps/gateway/src/lib/setup-state.ts +++ b/apps/gateway/src/lib/setup-state.ts @@ -1,4 +1,4 @@ -import { $Language, DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core'; +import { $Language, DEFAULT_ACTIVE_LANGUAGES, resolveActiveLanguage } from '@opendatacapture/schemas/core'; import type { ActiveLanguages, Language } from '@opendatacapture/schemas/core'; import type { RemoteSetupState } from '@opendatacapture/schemas/gateway'; @@ -25,8 +25,8 @@ export function getActiveLanguages(): ActiveLanguages { export function resolveLanguage(requested: unknown): Language { const activeLanguages = getActiveLanguages(); const requestedLanguage = $Language.safeParse(requested); - return requestedLanguage.success && activeLanguages.includes(requestedLanguage.data) - ? requestedLanguage.data + return requestedLanguage.success + ? resolveActiveLanguage(requestedLanguage.data, activeLanguages) : activeLanguages[0]; } diff --git a/apps/web/src/__tests__/app-route-guard.test.ts b/apps/web/src/__tests__/app-route-guard.test.ts index 741e92d09..ca4898ce5 100644 --- a/apps/web/src/__tests__/app-route-guard.test.ts +++ b/apps/web/src/__tests__/app-route-guard.test.ts @@ -24,7 +24,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } })); const runGuard = async () => { const beforeLoad = Route.options.beforeLoad as (opts: object) => Promise; - const queryClient = { fetchQuery: vi.fn().mockResolvedValue({ isSetup: true }) }; + const queryClient = { fetchQuery: vi.fn().mockResolvedValue({ activeLanguages: ['en', 'fr'], isSetup: true }) }; try { await beforeLoad({ context: { queryClient } }); } catch (err) { 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 32f322af6..f0509058f 100644 --- a/apps/web/src/routes/_app/route.tsx +++ b/apps/web/src/routes/_app/route.tsx @@ -8,6 +8,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')({ @@ -33,6 +34,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 39dc4d32b..df5602b6e 100644 --- a/packages/schemas/src/core/core.test.ts +++ b/packages/schemas/src/core/core.test.ts @@ -7,6 +7,7 @@ import { $RegexString, $UserPermission, isGrantablePermission, + resolveActiveLanguage, toInstrumentAuthoringLanguage } from './core.js'; @@ -72,6 +73,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 71fd9bdf0..d1010e7af 100644 --- a/packages/schemas/src/core/core.ts +++ b/packages/schemas/src/core/core.ts @@ -119,6 +119,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 9671559b0..32f2d7dd9 100644 --- a/testing/src/specs/admin-settings.spec.ts +++ b/testing/src/specs/admin-settings.spec.ts @@ -46,26 +46,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 }) => {