Formly migration (Phase 4): retire bespoke webform reimplementation for @ngx-formly
Web-modernisation roadmap (`ichec-angular-core/plan.md`, Tier 2 Phase 4). The dynamic-form layer is a partial hand-rolled reimplementation of what `@ngx-formly` already provides; consolidate onto formly so form config is declarative and the bespoke webform code is retired. Old/new stacks coexist during migration; land incrementally behind existing views. Consumed by the marinerg frontend via a pinned release **per slice**.
## Current bespoke surface (what formly replaces)
The `field_type` → control mapping is hand-rolled and **duplicated across four templates that have already drifted**:
- `view/dynamic-form.component` + `detail/form-field-detail.component` — end-user **fill** inputs
- `populated-form/populated-form.component` — read-only **display** of submitted values
- `edit/form-field-edit.component` — admin **builder** per-type default-value inputs
- `detail/form-field-detail.component` — field metadata read
Form-class scaffolding: `DynamicFormForm`, `PopulatedFormForm`, `FormGroupForm`, `FormFieldForm`, `FormFieldValueForm`, `form-field-table`, `dynamic-form-builder`, `group-edit`.
Static reactive forms implementing `IForm<C,D,U>` (`models/core.ts`): `AddressForm`, `GroupForm`, `OrganizationForm`, `UserForm`, `UserCreateForm`, `FeedbackForm`, `SearchForm`.
**Drift bugs the consolidation fixes by construction** (evidence the duplication is a liability):
- `form-field-detail` (fill): **no `RICH_TEXT` case** → rich-text fields are un-fillable.
- `form-field-edit.html` `@case(FieldType.Text || FieldType.RichText)` → always evaluates to `Text`; the RichText branch is dead.
- SELECTION/INTEGER/CHAR fill inputs use literal `formControlName="key"` / `name="key"` strings instead of the bound field key.
- `populated-form.html` `href="value.asset"` is a literal string, not a binding.
## Custom field types (the substrate)
- **`quill` rich-text** (`RICH_TEXT`): ~30-line formly wrapper. NOTE `ngx-quill` is **not yet** in the library `package.json` (the roadmap's "ngx-quill already adopted" refers to the marinerg frontend) — adding + version-verifying it is part of Slice 0.
- **`file-upload`** (`FILE`): formly wrapper over the existing `lib-file-upload` component.
## Slices (each independently released; marinerg pin bumped per slice)
- [x] **Slice 0 — Formly foundation + custom field types** (prereq). Add `@ngx-formly/core` + `@ngx-formly/material` (+ `ngx-quill`), versions verified live vs Angular 22. Register the standard type map (input/textarea/checkbox/select/number → Material). Build + spec the `quill` and `file-upload` custom types. Ships behind public-api, no existing view touched. (Roadmap step 1: "author new forms against formly first — proves the custom field types".)
- [x] **Slice 1 — Dynamic fill/display** — DONE, released in **1.1.0** (MR !112; consumer cutover frontend !106). Single pure mapper `formly-mapping.ts` replaced the four `@switch` ladders; `form()` stays id-keyed so `FormService`/consumer submit path unchanged. Original brief kept below for history:
**Slice 0 gives you (merged, on the public API):** `provideIchecFormly()`, plus custom types `quill` (rich text) and `file` (wraps `lib-file-upload`). Import from `ichec-angular-core`.
**Build one pure mapper** `formlyFieldsFromForm(form: IFormDetail): FormlyFieldConfig[]` (new file `views/base/dynamic-form/formly-mapping.ts`) — replaces the four duplicated `@switch` ladders. Per `IFormFieldDetail` → `FormlyFieldConfig`:
| field_type | formly `type` | props / notes |
|---|---|---|
| CHAR | `input` | |
| TEXT | `textarea` | |
| RICH_TEXT | `quill` | Slice 0 |
| INTEGER | `input` | `props.type: 'number'` |
| BOOLEAN | `checkbox` | |
| SELECTION | `select` | `props.options = resolveOptions(field.options)` |
| FILE | `file` | Slice 0 |
Common props: `label`, `required`, `description`, `placeholder: field.default`, tooltip. A form group → a `FormlyFieldConfig` with `fieldGroup` + label/description.
**Keying + value round-trip (the gotcha):** the current fill FormGroup keys controls by `field.id.toString()` (see `detail/form-field-detail.component.ts` `key` getter), **not** `field.key`. Submitted values are `IFormFieldValueCreate { value, field: <field.id>, asset }` (see `edit/form-field-value-form.ts`). So key the formly model by field id and write a submit adapter formly-model → `IFormFieldValueCreate[]`. `SELECTION` options are a URI-encoded JSON string: `JSON.parse(decodeURIComponent(field.options))` → `IOption[]` (`{key: displayName, value}`) — reuse `resolveOptions`.
**Files to change:** `view/dynamic-form.component.{ts,html}` (fill host → `<formly-form>`), `detail/form-field-detail.component.{ts,html}` (the per-field switch — retire), `populated-form/populated-form.component.{ts,html}` (read-only display switch → formly read-only or a small display map). `edit/populated-form-form.ts` submit path adapts to the mapper. Keep old components until verified (Slice 4 deletes them).
**This fixes, provably:** RICH_TEXT now fillable; SELECTION/INTEGER/CHAR bind to the real key not literal `"key"`; populated-form `href` binding.
**Verify:** mapper unit tests per field_type; a component round-trip test (all 7 types render + submit → correct `IFormFieldValueCreate[]`). **Cutover:** release library; in `marinerg/frontend` add the four formly deps + `provideIchecFormly()` to app config, `npm install ichec-angular-core@<ver>` (lockfile), verify the application wizard submits end-to-end.
Optional follow-on: Django emits formly-adjacent field config so the mapper shrinks.
- [x] **Slice 2 — Dynamic builder** — DONE (MR !114). Decision: **retain** the builder (a bespoke master-detail schema-*authoring* UI — group/field list, ordering, choice builder — not a formly-shaped problem). Fixed the real defect: the per-type default-value editor drift (`@case(Text || RichText)` collapsed to Text; no Integer case) so RICH_TEXT + INTEGER defaults now render. Release bundled into the next patch.
- [ ] **Slice 3 — Static entity forms → schema-driven formly (DIRECTION 1, decided 2026-07-18 w/ James).** Do NOT hand-migrate each family to a hand-written formly config — that moves boilerplate (form-def mirrors the model) rather than removing it. **Prerequisite: `ichec-django-core#50`** (adopt the generated OpenAPI types, resolve serializer mismatches). Then generate formly field skeletons from the committed `schema.yaml` at build time (field→widget, `maxLength`→validator, enum→select) with small per-entity override hooks for layout/labels; a thin `FormlyEntityForm<C,D,U>` adapter consumes the generated fields and preserves the `IForm<C,D,U>` contract the generic edit views call. Entities: Address (composed into Organization) → Organization → User/UserCreate → Group → Feedback; **exclude `SearchForm`**. Stated end-state (option 2, later): drive both static and dynamic forms from the DRF `OPTIONS` metadata the app already fetches. Old/new coexist until Slice 4.
- [ ] **Slice 4 — Retire bespoke scaffolding**. Once dynamic + static families are migrated, delete the `FieldType` rendering enums in `models/form.ts`, the duplicated templates, and the now-unused hand-rolled `IForm` plumbing. Final release.
Order rationale: 0 (substrate) → 1 (biggest reimplementation + bug cluster) → 3 (independent, parallelizable families) → 2 (meta builder) → 4 (cleanup). **Amended 2026-07-18:** Slice 2 done; Slice 3 reframed schema-driven (direction 1) and now depends on `ichec-django-core#50` landing first.
## Cross-cutting prerequisite — marinerg backend form-shape (found in #50 Part D, 2026-07-19)
The marinerg backend still emits the **old dynamic-form read shape**: `PopulatedForm` value `field` as a bare PK, `FormField.template` a bool, no `asset`. angular-core (3.0.0) already carries the django-core form-serializer changes — nested value `field`, `asset`/`template` as download URLs. **Write** form shapes already match across both libraries.
The marinerg frontend types `.form` onto angular-core's `ApiForm`/`ApiPopulatedForm` (contract preserved, no regression), but the app's dynamic forms only become **runtime**-correct once the marinerg backend bumps `ichec_django_core` to the version with those serializers. **Do that bump before/with the dynamic-form formly cutover verification** (and re-vendor the frontend schema).
---
_Delivery principle: foundations first, no deadline hacks — build to full quality; if quality and the date conflict, the date flexes, never the foundation. Do not work around a foundation — raise the tension._
Sizing: w5 — worked as a task sequence under this one issue; each slice is an independent release + consumer pin bump.
---
### Scope boundary (2026-07-19) — dataset-template excluded
The dynamic-form path serves **two worlds**: application forms (`AccessCall`/`AccessApplication` — flagship, KEEP) and dataset metadata forms (`DatasetTemplate`/`Dataset` — retired to CKAN scheming; see marinerg-i/frontend#72). Migrate the access-form path in full; **exclude dataset-template** (deleted, not migrated). The retire *shrinks* this issue's surface, it does not block it.
issue