Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
6d09760
fix(formulus): Limiting keyboard re-popping up during text editing
r0ssing Jun 30, 2026
774cd1c
feat(desktop): improved bundle handling, stability
r0ssing Jun 30, 2026
7355b2d
chore: formatting
r0ssing Jun 30, 2026
a7a5fc5
chore: formatting
r0ssing Jun 30, 2026
fd744eb
chore: formatting
r0ssing Jun 30, 2026
fd0fc92
Merge pull request #689 from HelloSapiens/fix/desktop-bundle-handling
r0ssing Jun 30, 2026
6629c59
Merge branch 'dev' into fix/formulus_keyboard_fix
r0ssing Jun 30, 2026
b62b3d4
Merge pull request #688 from HelloSapiens/fix/formulus_keyboard_fix
najuna-brian Jun 30, 2026
d442d9a
feat(formulus): add support for i18n
r0ssing Jun 30, 2026
eaafe36
chore: fix tests
r0ssing Jun 30, 2026
4a85298
wip
r0ssing Jun 30, 2026
c7f8fa2
wip
r0ssing Jun 30, 2026
c4a7266
feat(formplayer): better label handling and i18n
r0ssing Jun 30, 2026
b5c6b28
chore: formatting
r0ssing Jun 30, 2026
0327180
docs(ode): added user guide on form localizations
r0ssing Jun 30, 2026
4f74348
chore: formatting (and added editorconfig for lineendings)
r0ssing Jun 30, 2026
d14bc2d
chore: formatting
r0ssing Jun 30, 2026
f6c58ee
Merge pull request #690 from HelloSapiens/feat/i18n
r0ssing Jun 30, 2026
f5d6f2b
fix(formplayer): include non-input elements in counts
r0ssing Jun 30, 2026
d47f318
Merge pull request #691 from HelloSapiens/fix/label-only-screens-are-…
r0ssing Jun 30, 2026
0b95e62
feat(formplayer): add Likert scale and duration question types
najuna-brian Jul 2, 2026
1e0bf59
refactor(formplayer): polish Likert UX and fix schema typing
najuna-brian Jul 2, 2026
b40a47e
fix(formplayer): type Likert story helper with LikertConfig
najuna-brian Jul 2, 2026
8841981
Merge remote-tracking branch 'upstream/dev' into feat/formplayer-like…
najuna-brian Jul 2, 2026
e12c9e6
docs(formplayer): trim Likert/duration README to a docs-site pointer
najuna-brian Jul 2, 2026
7bc42f8
fix(formplayer): equal-width Likert layout and N/A validation
najuna-brian Jul 2, 2026
06ebe97
style(formplayer): fix Prettier formatting for Likert files
najuna-brian Jul 2, 2026
f6c06f7
Merge pull request #695 from najuna-brian/feat/formplayer-likert-and-…
IamLRBA Jul 2, 2026
c2e71b9
feat(formplayer): add locale utils + tests
r0ssing Jul 3, 2026
17908ed
feat(formulus): add forms and UI locale selectors
r0ssing Jul 3, 2026
389b432
fix(desktop): formatting, tauri casing support
r0ssing Jul 3, 2026
9306ab4
fix(formulus): Fix unrelated react-native / jest incompatibility by p…
r0ssing Jul 3, 2026
4022bd6
Merge pull request #696 from HelloSapiens/feat/split-language-selector
r0ssing Jul 3, 2026
3a6ad9c
feat(desktop): Import UI enhancements
r0ssing Jul 11, 2026
fdc4ff0
feat(desktop): Add observations overview tab
r0ssing Jul 11, 2026
4ab4088
chore: formatting
r0ssing Jul 11, 2026
0fe023a
chore: formatting
r0ssing Jul 11, 2026
faddee8
feat(desktop): Add charts and map on overview page
r0ssing Jul 12, 2026
8279262
chore: lint
r0ssing Jul 12, 2026
f411a65
chore: make clippy happy
r0ssing Jul 12, 2026
5091cca
Merge pull request #697 from HelloSapiens/feat/desktop_enhancements
r0ssing Jul 12, 2026
9181b84
feat(desktop): Add size selector to custom app preview
r0ssing Jul 12, 2026
1d009fc
feat(desktop): Add size selector to form preview
r0ssing Jul 12, 2026
a205625
feat(desktop): Update size to match device pixels (simulated)
r0ssing Jul 12, 2026
5442c4d
Merge pull request #698 from HelloSapiens/feat/custom_app_enhancements
r0ssing Jul 12, 2026
3570b78
fix(desktop): fixing ui language selection
r0ssing Jul 13, 2026
d142af2
chore(desktop): linting and formatting
r0ssing Jul 13, 2026
bc2cdce
Merge pull request #699 from HelloSapiens/fix/desktop-uilanguage
najuna-brian Jul 13, 2026
7d93cea
feat(desktop): Accordion panels on obs editor
r0ssing Jul 13, 2026
393d140
Merge pull request #700 from HelloSapiens/fix/desktop-observation-editor
najuna-brian Jul 13, 2026
aa67208
feat(portal): add one-time Formulus onboarding QR
najuna-brian Jul 13, 2026
8cfe840
fix(synkronus): align user delete route with OpenAPI
najuna-brian Jul 13, 2026
b483006
fix(portal): use existing user delete endpoint
najuna-brian Jul 14, 2026
433f512
Merge pull request #701 from najuna-brian/feat/portal-formulus-onboar…
r0ssing Jul 14, 2026
144b0fe
feat(desktop): Text changes to messages
r0ssing Jul 14, 2026
ec38eaf
feat(desktop): version bumped to v1.2.0
r0ssing Jul 14, 2026
37f38d5
feat: version bumped to v1.2.0
r0ssing Jul 14, 2026
2c5c738
Merge pull request #702 from HelloSapiens/feat/desktop_messages
najuna-brian Jul 15, 2026
7a38dcc
feat(desktop): Add app_bundle upload functionality
r0ssing Jul 15, 2026
5762f40
Merge pull request #703 from HelloSapiens/feat/desktop_upload_bundle
najuna-brian Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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).

Expand All @@ -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`).
247 changes: 247 additions & 0 deletions FORM_LOCALIZATION_GUIDE.md
Original file line number Diff line number Diff line change
@@ -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.<locale>`** 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.<locale>.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.<locale>.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
12 changes: 8 additions & 4 deletions desktop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ Persisted per profile via `upsertProfileRemote` / Rust `ServerProfile`.
| Off | `bundles/active/app/` | `bundles/active/forms/` |
| On | `bundles/dev-local/app/` (mirror) | `bundles/dev-local/forms/` (mirror if `<folder>/forms` exists) |

Synk downloads and **Refresh from server** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified.
Synk downloads and **Download & apply** on the Bundles page only touch `bundles/active/`. The source folder on disk is **never** modified.

**Bundle download:** `download_and_apply_app_bundle` (TS: `tauriClient.downloadAndApplyAppBundle()`) — native Rust HTTP download; no zip bytes over IPC. Progress events: `bundle/apply-progress`, `bundle/index-rebuild`. Observation index rebuild runs in the background after apply.

### UI

Expand All @@ -64,13 +66,15 @@ On success, Zustand bumps `devMirrorGeneration` so embeds and form lists reload.
- `src/hooks/useDeveloperMode.ts` — profile read/write, refresh, generation counter from store.
- `src/components/DeveloperModePanel.tsx` — full vs banner UI; auto-mirror `useEffect` only on `variant="full"`.
- `src/lib/bundleLayout.ts` — `bundleSegment()`, `bundleFormsRel()`.
- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`.
- `src/store/useCustodianStore.ts` — `devMirrorGeneration`, `devBusy`, `devError`, `refreshDevMirror`, `bundleActivity`.
- `src/lib/bundleTauriEvents.ts` — bundle apply progress listeners.

### Key Rust

- `profile_developer_mode`, `bundle_segment`, `bundle_form_roots_for_ctx`
- Dev-aware: `list_active_bundle_forms`, `read_bundle_form_spec`, `get_active_bundle_forms_file_base_url`, `scan_bundle_custom_question_types`, `bundle_app_config_path`
- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree`
- `download_and_apply_app_bundle` — streamed Synkronus zip download + apply (never pass large binaries through WebView IPC as `number[]`)
- Tests: `validate_custom_app_dev_source_requires_index_html`, `mirror_custom_app_dev_folder_copies_tree`, `apply_app_bundle_zip_at_workspace_writes_state_and_active`

### Errors

Expand All @@ -81,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.

---
Expand Down
10 changes: 6 additions & 4 deletions desktop/docs/UI_FEEDBACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ Single source of truth for how the shell surfaces status, errors, and confirmati

## Progress banner (full-width)

**Use for:** long-running sync and import jobs only.
**Use for:** long-running sync, import, and **app bundle download/apply** jobs.

- Renders below the mode switch / dev bar in `Shell`.
- Shows spinner + status text; dismissible but reappears on next activity.
- Do not use for quick actions (save, auth, bundle download).
- Shows spinner + status text; optional determinate progress bar when `bundleActivity.total > 0`.
- Dismissible but reappears on next activity.
- Bundle apply: Rust emits `bundle/apply-progress` and `bundle/index-rebuild`; `bundleActivity` in `useCustodianStore` drives the banner.
- Do not use for quick actions (save, auth, dev mirror refresh).

## Toasts (bottom-right stack)

Expand All @@ -32,7 +34,7 @@ Single source of truth for how the shell surfaces status, errors, and confirmati

**Use for:** destructive actions, import-with-issues, closing unsaved observation tabs.

- `confirmDestructiveAction()` for destructive flows (always strict production-tier wording).
- `confirmDestructiveAction()` for destructive flows (clear, profile-scoped wording).
- `confirm()` from `@tauri-apps/plugin-dialog` for save-anyway / import-anyway / tab discard (Save / Don't save / Cancel via separate flows).

## Retired patterns
Expand Down
8 changes: 8 additions & 0 deletions desktop/docs/screens/import.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ Bring external JSON observation files into the active profile’s local reposito

- Store: `import` flow via `tauriClient.importObservations`, refresh `loadObservations` / `loadHealth` after import
- Active profile determines target SQLite repository

## Observation indexes

When the active app bundle declares `observationIndexes` in `app.config.json`:

1. Import writes observations in batches (default 2000 rows per IPC call); intermediate batches skip index work.
2. After the final batch commits, Rust schedules **one** coalesced background full index rebuild (`bundle/index-rebuild` progress events). Overlapping rebuild requests while one is running are merged into a single follow-up pass.
3. Sync pull uses incremental indexing per page instead (no full rebuild after each pull).
Loading
Loading