Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions desktop/src/lib/__tests__/formLocale.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { scanActiveBundleFormLocales } from '../formLocale';

vi.mock('../tauriClient', () => ({
tauriClient: {
listActiveBundleForms: vi.fn(),
readBundleFormSpec: vi.fn(),
},
}));

import { tauriClient } from '../tauriClient';

describe('scanActiveBundleFormLocales', () => {
beforeEach(() => {
vi.mocked(tauriClient.listActiveBundleForms).mockReset();
vi.mocked(tauriClient.readBundleFormSpec).mockReset();
});

it('collects locales from uiSchema (camelCase from Tauri)', async () => {
vi.mocked(tauriClient.listActiveBundleForms).mockResolvedValue([
{ formType: 'register_bean' },
]);
vi.mocked(tauriClient.readBundleFormSpec).mockResolvedValue({
formType: 'register_bean',
formSchema: {},
uiSchema: {
translations: { it: { label: 'Registra' } },
elements: [
{
type: 'Control',
translations: { it: { label: 'Nome' } },
},
],
},
});

await expect(scanActiveBundleFormLocales()).resolves.toEqual(['it']);
});
});
11 changes: 10 additions & 1 deletion desktop/src/lib/buildFormPreviewInit.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FormInitData } from './formplayerHost';
import { sanitizePortableAttachmentsInFormData } from './sanitizeFormSavedData';
import { resolveDesktopUiLocale } from './uiLocale';
import { resolveDesktopFormLocale } from './formLocale';
import {
buildLinkedFormSpecs,
type LoadLinkedFormSpec,
Expand Down Expand Up @@ -42,10 +43,18 @@ export function buildFormPreviewInit(args: {
typeof args.params.locale === 'string'
? resolveDesktopUiLocale(args.params.locale)
: resolveDesktopUiLocale();
const savedFormLocale =
typeof args.savedData.formLocale === 'string'
? args.savedData.formLocale
: null;
const formLocale = resolveDesktopFormLocale(
typeof args.params.formLocale === 'string' ? args.params.formLocale : null,
savedFormLocale,
);
const init: FormInitData = {
formType: args.formType,
observationId: args.observationId ?? null,
params: { ...args.params, locale },
params: { ...args.params, locale, formLocale },
savedData: sanitizePortableAttachmentsInFormData(args.savedData),
formSchema: args.formSchema,
uiSchema: args.uiSchema,
Expand Down
48 changes: 48 additions & 0 deletions desktop/src/lib/collectTranslationLocales.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/** Collect BCP-47 locale keys from embedded `translations` blocks in ui.json trees. */

function collectFromNode(node: unknown, out: Set<string>): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) {
collectFromNode(child, out);
}
return;
}

const obj = node as Record<string, unknown>;
const translations = obj.translations;
if (
translations &&
typeof translations === 'object' &&
!Array.isArray(translations)
) {
for (const key of Object.keys(translations)) {
const trimmed = key.trim();
if (trimmed) out.add(trimmed);
}
}

if (Array.isArray(obj.elements)) {
for (const el of obj.elements) {
collectFromNode(el, out);
}
}

const options = obj.options;
if (options && typeof options === 'object' && !Array.isArray(options)) {
const opts = options as Record<string, unknown>;
if (Array.isArray(opts.columns)) {
for (const col of opts.columns) {
collectFromNode(col, out);
}
}
}
}

export function collectTranslationLocalesFromUiSchema(
uiSchema: unknown,
): string[] {
const out = new Set<string>();
collectFromNode(uiSchema, out);
return Array.from(out).sort((a, b) => a.localeCompare(b));
}
60 changes: 60 additions & 0 deletions desktop/src/lib/formLocale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { tauriClient } from './tauriClient';
import { collectTranslationLocalesFromUiSchema } from './collectTranslationLocales';

export const FORM_LOCALE_DEFAULT = 'default' as const;
export type FormLocalePreference = typeof FORM_LOCALE_DEFAULT | string;

const STORAGE_KEY = '@ode/formLocale';

export function getDesktopFormLocalePreference(): FormLocalePreference {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored != null && stored.trim()) {
return stored.trim();
}
} catch {
// ignore
}
return FORM_LOCALE_DEFAULT;
}

export function setDesktopFormLocalePreference(
preference: FormLocalePreference,
): void {
localStorage.setItem(STORAGE_KEY, preference);
}

export async function scanActiveBundleFormLocales(): Promise<string[]> {
const forms = await tauriClient.listActiveBundleForms();
const localeSet = new Set<string>();
for (const form of forms) {
try {
const spec = await tauriClient.readBundleFormSpec(form.formType);
for (const code of collectTranslationLocalesFromUiSchema(spec.uiSchema)) {
localeSet.add(code);
}
} catch {
// skip unreadable forms
}
}
return Array.from(localeSet).sort((a, b) => a.localeCompare(b));
}

export function resolveDesktopFormLocale(
sessionOverride?: string | null,
savedFormLocale?: string | null,
): FormLocalePreference {
if (typeof sessionOverride === 'string' && sessionOverride.trim()) {
const trimmed = sessionOverride.trim();
return trimmed.toLowerCase() === FORM_LOCALE_DEFAULT
? FORM_LOCALE_DEFAULT
: trimmed;
}
if (typeof savedFormLocale === 'string' && savedFormLocale.trim()) {
const trimmed = savedFormLocale.trim();
return trimmed.toLowerCase() === FORM_LOCALE_DEFAULT
? FORM_LOCALE_DEFAULT
: trimmed;
}
return getDesktopFormLocalePreference();
}
53 changes: 52 additions & 1 deletion desktop/src/pages/FormPreviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ import {
dropPendingSubObservationOpen,
registerPendingSubObservationOpen,
} from '../lib/formPreviewSubObservationBridge';
import {
FORM_LOCALE_DEFAULT,
getDesktopFormLocalePreference,
scanActiveBundleFormLocales,
setDesktopFormLocalePreference,
type FormLocalePreference,
} from '../lib/formLocale';
import {
getDesktopLocalePreference,
setDesktopLocalePreference,
Expand Down Expand Up @@ -75,6 +82,9 @@ export function FormPreviewPage() {
>(null);
const [uiLocalePreference, setUiLocalePreference] =
useState<UiLocalePreference>(() => getDesktopLocalePreference());
const [formLocalePreference, setFormLocalePreference] =
useState<FormLocalePreference>(() => getDesktopFormLocalePreference());
const [scannedFormLocales, setScannedFormLocales] = useState<string[]>([]);

const [formInitData, setFormInitData] = useState<FormInitData | null>(null);

Expand Down Expand Up @@ -145,11 +155,16 @@ export function FormPreviewPage() {
setListLoading(true);
setListError(null);
try {
const rows = await tauriClient.listActiveBundleForms();
const [rows, locales] = await Promise.all([
tauriClient.listActiveBundleForms(),
scanActiveBundleFormLocales(),
]);
setForms(rows);
setScannedFormLocales(locales);
} catch (e) {
setListError(e instanceof Error ? e.message : String(e));
setForms([]);
setScannedFormLocales([]);
} finally {
setListLoading(false);
}
Expand Down Expand Up @@ -658,6 +673,42 @@ export function FormPreviewPage() {
<option value="pt">Português</option>
<option value="fr">Français</option>
</select>
<label className="form-preview-label" htmlFor="form-locale-select">
Form language
</label>
<select
id="form-locale-select"
className="form-preview-type-select"
value={formLocalePreference}
disabled={scannedFormLocales.length === 0}
onChange={e => {
const v = e.target.value;
setFormLocalePreference(v);
setDesktopFormLocalePreference(v);
if (spec) {
void (async () => {
const p = parseJsonObject(paramsJson, 'params');
const sv = parseJsonObject(savedJson, 'savedData');
if (p.ok && sv.ok) {
setFormInitData(
await buildInitFromSpec(
spec,
{ ...p.value, formLocale: v },
sv.value,
previewObservationId,
),
);
}
})();
}
}}>
<option value={FORM_LOCALE_DEFAULT}>Default</option>
{scannedFormLocales.map(code => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
{listError ? (
<p className="notice error">{listError}</p>
) : forms.length === 0 && !listLoading ? (
Expand Down
23 changes: 17 additions & 6 deletions formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ import {
} from './utils/stickyFieldHelpers';
import { stickyService } from './services/StickyService';
import { applyFormUiTranslations } from './i18n/applyFormUiTranslations';
import {
resolveEffectiveFormLocale,
stampFormLocaleOnObservationData,
} from './i18n/formLocaleUtils';
import { LinkedFormSpecsMap } from './utils/controlDisplayText';
import { createOdeI18n } from './i18n/createOdeI18n';
import { odeT } from './i18n/createOdeI18n';
Expand Down Expand Up @@ -338,7 +342,7 @@ interface FormContextType {

function prepareLinkedFormSpecs(
raw: FormInitData['linkedFormSpecs'],
locale: string,
formLocale: string,
): LinkedFormSpecsMap {
if (!raw || typeof raw !== 'object') return {};
const out: LinkedFormSpecsMap = {};
Expand All @@ -349,7 +353,7 @@ function prepareLinkedFormSpecs(
if (!schema || !uiRaw) continue;
out[id] = {
schema,
uiSchema: applyFormUiTranslations(uiRaw, locale) as UISchemaElement,
uiSchema: applyFormUiTranslations(uiRaw, formLocale) as UISchemaElement,
};
}
return out;
Expand Down Expand Up @@ -512,9 +516,10 @@ function App() {
const resolvedLocale = resolveFormplayerLocale(
(params as Record<string, unknown> | null)?.locale,
);
const resolvedFormLocale = resolveEffectiveFormLocale(params);
setUiLocale(resolvedLocale);
setLinkedFormSpecs(
prepareLinkedFormSpecs(initData.linkedFormSpecs, resolvedLocale),
prepareLinkedFormSpecs(initData.linkedFormSpecs, resolvedFormLocale),
);

// Debug: log schema details, especially x-dynamicEnum usage
Expand Down Expand Up @@ -669,7 +674,7 @@ function App() {
const swipeLayoutUISchema = ensureSwipeLayoutRoot(null);
const withLocale = applyFormUiTranslations(
processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize),
resolvedLocale,
resolvedFormLocale,
);
setUISchema(withLocale);
} else {
Expand All @@ -679,7 +684,7 @@ function App() {
);
const withLocale = applyFormUiTranslations(
processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize),
resolvedLocale,
resolvedFormLocale,
);
setUISchema(withLocale);
}
Expand Down Expand Up @@ -1238,11 +1243,17 @@ function App() {
}

const rootPayload = prepareRootObservationData(rawPayload ?? {}, schema);
const effectiveFormLocale = resolveEffectiveFormLocale(
payloadFormInit.params,
);
const { errors: finalizeValidatorErrors, data: payloadData } =
runCustomValidatorsAndRefreshData(
uischema ?? undefined,
schema ?? undefined,
rootPayload as Record<string, unknown>,
stampFormLocaleOnObservationData(
rootPayload as Record<string, unknown>,
effectiveFormLocale,
),
ajv,
);

Expand Down
10 changes: 10 additions & 0 deletions formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ describe('applyFormUiTranslations', () => {
expect(applyFormUiTranslations(ui, 'fr')).toBe(ui);
});

it('skips merge for default locale even when translations exist', () => {
const ui = {
type: 'Control',
scope: '#/properties/name',
label: 'Name',
translations: { fr: { label: 'Nom' } },
};
expect(applyFormUiTranslations(ui, 'default')).toBe(ui);
});

it('merges partial control label', () => {
const ui = {
type: 'Control',
Expand Down
2 changes: 2 additions & 0 deletions formulus-formplayer/src/i18n/applyFormUiTranslations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ function processNode(node: unknown, locale: string): unknown {
*/
export function applyFormUiTranslations<T>(uischema: T, locale: string): T {
if (!uischema || typeof uischema !== 'object') return uischema;
const tag = locale.trim().toLowerCase();
if (!tag || tag === 'default') return uischema;
if (!hasTranslationsSubtree(uischema)) return uischema;
return processNode(uischema, locale) as T;
}
22 changes: 22 additions & 0 deletions formulus-formplayer/src/i18n/formLocaleUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import {
resolveEffectiveFormLocale,
stampFormLocaleOnObservationData,
} from './formLocaleUtils';

describe('formLocaleUtils', () => {
it('resolves default when formLocale missing', () => {
expect(resolveEffectiveFormLocale({ locale: 'en' })).toBe('default');
});

it('passes through BCP-47 tags', () => {
expect(resolveEffectiveFormLocale({ formLocale: 'fj' })).toBe('fj');
});

it('stamps formLocale on observation data', () => {
expect(stampFormLocaleOnObservationData({ name: 'Ada' }, 'fj')).toEqual({
name: 'Ada',
formLocale: 'fj',
});
});
});
Loading
Loading