Skip to content
All articles

Lookups Master Data — config-driven use case

Test post: a FinAlyzer plan document used to check how long Markdown with tables, code blocks and nested headings renders on the blog.

Add a Lookups master-data screen under Platform Settings, built entirely on the existing config-driven runtime (CrudConfigWrapper):

  • a Lookup Code dropdown above a grid of that lookup’s entries (Key, Value);
  • Add Entry (header button) adds a Key / Value to any USER lookup;
  • Edit (Value only) and Delete an entry from the row menu, for USER lookups only;
  • SYSTEM lookups are view-only.

No new React components: every gap is closed by extending the runtime’s config vocabulary (types, schema validator, request/response utils, mutation hooks), so any future config can use the same keys.

Out of scope:

  • creating a new lookup table (POST /lms/api/v1/lookups) — deferred by the owner (Decided #7);
  • deleting a lookup table, renaming a lookupCode, changing a locale (no API);
  • backend changes (the contract is followed as given, Decided #6);
  • app-owned crud configs (registerCrudConfigModules, still pending in docs/plan/archived/app-owned-crud-configs/);
  • the Lease Closure Tracker Playwright work (its #7 resumes after this plan).

Background docs: requirements, config-driven design.


1 — What we know (current state)

Purpose

Lookups are the tenant’s key/value lists behind the app’s dropdowns and labels (lease types, payment frequencies, GL account tags, closure-checklist categories, …). The app can only read them today. Admins need to add, reword and remove entries in the lists they own (USER), without touching the lists the code depends on (SYSTEM).

Backend contract (Decided #1, #2, #6)

OperationMethodURLBodyResponse
List all tablesGET/lms/api/v1/lookups— (Accept-Language: en)bare array of lookup tables
Read one by codeGET/lms/api/v1/lookups/code/{lookupCode}— (Accept-Language: en)bare lookup table
Modify entriesPUT/lms/api/v1/lookups/{id}/modification{ lookupData: [{key, value}] }bare lookup table
Create tablePOST/lms/api/v1/lookupsLookupTableDTOnot used (Decided #7)
  • The PUT replaces the whole lookupData list. The contract’s samples show it: the GET and PUT samples share id a1b2c3d4-…, and entries missing from the PUT body are missing from its response.
  • A lookup table is { id, lookupCode, lookupData[], listType: SYSTEM | USER, appLocale, tenantName, createdAt, updatedAt, createdBy, updatedBy }.
  • SYSTEM lookups cannot be modified; only USER lookups can (Decided #8).

Runtime architecture today (the parts this plan uses)

platformSettings.json leaf ──► CrudConfigWrapper(<slug>_config.json, hydrated + schema-validated)
  ├─ CrudConfigFilterBar ── list.filters[] (dropdown, options.api, default_first)   ✅ reused
  │     └─ fetchCrudConfigFilterOptions ── ApiService.get(url)                      ❌ no headers
  ├─ useCrudConfigListQuery ── createGetQueryHook (one shared hook for all configs)
  │     ├─ URL {token} ← filter values (constructURL binds :lookupCode)             ✅ reused
  │     ├─ static `headers` per hook factory                                        ❌ not per config
  │     └─ transformCrudConfigListResponse ── rows_path / rename / flatten / defaults
  │                                            rows lose the parent record          ❌
  ├─ buildCrudConfigListRows ── row_level_actions[].conditions (equals)             ✅ reused
  ├─ CrudConfigToolbar ── one `add` button                                          ✅ reused
  └─ CrudConfigListDialogs
        ├─ AddCrudConfigItem   ─► useCrudConfigAddMutation    (always POST, form values + filters)
        ├─ EditCrudConfigItem  ─► useCrudConfigEditMutation   (always PUT,  form values + row)
        └─ DeleteConfirm       ─► useCrudConfigDeleteMutation (always DELETE, row values)
              all three ─► createCrudConfigDynamicMutationHook ─► runCrudConfigDynamicMutation
                            sends ONE row's values; never the rest of the list   ❌
  • Dropdown fields (FieldApi) already support headers and response_transformation.rename; the label falls back to the value (transformCrudConfigFieldOptionsResponse).
  • The schema validator rejects unknown keys (SUPPORTED_RESPONSE_TRANSFORMATION_KEYS, SUPPORTED_PAYLOAD_TRANSFORMATION_KEYS, …), so every new key must be registered there.
  • Configs live in packages/bsq-platform/src/config-driven/data/, are auto-discovered, and are tied to a nav leaf through meta.child_slug (Counter Parties is the closest precedent: finlease /lms URLs, mdm_counter_parties screen key, __passthrough__ action keys).
  • Config literals are localized by npm run sync-config-driven-i18n into packages/bsq-shared/src/i18n/locales/*.json (admin.configDriven.<slug>.*).
  • Finlease’s own lookups cache (useGetLookupsQuery, plain useQuery) uses the default staleTime of 0 from new QueryClient() in main.tsx, so it refetches whenever a lookup consumer mounts.

Mocks today

  • apps/finlease/__mocks__/handlers/lookupsHandlers.ts has one handler, GET /lms/api/v1/lookups, returning buildMockLookups(entities) — rebuilt from constants on every request, so nothing can be changed.
  • Every seeded table is SYSTEM except GL_ACCOUNT_TAGS (USER). CLOSURE_CHECKLIST is SYSTEM in the mocks but USER in the backend’s sample.
  • SEEDED_CLOSURE_CHECKLIST_LOOKUP is also consumed by the closure-checklist mocks and the Lease Closure Tracker specs.

Key files (for implementers)

AreaFile
Config typespackages/bsq-platform/src/config-driven/types/crudConfig.types.ts
Schema validator.../config-driven/components/CrudConfig/config/crudConfigSchema.utils.ts
Request / response utils.../config-driven/components/CrudConfig/utils/crudConfigQuery.utils.ts
Payload transforms.../config-driven/components/CrudConfig/utils/crudConfigPayloadTransformation.utils.ts
Query + mutation hooks.../config-driven/queries/CrudConfig.queries.ts
Row conditions.../config-driven/utils/evaluateFieldCondition.ts (rowMatchesActionConditions)
Config data.../config-driven/data/<slug>_config.json (precedent: counterparty_config.json)
MDM access keys.../config-driven/utils/mdmAccessControl.ts
Platform i18npackages/bsq-shared/src/i18n/locales/*.json
Finlease nav leafapps/finlease/src/config/platformSettings.json
Finlease landing action idsapps/finlease/src/utils/adminLandingActionIds.ts
Finlease lookups mocksapps/finlease/__mocks__/handlers/lookupsHandlers.ts, __mocks__/mocks/lms/lookupsData.ts, persist.ts

2 — What we are changing

Product ask

#AskGap vs today
1Pick a lookup from a Lookup Code dropdown; the grid shows its entries.Runtime supports it, except Accept-Language: en on the list and filter-option GETs (G1).
2Add Entry to any USER lookup: form with Lookup Code (dropdown), Key, Value.The PUT needs the table id and the full list (G2); the dropdown must exclude SYSTEM (G3).
3Edit an entry’s Value (Key read-only).Same as #2 (G2); the row must know its lookup code and type (G3).
4Delete an entry, after confirmation (Decided #3).Same as #3.
5SYSTEM lookups are view-only: no Edit / Delete, not offered in Add Entry.Rows don’t carry listType; options can’t be filtered (G3).

Runtime extensions (generic config vocabulary)

GapNew config keyWhat the runtime does with it
G1headers on OperationApiSent with the list GET and with filter-option GETs, the same way FieldApi.headers is sent today.
G3response_transformation.parent_fields: string[]Copies those keys from the object that holds the rows_path array onto every row (e.g. lookupCode, listType).
G3response_transformation.row_conditions: Condition[]Keeps only rows matching every condition (rowMatchesActionConditions), for list rows and dropdown-field options.
G2read_merge on OperationApi (add / edit write / delete)Read the target record (read URL, tokens from form values + row) → merge the change into items_path (append / replace / remove, keyed by item_id_key, keeping item_fields) → write the whole list with the configured url + method, {tokens} filled from the read response.

Save rule (G2, read → merge → write)

OperationactionMergeError when
Addappend[...items, { key, value }]key already exists → duplicate
Editreplaceitem with the same key gets the new valuekey no longer exists → not found
Deleteremoveitem with the same key droppedkey no longer exists → not found

Every write sends { lookupData: [...] } with only key and value on each item, to PUT /lms/api/v1/lookups/{id}/modification where {id} comes from the read.


Mandates

  1. Mocks first. The lookups MSW store, by-code GET and PUT handlers land before any config, so dev and Playwright exercise the contract from Phase #1 on.
  2. Reuse the config-driven components; add no new ones. CrudConfigWrapper, CrudConfigFilterBar, the toolbar, AddCrudConfigItem, EditCrudConfigItem, the delete confirm and the row-actions menu are used as they are. Gaps are closed in types, the schema validator, utils and the existing query/mutation hooks only.
  3. Generic, not lookups-specific. No lookup identifiers in bsq-platform runtime code. Every new key is named and specified so another config could use it.
  4. Schema in the same phase. Every new config key is added to crudConfig.types.ts and validated in crudConfigSchema.utils.ts in the phase that introduces it.
  5. Opt-in only. Configs that don’t use a new key behave exactly as today: same verbs, same payloads, same headers. In particular, Add/Edit/Delete keep their hard-coded POST/PUT/DELETE unless read_merge is present.
  6. Contract as given. /lms URLs, bare responses, the PUT replaces the list, exact Accept-Language: en (Decided #1, #2, #4, #6).
  7. SYSTEM is view-only in the UI (Decided #8), and the mock rejects SYSTEM modifications like the backend does.
  8. Access: screen key mdm_lookups; every action key __passthrough__ (Decided #5).
  9. Tests:
    • no specs for MSW handlers or fixtures;
    • no *.queries.spec — mutation and query behaviour is tested through the consuming component specs;
    • no config-only spec files (asserting JSON through resolver utils).
  10. No new inline comments. One-line JSDoc on new exported symbols only.

Per-phase delivery gate

Each phase must pass before the next starts:

  1. Implement. Apply only that phase’s diff.

  2. Test. Every source file whose behaviour changes gets its colocated spec updated in the same phase. Run only the affected specs:

    • packages/bsq-platform: npx jest --config jest.config.cjs <spec paths>;
    • apps/finlease: npm test -- <spec paths>.

    Never run a whole suite.

  3. Lint. npx eslint <changed files> --config ../../eslint/lint-ci.config.js --quiet, and npx prettier --check --ignore-path ../../.prettierignore <files>.

  4. Type-check. npm run type-check in every touched package (packages/bsq-platform, apps/finlease).

  5. Duplication. npm run jscpd and npm run jsinspect when a phase adds helpers.

  6. i18n. npm run check-i18n-keys, plus npm run sync-config-driven-i18n:check once a config exists.


Target architecture

Platform Settings › Account Settings › Lookups      (platformSettings.json leaf, meta.child_slug "lookups")
  └─ CrudConfigWrapper(lookups_config.json)          access: list "mdm_lookups", everything else "__passthrough__"
       ├─ filter lookupCode ── GET /lms/api/v1/lookups  [G1 headers]  label/value = lookupCode, default_first
       ├─ list ── GET /lms/api/v1/lookups/code/{lookupCode}  [G1 headers]
       │     rows_path "lookupData" + parent_fields ["lookupCode","listType"]  [G3]
       │     → rows { key, value, lookupCode, listType }
       ├─ row ⋯ Edit / Delete ── conditions listType equals USER             (existing row conditions)
       ├─ toolbar Add Entry ── AddCrudConfigItem
       │     fields: lookupCode (dropdown: GET /lookups, headers, row_conditions listType=USER [G3]), key, value
       └─ add / edit.write / delete api = PUT /lms/api/v1/lookups/{id}/modification + read_merge  [G2]
             read  GET /lms/api/v1/lookups/code/{lookupCode}   ({lookupCode} ← form values / row)
             merge append | replace | remove on lookupData, keyed by "key", items keep {key, value}
             write PUT … {id} ← read response; body { lookupData: [...] }
             success → existing list invalidation; finlease lookup consumers refetch on mount

Dropped (do not implement)

IdApproachWhy
AA finlease page or new React components (key/value repeater, custom lookups grid)The runtime already renders the dropdown, grid, forms, confirm and row menu (Mandate 2).
BA top-level collection block that rewrites the grid’s rowsAdd Entry can target a lookup that isn’t on screen, so the grid’s rows aren’t the right list. Per-operation read_merge reads the real target (Decided #10).
CPassing the filter state into Edit / Delete mutationsparent_fields puts lookupCode on each row; Edit already gets the row and Delete the row’s values.
DPrefilling Add Entry’s Lookup Code from the grid’s dropdownNot needed: the user picks the code. A SYSTEM selection would also prefill a code the USER-only dropdown doesn’t offer.
ECreate a lookup table (second toolbar form, nest_array payload, POST mock)Deferred by the owner (Decided #7). Design kept in the config-driven design.
FAsking the backend for per-entry endpoints or a merging PUTThe contract is followed as given (Decided #6).
GA client-side SYSTEM guard inside read_mergeThe UI never offers a SYSTEM write (row conditions + USER-only dropdown), and the backend (and mock) rejects one.
HCross-invalidating finlease’s lookupsListQuery from the config-driven mutationsFinlease’s QueryClient uses staleTime: 0, so lookup consumers refetch on mount; they are never mounted alongside this screen.
IMaking Add/Edit/Delete honour api.method for every configChanges behaviour of every existing config. Only the read_merge path uses the configured method (Mandate 5).
JA pattern validation rule for UPPER_SNAKE_CASE keysFormat enforcement is an open question; the backend validates for now.
KApp-owned config (registerCrudConfigModules)Not built yet. Follow Counter Parties: base config in bsq-platform/src/config-driven/data/.

Phases

#0 — Contract and decisions (P0, no code) — Done

Recorded under Decided #1–#10. One question remains open (limits); it doesn’t block #1–#5.


#1 — Lookups mocks: mutable store, by-code GET, PUT (P0)

Files:

  • apps/finlease/__mocks__/mocks/lms/lookupsData.ts, __mocks__/mocks/lms/persist.ts
  • apps/finlease/__mocks__/handlers/lookupsHandlers.ts

Changes:

  • Store: one in-memory list of lookup tables, seeded from today’s constants and persisted to localStorage through persist.ts (new storage key), like the closure-checklist mocks. ENTITY_NAME stays derived from the entities mock on every read (SYSTEM, never stored).
  • CLOSURE_CHECKLIST becomes USER, matching the backend sample, so the Lease Closure Tracker categories can be managed. GL_ACCOUNT_TAGS stays USER; everything else stays SYSTEM.
  • GET /lms/api/v1/lookups: reads the store (bare array, unchanged shape).
  • GET /lms/api/v1/lookups/code/:lookupCode: bare table; 404 for an unknown code.
  • PUT /lms/api/v1/lookups/:id/modification: replaces lookupData, bumps updatedAt / updatedBy, returns the bare table; 404 for an unknown id; 400 with a message for a SYSTEM table (status to confirm, open question 2).
  • Field names stay camelCase (the real contract).

Acceptance:

  • ChecklistTab.spec, ChecklistItemDialog.spec and the closure-checklist mocks still pass unchanged (they import SEEDED_CLOSURE_CHECKLIST_LOOKUP, whose entries don’t change).
  • Dev server: a PUT through the browser console changes what the Lease Closure Tracker’s Category dropdown shows on the next visit.
  • No handler specs (Mandate 9).

#2 — G1: headers on operation APIs (P0)

Files:

  • crudConfig.types.ts — OperationApi.headers?: Record<string, string>.
  • crudConfigSchema.utils.ts — validate operation-API headers with the existing validateFieldApiHeaders.
  • CrudConfig.queries.ts — a GET list API that declares headers reads through useCrudConfigListGetWithHeadersQuery (useQuery + fetchCrudConfigListWithHeaders); the shared createGetQueryHook list hook is unchanged. createQueryHook.ts is not touched.
  • crudConfigQuery.utils.ts — fetchCrudConfigFilterOptions passes options.api.headers to ApiService.get / .post, like fetchCrudConfigFieldOptions does today.
  • Specs: crudConfigQuery.utils.spec.ts, crudConfigSchema.utils.spec.ts.

Acceptance (specs):

  • a function headers is called with the transformed input and its result is sent; an object headers is sent as before; no headers → no extra header;
  • filter options are fetched with the configured headers (GET and POST);
  • schema accepts string-valued headers on an operation API and rejects non-string values.

#3 — Read-only Lookups screen (P0)

Files:

  • packages/bsq-platform/src/config-driven/data/lookups_config.json — new.
  • .../config-driven/utils/mdmAccessControl.ts — LOOKUPS: 'mdm_lookups'.
  • apps/finlease/src/utils/adminLandingActionIds.ts (+ spec) — lookups: MDM_ACCESS_CONTROL_KEYS.LOOKUPS.
  • apps/finlease/src/config/platformSettings.json — leaf { "id": "lookups", "seq_no": "01#015#12", "name": "lookups", "menu_type": "Tab", "children": [] }.
  • apps/finlease/src/config/menuJsonPatches.ts — mdm_lookups entry after mdm_counter_parties (backend menu_json doesn’t return it).
  • packages/bsq-shared/src/i18n/locales/*.json — via npm run sync-config-driven-i18n.

Changes:

  • Config: title, entity_id_key: "key", meta.child_slug: "lookups", fields key / value.
  • operations.list:
    • api: GET /lms/api/v1/lookups/code/{lookupCode}, headers: { "Accept-Language": "en" }, response_transformation.rows_path: "lookupData";
    • filters: lookupCode dropdown, options.api GET /lms/api/v1/lookups with the same header, label_key / value_key lookupCode, default_first: true, access_control_key: "__passthrough__";
    • layout: Key, Value; pagination.enabled: false; access_control_key: "mdm_lookups".
  • No toolbar, add, edit or delete yet.

Acceptance:

  • adminLandingActionIds.spec covers the new slug.
  • Manual: the leaf opens the screen; the first lookup is selected; switching codes reloads the grid; both GETs carry Accept-Language: en (network tab).
  • No config-only spec (Mandate 9).

#4 — G3: parent_fields and row_conditions (P0)

Files:

  • crudConfig.types.ts — ResponseTransformation.parent_fields?: string[], ResponseTransformation.row_conditions?: ListRowLevelActionCondition[].
  • crudConfigSchema.utils.ts — add both to SUPPORTED_RESPONSE_TRANSFORMATION_KEYS; validate shapes; parent_fields entries count as declared row keys (so row-action conditions may reference them).
  • crudConfigQuery.utils.ts:
    • transformCrudConfigListResponse — after rows_path, copy parent_fields from the object that holds the array (the path minus its last segment; the response root for a one-segment path) onto each row, then keep rows passing row_conditions (rowMatchesActionConditions);
    • transformCrudConfigFieldOptionsResponse — apply row_conditions to option rows before mapping to { label, value }.
  • lookups_config.json — list parent_fields: ["lookupCode", "listType"].
  • Specs: crudConfigQuery.utils.spec.ts, crudConfigSchema.utils.spec.ts, buildCrudConfigListRows.spec.tsx (a row action hidden by a condition on a parent field).

Acceptance (specs):

  • one-segment and nested rows_path both receive the parent fields; a row’s own key wins over a parent key with the same name;
  • row_conditions filter list rows and field options; absent → unchanged output;
  • schema rejects a non-array parent_fields and malformed conditions.

#5 — G2: read_merge on add / edit / delete (P0)

Files:

  • crudConfig.types.ts — OperationApi.read_merge?: { read: OperationApi; items_path: string; item_id_key: string; item_fields: string[]; action: 'append' | 'replace' | 'remove' }.
  • crudConfigSchema.utils.ts — validate read_merge (read API, non-empty item_fields, action in the closed set) on add.api, edit.write and delete.api only.
  • crudConfigPayloadTransformation.utils.ts — pure mergeCrudConfigCollectionItem(items, item, { item_id_key, item_fields, action }) returning the new list or a typed error (duplicate / not_found).
  • CrudConfig.queries.ts:
    • createCrudConfigDynamicMutationHook takes the operation name;
    • when that operation’s API has read_merge, mutationFn runs runCrudConfigReadMergeMutation: interpolate read.url from { ...row, ...values, ...filters }, GET it (with read.headers), merge, interpolate the write URL from the read response, send with the configured method (resolveCrudConfigBodyMutationMethod);
    • otherwise the existing runCrudConfigDynamicMutation path is untouched (Mandate 5).
  • packages/bsq-shared/src/i18n/locales/*.json — admin.crudConfig.readMerge.duplicate / .notFound messages, surfaced through the existing mutation-error path (verify crudConfigApiErrorUserMessage shows a thrown error’s message; extend it if not).
  • Specs: crudConfigPayloadTransformation.utils.spec.ts (merge), crudConfigSchema.utils.spec.ts, and one read-merge case each in AddCrudConfigItem.spec.tsx, EditCrudConfigItem.spec.tsx and useCrudConfigConfirms.spec.ts (Mandate 9: query behaviour through consumers).

Acceptance (specs):

  • append / replace / remove produce the right list and keep only item_fields; duplicate append and missing replace/remove return the typed error and make no write;
  • Add: the read URL uses the form’s value; Edit: the row’s parent field; Delete: the row values; the write URL {id} comes from the read response; the configured method is used;
  • configs without read_merge still POST/PUT/DELETE exactly as before (existing specs unchanged and green).

#6 — Add / Edit / Delete in the Lookups config (P0)

Files:

  • lookups_config.json
  • packages/bsq-shared/src/i18n/locales/*.json — via npm run sync-config-driven-i18n -- --force for en (new labels); hi / ms reviewed by hand per the script’s rules.

Changes:

  • Field lookupCode: display.helper_text: "Only user lookups are shown"; dropdown with api GET /lms/api/v1/lookups, headers: { "Accept-Language": "en" }, response_transformation: { "rename": { "lookupCode": "value" }, "row_conditions": [{ "field": "listType", "operator": "equals", "value": "USER" }] }, required.
  • Fields key / value: required; max_length placeholders 100 / 255 until open question 1 is answered.
  • Toolbar: one add button, “Add Entry”, access_control_key: "__passthrough__".
  • add: layout Lookup Code, Key, Value; api = PUT modification with read_merge append; access and cancel keys __passthrough__.
  • edit: prefill: "row"; layout Key (disabled), Value; write = PUT modification with read_merge replace; access and cancel keys __passthrough__.
  • delete: api = PUT modification with read_merge remove; confirmation title/message.
  • row_level_actions: Edit and Delete, each with disabled_conditions: [{ "field": "listType", "operator": "equals", "value": "SYSTEM" }], display.disabled_tooltip (“System lookups cannot be edited” / ”… deleted”) and access_control_key: "__passthrough__".

Acceptance (manual, dev server on mocks):

  • CLOSURE_CHECKLIST (USER): add an entry from the header (choosing any USER code), edit its value, delete it; each change survives a reload.
  • A SYSTEM lookup shows no Edit / Delete, and SYSTEM codes are absent from the Add Entry dropdown.
  • A duplicate key shows the duplicate message and makes no PUT.
  • npm run sync-config-driven-i18n:check passes.

#7 — E2E + manual verify (P1)

TargetCase
apps/finlease/playwright/development/... (new Lookups spec)Open the screen; first code selected; switching codes reloads the grid
sameAdd Entry to CLOSURE_CHECKLIST; the row appears; the PUT body holds the full list plus the new entry
sameEdit the new entry’s Value; Key is read-only; Delete it after confirming
sameA SYSTEM lookup has no row actions; the Add Entry dropdown lists only USER codes
Cross-feature (manual)A CLOSURE_CHECKLIST entry added here appears in the Lease Closure Tracker’s Category dropdown on next visit

Then the Lease Closure Tracker’s #7 (Playwright) resumes.


Sequencing

  1. #1 mocks — first (Mandate 1).
  2. #2 headers — independent of #1; can run in parallel.
  3. #3 read-only screen — needs #1 (by-code GET) and #2 (headers).
  4. #4 parent fields + row conditions — independent of #3; needed by #6.
  5. #5 read-merge — needs #1 (PUT handler) for manual checks; independent of #3 / #4 in code.
  6. #6 config add/edit/delete — needs #3, #4, #5.
  7. #7 Playwright + manual — after #6.

Merge candidates:

  • #2 and #3 ship the first visible screen and can be one commit.
  • #4 is small and can go with #5.

File touch list

FilePhaseRationale
apps/finlease/__mocks__/mocks/lms/lookupsData.ts, persist.ts#1Mutable, persisted store; CLOSURE_CHECKLIST → USER
#2OperationApi.headers (types + schema: validateApiUrlAndMethod now validates headers for every API). Filter options send options.api.headers (GET and POST). A GET list API with headers reads through a dedicated useQuery (fetchCrudConfigListWithHeaders, key [crud-config-list-get, 'with-headers', input], default caching); other lists keep the shared createGetQueryHook hook.Deviation: createQueryHook.ts and bsq-shared queryHook.ts untouched (owner); createGetQueryHook only takes static headers and the React Compiler forbids picking a hook at runtime, so the headers path uses useQuery. POST lists don’t send headers. Gate green: 306 specs, lint-ci, prettier, type-check (platform + finlease), jscpd, jsinspect, check-i18n-keys.
#3lookups_config.json (list GET by code + Accept-Language: en, rows_path: lookupData; lookupCode filter from GET /lookups, default_first; mdm_lookups); MDM_ACCESS_CONTROL_KEYS.LOOKUPS; finlease leaf lookups (01#015#12) + landing action id; admin.platformSettingsLabels.lookups (en/hi/ms, English like Counter Parties); admin.configDriven.lookups.* via sync. Menu: the backend menu_json doesn’t return Lookups, so apps/finlease/src/config/menuJsonPatches.ts adds mdm_lookups (url /platform-settings/account-settings/lookups, label admin.platformSettingsLabels.lookups) after mdm_counter_parties, inheriting its access.The config sync never localizes filter labels (existing behaviour, e.g. Conversion Table). Config checked once through resolveCrudConfig + URL build with a throwaway spec (deleted). Gate green: 104 specs, lint-ci, prettier, type-check (platform + finlease), sync check, check-i18n-keys, jscpd, jsinspect. Manual dev-server check not run. Fix: buildCrudConfigRequestConfig now sends a copy of headers. ApiService (addAuthHeaders) writes Authorization + a new X-Request-Id into the object it receives, which mutated the config inside the list query key, so every response refetched (917 calls / 8 s on mocks). It also froze the first bearer token into the config (field dropdowns too). Regression spec added; Playwright on mocks shows one call and rows rendered.
#4ResponseTransformation.parent_fields (copied from the object holding the rows_path array, before rename; a row’s own key wins) and row_conditions (filter the final list rows and dropdown options); used by list, filter options and dropdown fields. Schema: both keys registered + shape-checked (operation and field APIs); parent_fields count as declared row keys. Row actions: disabled_conditions + display.disabled_tooltip (menu item disabled inside BsqTooltipWrapper), isRowActionDisabled. lookups_config.json: parent_fields: [lookupCode, listType]. Helper text (owner, same phase): field display.helper_text on any form field (schema: string) → props.helperText on the form field, the existing BaseInput/DateInput convention; BaseInput external-label path, TextAreaInput, BsqDropdownInput and BsqDialogTrigger now render it, a validation error replaces it (BaseInput keeps error={true} on an empty slot, as before). helper_text added to the translatable display keys in the i18n sync script and runtime localizer. Tooltip translation (owner, same phase): list row_level_actions[].display.disabled_tooltip extracted by the sync script and runtime localizer, keyed operations.list.row_level_actions.<operation>.display.disabled_tooltip, written back by array index (configPathSegments); list row-action labels stay untranslated (unchanged). hi/ms: admin.configDriven.lookups (title, Key, Value) and admin.platformSettingsLabels.lookups translated (hi लुकअप / कुंजी / मूल्य; ms Senarai Rujukan / Kunci / Nilai); other platformSettingsLabels remain English in hi/ms (existing).Owner chose disable + tooltip over hiding, and USER-only Add dropdown (kept row_conditions). List row_level_actions labels/tooltips are not localized by the config i18n sync today (only account-customization panels are), so the tooltip is English like the labels. Gate green: 475 platform + 177 bsq-components specs (latest run), lint-ci, prettier, type-check (platform + finlease), jscpd, jsinspect, check-i18n-keys, sync check.
#5OperationApi.read_merge (read, items_path, item_id_key, item_fields, action append/replace/remove) + CRUD_READ_MERGE_ACTION. Pure mergeCrudConfigCollectionItem (payload utils). executeCrudConfigReadMerge (query utils): GET read.url (tokens from filters, row, values; read headers) → merge → PUT/POST/PATCH per api.method to url with {id} from the read record, body nested at items_path; duplicate / not-found throw a translated error before any write. resolveCrudConfigReadMergeApi; add/edit/delete mutation hooks take their operation name and use read-merge only when the API declares it (t from useTranslation in the hook). Schema: read_merge only on add.api / edit.write / edit.api / delete.api, read must be GET, item_fields must include item_id_key, known action. i18n admin.crudConfig.readMerge.duplicate / .notFound (en/hi/ms).Deviation: the async runner lives in crudConfigQuery.utils.ts (next to executeCrudConfigPerIdAction) so it has unit specs; consumer specs run the real Add / Edit / Delete mutations (jest.requireActual) since they mock the query module. Owner kept both duplicate and not-found checks (see open question 2). Gate green: 500 platform specs, lint-ci, prettier, type-check (platform + finlease), jscpd, jsinspect, check-i18n-keys, sync check.
#6lookups_config.json: field lookupCode (dropdown GET /lookups + Accept-Language: en, rename lookupCode→value, row_conditions listType=USER, required, helper_text “Only user lookups are shown”); key / value required, max 100 / 255; toolbar “Add Entry”; add / edit.write (prefill row, key disabled) / delete = PUT /lookups/{id}/modification with read_merge append / replace / remove (read GET /lookups/code/{lookupCode}); Edit / Delete row actions with disabled_conditions listType=SYSTEM + tooltips; delete confirmation “Do you want to delete {key}?” / “This entry will be removed from {lookupCode}.”; success messages. en via sync --force (only the lookups section kept; the run also reordered entity keys, reverted), hi/ms translated by hand.Manual acceptance on mocks (throwaway Playwright, deleted): SYSTEM row Edit/Delete aria-disabled + tooltip; Add dropdown lists only CLOSURE_CHECKLIST, GL_ACCOUNT_TAGS; helper text shown (grey); add PUT = full list + new entry; duplicate → “An entry with key … already exists.”, no PUT; edit Key disabled, PUT replaces value; delete confirm interpolated, PUT drops the entry; survives reload. Add dialog title is the generic “Add Lookups” (no per-config add title today). Gate green: config/i18n specs, prettier, type-check (platform + finlease), sync check, check-i18n-keys, jscpd, jsinspect.
#7apps/finlease/playwright/development/accountSettings/lookups/Lookups.spec.ts (4 tests): first code selected + list GET carries Accept-Language: en + switching codes reloads the grid; SYSTEM row Edit/Delete disabled with tooltip + Add Entry lists only USER codes with the helper text; add (PUT body = seeded list + new entry) → edit (Key disabled) → delete (confirm names the lookup code); duplicate key → toast, form stays, no PUT. ROUTES.accountSettingsLookups.4/4 pass, 12/12 with --repeat-each=3; lands in the other playwright_ci group (check-playwright-ci-groups:fail green). Cross-feature (throwaway run, deleted): an entry added to CLOSURE_CHECKLIST here appears in the Lease Closure Tracker Category dropdown of the TERMINATED mock lease and fills its name. Lint-ci, prettier, finlease type-check, jscpd, jsinspect green. Lease Closure Tracker #7 resumes next.
apps/finlease/__mocks__/handlers/lookupsHandlers.ts#1By-code GET, PUT modification
packages/bsq-platform/src/config-driven/types/crudConfig.types.ts#2, #4, #5headers, parent_fields, row_conditions, read_merge
.../CrudConfig/config/crudConfigSchema.utils.ts (+ spec)#2, #4, #5Validate every new key
.../CrudConfig/utils/crudConfigQuery.utils.ts (+ spec)#2, #4Filter-option headers; parent fields; row conditions
.../CrudConfig/utils/crudConfigPayloadTransformation.utils.ts (+ spec)#5Pure merge
.../config-driven/queries/CrudConfig.queries.ts#2, #5Headers list GET (useQuery); read-merge mutation path (no spec, Mandate 9)
.../AddCrudConfigItem.spec.tsx, EditCrudConfigItem.spec.tsx, useCrudConfigConfirms.spec.ts#5Read-merge through the consumers
.../buildCrudConfigListRows.spec.tsx#4Row action hidden by a parent-field condition
.../config-driven/data/lookups_config.json#3, #4, #6The use case
.../config-driven/utils/mdmAccessControl.ts#3mdm_lookups
packages/bsq-shared/src/i18n/locales/*.json#3, #5, #6Config literals; read-merge error messages
apps/finlease/src/config/platformSettings.json#3Nav leaf
apps/finlease/src/config/menuJsonPatches.ts#3FE-only mdm_lookups menu item
apps/finlease/src/utils/adminLandingActionIds.ts (+ spec)#3Landing action id
apps/finlease/playwright/development/...#7Lookups e2e

Do not touch:

  • createQueryHook.ts (+ spec) and bsq-shared types/queryHook.ts (owner, Phase #2);
  • CrudConfigWrapper, CrudConfigFilterBar, CrudConfigToolbar, AddCrudConfigItem.tsx, EditCrudConfigItem.tsx, CrudConfigListDialogs.tsx, the confirm dialog components (Mandate 2);
  • runCrudConfigDynamicMutation’s existing behaviour and the hard-coded verbs for non-read_merge configs (Dropped I);
  • finlease’s Lookups.queries.ts and LOOKUP_CODES (Dropped H);
  • other *_config.json files.

Decided

  1. URL prefix is /lms for every lookups endpoint.
  2. Responses are bare objects (no { success, data } envelope).
  3. Deleting an entry is allowed, after a confirmation.
  4. Accept-Language: en is required exactly on the lookups GETs (G1).
  5. Access: screen key mdm_lookups; every action key __passthrough__ for now, as Counter Parties does. Real ids later change config values only.
  6. The backend contract is followed as given: the PUT replaces the whole lookupData list; no per-entry endpoints (Dropped F).
  7. Creating a lookup table is deferred (Dropped E).
  8. SYSTEM lookups are view-only; only USER lookups can be modified. On SYSTEM rows Edit / Delete stay visible but disabled, with the tooltips “System lookups cannot be edited” / “System lookups cannot be deleted” (owner, Phase #4). Add Entry offers only USER codes.
  9. Add Entry is a header action for any USER lookup, with its own Lookup Code dropdown — not tied to the grid’s selection, and not a row action.
  10. Writes use read → merge → write per operation (read_merge), reading the target lookup by code at save time (Dropped B, C).

Open questions

  1. Limits: max lengths for key and value? The config uses 100 / 255 as placeholders. Should the key format (UPPER_SNAKE_CASE) be enforced client-side (Dropped J)?
  2. Errors: the status code and message the backend returns for a SYSTEM modification, an unknown id and an unknown code (the mock assumes 400 / 404 / 404). Does the PUT reject duplicate keys in lookupData, and with what message? Until confirmed the frontend keeps its own duplicate check (owner); the not-found check stays regardless, because the backend only sees the final list.

Progress checklist

  • #0 Contract and decisions
  • #1 Lookups mocks
  • #2 G1 — headers on operation APIs
  • #3 Read-only Lookups screen
  • #4 G3 — parent fields + row conditions + disabled row actions
  • #5 G2 — read_merge on add / edit / delete
  • #6 Add / Edit / Delete in the config
  • #7 E2E + manual verify

Progress log

PhaseWhat shippedNotes
#0Contract answers recorded (Decided #1–#10). No code.—
#1Persisted lookups store (finlease_mock_lookups_v1), CLOSURE_CHECKLIST → USER; GET …/lookups/code/:lookupCode (404 unknown); PUT …/lookups/:id/modification (replaces lookupData, bumps updatedAt/updatedBy; 404 unknown id; 400 SYSTEM or malformed body).Gate green: 7 affected specs, lint-ci, prettier, type-check, jscpd, jsinspect, check-i18n-keys. Manual console PUT not run.