diff --git a/.agents/docs/packages/libui.md b/.agents/docs/packages/libui.md index f0cb6b579..40e6719e3 100644 --- a/.agents/docs/packages/libui.md +++ b/.agents/docs/packages/libui.md @@ -30,7 +30,7 @@ There is no root `.` export — always import from a subpath. ### Translation — this is a hard rule -Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file: +Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, es, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file: ```tsx import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; @@ -38,9 +38,15 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; const { t } = useTranslation(); // inline strings const { t } = useTranslation('datahub'); // scoped to a translation namespace -; +; ``` +**Every interface language needs an entry.** A missing one resolves to English silently, so +`requireCompleteTranslations` is declared for `apps/web`, `apps/gateway` and `packages/react-core` +(`packages/react-core/src/complete-translations.d.ts` and each app's `src/services/i18n.ts`), which +makes an incomplete `t({ … })` a type error caught by `pnpm lint`. `apps/playground` is deliberately +outside that opt-in — its language selector offers only English and French. + `useTranslation` also returns `resolvedLanguage` when you need the active locale. ### Typing i18n to this app (`apps/web/src/services/i18n.ts`) diff --git a/.agents/docs/playbooks/add-web-data-hook.md b/.agents/docs/playbooks/add-web-data-hook.md index d8ad5755e..562c42508 100644 --- a/.agents/docs/playbooks/add-web-data-hook.md +++ b/.agents/docs/playbooks/add-web-data-hook.md @@ -54,7 +54,7 @@ step type-checks and compiles, and fails at runtime or serves the wrong cached d (`throwOnError: true` for both queries and mutations, in `apps/web/src/services/react-query.ts`, which routes errors to the route error boundary). Set `throwOnError: false` on the mutation _and_ `meta: { disableDefaultErrorNotification: true }` on the request, then notify in `onError` with - `getApiErrorMessage(err, t({ en: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows + `getApiErrorMessage(err, t({ en: …, es: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows two toasts. Canonical: `apps/web/src/hooks/useDeleteSeriesInstrumentMutation.ts`. 11. **Write the unit test in `apps/web/src/hooks/__tests__/.test.ts` — never under diff --git a/.agents/docs/playbooks/promote-to-react-core.md b/.agents/docs/playbooks/promote-to-react-core.md index dce21e82d..1325185a4 100644 --- a/.agents/docs/playbooks/promote-to-react-core.md +++ b/.agents/docs/playbooks/promote-to-react-core.md @@ -38,7 +38,7 @@ so nothing that passed lint in `apps/web` fails on style here; what breaks is re the component is an internal part of `InstrumentRenderer` rather than something a consumer imports directly, leave it off the barrel and add it to that AGENTS.md's deliberately-unexported list. -4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', fr: '…' })`** — libui's own +4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', es: '…', fr: '…' })`** — libui's own registered namespace is the exception (`.agents/skills/odc-frontend/SKILL.md`). `apps/gateway` initialises i18n with no resources (`apps/gateway/src/services/i18n.ts`), and libui's `Translator.t()` logs `Failed to extract translation from object '{}'` and returns the diff --git a/AGENTS.md b/AGENTS.md index d175936ce..042a34c90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,8 +51,10 @@ not every tool loads nested files. workspace name without the prefix. A `commit-msg` hook and the `Commitlint` CI job enforce it, and `CHANGELOG.md` is generated from the `feat`, `fix`, `perf` and breaking commits: one commit is one entry. -- **All frontend user-facing strings go through `useTranslation`.** Prefer inline - `t({ en: '...', fr: '...' })` unless the string is used more than once. +- **All frontend user-facing strings go through `useTranslation`.** Every language the frontend + exposes gets an entry. In web, gateway and react-core, prefer inline + `t({ en: '...', es: '...', fr: '...' })` unless the string is used more than once; each workspace + enables `requireCompleteTranslations`, so omitting a language is a type error caught by `pnpm lint`. - **Never run the `apps/web` route-tree generator.** `src/route-tree.ts` is generated and git-tracked, but the user regenerates it manually after route changes. Never hand-edit it either. - **If code needs a comment to be understood, the code is wrong** — rewrite it. Comments are for diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index 52dcc9641..032d6895d 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -106,7 +106,9 @@ hydrated tree can disagree with the SSR'd HTML until you do. - The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`, `jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still required, and there are no translation resource files — `src/services/i18n.ts` initializes with - `{}`, so every string is inline `t({ en, fr })`. + `{}`, so every string is inline `t({ en, es, fr })`. Every interface language needs an entry; + `requireCompleteTranslations` in that service makes a missing one a type error caught by + `pnpm lint`. - Shared UI comes from `packages/react-core` (`InstrumentRenderer`, `Branding`). Put anything both apps need there, not here. - Validation messages are localized by `src/services/zod.ts`, a thin call to react-core's diff --git a/apps/gateway/src/components/Cap.tsx b/apps/gateway/src/components/Cap.tsx index dbd4917db..bb08c4035 100644 --- a/apps/gateway/src/components/Cap.tsx +++ b/apps/gateway/src/components/Cap.tsx @@ -10,6 +10,7 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve }) const label = t({ en: "I'm a human", + es: 'Soy una persona', fr: 'Je suis un humain' }); @@ -33,10 +34,12 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve }) data-cap-api-endpoint="/api/auth/" data-cap-i18n-error-label={t({ en: 'Error', + es: 'Error', fr: 'Erreur' })} data-cap-i18n-initial-state={t({ en: "I'm a human", + es: 'Soy una persona', fr: 'Je suis un humain' })} data-cap-i18n-solved-label={label} diff --git a/apps/gateway/src/components/Landing.tsx b/apps/gateway/src/components/Landing.tsx index f6adb5075..dce0bd0a5 100644 --- a/apps/gateway/src/components/Landing.tsx +++ b/apps/gateway/src/components/Landing.tsx @@ -11,12 +11,14 @@ const Landing: React.FC = () => { {t({ en: 'Welcome', + es: 'Bienvenido', fr: 'Bienvenue' })}

{t({ en: 'This site is where you complete questionnaires assigned to you on Open Data Capture. To begin, open the secure link you were sent.', + es: 'En este sitio puede completar los cuestionarios que se le han asignado en Open Data Capture. Para comenzar, abra el enlace seguro que se le envió.', fr: 'Ce site vous permet de compléter les questionnaires qui vous ont été assignés sur Open Data Capture. Pour commencer, ouvrez le lien sécurisé qui vous a été envoyé.' })}

diff --git a/apps/gateway/src/services/axios.ts b/apps/gateway/src/services/axios.ts index 8fc2f899c..c1df9cea0 100644 --- a/apps/gateway/src/services/axios.ts +++ b/apps/gateway/src/services/axios.ts @@ -7,6 +7,7 @@ axios.interceptors.request.use((config) => { config.timeout = 10000; // abort request after 10 seconds config.timeoutErrorMessage = i18n.t({ en: 'Network Error', + es: 'Error de red', fr: 'Erreur de réseau' }); return config; @@ -20,6 +21,7 @@ axios.interceptors.response.use( notifications.addNotification({ message: i18n.t({ en: 'Unknown Error', + es: 'Error desconocido', fr: 'Erreur inconnue' }), type: 'error' @@ -30,6 +32,7 @@ axios.interceptors.response.use( notifications.addNotification({ message: i18n.t({ en: 'HTTP Request Failed', + es: 'Error en la solicitud HTTP', fr: 'Échec de la requête HTTP' }), title: error.response?.status.toString(), diff --git a/apps/gateway/src/services/i18n.ts b/apps/gateway/src/services/i18n.ts index c32af3fc4..82098fa06 100644 --- a/apps/gateway/src/services/i18n.ts +++ b/apps/gateway/src/services/i18n.ts @@ -1,5 +1,16 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +/* eslint-disable @typescript-eslint/no-namespace */ + import { i18n } from '@douglasneuroinformatics/libui/i18n'; +declare module '@douglasneuroinformatics/libui/i18n' { + export namespace UserConfig { + export interface Options { + requireCompleteTranslations: true; + } + } +} + // `defaultLanguage` is deliberately left unset. It is what `t()` falls back to when a string has no // entry in the resolved language, so pointing it at the patient's language would make every string // missing a translation render as nothing. The session's language is set with `changeLanguage`. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 86df8f258..1a178d53b 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -117,12 +117,17 @@ In components use a selector: `useAppStore((store) => store.currentGroup)`. Outs `useTranslation` comes from `@douglasneuroinformatics/libui/hooks`. Two forms: ```tsx -t({ en: 'Connection Problem', fr: 'Problème de connexion' }); // inline — prefer this +t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' }); // inline — prefer this t('layout.tabs.table'); // keyed, from a namespace JSON ``` +**Every interface language needs an entry.** libui resolves `obj[resolvedLanguage] ?? obj.en`, so a +missing language renders in English and nothing complains — which is why +`requireCompleteTranslations` is set in `src/services/i18n.ts`: omitting a language from an inline +`t()` call is a type error caught by `pnpm lint`. + Use the keyed form only when a string is reused. Resource files are `src/translations/*.json`, keyed -per-leaf as `{ "en": ..., "fr": ... }` and kept sorted by +per-leaf as `{ "en": ..., "es": ..., "fr": ... }` and kept sorted by `pnpm --filter @opendatacapture/web format:translations`. **Adding a namespace means three edits in `src/services/i18n.ts`** — the import, the `declare module` diff --git a/apps/web/src/components/AppErrorComponent.tsx b/apps/web/src/components/AppErrorComponent.tsx index ed9b048c4..cfcc72aa4 100644 --- a/apps/web/src/components/AppErrorComponent.tsx +++ b/apps/web/src/components/AppErrorComponent.tsx @@ -45,22 +45,24 @@ export const AppErrorComponent = ({ error, reset }: ErrorComponentProps) => { )}

- {t({ en: 'Connection Problem', fr: 'Problème de connexion' })} + {t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' })}

{isOnline ? t({ en: "We couldn't reach the server. This is usually temporary — please try again.", + es: 'No se pudo contactar con el servidor. Suele ser algo temporal: vuelva a intentarlo.', fr: 'Impossible de joindre le serveur. Le problème est généralement temporaire — veuillez réessayer.' }) : t({ en: "You appear to be offline. We'll reconnect automatically as soon as your connection returns.", + es: 'Parece que no tiene conexión. Nos reconectaremos automáticamente en cuanto se restablezca.', fr: 'Vous semblez être hors ligne. La reconnexion se fera automatiquement dès le retour de votre connexion.' })}

diff --git a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx index 71808cf51..0a2e21d8c 100644 --- a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx +++ b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx @@ -43,7 +43,10 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm // The group's active template sorts first; boolean subtraction is a total order, unlike the // ternary chain it replaces, so the result does not depend on the sort's visiting order. const templateOptions = [ - { label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION }, + { + label: t({ en: 'Built-in default', es: 'Predeterminada integrada', fr: 'Modèle par défaut' }), + value: DEFAULT_TEMPLATE_OPTION + }, ...templates.map((template) => ({ label: template.name, value: template.id })) ].sort((a, b) => Number(b.value === activeValue) - Number(a.value === activeValue)); @@ -68,7 +71,11 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm setFeedback(null); const fail = (message: string) => { setFeedback({ message, tone: 'error' }); - addNotification({ message, title: t({ en: 'Email failed', fr: 'Échec du courriel' }), type: 'error' }); + addNotification({ + message, + title: t({ en: 'Email failed', es: 'Error al enviar el correo', fr: 'Échec du courriel' }), + type: 'error' + }); }; sendEmailMutation.mutate( { @@ -78,18 +85,33 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate }, { - onError: () => fail(t({ en: 'The email could not be sent', fr: "Le courriel n'a pas pu être envoyé" })), + onError: () => + fail( + t({ + en: 'The email could not be sent', + es: 'No se pudo enviar el correo', + fr: "Le courriel n'a pas pu être envoyé" + }) + ), onSuccess: (result) => { if (result.status !== 'SENT') { fail(mailErrorMessage(result.error)); return; } const message = t( - { en: 'Assignment link sent to {}', fr: "Lien d'évaluation envoyé à {}" }, + { + en: 'Assignment link sent to {}', + es: 'Enlace de la tarea enviado a {}', + fr: "Lien d'évaluation envoyé à {}" + }, { args: [recipient] } ); setFeedback({ message, tone: 'success' }); - addNotification({ message, title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), type: 'success' }); + addNotification({ + message, + title: t({ en: 'Email sent', es: 'Correo enviado', fr: 'Courriel envoyé' }), + type: 'success' + }); setRecipient(''); } } @@ -104,7 +126,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
{templates.length > 0 && (
- + setRecipient(event.target.value)} @@ -171,8 +206,8 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm onClick={sendEmail} > {sendEmailMutation.isPending - ? t({ en: 'Sending…', fr: 'Envoi en cours…' }) - : t({ en: 'Email assignment', fr: 'Envoyer par courriel' })} + ? t({ en: 'Sending…', es: 'Enviando…', fr: 'Envoi en cours…' }) + : t({ en: 'Email assignment', es: 'Enviar la tarea por correo', fr: 'Envoyer par courriel' })}
{feedback && ( diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx index b4a742ae9..6c55a5a15 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -112,6 +112,7 @@ export const BulkRemoteAssignmentWizard = ({ {wizard.didCopy - ? t({ en: 'Copied', fr: 'Copié' }) - : t({ en: 'Copy All Links', fr: 'Copier tous les liens' })} + ? t({ en: 'Copied', es: 'Copiado', fr: 'Copié' }) + : t({ en: 'Copy All Links', es: 'Copiar todos los enlaces', fr: 'Copier tous les liens' })} } step={null} title={t({ en: `${wizard.assignments.length} Assignments Created`, + es: `${wizard.assignments.length} tareas creadas`, fr: `${wizard.assignments.length} tâches créées` })} > @@ -148,7 +150,7 @@ export const BulkRemoteAssignmentWizard = ({ head={ {t('datahub.index.table.subject')} - {t({ en: 'Link', fr: 'Lien' })} + {t({ en: 'Link', es: 'Enlace', fr: 'Lien' })} } diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx index 56c96d532..b92365726 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ErrorList.tsx @@ -22,6 +22,7 @@ export const ErrorList = ({ errors }: { errors: BulkParseError[] }) => {

{t({ en: 'Nothing has been created. Fix the following and try again:', + es: 'No se ha creado nada. Corrija lo siguiente y vuelva a intentarlo:', fr: 'Rien n’a été créé. Corrigez ce qui suit et réessayez :' })}

@@ -29,7 +30,9 @@ export const ErrorList = ({ errors }: { errors: BulkParseError[] }) => { {errors.map((error, index) => (
  • {error.row !== undefined && ( - {t({ en: `Row ${error.row}: `, fr: `Ligne ${error.row} : ` })} + + {t({ en: `Row ${error.row}: `, es: `Fila ${error.row}: `, fr: `Ligne ${error.row} : ` })} + )} {error.message} {error.items && error.items.length > 0 && ( diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx index e32159c8e..f845881af 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/MapStep.tsx @@ -15,12 +15,12 @@ import type { WizardStep } from './types'; const CANONICAL_FIELDS: CanonicalField[] = ['subjectId', 'firstName', 'lastName', 'dateOfBirth', 'sex']; -const FIELD_LABELS: { [K in CanonicalField]: { en: string; fr: string } } = { - dateOfBirth: { en: 'Date of Birth', fr: 'Date de naissance' }, - firstName: { en: 'First Name', fr: 'Prénom' }, - lastName: { en: 'Last Name', fr: 'Nom' }, - sex: { en: 'Sex at Birth', fr: 'Sexe à la naissance' }, - subjectId: { en: 'Subject ID', fr: 'Identifiant du sujet' } +const FIELD_LABELS: { [K in CanonicalField]: { en: string; es: string; fr: string } } = { + dateOfBirth: { en: 'Date of Birth', es: 'Fecha de nacimiento', fr: 'Date de naissance' }, + firstName: { en: 'First Name', es: 'Nombre', fr: 'Prénom' }, + lastName: { en: 'Last Name', es: 'Apellido', fr: 'Nom' }, + sex: { en: 'Sex at Birth', es: 'Sexo al nacer', fr: 'Sexe à la naissance' }, + subjectId: { en: 'Subject ID', es: 'Identificador del sujeto', fr: 'Identifiant du sujet' } } as const; const NOT_USED = '__not_used__'; @@ -61,6 +61,7 @@ export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }: { message: t({ en: 'Map a subject ID column, or a complete set of first name, last name, date of birth and sex.', + es: 'Asocie una columna de identificador de sujeto, o un conjunto completo de nombre, apellido, fecha de nacimiento y sexo.', fr: "Associez une colonne d'identifiant, ou un ensemble complet de prénom, nom, date de naissance et sexe." }) } @@ -85,27 +86,28 @@ export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }: mode ? ( {mode === 'ID' - ? t({ en: 'Subject ID', fr: 'Identifiant du sujet' }) - : t({ en: 'Personal Information', fr: 'Renseignements personnels' })} + ? t({ en: 'Subject ID', es: 'Identificador del sujeto', fr: 'Identifiant du sujet' }) + : t({ en: 'Personal Information', es: 'Información personal', fr: 'Renseignements personnels' })} ) : null } description={t({ en: `Check that the columns were read correctly. ${parsed.rows.length} rows found.`, + es: `Compruebe que las columnas se leyeron correctamente. Se encontraron ${parsed.rows.length} filas.`, fr: `Vérifiez que les colonnes ont été lues correctement. ${parsed.rows.length} lignes trouvées.` })} footer={ } step="SUBJECTS" - title={t({ en: 'Confirm the Columns', fr: 'Confirmer les colonnes' })} + title={t({ en: 'Confirm the Columns', es: 'Confirmar las columnas', fr: 'Confirmer les colonnes' })} onStepChange={onStepChange} >
    @@ -115,18 +117,23 @@ export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }:

    {t({ en: 'Subject identifiers are derived in your browser. The personal information in this file is never sent.', + es: 'Los identificadores de los sujetos se derivan en su navegador. La información personal de este archivo nunca se envía.', fr: 'Les identifiants sont dérivés dans votre navigateur. Les renseignements personnels de ce fichier ne sont jamais envoyés.' })}

    )}
    -

    {t({ en: 'Column Mapping', fr: 'Correspondance des colonnes' })}

    +

    + {t({ en: 'Column Mapping', es: 'Correspondencia de columnas', fr: 'Correspondance des colonnes' })} +

    - {t({ en: 'Column in Your File', fr: 'Colonne de votre fichier' })} - {t({ en: 'Read As', fr: 'Interprétée comme' })} + + {t({ en: 'Column in Your File', es: 'Columna de su archivo', fr: 'Colonne de votre fichier' })} + + {t({ en: 'Read As', es: 'Interpretada como', fr: 'Interprétée comme' })} } > @@ -144,7 +151,9 @@ export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }: - {t({ en: 'Not Used', fr: 'Non utilisée' })} + + {t({ en: 'Not Used', es: 'No utilizada', fr: 'Non utilisée' })} + {CANONICAL_FIELDS.filter((f) => !claimed.has(f) || f === field).map((f) => ( {t(FIELD_LABELS[f])} @@ -163,6 +172,7 @@ export const MapStep = ({ groupName, onBack, onResolved, onStepChange, parsed }:

    {t({ en: `Preview of the first ${parsed.preview.length} rows`, + es: `Vista previa de las primeras ${parsed.preview.length} filas`, fr: `Aperçu des ${parsed.preview.length} premières lignes` })}

    diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx index 6077e9d3b..079902bc8 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/ReviewStep.tsx @@ -40,6 +40,7 @@ const useIssueMessages = (describeSubject: (subjectId: string) => string) => { items: issue.conflicts.map(({ subjectId }) => describeSubject(subjectId)), message: t({ en: `${issue.conflicts.length} subject(s) already have an outstanding assignment for one of these instruments:`, + es: `${issue.conflicts.length} sujeto(s) ya tienen una tarea pendiente para uno de estos instrumentos:`, fr: `${issue.conflicts.length} sujet(s) ont déjà une tâche en cours pour l’un de ces instruments :` }) }; @@ -48,6 +49,7 @@ const useIssueMessages = (describeSubject: (subjectId: string) => string) => { items: issue.instrumentIds, message: t({ en: `This group cannot assign ${issue.instrumentIds.length} of the selected instrument(s):`, + es: `Este grupo no puede asignar ${issue.instrumentIds.length} de los instrumentos seleccionados:`, fr: `Ce groupe ne peut pas attribuer ${issue.instrumentIds.length} des instruments sélectionnés :` }) }; @@ -56,6 +58,7 @@ const useIssueMessages = (describeSubject: (subjectId: string) => string) => { items: issue.subjectIds.map((subjectId) => describeSubject(subjectId)), message: t({ en: `${issue.subjectIds.length} subject(s) are not available in this group:`, + es: `${issue.subjectIds.length} sujeto(s) no están disponibles en este grupo:`, fr: `${issue.subjectIds.length} sujet(s) ne sont pas disponibles dans ce groupe :` }) }; @@ -86,6 +89,7 @@ export const ReviewStep = ({ messages.push({ message: t({ en: 'The request could not be completed. Nothing has been created.', + es: 'No se pudo completar la solicitud. No se ha creado nada.', fr: 'La requête n’a pas pu être complétée. Rien n’a été créé.' }) }); @@ -95,12 +99,13 @@ export const ReviewStep = ({ } step="REVIEW" - title={t({ en: 'Review and Create', fr: 'Réviser et créer' })} + title={t({ en: 'Review and Create', es: 'Revisar y crear', fr: 'Réviser et créer' })} onStepChange={onStepChange} >
    @@ -122,6 +127,7 @@ export const ReviewStep = ({

    {t({ en: `${subjectCount} subjects × ${timepoints.length} instruments = ${subjectCount * timepoints.length} assignments`, + es: `${subjectCount} sujetos × ${timepoints.length} instrumentos = ${subjectCount * timepoints.length} tareas`, fr: `${subjectCount} sujets × ${timepoints.length} instruments = ${subjectCount * timepoints.length} tâches` })}

    @@ -136,6 +142,7 @@ export const ReviewStep = ({ /> {t({ en: 'Assign anyway, creating a second assignment for those subjects', + es: 'Asignar de todos modos, creando una segunda tarea para esos sujetos', fr: 'Attribuer quand même, en créant une seconde tâche pour ces sujets' })} diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx index adcc900d1..b8cc67421 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/SourceStep.tsx @@ -113,9 +113,9 @@ export const SourceStep = ({ }; const modes: { label: string; value: SourceMode }[] = [ - { label: t({ en: 'Select Subjects', fr: 'Sélectionner des sujets' }), value: 'SELECT' }, - { label: t({ en: 'Upload a File', fr: 'Téléverser un fichier' }), value: 'FILE' }, - { label: t({ en: 'Paste Data', fr: 'Coller des données' }), value: 'PASTE' } + { label: t({ en: 'Select Subjects', es: 'Seleccionar sujetos', fr: 'Sélectionner des sujets' }), value: 'SELECT' }, + { label: t({ en: 'Upload a File', es: 'Cargar un archivo', fr: 'Téléverser un fichier' }), value: 'FILE' }, + { label: t({ en: 'Paste Data', es: 'Pegar datos', fr: 'Coller des données' }), value: 'PASTE' } ]; // Every subject in the group is listed, not only those with a chosen identifier: a hash-identified @@ -123,7 +123,7 @@ export const SourceStep = ({ // rendered the way the rest of the app renders them - group scope removed, truncated to the group's // display length - with date of birth and sex alongside, since a digest identifies nobody on sight. const rows: PickerRow[] = subjects.map((subject) => ({ - dateOfBirth: subject.dateOfBirth ? toBasicISOString(subject.dateOfBirth) : t({ en: 'NULL', fr: 'NUL' }), + dateOfBirth: subject.dateOfBirth ? toBasicISOString(subject.dateOfBirth) : t({ en: 'NULL', es: 'NULO', fr: 'NUL' }), id: subject.id, sex: subject.sex ?? '', subject: removeSubjectIdScope(subject.id).slice(0, subjectIdDisplayLength) @@ -151,7 +151,7 @@ export const SourceStep = ({ type="button" onClick={() => void run(() => parseDelimitedText(pasted))} > - {t({ en: 'Use Pasted Data', fr: 'Utiliser les données collées' })} + {t({ en: 'Use Pasted Data', es: 'Usar los datos pegados', fr: 'Utiliser les données collées' })} ), SELECT: ( @@ -160,6 +160,7 @@ export const SourceStep = ({

    {t({ en: `Selection is limited to ${BULK_ASSIGNMENT_MAX_SUBJECTS} subjects`, + es: `La selección está limitada a ${BULK_ASSIGNMENT_MAX_SUBJECTS} sujetos`, fr: `La sélection est limitée à ${BULK_ASSIGNMENT_MAX_SUBJECTS} sujets` })}

    @@ -170,7 +171,7 @@ export const SourceStep = ({ type="button" onClick={() => onSubjectsSelected([...selected])} > - {t({ en: 'Continue With Selected', fr: 'Continuer avec la sélection' })} + {t({ en: 'Continue With Selected', es: 'Continuar con la selección', fr: 'Continuer avec la sélection' })} {selected.size > 0 && ` (${selected.size})`} @@ -181,11 +182,12 @@ export const SourceStep = ({
    @@ -222,12 +224,17 @@ export const SourceStep = ({

    {t({ en: 'Choose existing subjects in this group. Search, sort or filter to narrow the list.', + es: 'Elija sujetos existentes de este grupo. Busque, ordene o filtre para reducir la lista.', fr: 'Choisissez des sujets existants de ce groupe. Cherchez, triez ou filtrez pour réduire la liste.' })}

    {rows.length === 0 ? (

    - {t({ en: 'This group has no subjects.', fr: 'Ce groupe n’a aucun sujet.' })} + {t({ + en: 'This group has no subjects.', + es: 'Este grupo no tiene sujetos.', + fr: 'Ce groupe n’a aucun sujet.' + })}

    ) : (
    @@ -249,7 +256,11 @@ export const SourceStep = ({ filteredRows.length > 0 && filteredRows.every((row) => selected.has(row.id)); return ( toggleFiltered(filteredRows)} @@ -282,7 +293,7 @@ export const SourceStep = ({ if (value === 'MALE') { return t('core.identificationData.sex.male'); } - return t({ en: 'NULL', fr: 'NUL' }); + return t({ en: 'NULL', es: 'NULO', fr: 'NUL' }); }, header: ({ column }) => ( @@ -306,6 +317,7 @@ export const SourceStep = ({

    {t({ en: 'CSV, TSV or Excel. Include a subject ID column, or first name, last name, date of birth and sex.', + es: 'CSV, TSV o Excel. Incluya una columna de identificador de sujeto, o nombre, apellido, fecha de nacimiento y sexo.', fr: 'CSV, TSV ou Excel. Incluez une colonne d’identifiant, ou prénom, nom, date de naissance et sexe.' })}

    @@ -329,6 +341,7 @@ export const SourceStep = ({

    {t({ en: 'Comma, tab or semicolon separated, with a header row. Include a subject ID column, or first name, last name, date of birth and sex.', + es: 'Separado por comas, tabulaciones o puntos y comas, con una fila de encabezado. Incluya una columna de identificador de sujeto, o nombre, apellido, fecha de nacimiento y sexo.', fr: 'Séparé par des virgules, tabulations ou points-virgules, avec une ligne d’en-tête. Incluez une colonne d’identifiant, ou prénom, nom, date de naissance et sexe.' })}

    diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx index 98a4a16b0..1f3a01397 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/StepLayout.tsx @@ -10,9 +10,9 @@ import type { WizardStep } from './types'; const WIZARD_STEPS = ['SUBJECTS', 'INSTRUMENTS', 'REVIEW'] as const; const STEP_LABELS = { - INSTRUMENTS: { en: 'Instruments', fr: 'Instruments' }, - REVIEW: { en: 'Review', fr: 'Révision' }, - SUBJECTS: { en: 'Subjects', fr: 'Sujets' } + INSTRUMENTS: { en: 'Instruments', es: 'Instrumentos', fr: 'Instruments' }, + REVIEW: { en: 'Review', es: 'Revisión', fr: 'Révision' }, + SUBJECTS: { en: 'Subjects', es: 'Sujetos', fr: 'Sujets' } } as const; /** The screen a breadcrumb returns to. Subjects re-enters at the source step, not the mapping one. */ @@ -53,7 +53,7 @@ const StepLayout = ({ aside, children, description, footer, onStepChange, step, {currentIndex !== -1 && (