From 87ab4f0b7af20df5d1395f864f39862e46c2113c Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Thu, 23 Jul 2026 23:45:18 -0400 Subject: [PATCH 1/2] feat(i18n): add per-instance language selection mechanism Adds the mechanism for supporting additional UI languages (e.g. Spanish) without shipping any translations. Deployments choose which languages are active; the language toggle and instrument rendering derive from that set. - Store activeLanguages in SetupState (default ['en', 'fr']); expose via the setup API and admin settings, which autosaves language changes. - Augment libui's UserConfig.LanguageOptions with es in the web and gateway apps so the UI can resolve to Spanish; instrument content types are left at en|fr (partial translations are accepted), and sites that pass the resolved UI language into instrument APIs narrow it back to a supported language. - Derive Navbar/Sidebar language toggle options from activeLanguages and hide the toggle when only one language is active. Co-Authored-By: Claude Opus 4.6 --- apps/api/prisma/schema.prisma | 1 + .../src/setup/dto/update-setup-state.dto.ts | 3 + apps/api/src/setup/setup.service.ts | 1 + apps/gateway/src/services/i18n.ts | 27 +++++ .../LoginBranding/LoginBrandingPanel.tsx | 29 +++-- apps/web/src/components/Navbar/Navbar.tsx | 18 +-- apps/web/src/components/SaveStatus.tsx | 28 +++++ apps/web/src/components/Sidebar/Sidebar.tsx | 27 +++-- .../StartSessionForm/StartSessionForm.tsx | 4 +- apps/web/src/hooks/useInstrument.ts | 9 +- apps/web/src/hooks/useInstrumentInfoQuery.ts | 3 +- apps/web/src/routes/_app/admin/settings.tsx | 109 ++++++++++++------ apps/web/src/routes/_app/group/manage.tsx | 2 +- apps/web/src/routes/auth/login.tsx | 6 +- apps/web/src/services/i18n.ts | 1 + apps/web/src/utils/languages.ts | 1 + .../InteractiveContent/InteractiveContent.tsx | 8 +- .../src/hooks/useInterpretedInstrument.ts | 5 +- packages/schemas/src/setup/setup.ts | 2 + 19 files changed, 212 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/components/SaveStatus.tsx create mode 100644 apps/web/src/utils/languages.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 05a717c14..0401b960b 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -385,6 +385,7 @@ model SetupState { createdAt DateTime @default(now()) @db.Date updatedAt DateTime @updatedAt @db.Date id String @id @default(auto()) @map("_id") @db.ObjectId + activeLanguages String[] branding BrandingConfig? isDemo Boolean isExperimentalFeaturesEnabled Boolean? diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 233e89429..3bf53a731 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -5,6 +5,9 @@ import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/sche @ValidationSchema($UpdateSetupStateData) export class UpdateSetupStateDto implements UpdateSetupStateData { + @ApiProperty({ required: false }) + activeLanguages?: string[]; + @ApiProperty({ required: false }) branding?: BrandingConfig | null; diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index 85a131d74..f63169141 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -51,6 +51,7 @@ export class SetupService { // older $BrandingConfig will silently drop newer branding fields on read. const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null); return { + activeLanguages: savedOptions?.activeLanguages?.length ? savedOptions.activeLanguages : ['en', 'fr'], branding: branding.success ? branding.data : null, isDemo: Boolean(savedOptions?.isDemo), isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled), diff --git a/apps/gateway/src/services/i18n.ts b/apps/gateway/src/services/i18n.ts index 48cd67600..40ea69ca9 100644 --- a/apps/gateway/src/services/i18n.ts +++ b/apps/gateway/src/services/i18n.ts @@ -1,5 +1,32 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +/* eslint-disable @typescript-eslint/no-namespace */ + import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; + +declare module '@douglasneuroinformatics/libui/i18n' { + export namespace UserConfig { + export interface LanguageOptions { + en: true; + es: true; + fr: true; + } + } +} + +const VALID_LANGUAGES = ['en', 'es', 'fr']; + +function detectLanguage(): Language { + if (typeof window !== 'undefined') { + const param = new URLSearchParams(window.location.search).get('lang'); + if (param && VALID_LANGUAGES.includes(param)) { + return param as Language; + } + } + return 'en'; +} i18n.init({ + defaultLanguage: detectLanguage(), translations: {} }); diff --git a/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx index f0443f81e..ced9c5e9a 100644 --- a/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx +++ b/apps/web/src/components/LoginBranding/LoginBrandingPanel.tsx @@ -124,11 +124,11 @@ const LogoSection = ({ alignment, branding, instanceName, preview }: LogoSection type ResourcesSectionProps = { boldResourceLinks: boolean; fontSize: null | number | undefined; - lang: 'en' | 'fr'; + lang: string; links: ResourceLink[]; preview: boolean; tc: (slateClass: string) => null | string; - tl: (obj: { en: string; fr: string }) => string; + tl: (obj: { [key: string]: string }) => string; }; const ResourcesSection = ({ boldResourceLinks, fontSize, lang, links, preview, tc, tl }: ResourcesSectionProps) => ( @@ -144,8 +144,13 @@ const ResourcesSection = ({ boldResourceLinks, fontSize, lang, links, preview, t style={fontStyle(fontSize, preview)} > {links.map((link, index) => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const linkLabel = link.label?.[lang]?.trim() || link.label?.en?.trim() || link.label?.fr?.trim() || ''; + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- blank strings must fall back, so `||` not `??` */ + const linkLabel = + (link.label as undefined | { [key: string]: string })?.[lang]?.trim() || + link.label?.en?.trim() || + link.label?.fr?.trim() || + ''; + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ if (!linkLabel) return null; return ( obj[lang]; + const tl = (obj: { [key: string]: string }): string => obj[lang] ?? obj.en ?? ''; const derived = useMemo(() => { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const instanceName = branding?.instanceName?.[lang]?.trim() || DEFAULT_INSTANCE_NAME; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const instanceTagline = branding?.instanceTagline?.[lang]?.trim() || null; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const instanceDetails = branding?.instanceDetails?.[lang]?.trim() || null; + /* eslint-disable @typescript-eslint/prefer-nullish-coalescing -- blank strings must fall back, so `||` not `??` */ + const instanceName = + (branding?.instanceName as undefined | { [key: string]: string })?.[lang]?.trim() || DEFAULT_INSTANCE_NAME; + const instanceTagline = + (branding?.instanceTagline as undefined | { [key: string]: string })?.[lang]?.trim() || null; + const instanceDetails = + (branding?.instanceDetails as undefined | { [key: string]: string })?.[lang]?.trim() || null; + /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ const panelTextColor = branding?.panelTextColor ?? null; return { boldDetails: branding?.boldDetails === true, diff --git a/apps/web/src/components/Navbar/Navbar.tsx b/apps/web/src/components/Navbar/Navbar.tsx index 2b93fed3c..d2d5231e8 100644 --- a/apps/web/src/components/Navbar/Navbar.tsx +++ b/apps/web/src/components/Navbar/Navbar.tsx @@ -8,7 +8,9 @@ import { MenuIcon, StopCircle } from 'lucide-react'; import { useIsDesktop } from '@/hooks/useIsDesktop'; import { useNavItems } from '@/hooks/useNavItems'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; +import { ALL_LANGUAGES } from '@/utils/languages'; import { GroupSwitcher, useIsGroupSwitcherVisible } from '../GroupSwitcher'; import { NavButton } from '../NavButton'; @@ -23,6 +25,12 @@ export const Navbar = () => { const { t } = useTranslation('layout'); const navigate = useNavigate(); const endSession = useAppStore((store) => store.endSession); + const setupStateQuery = useSetupStateQuery(); + + const activeLanguages = setupStateQuery.data.activeLanguages ?? ['en', 'fr']; + const languageOptions = Object.fromEntries( + Object.entries(ALL_LANGUAGES).filter(([key]) => activeLanguages.includes(key)) + ); // This is to prevent ugly styling when resizing the viewport const isDesktop = useIsDesktop(); @@ -124,13 +132,9 @@ export const Navbar = () => {
- + {Object.keys(languageOptions).length > 1 && ( + + )}
diff --git a/apps/web/src/components/SaveStatus.tsx b/apps/web/src/components/SaveStatus.tsx new file mode 100644 index 000000000..5353654ad --- /dev/null +++ b/apps/web/src/components/SaveStatus.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { CheckIcon, Loader2Icon } from 'lucide-react'; + +export const SaveStatus = ({ state }: { state: 'idle' | 'saved' | 'saving' }) => { + const { t } = useTranslation(); + if (state === 'idle') { + return null; + } + return ( +
+ {state === 'saving' ? ( + + + {t({ en: 'Saving…', es: 'Guardando…', fr: 'Enregistrement…' })} + + ) : ( + + + + {t({ en: 'All changes saved', es: 'Todos los cambios guardados', fr: 'Modifications enregistrées' })} + + + )} +
+ ); +}; diff --git a/apps/web/src/components/Sidebar/Sidebar.tsx b/apps/web/src/components/Sidebar/Sidebar.tsx index bbdfe0ea5..93de8c92f 100644 --- a/apps/web/src/components/Sidebar/Sidebar.tsx +++ b/apps/web/src/components/Sidebar/Sidebar.tsx @@ -10,7 +10,9 @@ import { StopCircle } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { useNavItems } from '@/hooks/useNavItems'; +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useAppStore } from '@/store'; +import { ALL_LANGUAGES } from '@/utils/languages'; import { GroupSwitcher, useIsGroupSwitcherVisible } from '../GroupSwitcher'; import { NavButton } from '../NavButton'; @@ -27,9 +29,15 @@ export const Sidebar = () => { const endSession = useAppStore((store) => store.endSession); const location = useLocation(); const navigate = useNavigate(); + const setupStateQuery = useSetupStateQuery(); const { t } = useTranslation(); + const activeLanguages = setupStateQuery.data.activeLanguages ?? ['en', 'fr']; + const languageOptions = Object.fromEntries( + Object.entries(ALL_LANGUAGES).filter(([key]) => activeLanguages.includes(key)) + ); + return (
{
- + {Object.keys(languageOptions).length > 1 && ( + + )}
diff --git a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx index 7f0937d72..63da241af 100644 --- a/apps/web/src/components/StartSessionForm/StartSessionForm.tsx +++ b/apps/web/src/components/StartSessionForm/StartSessionForm.tsx @@ -212,7 +212,9 @@ export const StartSessionForm = ({ ctx.addIssue({ code: z.ZodIssueCode.custom, message: - currentGroup.settings.idValidationRegexErrorMessage?.[resolvedLanguage] ?? + (currentGroup.settings.idValidationRegexErrorMessage as undefined | { [key: string]: string })?.[ + resolvedLanguage + ] ?? t({ en: `Must match regular expression: ${regex.source}`, fr: `Doit correspondre à l'expression régulière : ${regex.source}` diff --git a/apps/web/src/hooks/useInstrument.ts b/apps/web/src/hooks/useInstrument.ts index 1ccbd4aac..3b259c440 100644 --- a/apps/web/src/hooks/useInstrument.ts +++ b/apps/web/src/hooks/useInstrument.ts @@ -18,7 +18,14 @@ export function useInstrument(id: null | string) { const { bundle, id } = instrumentBundleQuery.data; interpreter .interpret(bundle, { id, validate: import.meta.env.DEV }) - .then((instrument) => setInstrument(translateInstrument(instrument, resolvedLanguage))) + .then((instrument) => + setInstrument( + translateInstrument( + instrument, + resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en' + ) + ) + ) .catch((err) => { console.error(err); setInstrument(null); diff --git a/apps/web/src/hooks/useInstrumentInfoQuery.ts b/apps/web/src/hooks/useInstrumentInfoQuery.ts index 8f98f7b37..f4912d872 100644 --- a/apps/web/src/hooks/useInstrumentInfoQuery.ts +++ b/apps/web/src/hooks/useInstrumentInfoQuery.ts @@ -26,7 +26,8 @@ export function useInstrumentInfoQuery({ params: { ...params, groupId: currentGroupId } }); const infos = await $InstrumentInfo.array().parseAsync(response.data); - return infos.map((instrument) => translateInstrumentInfo(instrument, resolvedLanguage ?? 'en')); + const instrumentLang = resolvedLanguage === 'en' || resolvedLanguage === 'fr' ? resolvedLanguage : 'en'; + return infos.map((instrument) => translateInstrumentInfo(instrument, instrumentLang)); }, queryKey: [ 'instrument-info', diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx index 430d84437..8ed09c6e7 100644 --- a/apps/web/src/routes/_app/admin/settings.tsx +++ b/apps/web/src/routes/_app/admin/settings.tsx @@ -1,17 +1,20 @@ -import React, { useState } from 'react'; +import React from 'react'; -import { Button, Card, Heading, HoverCard, Select } from '@douglasneuroinformatics/libui/components'; -import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { Card, Checkbox, Heading, HoverCard, Select } from '@douglasneuroinformatics/libui/components'; +import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; import { createFileRoute } from '@tanstack/react-router'; import { CircleHelpIcon } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; +import { SaveStatus } from '@/components/SaveStatus'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; import { useAppStore } from '@/store'; import type { GroupSwitcherPosition } from '@/store/types'; +import { ALL_LANGUAGES } from '@/utils/languages'; -/** libui ships no Switch, so this is hand-rolled — `label` names it for assistive technology. */ const Toggle = ({ checked, label, @@ -39,33 +42,29 @@ const RouteComponent = () => { const { t } = useTranslation(); const setupStateQuery = useSetupStateQuery(); const updateSetupStateMutation = useUpdateSetupStateMutation(); - const addNotification = useNotificationsStore((store) => store.addNotification); const groupSwitcherPosition = useAppStore((store) => store.groupSwitcherPosition); const setGroupSwitcherPosition = useAppStore((store) => store.setGroupSwitcherPosition); - const uploaderLabel = t({ en: 'Enable Uploader', fr: 'Activer le téléversement' }); - - // The toggle is staged locally until Save, so it holds its own state. Resync it whenever the server - // value changes underneath — saving invalidates the query, and the response is the authority on what - // was actually stored. - const savedUploaderEnabled = setupStateQuery.data.isExperimentalFeaturesEnabled ?? false; - const [uploaderEnabled, setUploaderEnabled] = useState(savedUploaderEnabled); - const [syncedUploaderEnabled, setSyncedUploaderEnabled] = useState(savedUploaderEnabled); - if (syncedUploaderEnabled !== savedUploaderEnabled) { - setSyncedUploaderEnabled(savedUploaderEnabled); - setUploaderEnabled(savedUploaderEnabled); - } + const [saveState, setSaveState] = React.useState<'idle' | 'saved' | 'saving'>('idle'); + const savedTimerRef = React.useRef>(undefined); - const handleSave = () => { - updateSetupStateMutation.mutate( - { isExperimentalFeaturesEnabled: uploaderEnabled }, - { - onSuccess: () => { - addNotification({ type: 'success' }); + const autosave = React.useCallback( + (data: Parameters[0]) => { + setSaveState('saving'); + updateSetupStateMutation.mutate(data, { + onSettled: () => { + setSaveState('saved'); + clearTimeout(savedTimerRef.current); + savedTimerRef.current = setTimeout(() => setSaveState('idle'), 2000); } - } - ); - }; + }); + }, + [updateSetupStateMutation] + ); + + const uploaderLabel = t({ en: 'Enable Uploader', fr: 'Activer le téléversement' }); + const uploaderEnabled = setupStateQuery.data.isExperimentalFeaturesEnabled ?? false; + const activeLanguages = setupStateQuery.data.activeLanguages ?? ['en', 'fr']; return ( @@ -100,17 +99,14 @@ const RouteComponent = () => {
- + autosave({ isExperimentalFeaturesEnabled: checked })} + /> - - - - {/* A separate card because these settings are not the instance's: they live in this browser and - apply instantly, so they are deliberately outside the Save button's scope above. */} {t({ en: 'Preferences', fr: 'Préférences' })} @@ -124,7 +120,10 @@ const RouteComponent = () => {

- {t({ en: 'Group Switcher Position', fr: 'Position du sélecteur de groupe' })} + {t({ + en: 'Group Switcher Position', + fr: 'Position du sélecteur de groupe' + })}