diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..1a4608179 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +# EditorConfig — https://editorconfig.org +# Aligns with .gitattributes (LF in repo). Cursor/VS Code apply this on save when +# the EditorConfig extension is enabled (built into Cursor/VS Code by default). + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[*.go] +indent_style = tab +indent_size = 4 + +[*.{bat,cmd}] +end_of_line = crlf + +[Makefile] +indent_style = tab diff --git a/AGENTS.md b/AGENTS.md index a0f08d97a..9647d7db5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ flowchart LR | Profile | Typical focus | Where to work | |--------|----------------|---------------| | **Platform developer** | You are editing **this repo**: RN, Go, React, shared packages, CI. | Package `AGENTS.md` below. | -| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app) and [documentation](https://opendataensemble.org/docs/). | +| **Custom app author** | You ship an **HTML/JS/CSS** app bundle and JSON forms for Formulus; you may **not** clone this monorepo. | [Custom app template (AI + author context)](https://github.com/OpenDataEnsemble/custom_app), [documentation](https://opendataensemble.org/docs/), and [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) for form i18n. | Do not assume custom app authors have local checkouts of **ODE** or internal example repos. @@ -135,13 +135,14 @@ git push origin v1.1.1-alpha.3 - **Custom app bridge (v1.1.0+):** `persistObservation` (headless write), `sync`, `getConnectivityStatus`, `getCurrentDataRevisionCount`, and `openFormplayer` options `skipFinalize` / `skipDraftSelection` — contract in [`FormulusInterfaceDefinition.ts`](formulus/src/webview/FormulusInterfaceDefinition.ts); run `pnpm run sync-interface` in formplayer after changes. - **Sub-observations:** Each nested Formplayer session validates its own schema; `skipFinalize` only skips the Finalize page. Custom validators are per-session — see [Custom Extensions — nested sessions](https://opendataensemble.org/docs/guides/custom-extensions#nested-sessions-and-custom-validators) (docs site). - **Shared UI tokens:** Install **tokens** before **components** / **formplayer** where the docs require it (see package READMEs and formplayer AGENTS). +- **i18n (two layers):** ODE-owned locales (`en`/`pt`/`fr`) for Formulus + Formplayer chrome via Settings → Language; form-owned copy via optional `translations` on `ui.json` elements (preprocessed at form init). See [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md) (monorepo) and [form translations guide](https://opendataensemble.org/docs/guides/form-translations) (published). --- ## CI and code quality - **Pipelines:** [.github/CICD.md](.github/CICD.md). -- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). +- **Lint/format:** Run the relevant scripts in the **package you touch** (see root [README.md](README.md) and each package). On Windows, use LF line endings — root [`.editorconfig`](.editorconfig) and [`.gitattributes`](.gitattributes) match Prettier/CI; run `pnpm run format` in the package if you see `Delete ␍` lint errors. - **Pre-flight before opening a PR:** each package `AGENTS.md` lists the local `lint` / `format` / `format:check` / `test` / `build` commands that match CI — run them in every package you changed (e.g. [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md#pre-flight-before-a-pr)). - **Commits/PRs:** Conventional Commits and PR expectations are documented in [formulus-formplayer/AGENTS.md](formulus-formplayer/AGENTS.md) (project-wide convention). @@ -157,4 +158,6 @@ ODE Desktop ships in [`desktop/`](desktop/) (see [desktop/AGENTS.md](desktop/AGE Authoritative **public** documentation: [opendataensemble.org](https://opendataensemble.org/docs/). +Form localization (embedded `ui.json` translations): [FORM_LOCALIZATION_GUIDE.md](FORM_LOCALIZATION_GUIDE.md). + Optional **AI-focused** context (no ODE clone required): [custom_app](https://github.com/OpenDataEnsemble/custom_app) on GitHub (`README.md`, `AGENTS.md`, `CONTEXT_*.md`). diff --git a/FORM_LOCALIZATION_GUIDE.md b/FORM_LOCALIZATION_GUIDE.md new file mode 100644 index 000000000..00acaa419 --- /dev/null +++ b/FORM_LOCALIZATION_GUIDE.md @@ -0,0 +1,247 @@ +# Form localization guide + +How **user-defined forms** (JSON Schema + `ui.json` in custom app bundles) support multiple languages in ODE. + +**Audience:** platform developers editing Formplayer/Formulus, and custom app authors shipping forms in app bundles. + +**Published user guide:** [Form translations](https://opendataensemble.org/docs/guides/form-translations) on opendataensemble.org (kept in sync conceptually; this file is the monorepo implementation reference). + +--- + +## Two i18n layers (do not mix them up) + +| Layer | Who owns it | Where strings live | When it applies | +|-------|-------------|-------------------|-----------------| +| **ODE chrome** | ODE platform | `formulus-formplayer/src/locales/{en,pt,fr}.json`, Formulus i18n catalogs | Next/Back, validation errors, sub-obs “+ Add …” template, loading text | +| **Form copy** | Form / app author | `ui.json` base fields + optional `translations` blocks | Field labels, descriptions, SwipeLayout headers, custom widget copy | + +Form authors **do not** edit ODE locale JSON. They embed translations in each form’s `ui.json`. ODE developers extend chrome strings when adding new platform UI. + +--- + +## How form locale is chosen + +1. User setting in Formulus **Settings → Language** (`auto`, `en`, `pt`, `fr`) +2. **Auto** → device language; if unsupported, bundle `defaultLocale` from `app.config.json` +3. Fallback `en` + +The host passes the resolved tag as **`params.locale`** when opening Formplayer. Formplayer merges form translations **once at init** (`applyFormUiTranslations()` in `formulus-formplayer/src/i18n/applyFormUiTranslations.ts`). + +```json +// app/public/app.config.json (custom app bundle) +{ + "defaultLocale": "pt" +} +``` + +Use `defaultLocale` when the study’s primary language is not English so **Auto** prefers the right default on devices whose language ODE does not ship. + +--- + +## Authoring pattern: base string + `translations` + +Put the **default** copy on the element (`label`, `description`, `text`, …). Add **`translations.`** only for locales that differ. + +```json +{ + "type": "Control", + "scope": "#/properties/participant_name", + "label": "Participant name", + "description": "Full legal name", + "translations": { + "pt": { + "label": "Nome do participante", + "description": "Nome legal completo" + }, + "fr": { + "label": "Nom du participant" + } + } +} +``` + +**Rules:** + +- Base `label` is required for every user-visible control — do not rely on `schema.json` `title` for display text in multi-locale forms. +- `translations` keys are **partial**; missing keys fall back to the base string. +- Locale lookup tries the full BCP-47 tag then the language subtag (`pt-BR` → `pt`). +- `translations..title` is accepted as an alias for `label` in that block (legacy convenience). + +### Portuguese-primary study with English override + +Base language can be anything; merge logic is symmetric. + +```json +{ + "type": "Control", + "scope": "#/properties/codigo", + "label": "Digitalizar código do envelope", + "translations": { + "en": { "label": "Scan the envelope code" } + } +} +``` + +| Active UI locale | Shown label | +|------------------|-------------| +| `pt` | Base (`label`) | +| `en` | `translations.en.label` | +| `fr` (no block) | Base (`label`) | + +--- + +## `schema.title` vs `ui.json` `label` + +| | `schema.json` → `properties.*.title` | `ui.json` → `Control.label` | +|---|--------------------------------------|-----------------------------| +| Translated at runtime? | No | Yes | +| Primary on-screen label? | Fallback only | Yes | + +```json +// schema.json — keep for validation / exports; not your i18n source of truth +{ + "properties": { + "codigo": { "type": "string", "title": "Código" } + } +} +``` + +```json +// ui.json — what the user actually reads +{ + "type": "Control", + "scope": "#/properties/codigo", + "label": "Digitalizar código do envelope", + "translations": { "en": { "label": "Scan the envelope code" } } +} +``` + +Formplayer resolves labels in order: **`Control.label`** (after translation merge) → JsonForms default → **`schema.title`** → field key. All built-in renderers, SwipeLayout header chips, Finalize summaries, and sub-observation column headers use this path (`controlDisplayText.ts`). + +--- + +## SwipeLayout chrome + +Put button and header copy in **`options`**. Override per locale via top-level keys in the translation block (merged into `options`) or nested `translations..options`: + +```json +{ + "type": "SwipeLayout", + "elements": [], + "options": { + "headerTitle": "Household interview", + "nextButtonLabel": "Next", + "finalizeButtonLabel": "Finish" + }, + "translations": { + "pt": { + "headerTitle": "Entrevista ao agregado", + "nextButtonLabel": "Seguinte", + "finalizeButtonLabel": "Concluir" + } + } +} +``` + +--- + +## Sub-observations + +**Add button:** if `options.addButtonLabel` is omitted, Formplayer composes `+ Add {itemLabel}` from schema `itemLabel` using ODE chrome strings (`subObservation.addItem`). Override when you need custom wording: + +```json +{ + "type": "Control", + "scope": "#/properties/quartos", + "label": "Quartos", + "options": { + "addButtonLabel": "+ Adicionar quarto" + }, + "translations": { + "en": { + "addButtonLabel": "+ Add room" + } + } +} +``` + +**Table columns:** list columns by **`key` only** in `schema.json`. Headers come from the **linked child form**’s `ui.json` labels (after translation), not from hardcoded schema column titles: + +```json +"x-subObservation": { + "formType": "censo_milda_quarto", + "itemLabel": "quarto", + "columns": [{ "key": "quarto_num" }, { "key": "quarto_display" }] +} +``` + +Child forms need their own `Control.label` + `translations` for those fields. Optional static `column.label` / `options.columns[].label` overrides all locales. + +--- + +## Custom question types + +Widget-specific copy belongs in **`Control.options`**, not hardcoded in `question_types/.../renderer.js`: + +```json +{ + "type": "Control", + "scope": "#/properties/confidence", + "label": "How confident are you?", + "options": { + "lowLabel": "Not at all", + "highLabel": "Very", + "oneOf": [{ "const": "yes", "title": "Yes" }] + }, + "translations": { + "pt": { + "label": "Qual é a sua confiança?", + "options": { + "lowLabel": "Nada", + "highLabel": "Muito", + "oneOf": [{ "const": "yes", "title": "Sim" }] + } + } + } +} +``` + +Behavioral config (`maxStars`, filters, …) stays in **`schema.json`** (`config` / validation), not in translated UI copy. + +--- + +## Translatable properties (v1) + +| Element | Properties | +|---------|------------| +| `Control` | `label`, `description`; nested `options.*` | +| `Group`, `Category` | `label` | +| `Label` | `text` | +| `SwipeLayout` (root) | `options.headerTitle`, `options.nextButtonLabel`, `options.finalizeButtonLabel` | +| Sub-observation | `options.addButtonLabel`; optional `options.columns[].label` | + +--- + +## Platform implementation map + +| Concern | Location | +|---------|----------| +| Merge `translations` into UI schema at init | `formulus-formplayer/src/i18n/applyFormUiTranslations.ts` | +| Label resolution at render time | `formulus-formplayer/src/utils/controlDisplayText.ts` | +| Form init + `params.locale` | `formulus-formplayer/src/App.tsx` | +| ODE chrome catalogs | `formulus-formplayer/src/locales/*.json` | +| UI locale preference (host) | `formulus/src/lib/locale.ts`, Settings → Language | +| Linked child specs for sub-obs columns | `FormInitData.linkedFormSpecs` (built in Formulus / ODE Desktop) | + +When changing merge rules or label resolution, update **`applyFormUiTranslations.test.ts`**, affected renderers, and the [published form translations guide](https://opendataensemble.org/docs/guides/form-translations) in **ode-docs**. + +--- + +## Author checklist + +- [ ] Every visible field has `Control.label` in `ui.json` (not only `schema.title`) +- [ ] Base `label` set; `translations` added for other ODE UI locales you ship (`en`, `pt`, `fr`) +- [ ] SwipeLayout headers/buttons translated where needed +- [ ] Sub-obs columns use `key` only; labels live on linked child forms +- [ ] Custom question type strings in `options`, not in renderer JS +- [ ] `defaultLocale` in `app.config.json` when the study default is not English diff --git a/desktop/AGENTS.md b/desktop/AGENTS.md index 9498708cb..02f2fe803 100644 --- a/desktop/AGENTS.md +++ b/desktop/AGENTS.md @@ -85,7 +85,7 @@ Developer mode on with missing/invalid folder → blocking error in UI; no silen ## Bridge and bundles - **Contract source of truth:** [`formulus/src/webview/FormulusInterfaceDefinition.ts`](../formulus/src/webview/FormulusInterfaceDefinition.ts). -- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. +- **Form preview:** `formPreviewBridge.ts` handles injection `postMessage` types; device APIs stubbed; observations/attachments use Tauri. **`linkedFormSpecs`** on `FormInitData` is populated via `buildLinkedFormSpecs` (see `buildFormPreviewInit.ts`) so sub-observation column headers match Formulus. - **Extensions:** `bundleExtensionLoader.ts` merges `forms/ext.json` like Formulus `ExtensionService`; pass `developerMode` for path prefix. --- diff --git a/desktop/src/lib/__tests__/buildFormPreviewInit.test.ts b/desktop/src/lib/__tests__/buildFormPreviewInit.test.ts index 27874fe38..5aa6182f7 100644 --- a/desktop/src/lib/__tests__/buildFormPreviewInit.test.ts +++ b/desktop/src/lib/__tests__/buildFormPreviewInit.test.ts @@ -29,6 +29,21 @@ describe('buildFormPreviewInit', () => { }); expect(init.subObservationMode).toBe(true); }); + + it('forwards linkedFormSpecs when provided', () => { + const linked = { + child: { schema: { type: 'object' }, uiSchema: {} }, + }; + const init = buildFormPreviewInit({ + formType: 'Parent', + params: {}, + savedData: {}, + formSchema: {}, + uiSchema: {}, + linkedFormSpecs: linked, + }); + expect(init.linkedFormSpecs).toEqual(linked); + }); }); describe('inferObservationIdFromSavedData', () => { diff --git a/desktop/src/lib/__tests__/buildLinkedFormSpecs.test.ts b/desktop/src/lib/__tests__/buildLinkedFormSpecs.test.ts new file mode 100644 index 000000000..1d1ba5165 --- /dev/null +++ b/desktop/src/lib/__tests__/buildLinkedFormSpecs.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from 'vitest'; +import { collectLinkedFormIds } from '../collectLinkedFormIds'; +import { buildLinkedFormSpecs } from '../buildLinkedFormSpecs'; + +describe('collectLinkedFormIds', () => { + it('collects linkedForm from nested properties', () => { + const ids = collectLinkedFormIds({ + properties: { + quartos: { + type: 'array', + linkedForm: 'censo_milda_quarto', + }, + }, + }); + expect([...ids]).toEqual(['censo_milda_quarto']); + }); +}); + +describe('buildLinkedFormSpecs', () => { + it('loads linked forms and follows nested chains', async () => { + const loadSpec = vi.fn(async (formType: string) => { + if (formType === 'parent') { + return { + formSchema: { + properties: { + items: { linkedForm: 'child' }, + }, + }, + uiSchema: { type: 'VerticalLayout' }, + }; + } + if (formType === 'child') { + return { + formSchema: { + properties: { name: { type: 'string', title: 'Name' } }, + }, + uiSchema: { + type: 'Control', + scope: '#/properties/name', + label: 'Child label', + }, + }; + } + throw new Error('missing'); + }); + + const specs = await buildLinkedFormSpecs( + { properties: { items: { linkedForm: 'parent' } } }, + loadSpec, + ); + + expect(loadSpec).toHaveBeenCalledWith('parent'); + expect(loadSpec).toHaveBeenCalledWith('child'); + expect(specs?.parent?.schema).toBeTruthy(); + expect(specs?.child?.uiSchema).toEqual({ + type: 'Control', + scope: '#/properties/name', + label: 'Child label', + }); + }); + + it('returns undefined when no linked forms', async () => { + const loadSpec = vi.fn(); + const specs = await buildLinkedFormSpecs( + { type: 'object', properties: { a: { type: 'string' } } }, + loadSpec, + ); + expect(specs).toBeUndefined(); + expect(loadSpec).not.toHaveBeenCalled(); + }); +}); diff --git a/desktop/src/lib/buildFormPreviewInit.ts b/desktop/src/lib/buildFormPreviewInit.ts index f7fc8c38a..518f29c97 100644 --- a/desktop/src/lib/buildFormPreviewInit.ts +++ b/desktop/src/lib/buildFormPreviewInit.ts @@ -1,5 +1,11 @@ import type { FormInitData } from './formplayerHost'; import { sanitizePortableAttachmentsInFormData } from './sanitizeFormSavedData'; +import { resolveDesktopUiLocale } from './uiLocale'; +import { + buildLinkedFormSpecs, + type LoadLinkedFormSpec, +} from './buildLinkedFormSpecs'; +import type { BundleFormSpec } from '../types/domain'; /** Infer SQLite observation id from embedded saved row data (matches Workbench navigate-from-custom-app). */ export function inferObservationIdFromSavedData( @@ -30,11 +36,16 @@ export function buildFormPreviewInit(args: { skipDraftSelection?: boolean; extensions?: FormInitData['extensions']; customQuestionTypes?: FormInitData['customQuestionTypes']; + linkedFormSpecs?: FormInitData['linkedFormSpecs']; }): FormInitData { + const locale = + typeof args.params.locale === 'string' + ? resolveDesktopUiLocale(args.params.locale) + : resolveDesktopUiLocale(); const init: FormInitData = { formType: args.formType, observationId: args.observationId ?? null, - params: args.params, + params: { ...args.params, locale }, savedData: sanitizePortableAttachmentsInFormData(args.savedData), formSchema: args.formSchema, uiSchema: args.uiSchema, @@ -50,9 +61,47 @@ export function buildFormPreviewInit(args: { if (args.skipDraftSelection) { init.skipDraftSelection = true; } + if (args.linkedFormSpecs) { + init.linkedFormSpecs = args.linkedFormSpecs; + } return init; } +/** + * Build preview init from a bundle form spec, including linked child forms for sub-obs columns. + */ +export async function buildFormPreviewInitFromBundleSpec(args: { + spec: BundleFormSpec; + params: Record; + savedData: Record; + observationId?: string | null; + subObservationMode?: boolean; + skipFinalize?: boolean; + skipDraftSelection?: boolean; + extensions?: FormInitData['extensions']; + customQuestionTypes?: FormInitData['customQuestionTypes']; + loadLinkedFormSpec: LoadLinkedFormSpec; +}): Promise { + const linkedFormSpecs = await buildLinkedFormSpecs( + args.spec.formSchema, + args.loadLinkedFormSpec, + ); + return buildFormPreviewInit({ + formType: args.spec.formType, + observationId: args.observationId ?? null, + params: args.params, + savedData: args.savedData, + formSchema: args.spec.formSchema, + uiSchema: args.spec.uiSchema, + extensions: args.extensions, + customQuestionTypes: args.customQuestionTypes, + subObservationMode: args.subObservationMode, + skipFinalize: args.skipFinalize, + skipDraftSelection: args.skipDraftSelection, + linkedFormSpecs, + }); +} + export function parseJsonObject( text: string, label: string, diff --git a/desktop/src/lib/buildLinkedFormSpecs.ts b/desktop/src/lib/buildLinkedFormSpecs.ts new file mode 100644 index 000000000..5cebca6d7 --- /dev/null +++ b/desktop/src/lib/buildLinkedFormSpecs.ts @@ -0,0 +1,47 @@ +import { collectLinkedFormIds } from './collectLinkedFormIds'; +import type { FormInitData } from './formplayerHost'; + +export type LoadLinkedFormSpec = (formType: string) => Promise<{ + formSchema: unknown; + uiSchema?: unknown; +}>; + +/** + * Load schema/ui for all forms referenced by `linkedForm` (including nested chains). + * Matches Formulus FormplayerModal behaviour for sub-observation column labels. + */ +export async function buildLinkedFormSpecs( + rootSchema: unknown, + loadSpec: LoadLinkedFormSpec, +): Promise { + const pending = [...collectLinkedFormIds(rootSchema)]; + const loaded = new Set(); + const specs: NonNullable = {}; + + while (pending.length > 0) { + const id = pending.pop()!; + if (loaded.has(id)) continue; + loaded.add(id); + + try { + const spec = await loadSpec(id); + if (!spec.formSchema) continue; + specs[id] = { + schema: spec.formSchema, + uiSchema: spec.uiSchema ?? {}, + }; + for (const nested of collectLinkedFormIds(spec.formSchema)) { + if (!loaded.has(nested) && !pending.includes(nested)) { + pending.push(nested); + } + } + } catch (error) { + console.warn( + `[buildLinkedFormSpecs] Failed to load linked form "${id}":`, + error, + ); + } + } + + return Object.keys(specs).length > 0 ? specs : undefined; +} diff --git a/desktop/src/lib/collectLinkedFormIds.ts b/desktop/src/lib/collectLinkedFormIds.ts new file mode 100644 index 000000000..677308804 --- /dev/null +++ b/desktop/src/lib/collectLinkedFormIds.ts @@ -0,0 +1,35 @@ +/** Collect linkedForm ids from a form schema (recursive). */ +export function collectLinkedFormIds( + schema: unknown, + out: Set = new Set(), +): Set { + if (!schema || typeof schema !== 'object') return out; + const obj = schema as Record; + + if (typeof obj.linkedForm === 'string' && obj.linkedForm.trim()) { + out.add(obj.linkedForm.trim()); + } + + if (obj.properties && typeof obj.properties === 'object') { + for (const val of Object.values( + obj.properties as Record, + )) { + collectLinkedFormIds(val, out); + } + } + + if (obj.items && typeof obj.items === 'object') { + collectLinkedFormIds(obj.items, out); + } + + for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + const branch = obj[key]; + if (Array.isArray(branch)) { + for (const sub of branch) { + collectLinkedFormIds(sub, out); + } + } + } + + return out; +} diff --git a/desktop/src/lib/formplayerHost.ts b/desktop/src/lib/formplayerHost.ts index 6f380f5d1..d467d330a 100644 --- a/desktop/src/lib/formplayerHost.ts +++ b/desktop/src/lib/formplayerHost.ts @@ -43,6 +43,17 @@ export interface FormInitData { skipDraftSelection?: boolean; extensions?: ExtensionMetadata; customQuestionTypes?: unknown; + /** + * Linked child form specs for sub-observation column label resolution. + * Populated by the host at open time (Formulus / ODE Desktop preview). + */ + linkedFormSpecs?: Record< + string, + { + schema: unknown; + uiSchema: unknown; + } + >; } /** @deprecated Use `FormInitData` */ diff --git a/desktop/src/lib/uiLocale.ts b/desktop/src/lib/uiLocale.ts new file mode 100644 index 000000000..6cf31b636 --- /dev/null +++ b/desktop/src/lib/uiLocale.ts @@ -0,0 +1,75 @@ +/** Desktop UI locale — mirrors Formulus Settings key and resolution rules. */ + +const STORAGE_KEY = '@ode/uiLocale'; +const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const; +export type DesktopUiLocale = (typeof ODE_UI_LOCALES)[number]; +export type UiLocalePreference = 'auto' | DesktopUiLocale; + +function normalizeLocaleTag(tag: string): string { + return tag.trim().replace(/_/g, '-').toLowerCase(); +} + +function localeLookupCandidates(tag: string): string[] { + const normalized = normalizeLocaleTag(tag); + if (!normalized) return ['en']; + const parts = normalized.split('-'); + const candidates: string[] = [normalized]; + if (parts.length > 1) candidates.push(parts[0]!); + return candidates; +} + +function matchOdeCatalogLocale(tag: string): DesktopUiLocale | null { + for (const candidate of localeLookupCandidates(tag)) { + if ((ODE_UI_LOCALES as readonly string[]).includes(candidate)) { + return candidate as DesktopUiLocale; + } + } + return null; +} + +export function getDesktopLocalePreference(): UiLocalePreference { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if ( + stored === 'auto' || + stored === 'en' || + stored === 'pt' || + stored === 'fr' + ) { + return stored; + } + } catch { + // ignore + } + return 'auto'; +} + +export function setDesktopLocalePreference( + preference: UiLocalePreference, +): void { + localStorage.setItem(STORAGE_KEY, preference); +} + +export function resolveDesktopUiLocale( + sessionOverride?: string | null, + bundleDefaultLocale?: string | null, +): DesktopUiLocale { + if (sessionOverride) { + const matched = matchOdeCatalogLocale(sessionOverride); + if (matched) return matched; + } + + const preference = getDesktopLocalePreference(); + if (preference !== 'auto') return preference; + + const device = navigator.language || 'en'; + const fromDevice = matchOdeCatalogLocale(device); + if (fromDevice) return fromDevice; + + if (bundleDefaultLocale) { + const fromBundle = matchOdeCatalogLocale(bundleDefaultLocale); + if (fromBundle) return fromBundle; + } + + return 'en'; +} diff --git a/desktop/src/pages/FormPreviewPage.tsx b/desktop/src/pages/FormPreviewPage.tsx index 317745108..ffd5144b2 100644 --- a/desktop/src/pages/FormPreviewPage.tsx +++ b/desktop/src/pages/FormPreviewPage.tsx @@ -6,7 +6,7 @@ import { type FormplayerEmbedHandle, } from '../components/FormplayerEmbed'; import { - buildFormPreviewInit, + buildFormPreviewInitFromBundleSpec, inferObservationIdFromSavedData, parseJsonObject, } from '../lib/buildFormPreviewInit'; @@ -28,6 +28,11 @@ import { dropPendingSubObservationOpen, registerPendingSubObservationOpen, } from '../lib/formPreviewSubObservationBridge'; +import { + getDesktopLocalePreference, + setDesktopLocalePreference, + type UiLocalePreference, +} from '../lib/uiLocale'; const DEFAULT_JSON = '{}'; @@ -68,6 +73,8 @@ export function FormPreviewPage() { const [previewObservationId, setPreviewObservationId] = useState< string | null >(null); + const [uiLocalePreference, setUiLocalePreference] = + useState(() => getDesktopLocalePreference()); const [formInitData, setFormInitData] = useState(null); @@ -148,6 +155,8 @@ export function FormPreviewPage() { } }, []); + const prevDevMirrorGenerationRef = useRef(devMirrorGeneration); + useEffect(() => { void loadForms(); }, [loadForms, devMirrorGeneration]); @@ -163,20 +172,60 @@ export function FormPreviewPage() { s.formType, developerMode, ); - return buildFormPreviewInit({ - formType: s.formType, + return buildFormPreviewInitFromBundleSpec({ + spec: s, observationId, params, savedData, - formSchema: s.formSchema, - uiSchema: s.uiSchema, extensions: ext.extensions, customQuestionTypes: ext.customQuestionTypes, + loadLinkedFormSpec: ft => tauriClient.readBundleFormSpec(ft), }); }, [developerMode], ); + /** Re-read schema/ui/extensions from the (dev) bundle and remount formplayer. */ + const reloadActivePreview = useCallback(async () => { + const formType = selectedFormType.trim(); + if (!formType) { + return; + } + const p = parseJsonObject(paramsJson, 'params'); + const sv = parseJsonObject(savedJson, 'savedData'); + if (!p.ok || !sv.ok) { + return; + } + try { + const s = await tauriClient.readBundleFormSpec(formType); + setSpec(s); + setSpecError(null); + setFormInitData( + await buildInitFromSpec(s, p.value, sv.value, previewObservationId), + ); + } catch (e) { + setSpecError(e instanceof Error ? e.message : String(e)); + } + }, [ + selectedFormType, + paramsJson, + savedJson, + previewObservationId, + buildInitFromSpec, + ]); + + useEffect(() => { + if (prevDevMirrorGenerationRef.current === devMirrorGeneration) { + return; + } + prevDevMirrorGenerationRef.current = devMirrorGeneration; + nestedEmbedByMessageIdRef.current.clear(); + nestedIframeByMessageIdRef.current.clear(); + nestedContentWindowByMessageIdRef.current.clear(); + setNestedSessions([]); + void reloadActivePreview(); + }, [devMirrorGeneration, reloadActivePreview]); + const loadSpec = useCallback( async (formType: string) => { if (!formType.trim()) { @@ -345,18 +394,17 @@ export function FormPreviewPage() { developerMode, ); const observationId = inferObservationIdFromSavedData(savedData); - const initData = buildFormPreviewInit({ - formType, + const initData = await buildFormPreviewInitFromBundleSpec({ + spec: s, observationId, params, savedData, - formSchema: s.formSchema, - uiSchema: s.uiSchema, extensions: ext.extensions, customQuestionTypes: ext.customQuestionTypes, subObservationMode: true, skipFinalize, skipDraftSelection, + loadLinkedFormSpec: ft => tauriClient.readBundleFormSpec(ft), }); setNestedSessions(prev => prev.map(sess => @@ -577,6 +625,39 @@ export function FormPreviewPage() { {formOptions} + + {listError ? (

{listError}

) : forms.length === 0 && !listLoading ? ( @@ -631,6 +712,7 @@ export function FormPreviewPage() {
{ rootContentWindowRef.current = cw; diff --git a/docs/custom-question-types.md b/docs/custom-question-types.md index 054c1f66b..d5181c820 100644 --- a/docs/custom-question-types.md +++ b/docs/custom-question-types.md @@ -50,6 +50,9 @@ interface CustomQuestionTypeProps { // 3. Schema parameters. Any non-standard JSON Schema keys are passed here. config: Record; + // 3b. Display strings from ui.json Control.options (after locale preprocess). + options?: Record; + // 4. Validation state for styling error cases validation: { error: boolean; @@ -117,6 +120,29 @@ module.exports = { --- +## Internationalization + +Custom question types use the **same** `ui.json` `translations` pattern as built-in controls. Put user-visible strings in `label`, `description`, and `Control.options` — not hardcoded in `renderer.js`: + +```json +{ + "type": "Control", + "scope": "#/properties/rating", + "label": "Rate this", + "options": { "hint": "Tap a star" }, + "translations": { + "pt": { + "label": "Avalie", + "options": { "hint": "Toque numa estrela" } + } + } +} +``` + +The renderer reads `props.options.hint`. Behavioral settings (`maxStars`, filters) stay in `schema.json` as `config`. See [Form translations](https://opendataensemble.org/docs/guides/form-translations). + +--- + ## Using it in your Form Once your custom question type is defined, use it in your form's `schema.json`. diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index ce0790119..002cdaf1a 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -58,6 +58,23 @@ This file gives AI assistants and developers enough context to work effectively 3. **Custom question types**: Loaded from a **manifest** (source strings) from the RN app, evaluated in a sandbox with `React` and `MaterialUI` on `window`. They use **format** in the schema (e.g. `"format": "signature"`), not only `type`. Contract: `src/types/CustomQuestionTypeContract.ts`. 4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens. 5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview. + +## Internationalization (i18n) + +Two **independent** layers: + +| Layer | Owner | Mechanism | +| ---------- | ----------- | ----------------------------------------------------------------------------------------- | +| ODE chrome | ODE | `src/locales/{en,pt,fr}.json`, `createOdeI18n()`, JsonForms `i18n` prop, `useOdeT()` hook | +| Form copy | Form author | Optional `translations` on `ui.json`; `applyFormUiTranslations()` at init in `App.tsx` | + +- Host passes `params.locale` (reserved bridge key, not observation data). +- Custom question types receive localized `options` from `ui.json` via `CustomQuestionTypeAdapter`. +- Display labels: `src/utils/controlDisplayText.ts` (`resolveControlLabel`, `resolveFieldLabel`) — use in renderers, SwipeLayout `headerFields`, Finalize, and error summaries instead of ad-hoc `uischema.label || schema.title`. +- `translations..title` aliases to `Control.label` when `label` is absent in that block. +- Sub-observation column headers: `key` in schema; labels resolved from linked child form via `linkedFormSpecs` on `FormInitData` (Formulus loads at open). See `subObservationColumnLabels.ts`. +- Docs: [form translations](https://opendataensemble.org/docs/guides/form-translations). + 6. **Numeric inputs** (`type: integer` / `type: number`): Built-in control is **`NumberStepperRenderer`** (`src/renderers/NumberStepperRenderer.tsx`) backed by **`useNumericDraftInput`** (`src/hooks/useNumericDraftInput.ts`). Policy: **draft text while focused** (`type="text"` + `inputMode` + `enterKeyHint` from `FormContext`); observation JSON stores **JSON numbers only** (parse on commit, never strings); **never clamp** to `minimum`/`maximum` while typing—surface AJV errors instead. Custom numeric question types must follow the same contract (see `CustomQuestionTypeContract.ts`). ## Adding or changing behavior diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index d25e8e7e8..04293ba41 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -31,6 +31,11 @@ If you run `pnpm install` only in formulus-formplayer, the tokens package’s `p Use `pnpm run build:copy` to build the project and copy the bundle into the Formulus app (Android + iOS) and ODE Desktop (`desktop/public/formplayer_dist/`). +## Internationalization + +- **ODE chrome** — `src/locales/{en,pt,fr}.json`, wired via `createOdeI18n()` and JsonForms `i18n`. Host sets `params.locale`. +- **Form copy** — optional `translations` objects on `ui.json`; merged once at init by `applyFormUiTranslations()`. See [form translations](https://opendataensemble.org/docs/guides/form-translations). + ## Javascript interface The javascript interface made available to the custom app is as follows: diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 7ea7fdb27..dfa85221e 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -14,7 +14,11 @@ import { materialRenderers, materialCells, } from '@jsonforms/material-renderers'; -import { JsonSchema7, JsonFormsRendererRegistryEntry } from '@jsonforms/core'; +import { + JsonSchema7, + JsonFormsRendererRegistryEntry, + UISchemaElement, +} from '@jsonforms/core'; import { Alert, Snackbar, @@ -46,6 +50,12 @@ import { applyStickyDefaults, } from './utils/stickyFieldHelpers'; import { stickyService } from './services/StickyService'; +import { applyFormUiTranslations } from './i18n/applyFormUiTranslations'; +import { LinkedFormSpecsMap } from './utils/controlDisplayText'; +import { createOdeI18n } from './i18n/createOdeI18n'; +import { odeT } from './i18n/createOdeI18n'; +import { resolveFormplayerLocale, type OdeUiLocale } from './i18n/localeUtils'; +import { FormplayerLocaleContext } from './i18n/FormplayerLocaleContext'; import SwipeLayoutRenderer, { swipeLayoutTester, @@ -152,6 +162,30 @@ function isMockActive(): boolean { (window as any).__FORMULUS_MOCK_ACTIVE__ ); } + +/** When formplayer runs outside a native WebView (and dev mock is off), fail fast without an effect. */ +function getStandaloneBrowserInitState(): { + isLoading: boolean; + loadError: string | null; +} { + if (typeof window === 'undefined') { + return { isLoading: true, loadError: null }; + } + if (window.ReactNativeWebView?.postMessage) { + return { isLoading: true, loadError: null }; + } + if (isMockActive()) { + return { isLoading: true, loadError: null }; + } + return { + isLoading: false, + loadError: odeT( + 'en', + 'form.noNativeHost', + 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', + ), + }; +} const DevTestbedLazy = import.meta.env.DEV ? React.lazy(() => import('./mocks/DevTestbed')) : null; @@ -291,6 +325,27 @@ interface FormContextType { * debounce races where `handleChange` alone does not reach `onChange` in time. */ commitFormData?: (data: Record) => void; + /** Localized specs for linked child forms (sub-observation column labels). */ + linkedFormSpecs?: LinkedFormSpecsMap; +} + +function prepareLinkedFormSpecs( + raw: FormInitData['linkedFormSpecs'], + locale: string, +): LinkedFormSpecsMap { + if (!raw || typeof raw !== 'object') return {}; + const out: LinkedFormSpecsMap = {}; + for (const [id, spec] of Object.entries(raw)) { + if (!spec || typeof spec !== 'object') continue; + const schema = spec.schema as JsonSchema7 | undefined; + const uiRaw = spec.uiSchema as UISchemaElement | undefined; + if (!schema || !uiRaw) continue; + out[id] = { + schema, + uiSchema: applyFormUiTranslations(uiRaw, locale) as UISchemaElement, + }; + } + return out; } export const FormContext = createContext({ @@ -298,6 +353,7 @@ export const FormContext = createContext({ keyboardEnterKeyHint: undefined, draftSessionKey: null, commitFormData: undefined, + linkedFormSpecs: undefined, }); export const useFormContext = () => useContext(FormContext); @@ -350,8 +406,12 @@ function App() { const [schema, setSchema] = useState(null); const [uischema, setUISchema] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [loadError, setLoadError] = useState(null); + const [isLoading, setIsLoading] = useState( + () => getStandaloneBrowserInitState().isLoading, + ); + const [loadError, setLoadError] = useState( + () => getStandaloneBrowserInitState().loadError, + ); const [showFinalizeMessage, setShowFinalizeMessage] = useState(false); const [submitError, setSubmitError] = useState(null); const [formInitData, setFormInitData] = useState(null); @@ -391,10 +451,20 @@ function App() { const [validationMode, setValidationMode] = useState< 'ValidateAndShow' | 'ValidateAndHide' | 'NoValidation' >('ValidateAndShow'); + const [uiLocale, setUiLocale] = useState('en'); + const uiLocaleRef = useRef(uiLocale); + uiLocaleRef.current = uiLocale; + const [linkedFormSpecs, setLinkedFormSpecs] = useState< + LinkedFormSpecsMap | undefined + >(undefined); + + const odeI18n = useMemo(() => createOdeI18n(uiLocale), [uiLocale]); // Reference to the FormulusClient instance and loading state const formulusClient = useRef(FormulusClient.getInstance()); - const isLoadingRef = useRef(true); // Use a ref to track loading state for the timeout + const isLoadingRef = useRef( + getStandaloneBrowserInitState().isLoading, + ); // Separate function to handle actual form initialization const initializeForm = useCallback( @@ -430,6 +500,14 @@ function App() { setFormInitData(initData); + const resolvedLocale = resolveFormplayerLocale( + (params as Record | null)?.locale, + ); + setUiLocale(resolvedLocale); + setLinkedFormSpecs( + prepareLinkedFormSpecs(initData.linkedFormSpecs, resolvedLocale), + ); + // Debug: log schema details, especially x-dynamicEnum usage try { const properties = (formSchema as any)?.properties || {}; @@ -571,26 +649,30 @@ function App() { 'formSchema was not provided. Form rendering might fail or be incomplete.', ); setLoadError( - 'Form schema is missing. Form rendering might fail or be incomplete.', + odeT( + resolvedLocale, + 'form.schemaMissing', + 'Form schema is missing. Form rendering might fail or be incomplete.', + ), ); setSchema({} as FormSchema); // Set to empty schema or handle as per requirements // First ensure SwipeLayout root, then process to ensure Finalize element is present const swipeLayoutUISchema = ensureSwipeLayoutRoot(null); - const processedUISchema = processUISchemaWithFinalize( - swipeLayoutUISchema, - skipFinalize, + const withLocale = applyFormUiTranslations( + processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), + resolvedLocale, ); - setUISchema(processedUISchema); + setUISchema(withLocale); } else { setSchema(formSchema as FormSchema); const swipeLayoutUISchema = ensureSwipeLayoutRoot( uiSchema as FormUISchema, ); - const processedUISchema = processUISchemaWithFinalize( - swipeLayoutUISchema, - skipFinalize, + const withLocale = applyFormUiTranslations( + processUISchemaWithFinalize(swipeLayoutUISchema, skipFinalize), + resolvedLocale, ); - setUISchema(processedUISchema); + setUISchema(withLocale); } const formSchemaTyped = formSchema as FormSchema | null; @@ -693,7 +775,12 @@ function App() { error instanceof Error ? error.message : 'Unknown error during form initialization'; - setLoadError(`Error initializing form: ${errorMessage}`); + const errorLocale = resolveFormplayerLocale( + (initData.params as Record | null)?.locale, + ); + setLoadError( + `${odeT(errorLocale, 'form.errorInitializing', 'Error initializing form')}: ${errorMessage}`, + ); setIsLoading(false); isLoadingRef.current = false; } @@ -721,7 +808,13 @@ function App() { console.error( 'formType is crucial and was not provided in onFormInit. Cannot proceed.', ); - setLoadError('Form ID is missing. Cannot initialize form.'); + setLoadError( + odeT( + resolveFormplayerLocale(initData.params?.locale), + 'form.missingFormId', + 'Form ID is missing. Cannot initialize form.', + ), + ); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage @@ -757,8 +850,12 @@ function App() { console.log( `Found ${availableDrafts.length} draft(s) for form ${receivedFormType}, showing draft selector`, ); - // Apply theme from params so draft selector respects light/dark mode + // Apply theme and locale from params so draft selector matches the session const params = initData.params; + const resolvedLocale = resolveFormplayerLocale( + (params as Record | null)?.locale, + ); + setUiLocale(resolvedLocale); const isDarkMode = params?.darkMode === true; setDarkMode(isDarkMode); if (params?.themeColors && typeof params.themeColors === 'object') { @@ -781,7 +878,9 @@ function App() { error instanceof Error ? error.message : 'Unknown error during form initialization'; - setLoadError(`Error processing form data: ${errorMessage}`); + setLoadError( + `${odeT(resolveFormplayerLocale(initData.params?.locale), 'form.errorProcessing', 'Error processing form data')}: ${errorMessage}`, + ); if ( window.ReactNativeWebView && window.ReactNativeWebView.postMessage @@ -816,9 +915,7 @@ function App() { globalAny.__formplayerOnInitRegistered = true; - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsLoading(true); - isLoadingRef.current = true; + // isLoading / isLoadingRef already start true (see useState/useRef above). console.log('Registering window.onFormInit handler.'); globalAny.onFormInit = handleFormInitByNative; @@ -840,26 +937,10 @@ function App() { console.log('Debug - NODE_ENV:', process.env.NODE_ENV); console.log('Debug - isMockActive():', isMockActive()); console.log('Debug - isLoadingRef.current:', isLoadingRef.current); - - // Potentially set an error or handle standalone mode if WebView context isn't available - // For example, if running in a standard browser for development - if (isLoadingRef.current) { - // Avoid setting error if already handled by timeout or success - if (isMockActive()) { - console.log( - 'Development mode: WebView mock is active, continuing without error', - ); - // Don't set error in development mode when mock is active - } else { - console.log( - 'Setting error message because mock is not active or not in development', - ); - setLoadError( - 'Cannot communicate with native host. Formplayer might be running in a standalone browser.', - ); - setIsLoading(false); - isLoadingRef.current = false; - } + if (!isMockActive() && isLoadingRef.current) { + console.log( + 'Standalone browser without native host (load error set at init).', + ); } } @@ -884,7 +965,11 @@ function App() { '[Formplayer] onFormInit timeout: Form failed to initialize after extended wait.', ); setLoadError( - 'Failed to initialize form: No data received from native host. Please try again.', + odeT( + uiLocaleRef.current, + 'form.initTimeout', + 'Failed to initialize form: No data received from native host. Please try again.', + ), ); setIsLoading(false); isLoadingRef.current = false; @@ -915,7 +1000,7 @@ function App() { console.log('Unregistered window.onFormInit handler.'); } }; - }, [handleFormInitByNative]); // Dependency: re-run if handleFormInitByNative changes + }, [handleFormInitByNative]); // Attachment handling is now fully encapsulated within individual components // using the Promise-based media/action APIs exposed by Formulus. @@ -1132,7 +1217,11 @@ function App() { '[App.tsx] Cannot finalize form: formInitData is missing', ); setSubmitError( - 'Cannot submit form because initialization data is missing.', + odeT( + uiLocale, + 'form.submitMissingInit', + 'Cannot submit form because initialization data is missing.', + ), ); return; } @@ -1149,7 +1238,11 @@ function App() { if (finalizeValidatorErrors.length > 0) { setCustomValidatorErrors(finalizeValidatorErrors); setSubmitError( - 'Cannot submit form until custom validation errors are resolved.', + odeT( + uiLocale, + 'form.submitValidatorErrors', + 'Cannot submit form until custom validation errors are resolved.', + ), ); return; } @@ -1188,7 +1281,13 @@ function App() { }) .catch(error => { console.error('[App.tsx] Error submitting form:', error); - setSubmitError('Failed to submit form. Please try again.'); + setSubmitError( + odeT( + uiLocale, + 'form.submitFailed', + 'Failed to submit form. Please try again.', + ), + ); }); }; @@ -1227,7 +1326,15 @@ function App() { handleRevalidate as EventListener, ); }; - }, [formInitData, draftSessionKey, uischema, schema, ajv, refreshFormData]); + }, [ + formInitData, + draftSessionKey, + uischema, + schema, + ajv, + refreshFormData, + uiLocale, + ]); // Create dynamic theme based on dark mode preference and custom app colors. // When a custom app provides themeColors, they override the default palette @@ -1264,13 +1371,15 @@ function App() { if (showDraftSelector && pendingFormInit) { return ( - + + + ); } @@ -1288,10 +1397,14 @@ function App() { }}> - Loading form... + {odeT(uiLocale, 'form.loading', 'Loading form...')} - Waiting for data from Formulus... + {odeT( + uiLocale, + 'form.waitingForData', + 'Waiting for data from Formulus...', + )} ); @@ -1316,7 +1429,7 @@ function App() { variant="h6" color="error" sx={{ mb: 2, textAlign: 'center' }}> - Error Loading Form + {odeT(uiLocale, 'form.errorLoading', 'Error Loading Form')} - Loading form... + {odeT(uiLocale, 'form.loading', 'Loading form...')} ); @@ -1361,110 +1474,122 @@ function App() { return ( - -
+ - {/* Main app content - 60% width in development mode */}
- - {loadError ? ( - - - Error Loading Form - - - {loadError} - - - ) : ( - <> - - - - {/* Success Snackbar */} - setShowFinalizeMessage(false)}> - setShowFinalizeMessage(false)} - severity="info"> - Form submitted successfully! - - - {/* Error Snackbar for submit failures */} - setSubmitError(null)}> - setSubmitError(null)} - severity="error"> - {submitError} - - - - )} - -
- - {/* Development testbed - 40% width in development mode (lazy-loaded, not in production bundle) */} - {DevTestbedLazy && ( + {/* Main app content - 60% width in development mode */}
- - - + {loadError ? ( + + + Error Loading Form + + + {loadError} + + + ) : ( + <> + + + + {/* Success Snackbar */} + setShowFinalizeMessage(false)}> + setShowFinalizeMessage(false)} + severity="info"> + {odeT( + uiLocale, + 'form.submitSuccess', + 'Form submitted successfully!', + )} + + + {/* Error Snackbar for submit failures */} + setSubmitError(null)}> + setSubmitError(null)} + severity="error"> + {submitError} + + + + )}
- )} -
-
+ + {/* Development testbed - 40% width in development mode (lazy-loaded, not in production bundle) */} + {DevTestbedLazy && ( +
+ + + + + +
+ )} +
+ + ); } diff --git a/formulus-formplayer/src/components/DraftSelector.tsx b/formulus-formplayer/src/components/DraftSelector.tsx index 454da4e59..aed60c4ee 100644 --- a/formulus-formplayer/src/components/DraftSelector.tsx +++ b/formulus-formplayer/src/components/DraftSelector.tsx @@ -21,6 +21,7 @@ import { import { Button } from '@ode/components/react-web'; import { Delete as DeleteIcon } from '@mui/icons-material'; import { draftService, DraftSummary } from '../services/DraftService'; +import { useOdeT } from '../i18n/useOdeT'; interface DraftSelectorProps { /** The form type to show drafts for */ @@ -50,6 +51,7 @@ export const DraftSelector: React.FC = ({ fullScreen = false, }) => { const theme = useTheme(); + const t = useOdeT(); const [drafts, setDrafts] = useState([]); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [draftToDelete, setDraftToDelete] = useState(null); @@ -64,12 +66,19 @@ export const DraftSelector: React.FC = ({ const oldDraftCount = draftService.getOldDraftCount(); if (oldDraftCount > 0) { setCleanupMessage( - `${oldDraftCount} draft${ - oldDraftCount === 1 ? '' : 's' - } older than 7 days will be automatically removed.`, + oldDraftCount === 1 + ? t( + 'draft.cleanupOldOne', + '1 draft older than 7 days will be automatically removed.', + ) + : t( + 'draft.cleanupOldMany', + '{{count}} drafts older than 7 days will be automatically removed.', + { count: oldDraftCount }, + ), ); } - }, [formType, formVersion]); + }, [formType, formVersion, t]); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect @@ -111,7 +120,9 @@ export const DraftSelector: React.FC = ({ - Recent drafts ({drafts.length}) + {t('draft.recentDrafts', 'Recent drafts ({{count}})', { + count: drafts.length, + })} = ({ color="text.primary" noWrap sx={{ textAlign: 'left' }}> - Draft saved {formatDate(draft.updatedAt)} + {t('draft.savedAtLine', 'Draft saved {{date}}', { + date: formatDate(draft.updatedAt), + })} = ({ variant="neutral" size="small" onPress={() => onResumeDraft(draft.id)}> - Resume + {t('draft.resume', 'Resume')} handleDeleteDraft(draft.id)} @@ -169,7 +182,7 @@ export const DraftSelector: React.FC = ({ ) : ( - No recent drafts found for this form. + {t('draft.none', 'No recent drafts found for this form.')} ); @@ -207,13 +220,13 @@ export const DraftSelector: React.FC = ({ mb: 0.5, textAlign: 'left', }}> - Resume Draft or Start New + {t('draft.title', 'Resume Draft or Start New')} - Form: {formType} + {t('draft.formLabel', 'Form: {{formType}}', { formType })} {formVersion && ( )} @@ -238,7 +251,7 @@ export const DraftSelector: React.FC = ({ gutterBottom color="text.primary" sx={{ fontWeight: 600, textAlign: 'left' }}> - New Form + {t('draft.newFormSection', 'New Form')} @@ -260,21 +273,23 @@ export const DraftSelector: React.FC = ({ setDeleteConfirmOpen(false)}> - Delete Draft + {t('draft.delete', 'Delete Draft')} - Are you sure you want to delete this draft? This action cannot - be undone. + {t( + 'draft.deleteConfirm', + 'Are you sure you want to delete this draft? This action cannot be undone.', + )} diff --git a/formulus-formplayer/src/components/FormLayout.tsx b/formulus-formplayer/src/components/FormLayout.tsx index 1c0d18f78..8647d7e62 100644 --- a/formulus-formplayer/src/components/FormLayout.tsx +++ b/formulus-formplayer/src/components/FormLayout.tsx @@ -2,6 +2,7 @@ import React, { ReactNode, useCallback } from 'react'; import { Box, Paper } from '@mui/material'; import { Button } from '@ode/components/react-web'; import { useKeyboardScrollClamp } from '../hooks/useKeyboardScrollClamp'; +import { useOdeT } from '../i18n/useOdeT'; /** Keeps a submit control in the DOM so mobile keyboards can trigger the primary action (Go / Send / →). */ const visuallyHiddenSubmitStyle: React.CSSProperties = { @@ -104,6 +105,7 @@ const FormLayout: React.FC = ({ scrollRefMerge, scrollHandlers, }) => { + const t = useOdeT(); const scrollRef = useKeyboardScrollClamp(); const mergedScrollRef = useCallback( @@ -193,7 +195,7 @@ const FormLayout: React.FC = ({ disabled={previousButton.disabled} size="medium" style={{ width: '100%', maxWidth: '100%' }}> - {previousButton.label || 'Previous'} + {previousButton.label || t('nav.previous', 'Previous')} )} @@ -213,7 +215,7 @@ const FormLayout: React.FC = ({ size="medium" className="formplayer-solid-primary" style={{ width: '100%', maxWidth: '100%' }}> - {nextButton.label || 'Next'} + {nextButton.label || t('nav.next', 'Next')} )} diff --git a/formulus-formplayer/src/components/QuestionShell.tsx b/formulus-formplayer/src/components/QuestionShell.tsx index cb07e0087..9b0d26b9b 100644 --- a/formulus-formplayer/src/components/QuestionShell.tsx +++ b/formulus-formplayer/src/components/QuestionShell.tsx @@ -127,6 +127,8 @@ const QuestionShell: React.FC = ({ const effectiveLayout = labelLayout ?? contextLayout; const useInline = !block && effectiveLayout === 'inline' && Boolean(title); const labelVerticalAlign = description ? 'top' : 'middle'; + /** Block controls (QR, photo, …) among inline rows need matching top inset. */ + const blockAmongInlineRows = block && effectiveLayout === 'inline'; const rowDividerColor = isDark ? tokens.color.neutral[600] @@ -178,6 +180,7 @@ const QuestionShell: React.FC = ({ display: 'flex', flexDirection: 'column', gap: 1, + ...(blockAmongInlineRows && { pt: INLINE_ROW_PY }), ...(useInline && { position: 'relative', '&::after': { diff --git a/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx b/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx new file mode 100644 index 000000000..412757f0a --- /dev/null +++ b/formulus-formplayer/src/i18n/FormplayerLocaleContext.tsx @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import type { OdeUiLocale } from './localeUtils'; + +export const FormplayerLocaleContext = createContext('en'); diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts new file mode 100644 index 000000000..c9683154c --- /dev/null +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { applyFormUiTranslations } from './applyFormUiTranslations'; + +describe('applyFormUiTranslations', () => { + it('returns same reference when no translations', () => { + const ui = { + type: 'VerticalLayout', + elements: [{ type: 'Control', scope: '#/properties/a', label: 'A' }], + }; + expect(applyFormUiTranslations(ui, 'fr')).toBe(ui); + }); + + it('merges partial control label', () => { + const ui = { + type: 'Control', + scope: '#/properties/name', + label: 'Name', + translations: { fr: { label: 'Nom' } }, + }; + const out = applyFormUiTranslations(ui, 'fr') as typeof ui; + expect(out.label).toBe('Nom'); + expect((out as { translations?: unknown }).translations).toBeUndefined(); + }); + + it('falls back to default for missing locale', () => { + const ui = { + type: 'Control', + scope: '#/properties/name', + label: 'Name', + translations: { fr: { label: 'Nom' } }, + }; + const out = applyFormUiTranslations(ui, 'de') as typeof ui; + expect(out.label).toBe('Name'); + }); + + it('merges SwipeLayout options', () => { + const ui = { + type: 'SwipeLayout', + options: { headerTitle: 'Census', nextButtonLabel: 'Next' }, + translations: { + pt: { + options: { headerTitle: 'Censo', nextButtonLabel: 'Seguinte' }, + }, + }, + elements: [], + }; + const out = applyFormUiTranslations(ui, 'pt') as { + options: { headerTitle: string; nextButtonLabel: string }; + }; + expect(out.options.headerTitle).toBe('Censo'); + expect(out.options.nextButtonLabel).toBe('Seguinte'); + }); + + it('merges oneOf by const', () => { + const ui = { + type: 'Control', + scope: '#/properties/q', + options: { + oneOf: [ + { const: 'yes', title: 'Yes' }, + { const: 'no', title: 'No' }, + ], + }, + translations: { + fr: { + options: { + oneOf: [{ const: 'yes', title: 'Oui' }], + }, + }, + }, + }; + const out = applyFormUiTranslations(ui, 'fr') as { + options: { oneOf: { const: string; title: string }[] }; + }; + expect(out.options.oneOf[0]?.title).toBe('Oui'); + expect(out.options.oneOf[1]?.title).toBe('No'); + }); + + it('handles pt-BR via pt fallback', () => { + const ui = { + type: 'Label', + text: 'Hello', + translations: { pt: { text: 'Olá' } }, + }; + const out = applyFormUiTranslations(ui, 'pt-BR') as { text: string }; + expect(out.text).toBe('Olá'); + }); + + it('maps translations.title to label when label absent', () => { + const ui = { + type: 'Control', + scope: '#/properties/codigo', + translations: { pt: { title: 'Digitalizar código do envelope' } }, + }; + const out = applyFormUiTranslations(ui, 'pt') as { label?: string }; + expect(out.label).toBe('Digitalizar código do envelope'); + }); + + it('prefers translations.label over translations.title', () => { + const ui = { + type: 'Control', + scope: '#/properties/codigo', + translations: { + pt: { label: 'Label wins', title: 'Title loses' }, + }, + }; + const out = applyFormUiTranslations(ui, 'pt') as { label?: string }; + expect(out.label).toBe('Label wins'); + }); + + it('routes addButtonLabel from translation block into options', () => { + const ui = { + type: 'Control', + scope: '#/properties/quartos', + label: 'Rooms', + translations: { + pt: { + addButtonLabel: '+ Adicionar quarto', + }, + }, + }; + const out = applyFormUiTranslations(ui, 'pt') as { + options?: { addButtonLabel?: string }; + }; + expect(out.options?.addButtonLabel).toBe('+ Adicionar quarto'); + }); + + it('perf smoke: large form with few translations', () => { + const elements = Array.from({ length: 200 }, (_, i) => ({ + type: 'Control', + scope: `#/properties/f${i}`, + label: `Field ${i}`, + ...(i < 20 ? { translations: { fr: { label: `Champ ${i}` } } } : {}), + })); + const ui = { type: 'VerticalLayout', elements }; + const start = performance.now(); + applyFormUiTranslations(ui, 'fr'); + expect(performance.now() - start).toBeLessThan(50); + }); +}); diff --git a/formulus-formplayer/src/i18n/applyFormUiTranslations.ts b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts new file mode 100644 index 000000000..d221697c0 --- /dev/null +++ b/formulus-formplayer/src/i18n/applyFormUiTranslations.ts @@ -0,0 +1,271 @@ +import { localeLookupCandidates } from './localeUtils'; + +type UiSchemaNode = Record; + +const TRANSLATABLE_TOP_KEYS = new Set([ + 'label', + 'description', + 'text', + 'headerTitle', + 'nextButtonLabel', + 'finalizeButtonLabel', +]); + +/** Keys that live under `options` on SwipeLayout / Control. */ +const SWIPE_OPTION_KEYS = new Set([ + 'headerTitle', + 'nextButtonLabel', + 'finalizeButtonLabel', +]); + +const CONTROL_OPTION_KEYS = new Set(['addButtonLabel']); + +function hasTranslationsSubtree(node: unknown): boolean { + if (!node || typeof node !== 'object') return false; + if (Array.isArray(node)) { + return node.some(hasTranslationsSubtree); + } + const obj = node as UiSchemaNode; + if (obj.translations && typeof obj.translations === 'object') return true; + if (Array.isArray(obj.elements)) { + return obj.elements.some(hasTranslationsSubtree); + } + if (obj.options && typeof obj.options === 'object') { + if (Array.isArray((obj.options as UiSchemaNode).columns)) { + if ( + ((obj.options as UiSchemaNode).columns as unknown[]).some( + hasTranslationsSubtree, + ) + ) { + return true; + } + } + } + return false; +} + +function pickLocaleBlock( + translations: Record, + locale: string, +): Record | null { + for (const candidate of localeLookupCandidates(locale)) { + const block = translations[candidate]; + if (block && typeof block === 'object' && !Array.isArray(block)) { + return block as Record; + } + } + return null; +} + +function mergeOptionsArraysByConst( + base: unknown[], + patch: unknown[], +): unknown[] { + const result = base.map(item => + item && typeof item === 'object' ? { ...(item as object) } : item, + ); + for (const patchItem of patch) { + if (!patchItem || typeof patchItem !== 'object') continue; + const patchObj = patchItem as Record; + const patchConst = patchObj.const; + let matched = false; + if (patchConst !== undefined) { + for (let i = 0; i < result.length; i++) { + const baseItem = result[i]; + if ( + baseItem && + typeof baseItem === 'object' && + (baseItem as Record).const === patchConst + ) { + result[i] = { ...(baseItem as object), ...patchObj }; + matched = true; + break; + } + } + } + if (!matched) { + result.push(patchItem); + } + } + return result; +} + +function deepMergeOptions( + base: Record, + patch: Record, +): Record { + const out: Record = { ...base }; + for (const [key, patchVal] of Object.entries(patch)) { + const baseVal = out[key]; + if ( + key === 'oneOf' || + key === 'columns' || + (Array.isArray(patchVal) && Array.isArray(baseVal)) + ) { + out[key] = mergeOptionsArraysByConst( + Array.isArray(baseVal) ? baseVal : [], + Array.isArray(patchVal) ? patchVal : [], + ); + } else if ( + patchVal && + typeof patchVal === 'object' && + !Array.isArray(patchVal) && + baseVal && + typeof baseVal === 'object' && + !Array.isArray(baseVal) + ) { + out[key] = deepMergeOptions( + baseVal as Record, + patchVal as Record, + ); + } else { + out[key] = patchVal; + } + } + return out; +} + +function applyBlockToNode( + node: UiSchemaNode, + block: Record, +): UiSchemaNode { + const out: UiSchemaNode = { ...node }; + const optionsBase = + out.options && + typeof out.options === 'object' && + !Array.isArray(out.options) + ? { ...(out.options as Record) } + : {}; + + for (const [key, val] of Object.entries(block)) { + if (key === 'title') { + continue; + } + if ( + key === 'options' && + val && + typeof val === 'object' && + !Array.isArray(val) + ) { + out.options = deepMergeOptions( + optionsBase, + val as Record, + ); + continue; + } + if (SWIPE_OPTION_KEYS.has(key)) { + out.options = { + ...((out.options as Record) ?? {}), + [key]: val, + }; + continue; + } + if (CONTROL_OPTION_KEYS.has(key)) { + out.options = { + ...((out.options as Record) ?? {}), + [key]: val, + }; + continue; + } + if (TRANSLATABLE_TOP_KEYS.has(key)) { + out[key] = val; + } + } + + const blockLabel = block.label; + const blockTitle = block.title; + if (typeof blockLabel === 'string') { + out.label = blockLabel; + } else if ( + typeof blockTitle === 'string' && + (out.label === undefined || out.label === null) + ) { + out.label = blockTitle; + } + + return out; +} + +function processNode(node: unknown, locale: string): unknown { + if (!node || typeof node !== 'object') return node; + if (Array.isArray(node)) { + let changed = false; + const next = node.map(child => { + const processed = processNode(child, locale); + if (processed !== child) changed = true; + return processed; + }); + return changed ? next : node; + } + + const obj = node as UiSchemaNode; + let result: UiSchemaNode = obj; + let changed = false; + + const translations = obj.translations; + if (translations && typeof translations === 'object') { + const block = pickLocaleBlock( + translations as Record, + locale, + ); + if (block) { + result = applyBlockToNode(result, block); + changed = true; + } + } + + if (Array.isArray(obj.elements)) { + let elementsChanged = false; + const newElements = obj.elements.map(el => { + const processed = processNode(el, locale); + if (processed !== el) elementsChanged = true; + return processed; + }); + if (elementsChanged) { + result = changed + ? { ...result, elements: newElements } + : { ...obj, elements: newElements }; + changed = true; + } + } + + if ( + result.options && + typeof result.options === 'object' && + !Array.isArray(result.options) + ) { + const opts = result.options as Record; + if (Array.isArray(opts.columns)) { + let colsChanged = false; + const newCols = opts.columns.map(col => { + const processed = processNode(col, locale); + if (processed !== col) colsChanged = true; + return processed; + }); + if (colsChanged) { + result = { + ...result, + options: { ...opts, columns: newCols }, + }; + changed = true; + } + } + } + + if (changed && result.translations) { + const { translations: _removed, ...without } = result; + return without; + } + + return changed ? result : node; +} + +/** + * Apply embedded ui.json translations for the active locale (once at form init). + * Returns the same reference when no translations exist in the tree. + */ +export function applyFormUiTranslations(uischema: T, locale: string): T { + if (!uischema || typeof uischema !== 'object') return uischema; + if (!hasTranslationsSubtree(uischema)) return uischema; + return processNode(uischema, locale) as T; +} diff --git a/formulus-formplayer/src/i18n/createOdeI18n.ts b/formulus-formplayer/src/i18n/createOdeI18n.ts new file mode 100644 index 000000000..45fefe578 --- /dev/null +++ b/formulus-formplayer/src/i18n/createOdeI18n.ts @@ -0,0 +1,117 @@ +import type { ErrorObject } from 'ajv'; +import type { JsonFormsI18nState, Translator } from '@jsonforms/core'; +import en from '../locales/en.json'; +import pt from '../locales/pt.json'; +import fr from '../locales/fr.json'; +import type { OdeUiLocale } from './localeUtils'; + +type Catalog = Record; + +const CATALOGS: Record = { + en: en as Catalog, + pt: pt as Catalog, + fr: fr as Catalog, +}; + +/** Simple {{key}} interpolation for catalog strings. */ +export function interpolate( + template: string, + vars?: Record, +): string { + if (!vars) return template; + return template.replace(/\{\{(\w+)\}\}/g, (_, key: string) => { + const val = vars[key]; + return val !== undefined ? String(val) : `{{${key}}}`; + }); +} + +export function getOdeCatalog(locale: OdeUiLocale): Catalog { + return CATALOGS[locale] ?? CATALOGS.en; +} + +export function odeT( + locale: OdeUiLocale, + key: string, + defaultMessage?: string, + vars?: Record, +): string { + const catalog = getOdeCatalog(locale); + const raw = catalog[key] ?? defaultMessage ?? key; + return interpolate(raw, vars); +} + +/** + * JsonForms-compatible i18n state for ODE-owned chrome and validation messages. + */ +export function createOdeI18n(locale: OdeUiLocale): JsonFormsI18nState { + const catalog = getOdeCatalog(locale); + + const translate: Translator = ( + key: string, + defaultMessage?: string, + _context?: unknown, + ) => { + if (defaultMessage === undefined) { + return catalog[key] ?? undefined; + } + return catalog[key] ?? defaultMessage; + }; + + const translateError = ( + error: ErrorObject, + t: typeof translate, + _uischema?: unknown, + ): string => { + const keyword = error.keyword; + const limit = + (error.params as { limit?: number })?.limit ?? + (error.params as { minimum?: number })?.minimum ?? + (error.params as { maximum?: number })?.maximum; + + const vars: Record = {}; + if (limit !== undefined) vars.limit = limit; + + const ajvMessage = + typeof error.message === 'string' ? error.message : undefined; + + if (keyword) { + const fieldKey = `${String(error.instancePath).replace(/^\//, '').replace(/\//g, '.')}.error.${keyword}`; + const fieldMsg = t(fieldKey, undefined); + if (fieldMsg) return fieldMsg; + + const globalKey = `error.${keyword}`; + const globalMsg = catalog[globalKey]; + if (globalMsg) return interpolate(globalMsg, vars); + } + + if (ajvMessage) { + const byMessage = + catalog[`error.${ajvMessage}`] ?? t(ajvMessage, ajvMessage); + if (byMessage) return interpolate(byMessage, vars); + } + + return ( + ajvMessage ?? catalog['error.Validation error'] ?? 'Validation error' + ); + }; + + return { + locale, + translate, + translateError, + }; +} + +/** Translate a single AJV error for ODE-owned chrome (e.g. Finalize screen). */ +export function translateAjvError( + locale: OdeUiLocale, + error: ErrorObject, +): string { + const { translate, translateError } = createOdeI18n(locale); + if (!translate || !translateError) { + return typeof error.message === 'string' + ? error.message + : odeT(locale, 'error.Validation error', 'Validation error'); + } + return translateError(error, translate); +} diff --git a/formulus-formplayer/src/i18n/localeUtils.ts b/formulus-formplayer/src/i18n/localeUtils.ts new file mode 100644 index 000000000..ab4fc6c4d --- /dev/null +++ b/formulus-formplayer/src/i18n/localeUtils.ts @@ -0,0 +1,46 @@ +/** + * Locale utilities (mirrored from formulus/src/lib/locale.ts for Formplayer). + */ + +export const ODE_UI_LOCALES = ['en', 'pt', 'fr'] as const; +export type OdeUiLocale = (typeof ODE_UI_LOCALES)[number]; + +export function normalizeLocaleTag(tag: string): string { + return tag.trim().replace(/_/g, '-').toLowerCase(); +} + +export function localeLookupCandidates(tag: string): string[] { + const normalized = normalizeLocaleTag(tag); + if (!normalized) return ['en']; + const parts = normalized.split('-'); + const candidates: string[] = [normalized]; + if (parts.length > 1) { + candidates.push(parts[0]!); + } + return candidates; +} + +export function matchOdeCatalogLocale( + tag: string, + supported: readonly string[] = ODE_UI_LOCALES, +): OdeUiLocale | null { + for (const candidate of localeLookupCandidates(tag)) { + if ((supported as readonly string[]).includes(candidate)) { + return candidate as OdeUiLocale; + } + } + return null; +} + +/** Resolve locale from bridge params; falls back to en. */ +export function resolveFormplayerLocale( + paramsLocale: unknown, + fallback = 'en', +): OdeUiLocale { + if (typeof paramsLocale === 'string' && paramsLocale.trim()) { + const matched = matchOdeCatalogLocale(paramsLocale); + if (matched) return matched; + } + const fromFallback = matchOdeCatalogLocale(fallback); + return fromFallback ?? 'en'; +} diff --git a/formulus-formplayer/src/i18n/useOdeT.ts b/formulus-formplayer/src/i18n/useOdeT.ts new file mode 100644 index 000000000..1c732a679 --- /dev/null +++ b/formulus-formplayer/src/i18n/useOdeT.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { odeT } from './createOdeI18n'; +import { FormplayerLocaleContext } from './FormplayerLocaleContext'; + +/** Translate ODE-owned chrome strings using the active Formplayer locale. */ +export function useOdeT() { + const locale = useContext(FormplayerLocaleContext); + return ( + key: string, + defaultMessage?: string, + vars?: Record, + ) => odeT(locale, key, defaultMessage, vars); +} diff --git a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx index 20073e0ff..7fde889d1 100644 --- a/formulus-formplayer/src/jsonforms/ShellInputControl.tsx +++ b/formulus-formplayer/src/jsonforms/ShellInputControl.tsx @@ -9,6 +9,10 @@ import { withJsonFormsControlProps } from '@jsonforms/react'; import { MuiInputText } from '@jsonforms/material-renderers'; import QuestionShell from '../components/QuestionShell'; import { useFormContext } from '../App'; +import { + resolveControlDescription, + resolveControlLabel, +} from '../utils/controlDisplayText'; /** * String control that renders the stock MuiInputText cell inside QuestionShell @@ -17,13 +21,12 @@ import { useFormContext } from '../App'; */ const ShellInputControl = (props: ControlProps) => { const { keyboardEnterKeyHint } = useFormContext(); - const { uischema, schema, errors, label, visible, required } = props; + const { uischema, schema, errors, visible, required } = props; if (visible === false) return null; - const title = - (uischema as { label?: string })?.label || schema?.title || label; - const description = schema?.description; + const title = resolveControlLabel(props); + const description = resolveControlDescription(props) ?? schema?.description; const isRequired = Boolean( (uischema as { options?: { required?: boolean } })?.options?.required ?? required, diff --git a/formulus-formplayer/src/locales/en.json b/formulus-formplayer/src/locales/en.json new file mode 100644 index 000000000..cacb2df77 --- /dev/null +++ b/formulus-formplayer/src/locales/en.json @@ -0,0 +1,101 @@ +{ + "form.loading": "Loading form...", + "form.waitingForData": "Waiting for data from Formulus...", + "form.submitSuccess": "Form submitted successfully!", + "form.errorLoading": "Error Loading Form", + "form.errorInitializing": "Error initializing form", + "form.errorProcessing": "Error processing form data", + "form.schemaMissing": "Form schema is missing. Form rendering might fail or be incomplete.", + "form.submitFailed": "Failed to submit form. Please try again.", + "form.submitMissingInit": "Cannot submit form because initialization data is missing.", + "form.submitValidatorErrors": "Cannot submit form until custom validation errors are resolved.", + "form.missingFormId": "Form ID is missing. Cannot initialize form.", + "form.noNativeHost": "Cannot communicate with native host. Formplayer might be running in a standalone browser.", + "form.initTimeout": "Failed to initialize form: No data received from native host. Please try again.", + "nav.previous": "Previous", + "nav.next": "Next", + "nav.done": "Done", + "nav.finalize": "Finalize", + "nav.previousScreen": "Previous screen", + "nav.nextScreen": "Next screen", + "draft.title": "Resume Draft or Start New", + "draft.resume": "Resume", + "draft.startNew": "Start New Form", + "draft.delete": "Delete Draft", + "draft.cancel": "Cancel", + "draft.savedAt": "Saved {{date}}", + "draft.savedAtLine": "Draft saved {{date}}", + "draft.recentDrafts": "Recent drafts ({{count}})", + "draft.none": "No recent drafts found for this form.", + "draft.formLabel": "Form: {{formType}}", + "draft.newFormSection": "New Form", + "draft.deleteConfirm": "Are you sure you want to delete this draft? This action cannot be undone.", + "draft.cleanupOldOne": "1 draft older than 7 days will be automatically removed.", + "draft.cleanupOldMany": "{{count}} drafts older than 7 days will be automatically removed.", + "finalize.title": "Finalize", + "finalize.summary": "FORM SUMMARY", + "finalize.notProvided": "Not provided", + "finalize.allValid": "All validations passed! Review your answers and submit.", + "finalize.fixErrors": "Please fix the following errors before finalizing:", + "finalize.reviewHint": "Review all your entered data below. Click on any field to edit it.", + "finalize.editField": "Edit field", + "finalize.yes": "Yes", + "finalize.no": "No", + "finalize.none": "None", + "finalize.empty": "Empty", + "finalize.value.photoCaptured": "Photo captured", + "finalize.value.photoWithName": "Photo: {{name}}", + "finalize.value.captured": "Captured", + "finalize.value.qrScanned": "QR Code scanned", + "finalize.value.qrWithData": "QR Code: {{data}}", + "finalize.value.signatureCaptured": "Signature captured", + "finalize.value.signatureProvided": "Signature provided", + "finalize.value.fileSelected": "File selected", + "finalize.value.fileWithName": "File: {{name}}", + "finalize.value.audioRecorded": "Audio recorded", + "finalize.value.audioWithName": "Audio: {{name}}{{duration}}", + "finalize.value.gpsCaptured": "GPS location captured", + "finalize.value.location": "Location: {{coords}}", + "finalize.value.videoCaptured": "Video captured", + "finalize.value.videoWithName": "Video: {{name}}", + "validation.missingRequiredFields": "Missing required fields", + "validation.missingRequiredField": "Missing required field", + "validation.missingRequiredFieldsNamed": "Missing required field(s): {{names}}", + "validation.fieldsNeedAttention": "{{count}} fields need attention: {{summary}} Tap Done to review.", + "validation.oneFieldAttention": "1 field needs attention", + "validation.fieldsAttention": "{{count}} fields need attention", + "validation.moreFields": "(+{{count}} more)", + "validation.tapDoneToReview": "Tap Done to review.", + "validation.draftOnReturn": "Some required fields are missing. Any unsaved changes will be available as a draft when you return.", + "validation.stayHere": "Stay here", + "validation.goBack": "Go back", + "subObservation.add": "+ Add observation", + "subObservation.addItem": "+ Add {{itemLabel}}", + "subObservation.none": "No observations", + "subObservation.adding": "Adding…", + "subObservation.addingItem": "Adding {{itemLabel}}…", + "media.photo": "Photo", + "media.photoTap": "Tap to capture photo", + "media.audio": "Audio", + "media.video": "Video", + "media.gps": "GPS Location", + "media.qrCode": "QR Code", + "media.approximateDate": "Approximate Date", + "media.takePhoto": "Take photo", + "media.recording": "Recording...", + "cqt.errorTitle": "Custom Question Type Error", + "cqt.errorBody": "The custom question type \"{{format}}\" encountered an error and could not be rendered.", + "cqt.errorDetails": "Error Details (click to expand)", + "cqt.errorContinue": "The form will continue to function, but this field cannot be edited.", + "cqt.unknownError": "Unknown error", + "error.required": "This field is required", + "error.is a required property": "This field is required", + "error.must have required property": "This field is required", + "error.minLength": "Must be at least {{limit}} characters", + "error.maxLength": "Must be at most {{limit}} characters", + "error.minimum": "Must be at least {{limit}}", + "error.maximum": "Must be at most {{limit}}", + "error.pattern": "Invalid format", + "error.type": "Invalid value", + "error.Validation error": "Validation error" +} diff --git a/formulus-formplayer/src/locales/fr.json b/formulus-formplayer/src/locales/fr.json new file mode 100644 index 000000000..44afd1d1f --- /dev/null +++ b/formulus-formplayer/src/locales/fr.json @@ -0,0 +1,101 @@ +{ + "form.loading": "Chargement du formulaire...", + "form.waitingForData": "En attente des données de Formulus...", + "form.submitSuccess": "Formulaire envoyé avec succès !", + "form.errorLoading": "Erreur de chargement du formulaire", + "form.errorInitializing": "Erreur d'initialisation du formulaire", + "form.errorProcessing": "Erreur lors du traitement des données du formulaire", + "form.schemaMissing": "Le schéma du formulaire est manquant. L'affichage peut échouer ou être incomplet.", + "form.submitFailed": "Échec de l'envoi du formulaire. Veuillez réessayer.", + "form.submitMissingInit": "Impossible d'envoyer le formulaire : les données d'initialisation sont manquantes.", + "form.submitValidatorErrors": "Impossible d'envoyer le formulaire tant que les erreurs de validation personnalisée ne sont pas corrigées.", + "form.missingFormId": "L'identifiant du formulaire est manquant. Impossible d'initialiser.", + "form.noNativeHost": "Impossible de communiquer avec l'hôte natif. Formplayer s'exécute peut-être dans un navigateur autonome.", + "form.initTimeout": "Échec de l'initialisation : aucune donnée reçue de l'hôte natif. Veuillez réessayer.", + "nav.previous": "Précédent", + "nav.next": "Suivant", + "nav.done": "Terminer", + "nav.finalize": "Finaliser", + "nav.previousScreen": "Écran précédent", + "nav.nextScreen": "Écran suivant", + "draft.title": "Reprendre le brouillon ou recommencer", + "draft.resume": "Reprendre", + "draft.startNew": "Nouveau formulaire", + "draft.delete": "Supprimer le brouillon", + "draft.cancel": "Annuler", + "draft.savedAt": "Enregistré {{date}}", + "draft.savedAtLine": "Brouillon enregistré {{date}}", + "draft.recentDrafts": "Brouillons récents ({{count}})", + "draft.none": "Aucun brouillon récent pour ce formulaire.", + "draft.formLabel": "Formulaire : {{formType}}", + "draft.newFormSection": "Nouveau formulaire", + "draft.deleteConfirm": "Voulez-vous vraiment supprimer ce brouillon ? Cette action est irréversible.", + "draft.cleanupOldOne": "1 brouillon de plus de 7 jours sera supprimé automatiquement.", + "draft.cleanupOldMany": "{{count}} brouillons de plus de 7 jours seront supprimés automatiquement.", + "finalize.title": "Finaliser", + "finalize.summary": "RÉSUMÉ DU FORMULAIRE", + "finalize.notProvided": "Non renseigné", + "finalize.allValid": "Toutes les validations sont passées ! Vérifiez vos réponses et soumettez.", + "finalize.fixErrors": "Veuillez corriger les erreurs suivantes avant de finaliser :", + "finalize.reviewHint": "Vérifiez toutes vos réponses ci-dessous. Cliquez sur un champ pour le modifier.", + "finalize.editField": "Modifier le champ", + "finalize.yes": "Oui", + "finalize.no": "Non", + "finalize.none": "Aucun", + "finalize.empty": "Vide", + "finalize.value.photoCaptured": "Photo capturée", + "finalize.value.photoWithName": "Photo : {{name}}", + "finalize.value.captured": "Capturée", + "finalize.value.qrScanned": "Code QR scanné", + "finalize.value.qrWithData": "Code QR : {{data}}", + "finalize.value.signatureCaptured": "Signature capturée", + "finalize.value.signatureProvided": "Signature fournie", + "finalize.value.fileSelected": "Fichier sélectionné", + "finalize.value.fileWithName": "Fichier : {{name}}", + "finalize.value.audioRecorded": "Audio enregistré", + "finalize.value.audioWithName": "Audio : {{name}}{{duration}}", + "finalize.value.gpsCaptured": "Position GPS capturée", + "finalize.value.location": "Position : {{coords}}", + "finalize.value.videoCaptured": "Vidéo capturée", + "finalize.value.videoWithName": "Vidéo : {{name}}", + "validation.missingRequiredFields": "Champs obligatoires manquants", + "validation.missingRequiredField": "Champ obligatoire manquant", + "validation.missingRequiredFieldsNamed": "Champ(s) obligatoire(s) manquant(s) : {{names}}", + "validation.fieldsNeedAttention": "{{count}} champs nécessitent une attention : {{summary}} Appuyez sur Terminer pour vérifier.", + "validation.oneFieldAttention": "1 champ nécessite une attention", + "validation.fieldsAttention": "{{count}} champs nécessitent une attention", + "validation.moreFields": "(+{{count}} de plus)", + "validation.tapDoneToReview": "Appuyez sur Terminer pour vérifier.", + "validation.draftOnReturn": "Des champs obligatoires sont manquants. Les modifications non enregistrées seront disponibles en brouillon à votre retour.", + "validation.stayHere": "Rester ici", + "validation.goBack": "Retour", + "subObservation.add": "+ Ajouter une observation", + "subObservation.addItem": "+ Ajouter {{itemLabel}}", + "subObservation.none": "Aucune observation", + "subObservation.adding": "Ajout…", + "subObservation.addingItem": "Ajout de {{itemLabel}}…", + "media.photo": "Photo", + "media.photoTap": "Appuyez pour prendre une photo", + "media.audio": "Audio", + "media.video": "Vidéo", + "media.gps": "Position GPS", + "media.qrCode": "Code QR", + "media.approximateDate": "Date approximative", + "media.takePhoto": "Prendre une photo", + "media.recording": "Enregistrement...", + "cqt.errorTitle": "Erreur du type de question personnalisé", + "cqt.errorBody": "Le type de question personnalisé « {{format}} » a rencontré une erreur et n'a pas pu être affiché.", + "cqt.errorDetails": "Détails de l'erreur (cliquer pour développer)", + "cqt.errorContinue": "Le formulaire continuera de fonctionner, mais ce champ ne peut pas être modifié.", + "cqt.unknownError": "Erreur inconnue", + "error.required": "Ce champ est obligatoire", + "error.is a required property": "Ce champ est obligatoire", + "error.must have required property": "Ce champ est obligatoire", + "error.minLength": "Doit contenir au moins {{limit}} caractères", + "error.maxLength": "Doit contenir au plus {{limit}} caractères", + "error.minimum": "Doit être au moins {{limit}}", + "error.maximum": "Doit être au plus {{limit}}", + "error.pattern": "Format invalide", + "error.type": "Valeur invalide", + "error.Validation error": "Erreur de validation" +} diff --git a/formulus-formplayer/src/locales/pt.json b/formulus-formplayer/src/locales/pt.json new file mode 100644 index 000000000..f81439b8e --- /dev/null +++ b/formulus-formplayer/src/locales/pt.json @@ -0,0 +1,101 @@ +{ + "form.loading": "A carregar formulário...", + "form.waitingForData": "A aguardar dados do Formulus...", + "form.submitSuccess": "Formulário enviado com sucesso!", + "form.errorLoading": "Erro ao carregar formulário", + "form.errorInitializing": "Erro ao inicializar formulário", + "form.errorProcessing": "Erro ao processar dados do formulário", + "form.schemaMissing": "Falta o esquema do formulário. A apresentação pode falhar ou ficar incompleta.", + "form.submitFailed": "Falha ao enviar o formulário. Tente novamente.", + "form.submitMissingInit": "Não é possível enviar o formulário porque faltam dados de inicialização.", + "form.submitValidatorErrors": "Não é possível enviar o formulário até resolver os erros de validação personalizada.", + "form.missingFormId": "Falta o ID do formulário. Não é possível inicializar.", + "form.noNativeHost": "Não é possível comunicar com o anfitrião nativo. O Formplayer pode estar num browser autónomo.", + "form.initTimeout": "Falha ao inicializar o formulário: não foram recebidos dados do anfitrião nativo. Tente novamente.", + "nav.previous": "Anterior", + "nav.next": "Seguinte", + "nav.done": "Concluir", + "nav.finalize": "Finalizar", + "nav.previousScreen": "Ecrã anterior", + "nav.nextScreen": "Ecrã seguinte", + "draft.title": "Retomar rascunho ou começar de novo", + "draft.resume": "Retomar", + "draft.startNew": "Novo formulário", + "draft.delete": "Eliminar rascunho", + "draft.cancel": "Cancelar", + "draft.savedAt": "Guardado {{date}}", + "draft.savedAtLine": "Rascunho guardado {{date}}", + "draft.recentDrafts": "Rascunhos recentes ({{count}})", + "draft.none": "Não foram encontrados rascunhos recentes para este formulário.", + "draft.formLabel": "Formulário: {{formType}}", + "draft.newFormSection": "Novo formulário", + "draft.deleteConfirm": "Tem a certeza de que pretende eliminar este rascunho? Esta ação não pode ser anulada.", + "draft.cleanupOldOne": "1 rascunho com mais de 7 dias será removido automaticamente.", + "draft.cleanupOldMany": "{{count}} rascunhos com mais de 7 dias serão removidos automaticamente.", + "finalize.title": "Finalizar", + "finalize.summary": "RESUMO DO FORMULÁRIO", + "finalize.notProvided": "Não fornecido", + "finalize.allValid": "Todas as validações passaram! Reveja as respostas e submeta.", + "finalize.fixErrors": "Corrija os seguintes erros antes de finalizar:", + "finalize.reviewHint": "Reveja todos os dados introduzidos abaixo. Clique em qualquer campo para o editar.", + "finalize.editField": "Editar campo", + "finalize.yes": "Sim", + "finalize.no": "Não", + "finalize.none": "Nenhum", + "finalize.empty": "Vazio", + "finalize.value.photoCaptured": "Fotografia capturada", + "finalize.value.photoWithName": "Fotografia: {{name}}", + "finalize.value.captured": "Capturada", + "finalize.value.qrScanned": "Código QR lido", + "finalize.value.qrWithData": "Código QR: {{data}}", + "finalize.value.signatureCaptured": "Assinatura capturada", + "finalize.value.signatureProvided": "Assinatura fornecida", + "finalize.value.fileSelected": "Ficheiro selecionado", + "finalize.value.fileWithName": "Ficheiro: {{name}}", + "finalize.value.audioRecorded": "Áudio gravado", + "finalize.value.audioWithName": "Áudio: {{name}}{{duration}}", + "finalize.value.gpsCaptured": "Localização GPS capturada", + "finalize.value.location": "Localização: {{coords}}", + "finalize.value.videoCaptured": "Vídeo capturado", + "finalize.value.videoWithName": "Vídeo: {{name}}", + "validation.missingRequiredFields": "Campos obrigatórios em falta", + "validation.missingRequiredField": "Campo obrigatório em falta", + "validation.missingRequiredFieldsNamed": "Campo(s) obrigatório(s) em falta: {{names}}", + "validation.fieldsNeedAttention": "{{count}} campos precisam de atenção: {{summary}} Toque em Concluir para rever.", + "validation.oneFieldAttention": "1 campo precisa de atenção", + "validation.fieldsAttention": "{{count}} campos precisam de atenção", + "validation.moreFields": "(+{{count}} mais)", + "validation.tapDoneToReview": "Toque em Concluir para rever.", + "validation.draftOnReturn": "Faltam campos obrigatórios. As alterações não guardadas ficarão disponíveis como rascunho quando regressar.", + "validation.stayHere": "Ficar aqui", + "validation.goBack": "Voltar", + "subObservation.add": "+ Adicionar observação", + "subObservation.addItem": "+ Adicionar {{itemLabel}}", + "subObservation.none": "Sem observações", + "subObservation.adding": "A adicionar…", + "subObservation.addingItem": "A adicionar {{itemLabel}}…", + "media.photo": "Fotografia", + "media.photoTap": "Toque para capturar fotografia", + "media.audio": "Áudio", + "media.video": "Vídeo", + "media.gps": "Localização GPS", + "media.qrCode": "Código QR", + "media.approximateDate": "Data aproximada", + "media.takePhoto": "Tirar fotografia", + "media.recording": "A gravar...", + "cqt.errorTitle": "Erro no tipo de pergunta personalizado", + "cqt.errorBody": "O tipo de pergunta personalizado \"{{format}}\" encontrou um erro e não pôde ser apresentado.", + "cqt.errorDetails": "Detalhes do erro (clique para expandir)", + "cqt.errorContinue": "O formulário continuará a funcionar, mas este campo não pode ser editado.", + "cqt.unknownError": "Erro desconhecido", + "error.required": "Este campo é obrigatório", + "error.is a required property": "Este campo é obrigatório", + "error.must have required property": "Este campo é obrigatório", + "error.minLength": "Deve ter pelo menos {{limit}} caracteres", + "error.maxLength": "Deve ter no máximo {{limit}} caracteres", + "error.minimum": "Deve ser pelo menos {{limit}}", + "error.maximum": "Deve ser no máximo {{limit}}", + "error.pattern": "Formato inválido", + "error.type": "Valor inválido", + "error.Validation error": "Erro de validação" +} diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx new file mode 100644 index 000000000..5522695af --- /dev/null +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import { registerCustomQuestionTypes } from '../services/CustomQuestionTypeRegistry'; +import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; +import { applyFormUiTranslations } from '../i18n/applyFormUiTranslations'; + +const FORMAT = 'confidence-rating-test'; +const ajv = new Ajv({ allErrors: true, strict: false }); + +function SpyCqt(props: CustomQuestionTypeProps) { + return ( +
+ ); +} + +function readCapturedOptions(): Record | null { + const raw = screen.getByTestId('spy-cqt').getAttribute('data-options'); + return raw ? (JSON.parse(raw) as Record) : null; +} + +const schema: JsonSchema7 = { + type: 'object', + properties: { + confidence: { + type: 'number', + title: 'Confidence', + format: FORMAT, + }, + }, +}; + +const rawUischema = { + type: 'Control', + scope: '#/properties/confidence', + options: { + lowLabel: 'Not at all', + highLabel: 'Completely', + }, + translations: { + pt: { + options: { + lowLabel: 'Nada', + highLabel: 'Completamente', + }, + }, + }, +} as UISchemaElement; + +function renderWithUischema(uischema: UISchemaElement) { + const renderers = registerCustomQuestionTypes(new Map([[FORMAT, SpyCqt]])); + + render( + + + {}} + /> + + , + ); +} + +afterEach(() => { + cleanup(); +}); + +describe('CustomQuestionTypeAdapter', () => { + it('passes preprocessed ui.json options to the custom component', () => { + const uischema = applyFormUiTranslations( + rawUischema, + 'pt', + ) as UISchemaElement; + + renderWithUischema(uischema); + + expect(screen.getByTestId('spy-cqt')).toBeTruthy(); + expect(readCapturedOptions()).toEqual({ + lowLabel: 'Nada', + highLabel: 'Completamente', + }); + }); + + it('passes default options when locale has no translation block', () => { + // rawUischema.translations only defines `pt`; `de` is not in ui.json. + const uischema = applyFormUiTranslations( + rawUischema, + 'de', + ) as UISchemaElement; + + renderWithUischema(uischema); + + expect(readCapturedOptions()).toEqual({ + lowLabel: 'Not at all', + highLabel: 'Completely', + }); + }); +}); diff --git a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx index a0772f331..51d641653 100644 --- a/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx +++ b/formulus-formplayer/src/renderers/CustomQuestionTypeAdapter.tsx @@ -12,14 +12,24 @@ import type { ControlProps } from '@jsonforms/core'; import QuestionShell from '../components/QuestionShell'; import type { CustomQuestionTypeProps } from '../types/CustomQuestionTypeContract'; import { formatControlErrors } from '../utils/formatControlErrors'; +import { + resolveControlDescription, + resolveControlLabel, +} from '../utils/controlDisplayText'; +import { useOdeT } from '../i18n/useOdeT'; -// --------------------------------------------------------------------------- -// Error Boundary — catches crashes in custom components -// --------------------------------------------------------------------------- +interface ErrorBoundaryLabels { + title: string; + body: string; + details: string; + continueMessage: string; + unknownError: string; +} interface ErrorBoundaryProps { formatName: string; children: ReactNode; + labels: ErrorBoundaryLabels; } interface ErrorBoundaryState { @@ -58,15 +68,14 @@ class CustomQuestionErrorBoundary extends Component< margin: '8px 0', }}> - ⚠️ Custom Question Type Error + ⚠️ {this.props.labels.title}
- The custom question type "{this.props.formatName}"{' '} - encountered an error and could not be rendered. + {this.props.labels.body}
- Error Details (click to expand) + {this.props.labels.details}
-              {this.state.error?.message || 'Unknown error'}
+              {this.state.error?.message || this.props.labels.unknownError}
               {this.state.error?.stack && (
                 <>
                   {'\n\n'}
@@ -92,7 +101,7 @@ class CustomQuestionErrorBoundary extends Component<
               marginTop: '8px',
               fontStyle: 'italic',
             }}>
-            The form will continue to function, but this field cannot be edited.
+            {this.props.labels.continueMessage}
           
); @@ -115,18 +124,36 @@ export function createCustomQuestionTypeRenderer( formatName: string, CustomComponent: React.ComponentType, ): React.ComponentType { - const AdapterInner: React.FC = ({ - data, - handleChange, - path, - schema, - errors, - enabled, - label, - description, - required, - visible, - }) => { + const AdapterInner: React.FC = props => { + const { + data, + handleChange, + path, + schema, + uischema, + errors, + enabled, + label, + description, + required, + visible, + } = props; + const t = useOdeT(); + const errorBoundaryLabels = { + title: t('cqt.errorTitle', 'Custom Question Type Error'), + body: t( + 'cqt.errorBody', + 'The custom question type "{{format}}" encountered an error and could not be rendered.', + { format: formatName }, + ), + details: t('cqt.errorDetails', 'Error Details (click to expand)'), + continueMessage: t( + 'cqt.errorContinue', + 'The form will continue to function, but this field cannot be edited.', + ), + unknownError: t('cqt.unknownError', 'Unknown error'), + }; + // Build the simplified props for the custom component const hasErrors = errors && (Array.isArray(errors) ? errors.length > 0 : true); @@ -214,6 +241,15 @@ export function createCustomQuestionTypeRenderer( const customProps: CustomQuestionTypeProps = { value: data, config, + options: + uischema && + typeof uischema === 'object' && + 'options' in uischema && + uischema.options && + typeof uischema.options === 'object' && + !Array.isArray(uischema.options) + ? (uischema.options as Record) + : undefined, onChange: (newValue: unknown) => handleChange(path, newValue), validation: { error: Boolean(hasErrors), @@ -240,11 +276,13 @@ export function createCustomQuestionTypeRenderer( return ( - + diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index 6b5d5d2cc..00d334d33 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useContext, useMemo, useCallback } from 'react'; import { Box, List, ListItem, Typography, IconButton } from '@mui/material'; import { tokens } from '../theme/tokens-adapter'; import { Button } from '@ode/components/react-web'; @@ -9,6 +9,12 @@ import { ErrorObject } from 'ajv'; import { useFormContext } from '../App'; import EditIcon from '@mui/icons-material/Edit'; import { displayAdate } from '../utils/adateUtils'; +import { useOdeT } from '../i18n/useOdeT'; +import { translateAjvError } from '../i18n/createOdeI18n'; +import { FormplayerLocaleContext } from '../i18n/FormplayerLocaleContext'; +import { titleForErrorPath } from '../utils/errorPageNavigation'; +import { resolveFieldLabel } from '../utils/controlDisplayText'; +import type { JsonSchema7 } from '@jsonforms/core'; interface SummaryItem { label: string; @@ -21,26 +27,35 @@ interface SummaryItem { const FinalizeRenderer = ({ data }: ControlProps) => { const { core } = useJsonForms(); + const t = useOdeT(); + const locale = useContext(FormplayerLocaleContext); const errors = core?.errors || []; const { formInitData } = useFormContext(); const fullSchema = core?.schema; - const fullUISchema = formInitData?.uiSchema; - - // Helper function to get field label from schema - const getFieldLabel = (fieldPath: string, fieldSchema: any): string => { - if (!fieldSchema) return fieldPath; - return ( - fieldSchema.title || - fieldSchema.description || - fieldPath.split('/').pop() || - fieldPath - ); - }; + const localizedUiSchema = core?.uischema; + const fullUISchema = localizedUiSchema ?? formInitData?.uiSchema; + + const getFieldLabel = useCallback( + (fullPath: string, fieldSchema: any): string => { + const normalized = fullPath.replace(/^#\/properties\//, ''); + const segments = normalized.split('/').filter(Boolean); + const key = segments[segments.length - 1] || normalized; + if (segments.length === 1 && key) { + return resolveFieldLabel( + fullSchema as JsonSchema7 | undefined, + localizedUiSchema, + key, + ); + } + return fieldSchema?.title || fieldSchema?.description || key || fullPath; + }, + [fullSchema, localizedUiSchema], + ); // Helper function to format field value based on type const formatFieldValue = (value: any, fieldSchema: any): string => { if (value === null || value === undefined || value === '') { - return 'Not provided'; + return t('finalize.notProvided', 'Not provided'); } // Handle special formats @@ -48,21 +63,27 @@ const FinalizeRenderer = ({ data }: ControlProps) => { switch (fieldSchema.format) { case 'photo': if (typeof value === 'object' && value.uri) { - return `Photo: ${value.filename || 'Captured'}`; + return t('finalize.value.photoWithName', 'Photo: {{name}}', { + name: value.filename || t('finalize.value.captured', 'Captured'), + }); } - return 'Photo captured'; + return t('finalize.value.photoCaptured', 'Photo captured'); case 'qrcode': if (typeof value === 'object' && value.data) { - return `QR Code: ${value.data}`; + return t('finalize.value.qrWithData', 'QR Code: {{data}}', { + data: String(value.data), + }); } return typeof value === 'string' - ? `QR Code: ${value}` - : 'QR Code scanned'; + ? t('finalize.value.qrWithData', 'QR Code: {{data}}', { + data: value, + }) + : t('finalize.value.qrScanned', 'QR Code scanned'); case 'signature': if (typeof value === 'object' && value.uri) { - return 'Signature captured'; + return t('finalize.value.signatureCaptured', 'Signature captured'); } - return 'Signature provided'; + return t('finalize.value.signatureProvided', 'Signature provided'); case 'select_file': if (typeof value === 'object' && value.filename) { const original = @@ -71,27 +92,40 @@ const FinalizeRenderer = ({ data }: ControlProps) => { : ''; const label = original.length > 0 ? original : String(value.filename); - return `File: ${label}`; + return t('finalize.value.fileWithName', 'File: {{name}}', { + name: label, + }); } - return 'File selected'; + return t('finalize.value.fileSelected', 'File selected'); case 'audio': if (typeof value === 'object' && value.filename) { const duration = value.metadata?.duration ? ` (${Math.round(value.metadata.duration)}s)` : ''; - return `Audio: ${value.filename}${duration}`; + return t( + 'finalize.value.audioWithName', + 'Audio: {{name}}{{duration}}', + { + name: String(value.filename), + duration, + }, + ); } - return 'Audio recorded'; + return t('finalize.value.audioRecorded', 'Audio recorded'); case 'gps': if (typeof value === 'object' && value.latitude && value.longitude) { - return `Location: ${value.latitude.toFixed(6)}, ${value.longitude.toFixed(6)}`; + return t('finalize.value.location', 'Location: {{coords}}', { + coords: `${value.latitude.toFixed(6)}, ${value.longitude.toFixed(6)}`, + }); } - return 'GPS location captured'; + return t('finalize.value.gpsCaptured', 'GPS location captured'); case 'video': if (typeof value === 'object' && value.filename) { - return `Video: ${value.filename}`; + return t('finalize.value.videoWithName', 'Video: {{name}}', { + name: String(value.filename), + }); } - return 'Video captured'; + return t('finalize.value.videoCaptured', 'Video captured'); case 'date': return new Date(value).toLocaleDateString(); case 'date-time': @@ -105,7 +139,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { // Handle arrays if (Array.isArray(value)) { - if (value.length === 0) return 'None'; + if (value.length === 0) return t('finalize.none', 'None'); return value .map((item, idx) => { if (typeof item === 'object') { @@ -119,13 +153,13 @@ const FinalizeRenderer = ({ data }: ControlProps) => { // Handle objects if (typeof value === 'object') { // Check if it's a nested object with properties - if (Object.keys(value).length === 0) return 'Empty'; + if (Object.keys(value).length === 0) return t('finalize.empty', 'Empty'); return JSON.stringify(value, null, 2); } // Handle booleans if (typeof value === 'boolean') { - return value ? 'Yes' : 'No'; + return value ? t('finalize.yes', 'Yes') : t('finalize.no', 'No'); } // Default: convert to string @@ -232,29 +266,16 @@ const FinalizeRenderer = ({ data }: ControlProps) => { extractFields(fullSchema, data); return items; - }, [fullSchema, data, findFieldPageMemo]); - - const formatErrorPath = (path: string) => { - // Remove leading slash and convert to readable format - return path.replace(/^\//, '').replace(/\//g, ' > '); - }; + }, [fullSchema, data, findFieldPageMemo, getFieldLabel]); const formatErrorMessage = (error: ErrorObject) => { - const path = formatErrorPath(error.instancePath); - // Check if there's a custom error message in the error object - const customMessage = (error as any).params?.errorMessage; - // Title case the path and add spaces before capitalized letters - const formattedPath = path - ? path - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' ') - .replace(/([A-Z])/g, ' $1') - .trim() - : ''; - return formattedPath - ? `${formattedPath} ${customMessage || error.message}` - : customMessage || error.message; + const title = titleForErrorPath( + error.instancePath, + fullSchema as JsonSchema7 | undefined, + localizedUiSchema, + ); + const translated = translateAjvError(locale, error); + return title ? `${title}: ${translated}` : translated; }; const hasErrors = Array.isArray(errors) && errors.length > 0; @@ -306,7 +327,10 @@ const FinalizeRenderer = ({ data }: ControlProps) => { color="error" gutterBottom sx={{ textAlign: 'center' }}> - Please fix the following errors before finalizing: + {t( + 'finalize.fixErrors', + 'Please fix the following errors before finalizing:', + )} { color="success.main" gutterBottom sx={{ textAlign: 'center' }}> - All validations passed! You can now finalize your submission. + {t( + 'finalize.allValid', + 'All validations passed! You can now finalize your submission.', + )} )} @@ -352,7 +379,7 @@ const FinalizeRenderer = ({ data }: ControlProps) => { disabled={Boolean(hasErrors)} className="formplayer-solid-primary" style={{ width: '100%' }}> - Finalize + {t('nav.finalize', 'Finalize')} @@ -371,14 +398,17 @@ const FinalizeRenderer = ({ data }: ControlProps) => { variant="h5" gutterBottom sx={{ fontWeight: 700, textAlign: 'center' }}> - FORM SUMMARY + {t('finalize.summary', 'FORM SUMMARY')} - Review all your entered data below. Click on any field to edit it. + {t( + 'finalize.reviewHint', + 'Review all your entered data below. Click on any field to edit it.', + )} { }, flexShrink: 0, }} - aria-label="Edit field"> + aria-label={t('finalize.editField', 'Edit field')}> )} diff --git a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx index 1a2635e1c..5c3bfcc3e 100644 --- a/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/QrcodeQuestionRenderer.tsx @@ -22,6 +22,10 @@ import { ControlProps, rankWith, formatIs } from '@jsonforms/core'; import FormulusClient from '../services/FormulusInterface'; import { QrcodeResult } from '../types/FormulusInterfaceDefinition'; import QuestionShell from '../components/QuestionShell'; +import { + resolveControlDescription, + resolveControlLabel, +} from '../utils/controlDisplayText'; /** * Tester function — matches any schema field with "format": "qrcode". @@ -37,16 +41,17 @@ export const qrcodeQuestionTester = rankWith( formatIs('qrcode'), ); -const QrcodeQuestionRenderer: React.FC = ({ - data, - handleChange, - path, - errors, - schema, - uischema, - enabled = true, - visible = true, -}) => { +const QrcodeQuestionRenderer: React.FC = props => { + const { + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, + visible = true, + } = props; const [isScanning, setIsScanning] = useState(false); const [error, setError] = useState(null); const [showManualEntry, setShowManualEntry] = useState(false); @@ -135,8 +140,8 @@ const QrcodeQuestionRenderer: React.FC = ({ return null; } - const label = (uischema as any)?.label || schema.title || 'QR Code'; - const description = schema.description; + const label = resolveControlLabel(props) || 'QR Code'; + const description = resolveControlDescription(props) ?? schema.description; const isRequired = Boolean( (uischema as any)?.options?.required ?? (schema as any)?.options?.required ?? diff --git a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx index e08130a0c..962112270 100644 --- a/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx +++ b/formulus-formplayer/src/renderers/SubObservationQuestionRenderer.tsx @@ -4,7 +4,7 @@ * `openFormplayer` with `subObservationMode`. */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useContext, useMemo, useState } from 'react'; import { withJsonFormsControlProps, useJsonForms } from '@jsonforms/react'; import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; import { Box, Typography, Button, IconButton, Tooltip } from '@mui/material'; @@ -14,7 +14,13 @@ import QuestionShell from '../components/QuestionShell'; import FormulusClient from '../services/FormulusInterface'; import type { FormCompletionResult } from '../types/FormulusInterfaceDefinition'; import { useFormContext } from '../App'; +import { FormplayerLocaleContext } from '../i18n/FormplayerLocaleContext'; +import { odeT } from '../i18n/createOdeI18n'; import { tokens } from '../theme/tokens-adapter'; +import { + mergeSubObservationColumnDefs, + resolveSubObservationColumns, +} from '../utils/subObservationColumnLabels'; import { buildColumns, coerceSubObservationRows, @@ -32,6 +38,7 @@ import { writeSubObservationContextToWindow, type OrderBySpec, type SubObservationContextMergeConfig, + type SubObservationSchemaConfig, } from './subObservationHelpers'; const RESERVED_SCHEMA_KEYS = new Set([ @@ -117,7 +124,7 @@ const SubObservationQuestionRendererInner: React.FC = ({ required, }) => { const jsonForms = useJsonForms(); - const { commitFormData } = useFormContext(); + const { commitFormData, linkedFormSpecs } = useFormContext(); const config = useMemo(() => extractConfig(schema), [schema]); const childFormType = @@ -177,10 +184,48 @@ const SubObservationQuestionRendererInner: React.FC = ({ ) as Record[]; }, [jsonForms.core?.data, data, path, config.orderBy]); - const columns = useMemo(() => buildColumns(config, rows), [config, rows]); + const columnDefs = useMemo( + () => + mergeSubObservationColumnDefs( + (config as SubObservationSchemaConfig).columns, + uischema, + ), + [config, uischema], + ); + + const columns = useMemo(() => { + if (columnDefs.length > 0) { + return resolveSubObservationColumns( + columnDefs, + childFormType, + linkedFormSpecs, + ); + } + return buildColumns(config, rows); + }, [columnDefs, childFormType, linkedFormSpecs, config, rows]); const itemLabel = useMemo(() => resolveItemLabel(config), [config]); + const locale = useContext(FormplayerLocaleContext); + + const addButtonCopy = useMemo( + () => ({ + addDefault: odeT(locale, 'subObservation.add', '+ Add observation'), + addWithItem: odeT( + locale, + 'subObservation.addItem', + '+ Add {{itemLabel}}', + ), + adding: odeT(locale, 'subObservation.adding', 'Adding…'), + addingWithItem: odeT( + locale, + 'subObservation.addingItem', + 'Adding {{itemLabel}}…', + ), + }), + [locale], + ); + const addButtonLabelOverride = useMemo(() => { const opts = (uischema as { options?: { addButtonLabel?: unknown } }) ?.options; @@ -189,12 +234,15 @@ const SubObservationQuestionRendererInner: React.FC = ({ const addButtonText = useMemo( () => - resolveAddButtonLabel({ - itemLabel, - addButtonLabel: addButtonLabelOverride, - busy: busyId === 'add', - }), - [itemLabel, addButtonLabelOverride, busyId], + resolveAddButtonLabel( + { + itemLabel, + addButtonLabel: addButtonLabelOverride, + busy: busyId === 'add', + }, + addButtonCopy, + ), + [itemLabel, addButtonLabelOverride, busyId, addButtonCopy], ); const emptyLabel = useMemo(() => resolveEmptyLabel(itemLabel), [itemLabel]); @@ -496,17 +544,16 @@ const SubObservationQuestionRendererInner: React.FC = ({ ))} - Actions - + }} + /> diff --git a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx index 5cfbb7680..bb966f1c8 100644 --- a/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx +++ b/formulus-formplayer/src/renderers/SwipeLayoutRenderer.tsx @@ -44,6 +44,8 @@ import { } from '../utils/autofocusHelpers'; import { navigateToFirstBlockingError } from '../utils/validationNavigation'; import { formatBlockingErrorSummary } from '../utils/errorPageNavigation'; +import { resolveFieldLabel } from '../utils/controlDisplayText'; +import { useOdeT } from '../i18n/useOdeT'; // --------------------------------------------------------------------------- // Testers @@ -98,6 +100,7 @@ const SwipeLayoutRenderer = ({ ); const [snackbarMessage, setSnackbarMessage] = useState(''); const { core, config } = useJsonForms(); + const t = useOdeT(); const parentFormContext = useFormContext(); const { formInitData } = parentFormContext; @@ -369,9 +372,13 @@ const SwipeLayoutRenderer = ({ const missingFields = getMissingRequiredFieldsOnPage(); if (missingFields.length > 0) { - const message = `Missing required ${ - missingFields.length === 1 ? 'field' : 'fields' - }: ${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`; + const message = t( + 'validation.missingRequiredFieldsNamed', + `Missing required field(s): ${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`, + { + names: `${missingFields.slice(0, 2).join(', ')}${missingFields.length > 2 ? '...' : ''}`, + }, + ); setPendingNavigation(newPage); setSnackbarMessage(message); @@ -394,6 +401,7 @@ const SwipeLayoutRenderer = ({ getMissingRequiredFieldsOnPage, performNavigation, snackbarOpen, + t, ], ); @@ -435,8 +443,11 @@ const SwipeLayoutRenderer = ({ return formatBlockingErrorSummary( errors, (core?.schema ?? schema) as JsonSchema7, + 3, + t, + uischema, ); - }, [core?.errors, core?.schema, schema]); + }, [core?.errors, core?.schema, schema, t, uischema]); const trySubmitForm = useCallback(() => { if (!formInitData) return; @@ -491,13 +502,14 @@ const SwipeLayoutRenderer = ({ primaryKeyboardEnterKeyHint( isOnFinalizePage, nextVisiblePage !== null ? nextButtonLabelOption : undefined, - finalizeButtonLabelOption ?? 'Finalize', + finalizeButtonLabelOption ?? t('nav.finalize', 'Finalize'), ), [ isOnFinalizePage, nextVisiblePage, nextButtonLabelOption, finalizeButtonLabelOption, + t, ], ); @@ -584,8 +596,11 @@ const SwipeLayoutRenderer = ({ borderTop: `1px solid ${theme.palette.divider}`, })}> {headerFields.map((fieldKey: string) => { - const fieldSchema = (schema as any)?.properties?.[fieldKey]; - const label = fieldSchema?.title || fieldKey; + const label = resolveFieldLabel( + schema as JsonSchema7 | undefined, + uischema, + fieldKey, + ); const value = data?.[fieldKey]; const displayValue = value != null && value !== '' ? String(value) : '—'; @@ -642,7 +657,7 @@ const SwipeLayoutRenderer = ({ ? { onClick: trySubmitForm, disabled: isNavigating || !formInitData, - label: finalizeButtonLabelOption ?? 'Done', + label: finalizeButtonLabelOption ?? t('nav.done', 'Done'), } : nextVisiblePage !== null ? { @@ -711,14 +726,20 @@ const SwipeLayoutRenderer = ({ - Missing required fields + {t( + 'validation.missingRequiredFields', + 'Missing required fields', + )} {snackbarMessage || - 'Some required fields are missing. Any unsaved changes will be available as a draft when you return.'} + t( + 'validation.draftOnReturn', + 'Some required fields are missing. Any unsaved changes will be available as a draft when you return.', + )} - Stay here + {t('validation.stayHere', 'Stay here')} diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts index 4779fbeed..83b677356 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.test.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.test.ts @@ -13,6 +13,7 @@ import { optionalRecordMap, resolveItemLabel, resolveAddButtonLabel, + DEFAULT_ADD_BUTTON_COPY, resolveEmptyLabel, resolveDeleteFallbackLabel, mergeSessionIntoSubObservationContext, @@ -170,34 +171,53 @@ describe('subObservationHelpers', () => { }); it('resolveAddButtonLabel uses defaults, itemLabel, and ui override', () => { - expect(resolveAddButtonLabel({ itemLabel: null, busy: false })).toBe( + const copy = DEFAULT_ADD_BUTTON_COPY; + expect(resolveAddButtonLabel({ itemLabel: null, busy: false }, copy)).toBe( '+ Add observation', ); - expect(resolveAddButtonLabel({ itemLabel: null, busy: true })).toBe( + expect(resolveAddButtonLabel({ itemLabel: null, busy: true }, copy)).toBe( 'Adding…', ); - expect(resolveAddButtonLabel({ itemLabel: 'quarto', busy: false })).toBe( - '+ Add quarto', - ); - expect(resolveAddButtonLabel({ itemLabel: 'quarto', busy: true })).toBe( - 'Adding quarto…', - ); expect( - resolveAddButtonLabel({ - itemLabel: 'quarto', - addButtonLabel: '+ Adicionar quarto', - busy: false, - }), + resolveAddButtonLabel({ itemLabel: 'quarto', busy: false }, copy), + ).toBe('+ Add quarto'); + expect( + resolveAddButtonLabel({ itemLabel: 'quarto', busy: true }, copy), + ).toBe('Adding quarto…'); + expect( + resolveAddButtonLabel( + { + itemLabel: 'quarto', + addButtonLabel: '+ Adicionar quarto', + busy: false, + }, + copy, + ), ).toBe('+ Adicionar quarto'); expect( - resolveAddButtonLabel({ - itemLabel: 'quarto', - addButtonLabel: '+ Adicionar quarto', - busy: true, - }), + resolveAddButtonLabel( + { + itemLabel: 'quarto', + addButtonLabel: '+ Adicionar quarto', + busy: true, + }, + copy, + ), ).toBe('Adding…'); }); + it('resolveAddButtonLabel uses localized copy templates', () => { + const ptCopy = { + addDefault: '+ Adicionar observação', + addWithItem: '+ Adicionar {{itemLabel}}', + adding: 'A adicionar…', + addingWithItem: 'A adicionar {{itemLabel}}…', + }; + expect( + resolveAddButtonLabel({ itemLabel: 'quarto', busy: false }, ptCopy), + ).toBe('+ Adicionar quarto'); + }); + it('resolveEmptyLabel and resolveDeleteFallbackLabel respect itemLabel', () => { expect(resolveEmptyLabel(null)).toBe('No observations'); expect(resolveEmptyLabel('room')).toBe('No room'); diff --git a/formulus-formplayer/src/renderers/subObservationHelpers.ts b/formulus-formplayer/src/renderers/subObservationHelpers.ts index 98e8c465f..6a7f27e81 100644 --- a/formulus-formplayer/src/renderers/subObservationHelpers.ts +++ b/formulus-formplayer/src/renderers/subObservationHelpers.ts @@ -28,6 +28,24 @@ export function resolveItemLabel( return trimmed.length > 0 ? trimmed : null; } +import { interpolate } from '../i18n/createOdeI18n'; + +export type ResolveAddButtonCopy = { + addDefault: string; + /** Template with `{{itemLabel}}`. */ + addWithItem: string; + adding: string; + /** Template with `{{itemLabel}}`. */ + addingWithItem: string; +}; + +export const DEFAULT_ADD_BUTTON_COPY: ResolveAddButtonCopy = { + addDefault: '+ Add observation', + addWithItem: '+ Add {{itemLabel}}', + adding: 'Adding…', + addingWithItem: 'Adding {{itemLabel}}…', +}; + export type ResolveAddButtonLabelInput = { itemLabel: string | null; addButtonLabel?: unknown; @@ -37,18 +55,19 @@ export type ResolveAddButtonLabelInput = { /** Add-button text: ui override > composed from itemLabel > legacy default. */ export function resolveAddButtonLabel( input: ResolveAddButtonLabelInput, + copy: ResolveAddButtonCopy = DEFAULT_ADD_BUTTON_COPY, ): string { const override = typeof input.addButtonLabel === 'string' ? input.addButtonLabel.trim() : ''; if (override.length > 0) { - return input.busy ? 'Adding…' : override; + return input.busy ? copy.adding : override; } if (input.itemLabel) { return input.busy - ? `Adding ${input.itemLabel}…` - : `+ Add ${input.itemLabel}`; + ? interpolate(copy.addingWithItem, { itemLabel: input.itemLabel }) + : interpolate(copy.addWithItem, { itemLabel: input.itemLabel }); } - return input.busy ? 'Adding…' : '+ Add observation'; + return input.busy ? copy.adding : copy.addDefault; } /** Empty-table row text when the embedded array has no items. */ diff --git a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts index 438eec16a..13a2e8f4e 100644 --- a/formulus-formplayer/src/types/CustomQuestionTypeContract.ts +++ b/formulus-formplayer/src/types/CustomQuestionTypeContract.ts @@ -26,6 +26,11 @@ export interface CustomQuestionTypeProps { */ config: Record; + /** + * Display-oriented settings from ui.json Control.options (after locale preprocess). + */ + options?: Record; + /** * Callback to update the field value. Call with a JSON number/boolean/string/etc. — * never a display string for numeric schema types. diff --git a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts index 3be0c2205..d149227a6 100644 --- a/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts +++ b/formulus-formplayer/src/types/FormulusInterfaceDefinition.ts @@ -41,7 +41,7 @@ export interface ExtensionMetadata { * Data passed to the Formulus app when a form is initialized * @property {string} formType - The form type (e.g. 'form1') * @property {string | null} observationId - The observation ID (generated by the database on first form submission). NULL if this is a new form. - * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors` (and any future keys added to the formplayer allowlist)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus `locale` so polluted rows are cleaned on re-save. + * @property {Record} params - Host parameters for the formplayer WebView. Put **field prefills** in `params.defaultData` (a plain object). Top-level keys are reserved for bridge/UI: `defaultData`, `theme`, `darkMode`, `themeColors`, `locale` (active UI language), `context` (session context)—do not rely on those being stored on the observation. If `defaultData` is omitted, legacy behavior copies every other top-level key as prefill data (still excluding the reserved keys above). On load and submit, when the JSON Schema has non-empty root `properties`, the formplayer keeps only those property keys plus optional observation metadata keys such as schema-declared `locale`. * @property {Record} savedData - Previously saved form data (for editing) * @property {any} [formSchema] - JSON Schema for the form structure and validation (optional) * @property {any} [uiSchema] - UI Schema for form rendering layout (optional) @@ -67,6 +67,17 @@ export interface FormInitData { custom_types: Record; validators?: Record; }; + /** + * Linked child form specs for sub-observation column label resolution. + * Keys are form type ids (`linkedForm` values). Populated by Formulus at open time. + */ + linkedFormSpecs?: Record< + string, + { + schema: unknown; + uiSchema: unknown; + } + >; } /** diff --git a/formulus-formplayer/src/utils/controlDisplayText.test.ts b/formulus-formplayer/src/utils/controlDisplayText.test.ts new file mode 100644 index 000000000..e11ef9f24 --- /dev/null +++ b/formulus-formplayer/src/utils/controlDisplayText.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { + buildControlIndexByFieldKey, + resolveControlLabel, + resolveFieldLabel, +} from './controlDisplayText'; +import type { ControlProps } from '@jsonforms/core'; + +describe('controlDisplayText', () => { + it('resolveControlLabel prefers uischema.label over props.label', () => { + const props = { + label: 'Schema title from JsonForms', + uischema: { label: 'UI label' }, + schema: { title: 'Schema title' }, + } as ControlProps; + expect(resolveControlLabel(props)).toBe('UI label'); + }); + + it('resolveControlLabel falls back to props.label then schema title', () => { + const fromProps = { + label: 'From JsonForms', + uischema: {}, + schema: { title: 'Schema title' }, + } as ControlProps; + expect(resolveControlLabel(fromProps)).toBe('From JsonForms'); + + const schemaOnly = { + label: '', + uischema: {}, + schema: { title: 'Schema title' }, + } as ControlProps; + expect(resolveControlLabel(schemaOnly)).toBe('Schema title'); + }); + + it('resolveFieldLabel uses localized ui label over schema title', () => { + const schema = { + properties: { + codigo: { type: 'string', title: 'Envelope code' }, + }, + }; + const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/codigo', + label: 'Scan the envelope code', + }, + ], + }; + expect(resolveFieldLabel(schema, uischema, 'codigo')).toBe( + 'Scan the envelope code', + ); + }); + + it('buildControlIndexByFieldKey maps scope to control', () => { + const uischema = { + type: 'Control', + scope: '#/properties/foo', + label: 'Foo', + }; + const index = buildControlIndexByFieldKey(uischema); + expect(index.get('foo')?.label).toBe('Foo'); + }); +}); diff --git a/formulus-formplayer/src/utils/controlDisplayText.ts b/formulus-formplayer/src/utils/controlDisplayText.ts new file mode 100644 index 000000000..255e42eb4 --- /dev/null +++ b/formulus-formplayer/src/utils/controlDisplayText.ts @@ -0,0 +1,137 @@ +import { ControlProps, JsonSchema7, UISchemaElement } from '@jsonforms/core'; + +type ControlUiNode = { + type?: string; + scope?: string; + label?: string | boolean; + elements?: UISchemaElement[]; + options?: { columns?: UISchemaElement[] }; +}; + +function scopeToFieldKey(scope: string): string | null { + const match = scope.match(/^#\/properties\/([^/]+)$/); + return match?.[1] ?? null; +} + +function walkUiControls( + node: UISchemaElement | undefined, + visit: (control: ControlUiNode) => void, +): void { + if (!node || typeof node !== 'object') return; + const obj = node as ControlUiNode; + if (obj.type === 'Control' && typeof obj.scope === 'string') { + visit(obj); + } + if (Array.isArray(obj.elements)) { + for (const child of obj.elements) { + walkUiControls(child, visit); + } + } + const cols = obj.options?.columns; + if (Array.isArray(cols)) { + for (const col of cols) { + walkUiControls(col, visit); + } + } +} + +/** Build fieldKey → Control ui node index (first match wins). */ +export function buildControlIndexByFieldKey( + uischema: UISchemaElement | undefined, +): Map { + const index = new Map(); + walkUiControls(uischema, control => { + if (typeof control.scope !== 'string') return; + const key = scopeToFieldKey(control.scope); + if (key && !index.has(key)) { + index.set(key, control); + } + }); + return index; +} + +function labelFromControlAndSchema( + control: ControlUiNode | undefined, + fieldSchema: JsonSchema7 | undefined, + fieldKey: string, +): string { + if ( + control && + typeof control.label === 'string' && + control.label.length > 0 + ) { + return control.label; + } + if (control && control.label === false) { + return ''; + } + const title = fieldSchema?.title; + if (typeof title === 'string' && title.length > 0) { + return title; + } + return fieldKey; +} + +/** + * Resolved display label for a Control renderer. + * Prefer `uischema.label` (post-`applyFormUiTranslations`) over JsonForms + * `props.label`, which often falls back to `schema.title` for the default locale. + */ +export function resolveControlLabel(props: ControlProps): string { + const uischema = props.uischema as ControlUiNode | undefined; + if ( + uischema && + typeof uischema.label === 'string' && + uischema.label.length > 0 + ) { + return uischema.label; + } + if (uischema?.label === false) { + return ''; + } + const fromProps = props.label; + if (typeof fromProps === 'string' && fromProps.length > 0) { + return fromProps; + } + const schema = props.schema as JsonSchema7 | undefined; + const title = schema?.title; + if (typeof title === 'string' && title.length > 0) { + return title; + } + return typeof fromProps === 'string' ? fromProps : ''; +} + +export function resolveControlDescription( + props: ControlProps, +): string | undefined { + const fromProps = props.description; + if (typeof fromProps === 'string' && fromProps.length > 0) { + return fromProps; + } + const schema = props.schema as JsonSchema7 | undefined; + const desc = schema?.description; + return typeof desc === 'string' && desc.length > 0 ? desc : undefined; +} + +/** + * Display label for a top-level field when ControlProps is unavailable + * (header chips, finalize, errors). + */ +export function resolveFieldLabel( + schema: JsonSchema7 | undefined, + uischema: UISchemaElement | undefined, + fieldKey: string, + controlIndex?: Map, +): string { + const index = controlIndex ?? buildControlIndexByFieldKey(uischema); + const control = index.get(fieldKey); + const fieldSchema = schema?.properties?.[fieldKey] as JsonSchema7 | undefined; + return labelFromControlAndSchema(control, fieldSchema, fieldKey); +} + +export type LinkedFormSpec = { + schema: JsonSchema7; + uiSchema: UISchemaElement; +}; + +export type LinkedFormSpecsMap = Record; diff --git a/formulus-formplayer/src/utils/errorPageNavigation.ts b/formulus-formplayer/src/utils/errorPageNavigation.ts index 333b41187..0d2429738 100644 --- a/formulus-formplayer/src/utils/errorPageNavigation.ts +++ b/formulus-formplayer/src/utils/errorPageNavigation.ts @@ -8,6 +8,7 @@ import { type UISchemaElement, } from '@jsonforms/core'; import type { BlockingValidationError } from './validationNavigation'; +import { resolveFieldLabel } from './controlDisplayText'; function escapeRegex(segment: string): string { return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -185,27 +186,42 @@ function titleAtSchemaPath( export function titleForErrorPath( errorPath: string, schema: JsonSchema7 | undefined, + uischema?: UISchemaElement, ): string | null { const normalized = normalizeErrorInstancePath(errorPath); const propertyPath = normalized .split('/') .filter(segment => segment && !/^\d+$/.test(segment)); + + if (uischema && propertyPath.length === 1) { + return resolveFieldLabel(schema, uischema, propertyPath[0]!); + } + return titleAtSchemaPath(schema, propertyPath); } /** Human-readable summary for skipFinalize Done alert (field titles, not count only). */ +export type OdeTranslateFn = ( + key: string, + defaultMessage?: string, + vars?: Record, +) => string; + export function formatBlockingErrorSummary( errors: ReadonlyArray, schema: JsonSchema7 | undefined, maxTitles = 3, + t?: OdeTranslateFn, + uischema?: UISchemaElement, ): string { + const tr: OdeTranslateFn = t ?? ((_, def) => def ?? ''); if (errors.length === 0) return ''; const titles: string[] = []; for (const err of errors) { const path = err.instancePath ?? (typeof err.path === 'string' ? err.path : undefined); - const title = path ? titleForErrorPath(path, schema) : null; + const title = path ? titleForErrorPath(path, schema, uischema) : null; const label = title || err.message; if (label && !titles.includes(label)) titles.push(label); if (titles.length >= maxTitles) break; @@ -213,13 +229,20 @@ export function formatBlockingErrorSummary( const remaining = errors.length - titles.length; const joined = titles.join(', '); - const suffix = remaining > 0 ? ` (+${remaining} more)` : ''; + const suffix = + remaining > 0 + ? ` ${tr('validation.moreFields', '(+{{count}} more)', { count: remaining })}` + : ''; const countPhrase = - errors.length === 1 ? '1 field needs' : `${errors.length} fields need`; + errors.length === 1 + ? tr('validation.oneFieldAttention', '1 field needs attention') + : tr('validation.fieldsAttention', '{{count}} fields need attention', { + count: errors.length, + }); if (joined) { - return `${countPhrase} attention: ${joined}${suffix}. Tap Done to review.`; + return `${countPhrase}: ${joined}${suffix}. ${tr('validation.tapDoneToReview', 'Tap Done to review.')}`; } - return `${countPhrase} attention. Tap Done to review.`; + return `${countPhrase}. ${tr('validation.tapDoneToReview', 'Tap Done to review.')}`; } diff --git a/formulus-formplayer/src/utils/formObservationData.test.ts b/formulus-formplayer/src/utils/formObservationData.test.ts index 9f0f78297..0e97b3e1e 100644 --- a/formulus-formplayer/src/utils/formObservationData.test.ts +++ b/formulus-formplayer/src/utils/formObservationData.test.ts @@ -25,6 +25,7 @@ describe('initialFormDataFromParams', () => { theme: 'dark', darkMode: true, themeColors: { primary: '#000' }, + locale: 'pt', species: 'oak', }; expect(initialFormDataFromParams(params)).toEqual({ species: 'oak' }); diff --git a/formulus-formplayer/src/utils/formObservationData.ts b/formulus-formplayer/src/utils/formObservationData.ts index 70fdb2cd0..c7ee38543 100644 --- a/formulus-formplayer/src/utils/formObservationData.ts +++ b/formulus-formplayer/src/utils/formObservationData.ts @@ -9,6 +9,8 @@ export const FORMPARAMS_NON_DATA_KEYS = new Set([ 'theme', 'darkMode', 'themeColors', + // UI locale from host — not observation data (distinct from optional schema `locale` field). + 'locale', // Reserved read-only session context channel (see App init): a custom app may // pass `params.context` with session info (device role, selected cluster, ...) // that must never be persisted as observation data. diff --git a/formulus-formplayer/src/utils/linkedFormUtils.ts b/formulus-formplayer/src/utils/linkedFormUtils.ts new file mode 100644 index 000000000..d436dca09 --- /dev/null +++ b/formulus-formplayer/src/utils/linkedFormUtils.ts @@ -0,0 +1,35 @@ +/** Collect linkedForm ids from a form schema (recursive, includes nested sub-obs). */ +export function collectLinkedFormIds( + schema: unknown, + out: Set = new Set(), +): Set { + if (!schema || typeof schema !== 'object') return out; + const obj = schema as Record; + + if (typeof obj.linkedForm === 'string' && obj.linkedForm.trim()) { + out.add(obj.linkedForm.trim()); + } + + if (obj.properties && typeof obj.properties === 'object') { + for (const val of Object.values( + obj.properties as Record, + )) { + collectLinkedFormIds(val, out); + } + } + + if (obj.items && typeof obj.items === 'object') { + collectLinkedFormIds(obj.items, out); + } + + for (const key of ['allOf', 'anyOf', 'oneOf'] as const) { + const branch = obj[key]; + if (Array.isArray(branch)) { + for (const sub of branch) { + collectLinkedFormIds(sub, out); + } + } + } + + return out; +} diff --git a/formulus-formplayer/src/utils/subObservationColumnLabels.test.ts b/formulus-formplayer/src/utils/subObservationColumnLabels.test.ts new file mode 100644 index 000000000..25599e96b --- /dev/null +++ b/formulus-formplayer/src/utils/subObservationColumnLabels.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + mergeSubObservationColumnDefs, + resolveSubObservationColumns, +} from './subObservationColumnLabels'; + +describe('subObservationColumnLabels', () => { + const childSpec = { + schema: { + properties: { + quarto_num: { type: 'string', title: 'Room number' }, + quarto_display: { type: 'string', title: 'Summary' }, + }, + }, + uiSchema: { + type: 'VerticalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/quarto_num', + label: 'Room #', + translations: { pt: { label: 'Quarto nº' } }, + }, + ], + }, + }; + + it('resolveSubObservationColumns uses static label when provided', () => { + const cols = resolveSubObservationColumns( + [{ key: 'quarto_num', label: 'Static' }], + 'censo_milda_quarto', + { censo_milda_quarto: childSpec }, + ); + expect(cols).toEqual([{ key: 'quarto_num', label: 'Static' }]); + }); + + it('resolveSubObservationColumns resolves from linked child form', () => { + const cols = resolveSubObservationColumns( + [{ key: 'quarto_num' }, { key: 'quarto_display' }], + 'censo_milda_quarto', + { censo_milda_quarto: childSpec }, + ); + expect(cols[0]?.label).toBe('Room #'); + expect(cols[1]?.label).toBe('Summary'); + }); + + it('mergeSubObservationColumnDefs merges ui options over schema', () => { + const merged = mergeSubObservationColumnDefs([{ key: 'a' }], { + options: { columns: [{ key: 'a', label: 'Override' }] }, + }); + expect(merged).toEqual([{ key: 'a', label: 'Override' }]); + }); +}); diff --git a/formulus-formplayer/src/utils/subObservationColumnLabels.ts b/formulus-formplayer/src/utils/subObservationColumnLabels.ts new file mode 100644 index 000000000..146567794 --- /dev/null +++ b/formulus-formplayer/src/utils/subObservationColumnLabels.ts @@ -0,0 +1,75 @@ +import type { UISchemaElement } from '@jsonforms/core'; +import { + buildControlIndexByFieldKey, + type LinkedFormSpecsMap, + resolveFieldLabel, +} from './controlDisplayText'; +import type { ColumnSpec } from '../renderers/subObservationHelpers'; + +export type ColumnDef = { key: string; label?: string }; + +function mergeColumnDefsByKey( + schemaColumns: ColumnDef[] | undefined, + uiColumns: ColumnDef[] | undefined, +): ColumnDef[] { + const base = Array.isArray(schemaColumns) ? schemaColumns : []; + const patch = Array.isArray(uiColumns) ? uiColumns : []; + if (patch.length === 0) { + return base.map(c => ({ ...c })); + } + const result = base.map(c => ({ ...c })); + for (const patchCol of patch) { + if (!patchCol?.key) continue; + const idx = result.findIndex(c => c.key === patchCol.key); + if (idx >= 0) { + result[idx] = { ...result[idx], ...patchCol }; + } else { + result.push({ ...patchCol }); + } + } + return result; +} + +/** + * Resolve sub-observation table column headers. + * - Explicit column.label → static override (all locales). + * - Otherwise → linked child form field label via resolveFieldLabel. + */ +export function resolveSubObservationColumns( + columnDefs: ColumnDef[], + linkedForm: string, + linkedFormSpecs: LinkedFormSpecsMap | undefined, +): ColumnSpec[] { + const spec = linkedFormSpecs?.[linkedForm]; + const childSchema = spec?.schema; + const childUi = spec?.uiSchema; + const childIndex = + childUi !== undefined ? buildControlIndexByFieldKey(childUi) : undefined; + + return columnDefs.map(col => { + const staticLabel = + typeof col.label === 'string' && col.label.trim().length > 0 + ? col.label.trim() + : null; + if (staticLabel) { + return { key: col.key, label: staticLabel }; + } + if (childSchema && childIndex) { + return { + key: col.key, + label: resolveFieldLabel(childSchema, childUi, col.key, childIndex), + }; + } + return { key: col.key, label: col.key }; + }); +} + +export function mergeSubObservationColumnDefs( + schemaColumns: ColumnDef[] | undefined, + uischema: UISchemaElement | undefined, +): ColumnDef[] { + const uiCols = ( + uischema as { options?: { columns?: ColumnDef[] } } | undefined + )?.options?.columns; + return mergeColumnDefsByKey(schemaColumns, uiCols); +} diff --git a/formulus-formplayer/tsconfig.json b/formulus-formplayer/tsconfig.json index e9a73ae77..680739687 100644 --- a/formulus-formplayer/tsconfig.json +++ b/formulus-formplayer/tsconfig.json @@ -19,5 +19,10 @@ "jsx": "react-jsx" }, "include": ["src"], - "exclude": ["src/**/__tests__/**", "src/**/*.test.ts", "src/stories/**"] + "exclude": [ + "src/**/__tests__/**", + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/stories/**" + ] } diff --git a/formulus/.prettierignore b/formulus/.prettierignore index f83ff68d4..a7245668b 100644 --- a/formulus/.prettierignore +++ b/formulus/.prettierignore @@ -1,6 +1,6 @@ node_modules +third_party android ios coverage - diff --git a/formulus/AGENTS.md b/formulus/AGENTS.md index f6fee055c..5511c9670 100644 --- a/formulus/AGENTS.md +++ b/formulus/AGENTS.md @@ -33,6 +33,13 @@ - **Custom apps** are HTML/JS/CSS bundles loaded from Synkronus; they receive the **Formulus** injected API (see interface definition). Authors do not need this monorepo — public docs and [custom_app](https://github.com/OpenDataEnsemble/custom_app) describe usage. - **Formplayer** is a sibling package; after changing `FormulusInterfaceDefinition.ts`, run **`pnpm run sync-interface`** (or build) in **formulus-formplayer** so its copy stays aligned. +## UI language (i18n) + +- **Settings → Language** (`SettingsScreen`): Auto / `en` / `pt` / `fr`; stored in AsyncStorage (`@ode/uiLocale`) via [`LocaleSettingsService`](src/services/LocaleSettingsService.ts). +- Resolution: [`src/lib/locale.ts`](src/lib/locale.ts) — preference → device → `app.config.json` `defaultLocale` → `en`. +- Shell strings: `react-i18next` + [`src/locales/`](src/locales/). +- Formplayer receives resolved locale as `params.locale` from [`FormplayerModal`](src/components/FormplayerModal.tsx). + --- ## Changing the bridge diff --git a/formulus/App.tsx b/formulus/App.tsx index 58fdafba2..b944f94f5 100644 --- a/formulus/App.tsx +++ b/formulus/App.tsx @@ -4,8 +4,9 @@ import { DefaultTheme, DarkTheme, } from '@react-navigation/native'; -import { StatusBar, Alert } from 'react-native'; +import { StatusBar, Alert, View, ActivityIndicator } from 'react-native'; import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { I18nextProvider } from 'react-i18next'; import 'react-native-url-polyfill/auto'; import { FormService } from './src/services/FormService'; import { @@ -25,6 +26,7 @@ import SignatureCaptureModal from './src/components/SignatureCaptureModal'; import MainAppNavigator from './src/navigation/MainAppNavigator'; import { FormInitData } from './src/webview/FormulusInterfaceDefinition.ts'; import { FormSpec } from './src/services'; +import { initFormulusI18n, i18n } from './src/i18n'; /** * Inner component that consumes the AppTheme context to build a dynamic @@ -315,16 +317,35 @@ function AppInner(): React.JSX.Element { * custom app's brand colors are available to all native UI elements. */ function App(): React.JSX.Element { + const [i18nReady, setI18nReady] = useState(false); + + useEffect(() => { + void initFormulusI18n().then(() => setI18nReady(true)); + }, []); + + if (!i18nReady) { + return ( + + + + + + ); + } + return ( - - - - - - - - - + + + + + + + + + + + ); } diff --git a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js index 62d0f6a7e..278d8e244 100644 --- a/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js +++ b/formulus/android/app/src/main/assets/webview/FormulusInjectionScript.js @@ -1,6 +1,6 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-05-16T12:40:22.983Z +// Last generated: 2026-06-19T16:58:11.324Z (function () { // Enhanced API availability detection and recovery @@ -229,7 +229,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; } => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -664,6 +664,251 @@ }); }, + // getCachedLocation: fieldId: string => Promise + getCachedLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCachedLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCachedLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getCachedLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCachedLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise + allocateSequence: function (scopeKey, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'allocateSequence_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'allocateSequence', + messageId, + scopeKey: scopeKey, + options: options, + }), + ); + }); + }, + + // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'watchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'watchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'watchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'watchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + + // stopWatchLocation: fieldId: string => Promise + stopWatchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'stopWatchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'stopWatchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'stopWatchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'stopWatchLocation', + messageId, + fieldId: fieldId, + }), + ); + }); + }, + // requestFile: fieldId: string => Promise requestFile: function (fieldId) { return new Promise((resolve, reject) => { @@ -1637,6 +1882,245 @@ ); }); }, + + // persistObservation: input: PersistObservationInput => Promise + persistObservation: function (input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'persistObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'persistObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'persistObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'persistObservation', + messageId, + input: input, + }), + ); + }); + }, + + // sync: options: { includeAttachments?: boolean; } => Promise + sync: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'sync callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if (data.type === 'sync_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'sync' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'sync', + messageId, + options: options, + }), + ); + }); + }, + + // getConnectivityStatus: => Promise + getConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getConnectivityStatus', + messageId, + }), + ); + }); + }, + + // getCurrentDataRevisionCount: => Promise + getCurrentDataRevisionCount: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentDataRevisionCount_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); + } else { + resolve(data.result); + } + } + } catch (e) { + console.error( + "'getCurrentDataRevisionCount' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'getCurrentDataRevisionCount', + messageId, + }), + ); + }); + }, }; // Register the callback handler with the window object diff --git a/formulus/android/app/src/main/assets/webview/formulus-api.js b/formulus/android/app/src/main/assets/webview/formulus-api.js index c46263561..f4d47b754 100644 --- a/formulus/android/app/src/main/assets/webview/formulus-api.js +++ b/formulus/android/app/src/main/assets/webview/formulus-api.js @@ -5,7 +5,7 @@ * that's available in the WebView context as `globalThis.formulus`. * * This file is auto-generated from FormulusInterfaceDefinition.ts - * Last generated: 2026-05-16T12:40:24.252Z + * Last generated: 2026-06-19T16:58:11.756Z * * @example * // In your JavaScript file: @@ -29,24 +29,29 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus API - * / - * @returns {Promise} The API version + * Get the current version of the Formulus bridge API (the interface contract + * version, e.g. for {@link isCompatibleVersion} checks). + * @returns {Promise} The API version (semver) */ getVersion: function () {}, /** * Get a list of available forms - * / * @returns {Promise} Array of form information objects */ getAvailableForms: function () {}, /** * Open Formplayer with the specified form - * / + * Reserved keys are not treated as observation data: + * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and + * `context` — a read-only **session context** object (device role, selected cluster, + * ...) that Formplayer never persists and exposes to extensions as + * `window.formulusSessionContext`. Draft bypass is not a param key — use + * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). + * observation instead of creating a new one * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization + * @param {Object} params - Additional parameters for form initialization. * @param {Object} savedData - Previously saved form data (for editing) * @returns {Promise} Promise that resolves when the form is completed/closed with result details */ @@ -54,7 +59,6 @@ const FormulusAPI = { /** * Get observations for a specific form - * / * @param {string} formType - The identifier of the formtype * @returns {Promise} Array of form observations */ @@ -64,14 +68,12 @@ const FormulusAPI = { * Get observations with optional WHERE clause filtering (for dynamic choice lists). * Supports format: data.field = 'value' AND data.other = 'value' * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * / * @returns {Promise} Array of filtered observations */ getObservationsByQuery: function (options) {}, /** * Submit a completed form - * / * @param {string} formType - The identifier of the formtype * @param {Object} finalData - The final form data to submit * @returns {Promise} The observationId of the submitted form @@ -80,7 +82,6 @@ const FormulusAPI = { /** * Update an existing form - * / * @param {string} observationId - The identifier of the observation * @param {string} formType - The identifier of the formtype * @param {Object} finalData - The final form data to update @@ -90,7 +91,6 @@ const FormulusAPI = { /** * Request camera access for a field - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation */ @@ -98,15 +98,35 @@ const FormulusAPI = { /** * Request location for a field (captures into the form GPS field). - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with location result or rejects on error */ requestLocation: function (fieldId) {}, + /** + * Return the best cached device fix (if any) without forcing a new GPS read. + */ + getCachedLocation: function (fieldId) {}, + + /** + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + */ + allocateSequence: function (scopeKey, options) {}, + + /** + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + */ + watchLocation: function (fieldId) {}, + + /** + * Stop a prior `watchLocation` subscription for `fieldId`. + */ + stopWatchLocation: function (fieldId) {}, + /** * Request file selection for a field - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation */ @@ -114,7 +134,6 @@ const FormulusAPI = { /** * Launch an external intent - * / * @param {string} fieldId - The ID of the field * @param {Object} intentSpec - The intent specification * @returns {Promise} @@ -123,7 +142,6 @@ const FormulusAPI = { /** * Call a subform - * / * @param {string} fieldId - The ID of the field * @param {string} formType - The ID of the subform * @param {Object} options - Additional options for the subform @@ -133,7 +151,6 @@ const FormulusAPI = { /** * Request audio recording for a field - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation */ @@ -141,7 +158,6 @@ const FormulusAPI = { /** * Request video recording for a field (camera / picker — host-defined). - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation */ @@ -149,7 +165,6 @@ const FormulusAPI = { /** * Request QR code scanning for a field - * / * @param {string} fieldId - The ID of the field * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation */ @@ -157,7 +172,6 @@ const FormulusAPI = { /** * Request biometric authentication - * / * @param {string} fieldId - The ID of the field * @returns {Promise} */ @@ -165,21 +179,18 @@ const FormulusAPI = { /** * Request the current connectivity status - * / * @returns {Promise} */ requestConnectivityStatus: function () {}, /** * Request the current sync status - * / * @returns {Promise} */ requestSyncStatus: function () {}, /** * Run a local ML model - * / * @param {string} fieldId - The ID of the field * @param {string} modelId - The ID of the model to run * @param {Object} input - The input data for the model @@ -190,14 +201,12 @@ const FormulusAPI = { /** * Get information about the currently authenticated user. * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * / * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} */ getCurrentUser: function () {}, /** * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * / * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. */ getThemeMode: function () {}, @@ -211,7 +220,6 @@ const FormulusAPI = { * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. * Path segments and ".." are rejected. * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). - * / * @returns {Promise} Display URL, or `null` if none */ getAttachmentUri: function (fileName) {}, @@ -225,14 +233,12 @@ const FormulusAPI = { * parent directory, which mixed committed files with `draft/` and * `pending_upload/` subfolders. Custom apps that iterate this URL will now * see only fully-committed attachments. - * / * @returns {Promise} e.g. `file:///.../attachments/synced/` */ getAttachmentsUri: function () {}, /** * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * / * @returns {Promise} App directory URL for extensions, question_types, etc. */ getCustomAppUri: function () {}, @@ -240,10 +246,50 @@ const FormulusAPI = { /** * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). * Some bundles also use files under the custom app `forms/` subdirectory. - * / * @returns {Promise} Forms directory URL */ getFormSpecsUri: function () {}, + + /** + * Persist an observation **without opening Formplayer** (headless write). + * Intended for custom apps that create or update records programmatically. + * Uses the same persistence path as a Formplayer submit, including promotion + * of any referenced draft attachments. Provide `observationId` to update an + * existing row; omit it to create a new one. + * @since 1.3.0 + * @param {PersistObservationInput} input - The observation to persist + * @returns {Promise} The stored observation id and data + */ + persistObservation: function (input) {}, + + /** + * Trigger a synchronization with Synkronus (pull + push). + * Resolves when the sync completes; rejects if a sync is already in progress + * or the sync fails. Attachments are excluded by default for speed. + * @since 1.3.0 + * @returns {Promise} The final data version after sync + */ + sync: function (options) {}, + + /** + * Probe connectivity to the configured Synkronus server (`GET /health`). + * Never rejects for an offline device — returns `{ online: false }` so callers + * can branch on connectivity without try/catch. Suitable for the + * "verify subject details when online, fall back when offline" pattern. + * @since 1.3.0 + * @returns {Promise} The current connectivity status + */ + getConnectivityStatus: function () {}, + + /** + * Read the device's last-known Synkronus data revision (`current_version` + * from the most recent successful sync). Reflects server-stream alignment + * only — not unsynced local edits. Use with periodic polling or after + * {@link sync} to detect when another device has pushed changes. + * @since 1.4.0 + * @returns {Promise} Non-negative revision count (0 if never synced) + */ + getCurrentDataRevisionCount: function () {}, }; // Make the API available globally in browser environments diff --git a/formulus/android/app/src/main/assets/webview/placeholder_app.html b/formulus/android/app/src/main/assets/webview/placeholder_app.html index 9fe62ead1..a96d89fac 100644 --- a/formulus/android/app/src/main/assets/webview/placeholder_app.html +++ b/formulus/android/app/src/main/assets/webview/placeholder_app.html @@ -1,12 +1,12 @@ - + Custom App Placeholder - +
-

Your Custom
App

+

Your Custom
App

- Login and Update App Bundle to load your forms + Login and Update App Bundle to load + your forms

diff --git a/formulus/assets/webview/FormulusInjectionScript.js b/formulus/assets/webview/FormulusInjectionScript.js index 5c071dd1e..278d8e244 100644 --- a/formulus/assets/webview/FormulusInjectionScript.js +++ b/formulus/assets/webview/FormulusInjectionScript.js @@ -2,37 +2,50 @@ // Do not edit directly - this file will be overwritten // Last generated: 2026-06-19T16:58:11.324Z -(function() { +(function () { // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist - return globalThis.formulus || (typeof window !== 'undefined' ? window.formulus : undefined); + return ( + globalThis.formulus || + (typeof window !== 'undefined' ? window.formulus : undefined) + ); } function isFormulusAvailable() { const api = getFormulus(); - return api && typeof api === 'object' && typeof api.getVersion === 'function'; + return ( + api && typeof api === 'object' && typeof api.getVersion === 'function' + ); } // Idempotent guard to avoid double-initialization when scripts are reinjected - if ((globalThis).__formulusBridgeInitialized) { + if (globalThis.__formulusBridgeInitialized) { if (isFormulusAvailable()) { - console.debug('Formulus bridge already initialized and functional. Skipping duplicate injection.'); + console.debug( + 'Formulus bridge already initialized and functional. Skipping duplicate injection.', + ); return; } else { - console.warn('Formulus bridge flag is set but API is not functional. Proceeding with re-injection...'); + console.warn( + 'Formulus bridge flag is set but API is not functional. Proceeding with re-injection...', + ); } } // If API already exists and is functional, skip injection if (isFormulusAvailable()) { - console.debug('Formulus interface already exists and is functional. Skipping injection.'); + console.debug( + 'Formulus interface already exists and is functional. Skipping injection.', + ); return; } // If API exists but is not functional, log warning and proceed with re-injection if (getFormulus()) { - console.warn('Formulus interface exists but appears non-functional. Re-injecting...'); + console.warn( + 'Formulus interface exists but appears non-functional. Re-injecting...', + ); } // Helper function to handle callbacks @@ -48,7 +61,7 @@ // Initialize callbacks const callbacks = {}; - + // Global function to handle responses from React Native function handleMessage(event) { try { @@ -61,1625 +74,2087 @@ // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); return; // Or handle error, but for now, just return to avoid breaking others. } - + // Handle callbacks - if (data.type === 'callback' && data.callbackId && callbacks[data.callbackId]) { + if ( + data.type === 'callback' && + data.callbackId && + callbacks[data.callbackId] + ) { handleCallback(callbacks[data.callbackId], data.data); delete callbacks[data.callbackId]; } - + // Handle specific callbacks - - - if (data.type === 'onFormulusReady' && globalThis.formulusCallbacks?.onFormulusReady) { + + if ( + data.type === 'onFormulusReady' && + globalThis.formulusCallbacks?.onFormulusReady + ) { handleCallback(globalThis.formulusCallbacks.onFormulusReady); } } catch (e) { - console.error('Global handleMessage: Error processing message:', e, 'Raw event.data:', event.data); + console.error( + 'Global handleMessage: Error processing message:', + e, + 'Raw event.data:', + event.data, + ); } } - + // Set up message listener document.addEventListener('message', handleMessage); window.addEventListener('message', handleMessage); // Initialize the formulus interface globalThis.formulus = { - // getVersion: => Promise - getVersion: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + // getVersion: => Promise + getVersion: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getVersion callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getVersion_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getVersion callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getVersion callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getVersion_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getVersion' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getVersion' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getVersion', messageId, - - })); - - }); - }, - - // getAvailableForms: => Promise - getAvailableForms: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAvailableForms: => Promise + getAvailableForms: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAvailableForms callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAvailableForms_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAvailableForms callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAvailableForms callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAvailableForms_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAvailableForms' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAvailableForms' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAvailableForms', messageId, - - })); - - }); - }, - - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise - openFormplayer: function(formType, params, savedData, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise + openFormplayer: function (formType, params, savedData, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'openFormplayer callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'openFormplayer_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('openFormplayer callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('openFormplayer callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'openFormplayer_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'openFormplayer' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'openFormplayer' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'openFormplayer', messageId, - formType: formType, + formType: formType, params: params, savedData: savedData, - options: options - })); - - }); - }, - - // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise - getObservations: function(formType, isDraft, includeDeleted) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getObservations: formType: string, isDraft: boolean, includeDeleted: boolean => Promise + getObservations: function (formType, isDraft, includeDeleted) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservations callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservations_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservations callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservations callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservations_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservations' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getObservations' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservations', messageId, - formType: formType, + formType: formType, isDraft: isDraft, - includeDeleted: includeDeleted - })); - - }); - }, - - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise - getObservationsByQuery: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + includeDeleted: includeDeleted, + }), + ); + }); + }, + + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise + getObservationsByQuery: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getObservationsByQuery_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getObservationsByQuery callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getObservationsByQuery callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getObservationsByQuery_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getObservationsByQuery' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getObservationsByQuery' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getObservationsByQuery', messageId, - options: options - })); - - }); - }, - - // submitObservation: formType: string, finalData: Record => Promise - submitObservation: function(formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // submitObservation: formType: string, finalData: Record => Promise + submitObservation: function (formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'submitObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'submitObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('submitObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('submitObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'submitObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'submitObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'submitObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'submitObservation', messageId, - formType: formType, - finalData: finalData - })); - - }); - }, - - // updateObservation: observationId: string, formType: string, finalData: Record => Promise - updateObservation: function(observationId, formType, finalData) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + formType: formType, + finalData: finalData, + }), + ); + }); + }, + + // updateObservation: observationId: string, formType: string, finalData: Record => Promise + updateObservation: function (observationId, formType, finalData) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'updateObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'updateObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('updateObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('updateObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'updateObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'updateObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'updateObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'updateObservation', messageId, - observationId: observationId, + observationId: observationId, formType: formType, - finalData: finalData - })); - - }); - }, - - // requestCamera: fieldId: string => Promise - requestCamera: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + finalData: finalData, + }), + ); + }); + }, + + // requestCamera: fieldId: string => Promise + requestCamera: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestCamera callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestCamera_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestCamera callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestCamera callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestCamera_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestCamera' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestCamera' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestCamera', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestLocation: fieldId: string => Promise - requestLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestLocation: fieldId: string => Promise + requestLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // getCachedLocation: fieldId: string => Promise - getCachedLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // getCachedLocation: fieldId: string => Promise + getCachedLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCachedLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCachedLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCachedLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCachedLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getCachedLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getCachedLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCachedLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCachedLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise - allocateSequence: function(scopeKey, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // allocateSequence: scopeKey: string, options: { startAt?: number; peek?: boolean; } => Promise + allocateSequence: function (scopeKey, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'allocateSequence callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'allocateSequence_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('allocateSequence callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('allocateSequence callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'allocateSequence_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'allocateSequence' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'allocateSequence' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'allocateSequence', messageId, - scopeKey: scopeKey, - options: options - })); - - }); - }, - - // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> - watchLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + scopeKey: scopeKey, + options: options, + }), + ); + }); + }, + + // watchLocation: fieldId: string => Promise<{ status: "started" | "error"; message?: string; }> + watchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'watchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'watchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('watchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('watchLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'watchLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'watchLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'watchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'watchLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // stopWatchLocation: fieldId: string => Promise - stopWatchLocation: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // stopWatchLocation: fieldId: string => Promise + stopWatchLocation: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'stopWatchLocation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'stopWatchLocation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('stopWatchLocation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('stopWatchLocation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'stopWatchLocation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'stopWatchLocation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'stopWatchLocation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'stopWatchLocation', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestFile: fieldId: string => Promise - requestFile: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestFile: fieldId: string => Promise + requestFile: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestFile callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestFile_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestFile callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestFile callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestFile_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestFile' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestFile' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestFile', messageId, - fieldId: fieldId - })); - - }); - }, - - // launchIntent: fieldId: string, intentSpec: Record => Promise - launchIntent: function(fieldId, intentSpec) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // launchIntent: fieldId: string, intentSpec: Record => Promise + launchIntent: function (fieldId, intentSpec) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'launchIntent callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'launchIntent_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('launchIntent callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('launchIntent callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'launchIntent_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'launchIntent' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'launchIntent' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'launchIntent', messageId, - fieldId: fieldId, - intentSpec: intentSpec - })); - - }); - }, - - // callSubform: fieldId: string, formType: string, options: Record => Promise - callSubform: function(fieldId, formType, options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + intentSpec: intentSpec, + }), + ); + }); + }, + + // callSubform: fieldId: string, formType: string, options: Record => Promise + callSubform: function (fieldId, formType, options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'callSubform callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'callSubform_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('callSubform callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('callSubform callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'callSubform_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'callSubform' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'callSubform' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'callSubform', messageId, - fieldId: fieldId, + fieldId: fieldId, formType: formType, - options: options - })); - - }); - }, - - // requestAudio: fieldId: string => Promise - requestAudio: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // requestAudio: fieldId: string => Promise + requestAudio: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestAudio callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestAudio_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestAudio callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestAudio callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestAudio_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestAudio' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestAudio' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestAudio', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestVideo: fieldId: string => Promise - requestVideo: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestVideo: fieldId: string => Promise + requestVideo: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestVideo callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestVideo_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestVideo callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestVideo callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestVideo_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestVideo' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestVideo' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestVideo', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestQrcode: fieldId: string => Promise - requestQrcode: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestQrcode: fieldId: string => Promise + requestQrcode: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestQrcode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestQrcode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestQrcode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestQrcode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestQrcode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestQrcode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestQrcode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestQrcode', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestBiometric: fieldId: string => Promise - requestBiometric: function(fieldId) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestBiometric: fieldId: string => Promise + requestBiometric: function (fieldId) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestBiometric callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestBiometric_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestBiometric callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestBiometric callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestBiometric_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestBiometric' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestBiometric' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestBiometric', messageId, - fieldId: fieldId - })); - - }); - }, - - // requestConnectivityStatus: => Promise - requestConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fieldId: fieldId, + }), + ); + }); + }, + + // requestConnectivityStatus: => Promise + requestConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'requestConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'requestConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestConnectivityStatus', messageId, - - })); - - }); - }, - - // requestSyncStatus: => Promise - requestSyncStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // requestSyncStatus: => Promise + requestSyncStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'requestSyncStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'requestSyncStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('requestSyncStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('requestSyncStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'requestSyncStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'requestSyncStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'requestSyncStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'requestSyncStatus', messageId, - - })); - - }); - }, - - // runLocalModel: fieldId: string, modelId: string, input: Record => Promise - runLocalModel: function(fieldId, modelId, input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // runLocalModel: fieldId: string, modelId: string, input: Record => Promise + runLocalModel: function (fieldId, modelId, input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'runLocalModel callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'runLocalModel_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('runLocalModel callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('runLocalModel callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'runLocalModel_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'runLocalModel' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'runLocalModel' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'runLocalModel', messageId, - fieldId: fieldId, + fieldId: fieldId, modelId: modelId, - input: input - })); - - }); - }, - - // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> - getCurrentUser: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // getCurrentUser: => Promise<{ username: string; displayName?: string; role?: "read-only" | "read-write" | "admin"; }> + getCurrentUser: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentUser callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentUser_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentUser callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentUser callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCurrentUser_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCurrentUser' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCurrentUser' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentUser', messageId, - - })); - - }); - }, - - // getThemeMode: => Promise<"light" | "dark" | "system"> - getThemeMode: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getThemeMode: => Promise<"light" | "dark" | "system"> + getThemeMode: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getThemeMode callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getThemeMode_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getThemeMode callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getThemeMode callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getThemeMode_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getThemeMode' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getThemeMode' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getThemeMode', messageId, - - })); - - }); - }, - - // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise - getAttachmentUri: function(fileName) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getAttachmentUri: fileName: string | AttachmentDisplayDescriptor => Promise + getAttachmentUri: function (fileName) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getAttachmentUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getAttachmentUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAttachmentUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentUri', messageId, - fileName: fileName - })); - - }); - }, - - // getAttachmentsUri: => Promise - getAttachmentsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + fileName: fileName, + }), + ); + }); + }, + + // getAttachmentsUri: => Promise + getAttachmentsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getAttachmentsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getAttachmentsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getAttachmentsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getAttachmentsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getAttachmentsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getAttachmentsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getAttachmentsUri', messageId, - - })); - - }); - }, - - // getCustomAppUri: => Promise - getCustomAppUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCustomAppUri: => Promise + getCustomAppUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCustomAppUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCustomAppUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCustomAppUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCustomAppUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getCustomAppUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getCustomAppUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCustomAppUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCustomAppUri', messageId, - - })); - - }); - }, - - // getFormSpecsUri: => Promise - getFormSpecsUri: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getFormSpecsUri: => Promise + getFormSpecsUri: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getFormSpecsUri_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getFormSpecsUri callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getFormSpecsUri callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getFormSpecsUri_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getFormSpecsUri' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getFormSpecsUri' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getFormSpecsUri', messageId, - - })); - - }); - }, - - // persistObservation: input: PersistObservationInput => Promise - persistObservation: function(input) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // persistObservation: input: PersistObservationInput => Promise + persistObservation: function (input) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'persistObservation callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'persistObservation_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('persistObservation callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('persistObservation callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'persistObservation_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'persistObservation' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'persistObservation' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'persistObservation', messageId, - input: input - })); - - }); - }, - - // sync: options: { includeAttachments?: boolean; } => Promise - sync: function(options) { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + input: input, + }), + ); + }); + }, + + // sync: options: { includeAttachments?: boolean; } => Promise + sync: function (options) { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'sync callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if (data.type === 'sync_response' && data.messageId === messageId) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('sync callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('sync callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'sync_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'sync' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'sync' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'sync', messageId, - options: options - })); - - }); - }, - - // getConnectivityStatus: => Promise - getConnectivityStatus: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + options: options, + }), + ); + }); + }, + + // getConnectivityStatus: => Promise + getConnectivityStatus: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getConnectivityStatus_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getConnectivityStatus callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getConnectivityStatus callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; + resolve(data.result); } - if (data.type === 'getConnectivityStatus_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } - } - } catch (e) { - console.error("'getConnectivityStatus' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getConnectivityStatus' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getConnectivityStatus', messageId, - - })); - - }); - }, - - // getCurrentDataRevisionCount: => Promise - getCurrentDataRevisionCount: function() { - return new Promise((resolve, reject) => { - const messageId = 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); - - // Add response handler for methods that return values - - const callback = (event) => { - try { - let data; - if (typeof event.data === 'string') { - data = JSON.parse(event.data); - } else if (typeof event.data === 'object' && event.data !== null) { - data = event.data; // Already an object + }), + ); + }); + }, + + // getCurrentDataRevisionCount: => Promise + getCurrentDataRevisionCount: function () { + return new Promise((resolve, reject) => { + const messageId = + 'msg_' + Date.now() + '_' + Math.floor(Math.random() * 1000); + + // Add response handler for methods that return values + + const callback = event => { + try { + let data; + if (typeof event.data === 'string') { + data = JSON.parse(event.data); + } else if (typeof event.data === 'object' && event.data !== null) { + data = event.data; // Already an object + } else { + // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); + window.removeEventListener('message', callback); // Clean up listener + reject( + new Error( + 'getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + + String(event.data), + ), + ); + return; + } + if ( + data.type === 'getCurrentDataRevisionCount_response' && + data.messageId === messageId + ) { + window.removeEventListener('message', callback); + if (data.error) { + reject(new Error(data.error)); } else { - // console.warn('getCurrentDataRevisionCount callback: Received response with unexpected data type:', typeof event.data, event.data); - window.removeEventListener('message', callback); // Clean up listener - reject(new Error('getCurrentDataRevisionCount callback: Received response with unexpected data type. Raw: ' + String(event.data))); - return; - } - if (data.type === 'getCurrentDataRevisionCount_response' && data.messageId === messageId) { - window.removeEventListener('message', callback); - if (data.error) { - reject(new Error(data.error)); - } else { - resolve(data.result); - } + resolve(data.result); } - } catch (e) { - console.error("'getCurrentDataRevisionCount' callback: Error processing response:" , e, "Raw event.data:", event.data); - window.removeEventListener('message', callback); // Ensure listener is removed on error too - reject(e); - } - }; - window.addEventListener('message', callback); - - - // Send the message to React Native - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ + } + } catch (e) { + console.error( + "'getCurrentDataRevisionCount' callback: Error processing response:", + e, + 'Raw event.data:', + event.data, + ); + window.removeEventListener('message', callback); // Ensure listener is removed on error too + reject(e); + } + }; + window.addEventListener('message', callback); + + // Send the message to React Native + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ type: 'getCurrentDataRevisionCount', messageId, - - })); - - }); - }, + }), + ); + }); + }, }; - + // Register the callback handler with the window object globalThis.formulusCallbacks = {}; - + // Notify that the interface is ready console.log('Formulus interface initialized'); - (globalThis).__formulusBridgeInitialized = true; + globalThis.__formulusBridgeInitialized = true; // Simple API availability check for internal use function requestApiReinjection() { console.log('Formulus: Requesting re-injection from host...'); if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'requestApiReinjection', - timestamp: Date.now() - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'requestApiReinjection', + timestamp: Date.now(), + }), + ); } } globalThis.__formulusRequestApiReinjection = requestApiReinjection; // Notify React Native that the interface is ready if (globalThis.ReactNativeWebView) { - globalThis.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'onFormulusReady' - })); + globalThis.ReactNativeWebView.postMessage( + JSON.stringify({ + type: 'onFormulusReady', + }), + ); } - + // Make the API available globally in browser environments if (typeof window !== 'undefined') { window.formulus = globalThis.formulus; } - })(); diff --git a/formulus/assets/webview/formulus-api.js b/formulus/assets/webview/formulus-api.js index 2aee71918..f4d47b754 100644 --- a/formulus/assets/webview/formulus-api.js +++ b/formulus/assets/webview/formulus-api.js @@ -1,16 +1,16 @@ /** * Formulus API Interface (JavaScript Version) - * + * * This file provides type information and documentation for the Formulus API * that's available in the WebView context as `globalThis.formulus`. - * + * * This file is auto-generated from FormulusInterfaceDefinition.ts * Last generated: 2026-06-19T16:58:11.756Z - * + * * @example * // In your JavaScript file: * /// - * + * * // Now you'll get autocompletion and type hints in IDEs that support JSDoc * globalThis.formulus.getVersion().then(version => { * console.log('Formulus version:', version); @@ -29,268 +29,267 @@ */ const FormulusAPI = { /** - * Get the current version of the Formulus bridge API (the interface contract - * version, e.g. for {@link isCompatibleVersion} checks). - * @returns {Promise} The API version (semver) - */ - getVersion: function() {}, + * Get the current version of the Formulus bridge API (the interface contract + * version, e.g. for {@link isCompatibleVersion} checks). + * @returns {Promise} The API version (semver) + */ + getVersion: function () {}, /** - * Get a list of available forms - * @returns {Promise} Array of form information objects - */ - getAvailableForms: function() {}, - - /** - * Open Formplayer with the specified form - * Reserved keys are not treated as observation data: - * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and - * `context` — a read-only **session context** object (device role, selected cluster, - * ...) that Formplayer never persists and exposes to extensions as - * `window.formulusSessionContext`. Draft bypass is not a param key — use - * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). - * observation instead of creating a new one - * @param {string} formType - The identifier of the formtype to open - * @param {Object} params - Additional parameters for form initialization. - * @param {Object} savedData - Previously saved form data (for editing) - * @returns {Promise} Promise that resolves when the form is completed/closed with result details - */ - openFormplayer: function(formType, params, savedData, options) {}, + * Get a list of available forms + * @returns {Promise} Array of form information objects + */ + getAvailableForms: function () {}, /** - * Get observations for a specific form - * @param {string} formType - The identifier of the formtype - * @returns {Promise} Array of form observations - */ - getObservations: function(formType, isDraft, includeDeleted) {}, + * Open Formplayer with the specified form + * Reserved keys are not treated as observation data: + * `defaultData` (prefill), `theme`/`darkMode`/`themeColors` (theming), and + * `context` — a read-only **session context** object (device role, selected cluster, + * ...) that Formplayer never persists and exposes to extensions as + * `window.formulusSessionContext`. Draft bypass is not a param key — use + * `options.skipDraftSelection` on {@link openFormplayer} (same as `skipFinalize`). + * observation instead of creating a new one + * @param {string} formType - The identifier of the formtype to open + * @param {Object} params - Additional parameters for form initialization. + * @param {Object} savedData - Previously saved form data (for editing) + * @returns {Promise} Promise that resolves when the form is completed/closed with result details + */ + openFormplayer: function (formType, params, savedData, options) {}, /** - * Get observations with optional WHERE clause filtering (for dynamic choice lists). - * Supports format: data.field = 'value' AND data.other = 'value' - * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. - * @returns {Promise} Array of filtered observations - */ - getObservationsByQuery: function(options) {}, + * Get observations for a specific form + * @param {string} formType - The identifier of the formtype + * @returns {Promise} Array of form observations + */ + getObservations: function (formType, isDraft, includeDeleted) {}, /** - * Submit a completed form - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to submit - * @returns {Promise} The observationId of the submitted form - */ - submitObservation: function(formType, finalData) {}, + * Get observations with optional WHERE clause filtering (for dynamic choice lists). + * Supports format: data.field = 'value' AND data.other = 'value' + * Age filtering via age_from_dob(data.dob) is handled client-side in formplayer. + * @returns {Promise} Array of filtered observations + */ + getObservationsByQuery: function (options) {}, /** - * Update an existing form - * @param {string} observationId - The identifier of the observation - * @param {string} formType - The identifier of the formtype - * @param {Object} finalData - The final form data to update - * @returns {Promise} The observationId of the updated form - */ - updateObservation: function(observationId, formType, finalData) {}, + * Submit a completed form + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to submit + * @returns {Promise} The observationId of the submitted form + */ + submitObservation: function (formType, finalData) {}, /** - * Request camera access for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation - */ - requestCamera: function(fieldId) {}, + * Update an existing form + * @param {string} observationId - The identifier of the observation + * @param {string} formType - The identifier of the formtype + * @param {Object} finalData - The final form data to update + * @returns {Promise} The observationId of the updated form + */ + updateObservation: function (observationId, formType, finalData) {}, /** - * Request location for a field (captures into the form GPS field). - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with location result or rejects on error - */ - requestLocation: function(fieldId) {}, + * Request camera access for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with camera result or rejects on error/cancellation + */ + requestCamera: function (fieldId) {}, /** - * Return the best cached device fix (if any) without forcing a new GPS read. - */ - getCachedLocation: function(fieldId) {}, + * Request location for a field (captures into the form GPS field). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with location result or rejects on error + */ + requestLocation: function (fieldId) {}, /** - * Allocate the next monotonic integer in a device-local scope. - * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. - */ - allocateSequence: function(scopeKey, options) {}, + * Return the best cached device fix (if any) without forcing a new GPS read. + */ + getCachedLocation: function (fieldId) {}, /** - * Subscribe to location updates while the custom app WebView is active. - * Updates arrive as `locationWatchUpdate` window messages. - */ - watchLocation: function(fieldId) {}, + * Allocate the next monotonic integer in a device-local scope. + * Formulus prepends `device:{deviceId}:` to the app-authored `scopeKey` suffix. + */ + allocateSequence: function (scopeKey, options) {}, /** - * Stop a prior `watchLocation` subscription for `fieldId`. - */ - stopWatchLocation: function(fieldId) {}, + * Subscribe to location updates while the custom app WebView is active. + * Updates arrive as `locationWatchUpdate` window messages. + */ + watchLocation: function (fieldId) {}, /** - * Request file selection for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation - */ - requestFile: function(fieldId) {}, + * Stop a prior `watchLocation` subscription for `fieldId`. + */ + stopWatchLocation: function (fieldId) {}, /** - * Launch an external intent - * @param {string} fieldId - The ID of the field - * @param {Object} intentSpec - The intent specification - * @returns {Promise} - */ - launchIntent: function(fieldId, intentSpec) {}, + * Request file selection for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with file result or rejects on error/cancellation + */ + requestFile: function (fieldId) {}, /** - * Call a subform - * @param {string} fieldId - The ID of the field - * @param {string} formType - The ID of the subform - * @param {Object} options - Additional options for the subform - * @returns {Promise} - */ - callSubform: function(fieldId, formType, options) {}, + * Launch an external intent + * @param {string} fieldId - The ID of the field + * @param {Object} intentSpec - The intent specification + * @returns {Promise} + */ + launchIntent: function (fieldId, intentSpec) {}, /** - * Request audio recording for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation - */ - requestAudio: function(fieldId) {}, + * Call a subform + * @param {string} fieldId - The ID of the field + * @param {string} formType - The ID of the subform + * @param {Object} options - Additional options for the subform + * @returns {Promise} + */ + callSubform: function (fieldId, formType, options) {}, /** - * Request video recording for a field (camera / picker — host-defined). - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation - */ - requestVideo: function(fieldId) {}, + * Request audio recording for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with audio result or rejects on error/cancellation + */ + requestAudio: function (fieldId) {}, /** - * Request QR code scanning for a field - * @param {string} fieldId - The ID of the field - * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation - */ - requestQrcode: function(fieldId) {}, + * Request video recording for a field (camera / picker — host-defined). + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with video result or rejects on error/cancellation + */ + requestVideo: function (fieldId) {}, /** - * Request biometric authentication - * @param {string} fieldId - The ID of the field - * @returns {Promise} - */ - requestBiometric: function(fieldId) {}, + * Request QR code scanning for a field + * @param {string} fieldId - The ID of the field + * @returns {Promise} Promise that resolves with QR code result or rejects on error/cancellation + */ + requestQrcode: function (fieldId) {}, /** - * Request the current connectivity status - * @returns {Promise} - */ - requestConnectivityStatus: function() {}, + * Request biometric authentication + * @param {string} fieldId - The ID of the field + * @returns {Promise} + */ + requestBiometric: function (fieldId) {}, /** - * Request the current sync status - * @returns {Promise} - */ - requestSyncStatus: function() {}, + * Request the current connectivity status + * @returns {Promise} + */ + requestConnectivityStatus: function () {}, /** - * Run a local ML model - * @param {string} fieldId - The ID of the field - * @param {string} modelId - The ID of the model to run - * @param {Object} input - The input data for the model - * @returns {Promise} - */ - runLocalModel: function(fieldId, modelId, input) {}, + * Request the current sync status + * @returns {Promise} + */ + requestSyncStatus: function () {}, /** - * Get information about the currently authenticated user. - * When no one is logged in, resolves with `{ username: '' }` (does not reject). - * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} - */ - getCurrentUser: function() {}, + * Run a local ML model + * @param {string} fieldId - The ID of the field + * @param {string} modelId - The ID of the model to run + * @param {Object} input - The input data for the model + * @returns {Promise} + */ + runLocalModel: function (fieldId, modelId, input) {}, /** - * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. - * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. - */ - getThemeMode: function() {}, - - /** - * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). - * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: - * 1. `attachments/draft/` — unsaved capture (formplayer preview) - * 2. `attachments/synced/` — canonical committed / downloaded copy - * 3. `attachments/pending/` — queued for upload (fallback only) - * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. - * Path segments and ".." are rejected. - * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). - * @returns {Promise} Display URL, or `null` if none - */ - getAttachmentUri: function(fileName) {}, - - /** - * Base `file://` URL for the canonical attachments directory (trailing slash). - * Returns the `synced/` subfolder — only committed/downloaded files are - * iterable from here. Drafts and the upload queue are excluded by design so - * custom apps can safely list this directory. - * **Breaking change (v2 layout):** this used to return the `attachments/` - * parent directory, which mixed committed files with `draft/` and - * `pending_upload/` subfolders. Custom apps that iterate this URL will now - * see only fully-committed attachments. - * @returns {Promise} e.g. `file:///.../attachments/synced/` - */ - getAttachmentsUri: function() {}, + * Get information about the currently authenticated user. + * When no one is logged in, resolves with `{ username: '' }` (does not reject). + * @returns {Promise<{username: string, displayName?: string, role?: 'read-only' | 'read-write' | 'admin'} + */ + getCurrentUser: function () {}, /** - * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). - * @returns {Promise} App directory URL for extensions, question_types, etc. - */ - getCustomAppUri: function() {}, - + * Get the current theme mode (System / Light / Dark) so custom apps can match the host app. + * @returns {Promise<'light' | 'dark' | 'system'>} Current theme mode; 'system' means follow device preference. + */ + getThemeMode: function () {}, + /** - * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). - * Some bundles also use files under the custom app `forms/` subdirectory. - * @returns {Promise} Forms directory URL - */ - getFormSpecsUri: function() {}, - - /** - * Persist an observation **without opening Formplayer** (headless write). - * Intended for custom apps that create or update records programmatically. - * Uses the same persistence path as a Formplayer submit, including promotion - * of any referenced draft attachments. Provide `observationId` to update an - * existing row; omit it to create a new one. - * @since 1.3.0 - * @param {PersistObservationInput} input - The observation to persist - * @returns {Promise} The stored observation id and data - */ - persistObservation: function(input) {}, - - /** - * Trigger a synchronization with Synkronus (pull + push). - * Resolves when the sync completes; rejects if a sync is already in progress - * or the sync fails. Attachments are excluded by default for speed. - * @since 1.3.0 - * @returns {Promise} The final data version after sync - */ - sync: function(options) {}, - - /** - * Probe connectivity to the configured Synkronus server (`GET /health`). - * Never rejects for an offline device — returns `{ online: false }` so callers - * can branch on connectivity without try/catch. Suitable for the - * "verify subject details when online, fall back when offline" pattern. - * @since 1.3.0 - * @returns {Promise} The current connectivity status - */ - getConnectivityStatus: function() {}, - - /** - * Read the device's last-known Synkronus data revision (`current_version` - * from the most recent successful sync). Reflects server-stream alignment - * only — not unsynced local edits. Use with periodic polling or after - * {@link sync} to detect when another device has pushed changes. - * @since 1.4.0 - * @returns {Promise} Non-negative revision count (0 if never synced) - */ - getCurrentDataRevisionCount: function() {}, - + * Resolve an attachment to a WebView-loadable URL (`file://`, `http(s):`, or host-specific). + * **String `fileName`:** basename only (e.g. `photo.filename`). Lookup order, first hit wins: + * 1. `attachments/draft/` — unsaved capture (formplayer preview) + * 2. `attachments/synced/` — canonical committed / downloaded copy + * 3. `attachments/pending/` — queued for upload (fallback only) + * Legacy locations (`attachments/` and `attachments/pending_upload/`) are also checked. + * Path segments and ".." are rejected. + * **`AttachmentDisplayDescriptor`:** `{ filename }` basename only (same lookup as a string argument). + * @returns {Promise} Display URL, or `null` if none + */ + getAttachmentUri: function (fileName) {}, + + /** + * Base `file://` URL for the canonical attachments directory (trailing slash). + * Returns the `synced/` subfolder — only committed/downloaded files are + * iterable from here. Drafts and the upload queue are excluded by design so + * custom apps can safely list this directory. + * **Breaking change (v2 layout):** this used to return the `attachments/` + * parent directory, which mixed committed files with `draft/` and + * `pending_upload/` subfolders. Custom apps that iterate this URL will now + * see only fully-committed attachments. + * @returns {Promise} e.g. `file:///.../attachments/synced/` + */ + getAttachmentsUri: function () {}, + + /** + * Base `file://` URL for the custom app bundle root (`DocumentDirectory/app/`, trailing slash). + * @returns {Promise} App directory URL for extensions, question_types, etc. + */ + getCustomAppUri: function () {}, + + /** + * Primary `file://` URL for downloaded form specs (`DocumentDirectory/forms/`, trailing slash). + * Some bundles also use files under the custom app `forms/` subdirectory. + * @returns {Promise} Forms directory URL + */ + getFormSpecsUri: function () {}, + + /** + * Persist an observation **without opening Formplayer** (headless write). + * Intended for custom apps that create or update records programmatically. + * Uses the same persistence path as a Formplayer submit, including promotion + * of any referenced draft attachments. Provide `observationId` to update an + * existing row; omit it to create a new one. + * @since 1.3.0 + * @param {PersistObservationInput} input - The observation to persist + * @returns {Promise} The stored observation id and data + */ + persistObservation: function (input) {}, + + /** + * Trigger a synchronization with Synkronus (pull + push). + * Resolves when the sync completes; rejects if a sync is already in progress + * or the sync fails. Attachments are excluded by default for speed. + * @since 1.3.0 + * @returns {Promise} The final data version after sync + */ + sync: function (options) {}, + + /** + * Probe connectivity to the configured Synkronus server (`GET /health`). + * Never rejects for an offline device — returns `{ online: false }` so callers + * can branch on connectivity without try/catch. Suitable for the + * "verify subject details when online, fall back when offline" pattern. + * @since 1.3.0 + * @returns {Promise} The current connectivity status + */ + getConnectivityStatus: function () {}, + + /** + * Read the device's last-known Synkronus data revision (`current_version` + * from the most recent successful sync). Reflects server-stream alignment + * only — not unsynced local edits. Use with periodic polling or after + * {@link sync} to detect when another device has pushed changes. + * @since 1.4.0 + * @returns {Promise} Non-negative revision count (0 if never synced) + */ + getCurrentDataRevisionCount: function () {}, }; // Make the API available globally in browser environments diff --git a/formulus/assets/webview/placeholder_app.html b/formulus/assets/webview/placeholder_app.html index 52454cc61..a96d89fac 100644 --- a/formulus/assets/webview/placeholder_app.html +++ b/formulus/assets/webview/placeholder_app.html @@ -5,8 +5,8 @@