# Static Fields → Metadata-Driven Rendering (incl. Autocomplete Meta)

- **Date:** 2026-07-15
- **Status:** Complete
- **Status Date:** 2026-07-17
- **Phases:** 9
- **Phases Complete:** 9
- **Notes:** Depends on `docs/2026-07-15-autopopulate-fields-fix.md` (shipped). Phases 1–6
  built 2026-07-16; migrations 009 (meta columns + unique indexes + vw_stocklists label)
  and 010 (Parent Stocklist acceptance seed) written, awaiting apply. Phase 7 acceptance
  test gates phase 8 — no HTML is deleted until it passes. `field_active` stays honoured
  for static rows (acceptance test relies on deactivation removing the field); the admin
  UI's read-only lock on static rows is the guard against accidental deactivation instead.
  **Scope addition (user feedback 2026-07-16):** wayleave's meta tables are renamed to the
  common projects column naming by migration 011 (`011_wayleave_field_meta_align.sql`) —
  no aliasing shim in `get_update_form.php`, and wayleave joins the shared admin fields UI
  (module added to `admin_load.php`/`admin_save.php`/nav). 011 also drops the redundant
  wayleave unique index from 009 (`agreement_fields` already had a native unique
  constraint on `field_form_id`). Simplifies phase 8d.

## Plan Phases

- 1 Meta schema migration ✅
- 2 Renderer (JS) ✅
- 3 Options source for status dropdowns ✅
- 4 Load — label resolution ✅
- 5 Save guards ✅
- 6 Admin UI ✅
- 7 Acceptance test — Parent Stocklist ✅ (passed 2026-07-17: renders, autocompletes,
  saves, reloads with label + ID. Findings logged in `improvement-opportunities.md`:
  self-parent hangs the recursive load CTEs — serious, pre-existing; parent/child alert
  copy is projects-specific. Pre-8b action: `accounts.account_fields` has an existing row
  with `field_form_id = 'account_name'` colliding with the static input — the DOM check
  suppresses it today, but the 8b seed will refuse to run until it's resolved.)
- 8 Per-module migration: seed meta + delete HTML ✅
  - 8a projects ✅ (migration 012; checklist passed 2026-07-17. Parity notes: populate
    loops are key-driven and save serializes `.project-edit-form` within
    `#projectEditForm`, both unaffected; clear-button ids derive from helper ids now;
    status options come from `projects.project_status` via options_source.)
  - 8b accounts ✅ (migration 013 — normalises the pre-existing static `account_name`
    row into slot 1; template block deleted; checklist passed 2026-07-17.)
  - 8c stocklists ✅ (migration 014 — pins the db/010 parent row into slot 4; template
    block deleted incl. the commented-out parent input; checklist passed 2026-07-17.
    Note: the hardcoded status select offered only Identified/Complete — the meta
    dropdown shows whatever `stocklists.stocklist_status` contains; trim that table if
    the formerly-commented statuses shouldn't be selectable.)
  - 8d wayleave ✅ (migration 015 — seeds the ten statics, creates the Main Details
    section/category if missing, converts agreement_type's hardcoded JS option list to
    dropdown-options rows; `wayleave_team` added as an options source; the editor
    fetches meta from `get_update_form` in parallel with `wayleave_load` and renders
    statics via the shared builder; `WL.autocompleteField` + `.wl-*` autocomplete CSS
    retired; dynamic wayleave fields support autocomplete meta; labels align to the
    standard app `form-label` sizing. Checklist passed 2026-07-17.)
- 9 Docs ✅ (CLAUDE.md "Field Meta System" section rewritten; in-place retirements
  recorded in `docs/unusedfiles.md`)

## Goal

Move the hardcoded "Main Details" static-field HTML out of the editor templates and render
those fields from the `*_fields` meta tables — one rendering pipeline for static and dynamic
fields, with autocomplete driven by metadata. After this, the only form markup left in
`html_body_*edit*.php` is the page shell (tabs, panels, tool sidebars, modals) and the
entity-ID hidden input.

## Principles

1. **One live definition per field at any moment.** The renderers' DOM-presence check
   (`account_edit_v2.js:177` and equivalents) skips any meta field whose `field_form_id`
   already exists on the page. That check is **all-or-nothing per field** — it prevents
   duplicates, but it equally prevents the renderer from repairing partial HTML (it can never
   add a missing helper input next to a hand-written hidden input). Therefore meta rows for a
   static field are seeded **in the same change that deletes that field's HTML** — never
   before. The DOM check is demoted to a safety net (and the revert path: restore the HTML
   block and the meta rendering auto-suppresses), not load-bearing architecture.
2. **Static fields are system fields.** Defined by migrations only. Visible in admin
   read-only — so admins can see which names/form ids are taken, preventing duplicate field
   creation and explaining unique-check failures on `field_form_id` — but never
   creatable/editable/deletable through the UI. Their *presentation* (section, category,
   order, input type, autocomplete wiring) lives in the DB; their *storage* (entity table
   column, `$staticFields` whitelist, load view/query) stays code-defined. Admin cannot invent
   new static columns, only surface existing ones.
3. **Autocomplete is set at field creation, never updated.** Stored values are bare numerics;
   re-pointing `field_autocomplete_type` after data exists silently reinterprets IDs as a
   different entity (`user_id 1 != project_id 1`). Enforced server-side in `admin_save.php`,
   not just hidden in the UI. Possible later relaxation: allow the change when the field has
   zero stored values.

## Work items

### 1. Meta schema (migration `db/009_field_meta_autocomplete.sql`)

On all four meta tables (`projects.project_fields`, `accounts.account_fields`,
`stocklists.stocklist_fields`, `wayleave.agreement_fields`):

```sql
ALTER TABLE <schema>.<entity>_fields
    ADD COLUMN field_autocomplete_type      character varying(30),   -- NULL = not autocomplete
    ADD COLUMN field_autocomplete_helper_id character varying(100),  -- form id of the visible label input
    ADD COLUMN field_options_source         character varying(50);   -- registered options key (status dropdowns)
```

- `field_autocomplete_type`: one of the existing `data-autocomplete` types (`usernames`,
  `companyname`, `accountname`, `projectname`, `stocklistname`, `wayleavename`,
  `landregtitle`). NULL means "not autocomplete" — a separate boolean is redundant.
- `field_autocomplete_helper_id`: explicit, not a naming convention — existing pairs aren't
  uniform (`project_manager` → `project_manager_name`, but `account_id` → `account_name`).
- `field_options_source`: see item 3.
- **Unique index on `field_form_id` per meta table**, with a pre-check query for existing
  collisions shipped in the migration notes.
- Also in this migration: append `COALESCE(pp.stocklist_name,'') AS parent_stocklist_name` to
  `stocklists.vw_stocklists` (self-join) — label source for the acceptance test (item 7).

### 2. Renderer (JS)

- Shared pair builder in `main.js` emitting the projects-pattern markup: visible helper input
  (`data-autocomplete` / `data-autocomplete-target`), clear button, hidden input using
  `field_form_id`.
- In the `$.each(data.fields, ...)` switch of `project_edit_v2.js`, `account_edit_v2.js`,
  `stocklist_edit.js`: when `field_autocomplete_type` is non-null, render via the builder
  instead of a plain input. After the field loop, call `bindAutocompleteInputs()` and
  `setupAutoclearButton()` for each rendered pair.
- **Do not filter to `field_type = 'dynamic'`** — a static-typed meta row absent from the DOM
  must render through the same path. This is what makes enabling a backend-supported static
  field a pure meta-row insert.
- Upgrade the DOM-check skip from `console.log` to a visible console warning — a silently
  skipped field is the symptom of a form-id collision.

### 3. Options source for status dropdowns (`fn/get_update_form.php`)

The status selects' options are hardcoded `<option>` lists today, but canonical values live in
status tables (e.g. `projects.project_status`). Copying them into `*_field_dropdown_options`
would duplicate and drift. Instead: `field_options_source` holds a registered key
(`project_status`, `account_status`, `stocklist_status`) that a PHP-side whitelist in
`get_update_form.php` maps to a `(table, value_col, label_col)` query — never raw SQL from the
DB. Options are returned in the existing `options` array shape so renderers need no special
casing. Add `wayleave` as a fourth `type` in `get_update_form.php` while in here.

### 4. Load — label resolution

Meta-driven autocomplete fields store a bare ID (EAV value or static column); nothing turns it
back into a name on load. Add `resolveAutocompleteLabel($dbh, $type, $value)` to
`global_functions.php` (per-type lookups mirroring `fn/autocomplete.php`). Each `*_load.php`
resolves labels for fields with autocomplete meta — reading the value from wherever the
field's type says it lives (static column via `data_static`, or EAV) — and returns a
`data_autocomplete_labels` map keyed by `field_autocomplete_helper_id`; the populate code
writes those into the helper inputs. Fields whose label already arrives via a view alias need
no resolver entry.

### 5. Save guards

- In each `*_save.php`: fetch the set of `field_autocomplete_helper_id` values up front and
  skip those POST keys outright — a label can never be swallowed as an EAV text value, and the
  wasted per-key meta lookup goes away.
- `admin_save.php`: reject UPDATE/DELETE against `field_type = 'static'` rows; reject changes
  to `field_autocomplete_type` / `field_autocomplete_helper_id` on any update (set-once rule);
  reject creation of fields whose `field_form_id` **or helper id** collides with an existing
  form id or helper id (the unique index covers `field_form_id`; helper-id collisions need
  this application-side check).

### 6. Admin UI (`admin_fields` page, `fn/admin_load.php` / `fn/admin_save.php`)

- Static rows: listed, visually distinguished ("system field"), read-only, no delete.
- Create form: "Autocomplete type" dropdown (empty = none) + "Helper form id" input. Not shown
  on edit.
- Replace hardcoded static-column lists in admin JS (e.g. `admin_fields.js:50`) with the
  meta-driven list.
- Clear error messaging when a create fails the form-id/helper-id uniqueness checks.

### 7. Acceptance test — Parent Stocklist (before any HTML migration)

With **no template change**, insert an active `field_type='static'` meta row for
`parent_stocklist_id` (`field_autocomplete_type = 'stocklistname'`, helper
`parent_stocklist_name`, Main Details section): the field appears in the stocklist editor,
autocompletes, saves (already in `stocklist_save.php` `$staticFields`), and reloads with
label + ID. Deactivating the row removes it. This works precisely because the field has full
backend support but zero HTML (commented out in the v2 template; the route loads v2 only —
`fn/routes.php:105`). Passing this test proves the pipeline before touching any existing HTML.

### 8. Per-module migration: seed meta + delete HTML **atomically**

One module at a time — projects first (it's the template others copy), then accounts,
stocklists, wayleave last. Per module, one change containing:

1. **Parity audit first**: grep every static field id through the module's JS; diff the
   meta-rendered DOM against the hardcoded markup (ids, names, classes, `required`, grid
   columns, autocomplete attrs, clear buttons) — the populate loops, save endpoints and any
   `$('#field_id')` references must keep working untouched.
2. **Seed migration** (`db/0NN_<module>_static_field_seed.sql`): one `field_type='static'` row
   per field, `INSERT ... WHERE NOT EXISTS` on `field_form_id`, attached to the module's
   "Main Details" section/category **looked up by name/ref, not hardcoded id** (section ids
   are data and differ between environments), `field_display_order` preserving today's visual
   order, autocomplete metadata on lookup fields, `field_options_source` on status fields.
   Fails loudly on collision with an admin-created dynamic field — resolve manually.
3. **Delete the module's static-field HTML block** in the same commit (keep the entity-ID
   hidden input and the section shell divs the renderer targets).
4. Run the module's testing checklist before starting the next module.

Revert path per module: restore the HTML block — the DOM check auto-suppresses the meta
rendering.

Fields per module:

| Module | Fields to seed | Stays in template |
|---|---|---|
| Projects | `project_name`, `account_id` (+helper), `project_manager` (+helper), `company_id` (+helper), `parent_project_id` (+helper), `project_status_id` | `project_id` hidden (PHP-injected key) |
| Accounts | `account_name`, `account_manager` (+helper), `company_id` (+helper), `parent_account_id` (+helper), `account_status_id` | `account_id` hidden |
| Stocklists | `stocklist_name`, `stocklist_manager` (+helper), `company_id` (+helper), `account_id` (+helper), `stocklist_status_id` (`parent_stocklist_id` already live from item 7) | `stocklist_id` hidden |
| Wayleave | Replace `WL.autocompleteField` / JS-variable rendering with the shared pipeline; retire `.wl-autocomplete-wrap` / `.wl-clear-btn` CSS | `agreement_id` |

Not fields (unchanged): `geom` (map-managed), `cover_image_url` (upload widget),
`created_*` / `modified_*` (server-set), projects `stocklist_id` (set only via
create-from-stocklist).

### 9. Docs

Update `CLAUDE.md` architecture notes (static fields now meta-driven) and
`docs/unusedfiles.md` for any template sections that become dead.

## Testing checklist (per module before moving to the next)

- Editor renders identically: every field present, correct order/section, labels, required
  flags; status dropdown options match the status tables.
- Load: all values populate, including autocomplete labels + hidden IDs.
- Save: every field persists via the static UPDATE path; journal/history written; no stray EAV
  rows created for static fields.
- Autocomplete: search, select, clear, save, reload for each lookup field.
- Admin: static rows visible read-only; creating a dynamic field with a taken form id or
  helper id fails with a clear message; autocomplete settings not editable post-create.
- Regression: list pages, embedded tables, account header ("Details From: …"), premise-list
  naming, create-project-from-stocklist modal, opportunity editor (shares the stocklist field
  API — dynamic fields only, no static form of its own), PDF export if it reads these inputs.

## Risks

- **Meta becomes load-bearing for core fields**: a missing/deactivated static row silently
  drops a core field from the form. Mitigate: ignore (or force true) `field_active` for
  static rows in `get_update_form.php`; the per-module revert path covers the rest.
- **Unique-index migration fails on existing collisions** — pre-check query shipped with the
  migration notes.
- **Section/category ids differ between environments** (they're data): seed migrations look
  them up by name/ref.
- **Hidden JS coupling**: 439KB `project_edit_v2.js` references static-field ids throughout;
  the per-module parity audit is the guard — grep each field id before deleting its markup.
