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
USERlookup; - Edit (Value only) and Delete an entry from the row menu, for
USERlookups only; SYSTEMlookups 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 indocs/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)
| Operation | Method | URL | Body | Response |
|---|---|---|---|---|
| List all tables | GET | /lms/api/v1/lookups | — (Accept-Language: en) | bare array of lookup tables |
| Read one by code | GET | /lms/api/v1/lookups/code/{lookupCode} | — (Accept-Language: en) | bare lookup table |
| Modify entries | PUT | /lms/api/v1/lookups/{id}/modification | { lookupData: [{key, value}] } | bare lookup table |
| Create table | POST | /lms/api/v1/lookups | LookupTableDTO | not used (Decided #7) |
- The PUT replaces the whole
lookupDatalist. The contract’s samples show it: the GET and PUT samples shareida1b2c3d4-…, 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 }. SYSTEMlookups cannot be modified; onlyUSERlookups 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 supportheadersandresponse_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 throughmeta.child_slug(Counter Parties is the closest precedent: finlease/lmsURLs,mdm_counter_partiesscreen key,__passthrough__action keys). - Config literals are localized by
npm run sync-config-driven-i18nintopackages/bsq-shared/src/i18n/locales/*.json(admin.configDriven.<slug>.*). - Finlease’s own lookups cache (
useGetLookupsQuery, plainuseQuery) uses the defaultstaleTimeof 0 fromnew QueryClient()inmain.tsx, so it refetches whenever a lookup consumer mounts.
Mocks today
apps/finlease/__mocks__/handlers/lookupsHandlers.tshas one handler,GET /lms/api/v1/lookups, returningbuildMockLookups(entities)— rebuilt from constants on every request, so nothing can be changed.- Every seeded table is
SYSTEMexceptGL_ACCOUNT_TAGS(USER).CLOSURE_CHECKLISTisSYSTEMin the mocks butUSERin the backend’s sample. SEEDED_CLOSURE_CHECKLIST_LOOKUPis also consumed by the closure-checklist mocks and the Lease Closure Tracker specs.
Key files (for implementers)
| Area | File |
|---|---|
| Config types | packages/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 i18n | packages/bsq-shared/src/i18n/locales/*.json |
| Finlease nav leaf | apps/finlease/src/config/platformSettings.json |
| Finlease landing action ids | apps/finlease/src/utils/adminLandingActionIds.ts |
| Finlease lookups mocks | apps/finlease/__mocks__/handlers/lookupsHandlers.ts, __mocks__/mocks/lms/lookupsData.ts, persist.ts |
2 — What we are changing
Product ask
| # | Ask | Gap vs today |
|---|---|---|
| 1 | Pick 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). |
| 2 | Add 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). |
| 3 | Edit an entry’s Value (Key read-only). | Same as #2 (G2); the row must know its lookup code and type (G3). |
| 4 | Delete an entry, after confirmation (Decided #3). | Same as #3. |
| 5 | SYSTEM 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)
| Gap | New config key | What the runtime does with it |
|---|---|---|
| G1 | headers on OperationApi | Sent with the list GET and with filter-option GETs, the same way FieldApi.headers is sent today. |
| G3 | response_transformation.parent_fields: string[] | Copies those keys from the object that holds the rows_path array onto every row (e.g. lookupCode, listType). |
| G3 | response_transformation.row_conditions: Condition[] | Keeps only rows matching every condition (rowMatchesActionConditions), for list rows and dropdown-field options. |
| G2 | read_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)
| Operation | action | Merge | Error when |
|---|---|---|---|
| Add | append | [...items, { key, value }] | key already exists → duplicate |
| Edit | replace | item with the same key gets the new value | key no longer exists → not found |
| Delete | remove | item with the same key dropped | key 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
- 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.
- 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. - Generic, not lookups-specific. No
lookupidentifiers inbsq-platformruntime code. Every new key is named and specified so another config could use it. - Schema in the same phase. Every new config key is added to
crudConfig.types.tsand validated incrudConfigSchema.utils.tsin the phase that introduces it. - 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_mergeis present. - Contract as given.
/lmsURLs, bare responses, the PUT replaces the list, exactAccept-Language: en(Decided #1, #2, #4, #6). SYSTEMis view-only in the UI (Decided #8), and the mock rejectsSYSTEMmodifications like the backend does.- Access: screen key
mdm_lookups; every action key__passthrough__(Decided #5). - 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).
- No new inline comments. One-line JSDoc on new exported symbols only.
Per-phase delivery gate
Each phase must pass before the next starts:
-
Implement. Apply only that phase’s diff.
-
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.
-
Lint.
npx eslint <changed files> --config ../../eslint/lint-ci.config.js --quiet, andnpx prettier --check --ignore-path ../../.prettierignore <files>. -
Type-check.
npm run type-checkin every touched package (packages/bsq-platform,apps/finlease). -
Duplication.
npm run jscpdandnpm run jsinspectwhen a phase adds helpers. -
i18n.
npm run check-i18n-keys, plusnpm run sync-config-driven-i18n:checkonce 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 mountDropped (do not implement)
| Id | Approach | Why |
|---|---|---|
| A | A 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). |
| B | A top-level collection block that rewrites the grid’s rows | Add 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). |
| C | Passing the filter state into Edit / Delete mutations | parent_fields puts lookupCode on each row; Edit already gets the row and Delete the row’s values. |
| D | Prefilling Add Entry’s Lookup Code from the grid’s dropdown | Not needed: the user picks the code. A SYSTEM selection would also prefill a code the USER-only dropdown doesn’t offer. |
| E | Create a lookup table (second toolbar form, nest_array payload, POST mock) | Deferred by the owner (Decided #7). Design kept in the config-driven design. |
| F | Asking the backend for per-entry endpoints or a merging PUT | The contract is followed as given (Decided #6). |
| G | A client-side SYSTEM guard inside read_merge | The UI never offers a SYSTEM write (row conditions + USER-only dropdown), and the backend (and mock) rejects one. |
| H | Cross-invalidating finlease’s lookupsListQuery from the config-driven mutations | Finlease’s QueryClient uses staleTime: 0, so lookup consumers refetch on mount; they are never mounted alongside this screen. |
| I | Making Add/Edit/Delete honour api.method for every config | Changes behaviour of every existing config. Only the read_merge path uses the configured method (Mandate 5). |
| J | A pattern validation rule for UPPER_SNAKE_CASE keys | Format enforcement is an open question; the backend validates for now. |
| K | App-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.tsapps/finlease/__mocks__/handlers/lookupsHandlers.ts
Changes:
- Store: one in-memory list of lookup tables, seeded from today’s constants and persisted to
localStoragethroughpersist.ts(new storage key), like the closure-checklist mocks.ENTITY_NAMEstays derived from the entities mock on every read (SYSTEM, never stored). CLOSURE_CHECKLISTbecomesUSER, matching the backend sample, so the Lease Closure Tracker categories can be managed.GL_ACCOUNT_TAGSstaysUSER; everything else staysSYSTEM.GET /lms/api/v1/lookups: reads the store (bare array, unchanged shape).GET /lms/api/v1/lookups/code/:lookupCode: bare table;404for an unknown code.PUT /lms/api/v1/lookups/:id/modification: replaceslookupData, bumpsupdatedAt/updatedBy, returns the bare table;404for an unknownid;400with a message for aSYSTEMtable (status to confirm, open question 2).- Field names stay camelCase (the real contract).
Acceptance:
ChecklistTab.spec,ChecklistItemDialog.specand the closure-checklist mocks still pass unchanged (they importSEEDED_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-APIheaderswith the existingvalidateFieldApiHeaders.CrudConfig.queries.ts— a GET list API that declaresheadersreads throughuseCrudConfigListGetWithHeadersQuery(useQuery+fetchCrudConfigListWithHeaders); the sharedcreateGetQueryHooklist hook is unchanged.createQueryHook.tsis not touched.crudConfigQuery.utils.ts—fetchCrudConfigFilterOptionspassesoptions.api.headerstoApiService.get/.post, likefetchCrudConfigFieldOptionsdoes today.- Specs:
crudConfigQuery.utils.spec.ts,crudConfigSchema.utils.spec.ts.
Acceptance (specs):
- a function
headersis called with the transformed input and its result is sent; an objectheadersis sent as before; noheaders→ no extra header; - filter options are fetched with the configured headers (GET and POST);
- schema accepts string-valued
headerson 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_lookupsentry aftermdm_counter_parties(backendmenu_jsondoesn’t return it).packages/bsq-shared/src/i18n/locales/*.json— vianpm run sync-config-driven-i18n.
Changes:
- Config:
title,entity_id_key: "key",meta.child_slug: "lookups", fieldskey/value. operations.list:api:GET /lms/api/v1/lookups/code/{lookupCode},headers: { "Accept-Language": "en" },response_transformation.rows_path: "lookupData";filters:lookupCodedropdown,options.apiGET /lms/api/v1/lookupswith the same header,label_key/value_keylookupCode,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.speccovers 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 toSUPPORTED_RESPONSE_TRANSFORMATION_KEYS; validate shapes;parent_fieldsentries count as declared row keys (so row-action conditions may reference them).crudConfigQuery.utils.ts:transformCrudConfigListResponse— afterrows_path, copyparent_fieldsfrom 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 passingrow_conditions(rowMatchesActionConditions);transformCrudConfigFieldOptionsResponse— applyrow_conditionsto option rows before mapping to{ label, value }.
lookups_config.json— listparent_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_pathboth receive the parent fields; a row’s own key wins over a parent key with the same name; row_conditionsfilter list rows and field options; absent → unchanged output;- schema rejects a non-array
parent_fieldsand 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— validateread_merge(read API, non-emptyitem_fields,actionin the closed set) onadd.api,edit.writeanddelete.apionly.crudConfigPayloadTransformation.utils.ts— puremergeCrudConfigCollectionItem(items, item, { item_id_key, item_fields, action })returning the new list or a typed error (duplicate/not_found).CrudConfig.queries.ts:createCrudConfigDynamicMutationHooktakes the operation name;- when that operation’s API has
read_merge,mutationFnrunsrunCrudConfigReadMergeMutation: interpolateread.urlfrom{ ...row, ...values, ...filters }, GET it (withread.headers), merge, interpolate the write URL from the read response, send with the configuredmethod(resolveCrudConfigBodyMutationMethod); - otherwise the existing
runCrudConfigDynamicMutationpath is untouched (Mandate 5).
packages/bsq-shared/src/i18n/locales/*.json—admin.crudConfig.readMerge.duplicate/.notFoundmessages, surfaced through the existing mutation-error path (verifycrudConfigApiErrorUserMessageshows 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 inAddCrudConfigItem.spec.tsx,EditCrudConfigItem.spec.tsxanduseCrudConfigConfirms.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_mergestill POST/PUT/DELETE exactly as before (existing specs unchanged and green).
#6 — Add / Edit / Delete in the Lookups config (P0)
Files:
lookups_config.jsonpackages/bsq-shared/src/i18n/locales/*.json— vianpm run sync-config-driven-i18n -- --forceforen(new labels);hi/msreviewed by hand per the script’s rules.
Changes:
- Field
lookupCode:display.helper_text: "Only user lookups are shown";dropdownwithapiGET /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_lengthplaceholders 100 / 255 until open question 1 is answered. - Toolbar: one
addbutton, “Add Entry”,access_control_key: "__passthrough__". add: layout Lookup Code, Key, Value;api= PUT modification withread_mergeappend; access and cancel keys__passthrough__.edit:prefill: "row"; layout Key (disabled), Value;write= PUT modification withread_mergereplace; access and cancel keys__passthrough__.delete:api= PUT modification withread_mergeremove; confirmation title/message.row_level_actions: Edit and Delete, each withdisabled_conditions: [{ "field": "listType", "operator": "equals", "value": "SYSTEM" }],display.disabled_tooltip(“System lookups cannot be edited” / ”… deleted”) andaccess_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
SYSTEMlookup shows no Edit / Delete, andSYSTEMcodes are absent from the Add Entry dropdown. - A duplicate key shows the duplicate message and makes no PUT.
npm run sync-config-driven-i18n:checkpasses.
#7 — E2E + manual verify (P1)
| Target | Case |
|---|---|
apps/finlease/playwright/development/... (new Lookups spec) | Open the screen; first code selected; switching codes reloads the grid |
| same | Add Entry to CLOSURE_CHECKLIST; the row appears; the PUT body holds the full list plus the new entry |
| same | Edit the new entry’s Value; Key is read-only; Delete it after confirming |
| same | A 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 mocks — first (Mandate 1).
- #2 headers — independent of #1; can run in parallel.
- #3 read-only screen — needs #1 (by-code GET) and #2 (headers).
- #4 parent fields + row conditions — independent of #3; needed by #6.
- #5 read-merge — needs #1 (PUT handler) for manual checks; independent of #3 / #4 in code.
- #6 config add/edit/delete — needs #3, #4, #5.
- #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
| File | Phase | Rationale |
|---|---|---|
apps/finlease/__mocks__/mocks/lms/lookupsData.ts, persist.ts | #1 | Mutable, persisted store; CLOSURE_CHECKLIST → USER |
| #2 | OperationApi.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. |
| #3 | lookups_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. |
| #4 | ResponseTransformation.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. |
| #5 | OperationApi.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. |
| #6 | lookups_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. |
| #7 | apps/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 | #1 | By-code GET, PUT modification |
packages/bsq-platform/src/config-driven/types/crudConfig.types.ts | #2, #4, #5 | headers, parent_fields, row_conditions, read_merge |
.../CrudConfig/config/crudConfigSchema.utils.ts (+ spec) | #2, #4, #5 | Validate every new key |
.../CrudConfig/utils/crudConfigQuery.utils.ts (+ spec) | #2, #4 | Filter-option headers; parent fields; row conditions |
.../CrudConfig/utils/crudConfigPayloadTransformation.utils.ts (+ spec) | #5 | Pure merge |
.../config-driven/queries/CrudConfig.queries.ts | #2, #5 | Headers list GET (useQuery); read-merge mutation path (no spec, Mandate 9) |
.../AddCrudConfigItem.spec.tsx, EditCrudConfigItem.spec.tsx, useCrudConfigConfirms.spec.ts | #5 | Read-merge through the consumers |
.../buildCrudConfigListRows.spec.tsx | #4 | Row action hidden by a parent-field condition |
.../config-driven/data/lookups_config.json | #3, #4, #6 | The use case |
.../config-driven/utils/mdmAccessControl.ts | #3 | mdm_lookups |
packages/bsq-shared/src/i18n/locales/*.json | #3, #5, #6 | Config literals; read-merge error messages |
apps/finlease/src/config/platformSettings.json | #3 | Nav leaf |
apps/finlease/src/config/menuJsonPatches.ts | #3 | FE-only mdm_lookups menu item |
apps/finlease/src/utils/adminLandingActionIds.ts (+ spec) | #3 | Landing action id |
apps/finlease/playwright/development/... | #7 | Lookups e2e |
Do not touch:
createQueryHook.ts(+ spec) andbsq-sharedtypes/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_mergeconfigs (Dropped I);- finlease’s
Lookups.queries.tsandLOOKUP_CODES(Dropped H); - other
*_config.jsonfiles.
Decided
- URL prefix is
/lmsfor every lookups endpoint. - Responses are bare objects (no
{ success, data }envelope). - Deleting an entry is allowed, after a confirmation.
Accept-Language: enis required exactly on the lookups GETs (G1).- Access: screen key
mdm_lookups; every action key__passthrough__for now, as Counter Parties does. Real ids later change config values only. - The backend contract is followed as given: the PUT replaces the whole
lookupDatalist; no per-entry endpoints (Dropped F). - Creating a lookup table is deferred (Dropped E).
SYSTEMlookups are view-only; onlyUSERlookups can be modified. OnSYSTEMrows 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 onlyUSERcodes.- Add Entry is a header action for any
USERlookup, with its own Lookup Code dropdown — not tied to the grid’s selection, and not a row action. - Writes use read → merge → write per operation (
read_merge), reading the target lookup by code at save time (Dropped B, C).
Open questions
- Limits: max lengths for
keyandvalue? The config uses 100 / 255 as placeholders. Should the key format (UPPER_SNAKE_CASE) be enforced client-side (Dropped J)? - Errors: the status code and message the backend returns for a
SYSTEMmodification, an unknownidand an unknown code (the mock assumes400/404/404). Does the PUT reject duplicatekeys inlookupData, 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
| Phase | What shipped | Notes |
|---|---|---|
| #0 | Contract answers recorded (Decided #1–#10). No code. | — |
| #1 | Persisted 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. |