Skip to content
Merged
6 changes: 3 additions & 3 deletions apps/gateway/src/lib/setup-state.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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];
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/__tests__/app-route-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ vi.mock('@/store', () => ({ useAppStore: { getState: () => mocks.store } }));

const runGuard = async () => {
const beforeLoad = Route.options.beforeLoad as (opts: object) => Promise<void>;
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) {
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');
});
});
71 changes: 71 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,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<QueryClient, 'fetchQuery'> };
}) => Promise<void>;
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');
});
});
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 @@ -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')({
Expand All @@ -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 (
Expand Down
17 changes: 17 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,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;
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);
};
15 changes: 15 additions & 0 deletions packages/schemas/src/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
$RegexString,
$UserPermission,
isGrantablePermission,
resolveActiveLanguage,
toInstrumentAuthoringLanguage
} from './core.js';

Expand Down Expand Up @@ -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);
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 @@ -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
Expand Down
70 changes: 50 additions & 20 deletions testing/src/specs/admin-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading