portal-mojo 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/docs/DetailView.md +24 -3
  2. package/docs/ModelTable.md +44 -0
  3. package/docs/README.md +1 -0
  4. package/docs/charts.md +7 -0
  5. package/docs/client.md +109 -0
  6. package/docs/dataview.md +6 -3
  7. package/docs/defineModel.md +49 -2
  8. package/docs/grouping-and-fmt.md +17 -1
  9. package/docs/menus-and-access.md +43 -0
  10. package/docs/params.md +12 -0
  11. package/docs/personas.md +212 -0
  12. package/docs/realtime.md +40 -0
  13. package/docs/route-error.md +64 -0
  14. package/package.json +2 -1
  15. package/src/admin/core/index.ts +18 -3
  16. package/src/admin/identity/users/sections/actions.tsx +14 -5
  17. package/src/admin/jobs/JobDetail.tsx +4 -2
  18. package/src/admin/network/BlockedIPsPage.tsx +9 -6
  19. package/src/admin/network/FirewallLogPage.tsx +3 -1
  20. package/src/admin/phonehub/api.ts +3 -0
  21. package/src/admin/storage/models.ts +6 -0
  22. package/src/client/action-result.ts +95 -0
  23. package/src/client/active-group.ts +29 -0
  24. package/src/client/client.ts +8 -0
  25. package/src/client/endpoint-scope.ts +79 -0
  26. package/src/client/group.tsx +9 -0
  27. package/src/client/index.ts +5 -0
  28. package/src/client/mock.ts +7 -1
  29. package/src/client/model.ts +26 -2
  30. package/src/client/params.ts +16 -4
  31. package/src/client/persist.ts +54 -0
  32. package/src/client/realtime.ts +13 -0
  33. package/src/client/scoped.ts +93 -0
  34. package/src/personas/PersonaProvider.tsx +151 -0
  35. package/src/personas/audit.ts +73 -0
  36. package/src/personas/index.ts +13 -0
  37. package/src/personas/presets.ts +72 -0
  38. package/src/personas/registry.ts +111 -0
  39. package/src/personas/sections.tsx +202 -0
  40. package/src/ui/DetailView.tsx +30 -6
  41. package/src/ui/ModelTable.tsx +75 -9
  42. package/src/ui/RouteError.tsx +73 -0
  43. package/src/ui/SidebarNav.tsx +36 -5
  44. package/src/ui/active-persona.ts +34 -0
  45. package/src/ui/detail/DataView.tsx +10 -4
  46. package/src/ui/detail/KnownFieldsCard.tsx +13 -1
  47. package/src/ui/fixed-params.ts +58 -0
  48. package/src/ui/format.ts +36 -11
  49. package/src/ui/index.ts +3 -0
  50. package/src/ui/menu-registry.ts +128 -9
  51. package/src/ui/route-error.ts +60 -0
  52. package/src/ui/safe-node.tsx +88 -0
  53. package/src/ui/shell.ts +2 -0
  54. package/src/ui/warn-once.ts +12 -0
@@ -8,7 +8,7 @@ import {
8
8
  ```
9
9
 
10
10
  The record-detail surface: identity header (avatar, title, subtitle,
11
- chips, optional active switch, kebab menu, close) over a left rail of
11
+ chips, optional active switch, kebab menu, optional close) over a left rail of
12
12
  sections with side-label dividers. Open it inside `modal.detail(...)` for
13
13
  the standard row-click experience. Ported from web-mojo `SideNavView.js`
14
14
  (sections, gating, badges) + `ContextMenu.js` (the kebab).
@@ -31,7 +31,7 @@ of the toolkit it must render inside the app's `QueryClientProvider`
31
31
  title={...} subtitle={u.email}
32
32
  chips={[{ icon: 'bi-patch-check-fill', text: 'Email', tone: 'success' }]}
33
33
  active={{ value: u.is_active, onChange: (next) => ... }}
34
- onClose={onClose}
34
+ onClose={onClose} // optional — omit for page hosts (no ✕ renders)
35
35
  badges={{ sessions: openSessions }}
36
36
  menuContext={u}
37
37
  contextMenu={[
@@ -54,6 +54,27 @@ of the toolkit it must render inside the app's `QueryClientProvider`
54
54
  `{divider: 'Label'}` side-labels.
55
55
  - `initialSection` picks the starting rail entry.
56
56
 
57
+ ## Page embedding — `onClose` is optional
58
+
59
+ The ✕ is the component's only modal coupling (no Escape handling, no focus
60
+ trap — those belong to the host dialog). The affordance follows the callback:
61
+
62
+ - **Omit `onClose`** for a routed-page/embedded host — no close affordance
63
+ renders and no CSS overrides are needed.
64
+ - **Pass a history-back handler** when a routed record page should keep the
65
+ ✕ as "back" (`onClose={() => navigate(-1)}` — linkable record routes read
66
+ well this way).
67
+ - **Modal hosts MUST pass it.** Inside `modal.detail` this ✕ is the modal's
68
+ only visible dismiss — the envelope has no header X of its own, only
69
+ Escape/backdrop — and TypeScript no longer enforces the prop. Omitting it
70
+ in a modal yields an Escape-only dialog.
71
+
72
+ Sizing is the consuming app's concern: `.detail` carries
73
+ `min-height`/`max-height: 82vh` in the app `theme.css` (base at
74
+ `apps/portal/src/theme.css`) — page hosts relax it there. This is distinct
75
+ from the C1 chrome (`.rail-badge`, `.dv-menu*`, `.dv-keep`) that lives in
76
+ `theme/detailview.css` (see Pitfalls).
77
+
57
78
  ## Permission-gated sections (fail-closed)
58
79
 
59
80
  `Section.permissions` is a permission name or an ANY-of array, resolved
@@ -90,7 +111,7 @@ follows. There is no imperative handle.
90
111
  ## Header kebab menu
91
112
 
92
113
  `contextMenu?: Array<{ label, icon?, permissions?, when?, onSelect, danger? } | { divider: true }>`
93
- renders a three-dot button in the header gutter (before the close X) that
114
+ renders a three-dot button in the header gutter (before the close X, when one renders) that
94
115
  opens a `Popover` menu (`placement: bottom-end`, top-layer — works inside
95
116
  `modal.detail`). Filtering is `ContextMenu.visibleItems` parity:
96
117
 
@@ -45,6 +45,50 @@ drives the sort param. `render` is the ONE cell prop (no formatter/template
45
45
  aliases). `hideable: false` locks a column in the chooser (identity
46
46
  columns).
47
47
 
48
+ **Cells never crash the page.** django-mojo graphs expand FK fields
49
+ per-graph, so a cell value can flip from `"GC"` to `{id, code, name, …}`
50
+ without a frontend change. The default (no-`render`) leg degrades an object
51
+ to `fmt.code(value)` or a visible `[object]` marker plus one `console.warn`
52
+ naming the column. A custom `render` runs inside `RenderGuard` — a per-cell
53
+ error boundary — so a callback that throws on the new shape, or returns an
54
+ element with the raw object nested inside, degrades to a dim `[render
55
+ error]` marker instead of white-screening. `rowExpand` and
56
+ `groupHeaderLabel` are guarded the same way. Render FK-ish fields through
57
+ `fmt.code()` (see grouping-and-fmt.md) so the cell shows the code instead
58
+ of a marker.
59
+
60
+ ## fixedParams — locked scope
61
+
62
+ `fixedParams?: Params | null` is table IDENTITY, not table state: tenant /
63
+ group / graph scope merged into every wire request (list, export, exporter
64
+ callback) AFTER filter state and model normalization. It never renders a
65
+ pill, survives Clear all, is excluded from view persistence and the URL —
66
+ a bookmarked `?group=9` or a persisted view saved before the page was
67
+ scope-migrated is silently dropped rather than resurrected as a removable
68
+ filter.
69
+
70
+ - **Scope belongs in `fixedParams`; user-editable filter DEFAULTS belong
71
+ in `defaultParams`.** A tenant key in `defaultParams` is one click from
72
+ an un-scoped table — the bug class this prop closes. Passing the same
73
+ key in both warns once (the `defaultParams` copy is dead).
74
+ - **The merge runs after `normalizeListParams`** — positive-projection
75
+ normalizers would otherwise silently strip an un-allowlisted scope key
76
+ and the request would go out unscoped. Consequence: `fixedParams.graph`
77
+ overrides a normalizer-pinned graph (that's the documented
78
+ `graph: 'full'` use).
79
+ - **`null` means "scope pending"**: the table renders its skeleton and
80
+ fetches nothing until scope resolves — an async-scoped table must never
81
+ fire (and cache) an unscoped first request. `undefined` means the table
82
+ is unscoped by design and fetches normally.
83
+ - **Reserved keys can't be locked** (`start`/`size`/`page`/`sort`/
84
+ `search`) — they'd fight the pager/sort/search UI; ignored with one
85
+ console.warn.
86
+ - **A scope flip is a filter change to the user**: changing `fixedParams`
87
+ resets to page 1 and clears row selection (a batch action must never
88
+ fire against ids selected under the previous scope).
89
+ - **Presets must not carry fixed keys** — their writes are scrubbed from
90
+ filter state, so such a preset never matches as active.
91
+
48
92
  ## Filters (see also forms.md dialog types)
49
93
 
50
94
  `FilterDef` types: `text` (`__icontains`), `select`, `multiselect`
package/docs/README.md CHANGED
@@ -62,6 +62,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
62
62
  | [collection-multiselect.md](collection-multiselect.md) | Server-backed multi-pick dropdown (panel variant available) — model binding, 400ms search, select/deselect-all, shift-click ranges |
63
63
  | [detail-primitives.md](detail-primitives.md) | StatusPanel, FlowStrip, Timeline, KnownFieldsCard, MetadataSection, StackTraceView — the detail-page pack |
64
64
  | [dataview.md](dataview.md) | Auto-inferring key/value grid + safe JSON viewer — schema or inference, nested records |
65
+ | [route-error.md](route-error.md) | `RouteError`/`RouteErrorCard` — the route-level last-resort error card, the error-handling ladder, two-level `errorElement` wiring |
65
66
  | [markdown.md](markdown.md) | MarkdownView — server render via docit, allowlist sanitizer trust model, client fallback |
66
67
  | [record-feed.md](record-feed.md) | RecordFeed — ticket/incident adapters, chronological latest-window normalization, optimistic sends, controlled mode |
67
68
  | [right-panel.md](right-panel.md) | Explicit opt-in persistent shell slot — provider state, focus/Escape semantics, route preservation, responsive layout |
package/docs/charts.md CHANGED
@@ -58,6 +58,13 @@ daterange triple — `/api/metrics/fetch` ignores it silently. web-mojo sent
58
58
  />
59
59
  ```
60
60
 
61
+ > **Money in charts:** metric values reach `valueFormatter` (and the KPITile
62
+ > `formatter`) as plain numbers with no unit semantics. If a series carries
63
+ > money, keep it in integer minor units end-to-end and write
64
+ > `(n) => fmt.currency(n)` — never pre-convert to dollars (renders 100×
65
+ > small). Avg/Median stats and "nice" Y-ticks pass DERIVED, possibly
66
+ > fractional cents; `fmt.currency` rounds them for display.
67
+
61
68
  Legend entries toggle series; hover shows the crosshair index tooltip;
62
69
  ticks are "nice"-rounded; container width is measured (no viewBox
63
70
  smearing).
package/docs/client.md CHANGED
@@ -61,6 +61,115 @@ Mock-only contract controls are exported in development: call
61
61
  `armMockReauth(method, path)` to make one authenticated method+path match
62
62
  answer 440. Ordinary requests and real transports are unaffected.
63
63
 
64
+ ## Actions — `mojoAction` / `readActionResult`
65
+
66
+ django-mojo POST_SAVE_ACTION handlers can refuse **inside an HTTP 200**, in
67
+ two wire shapes — flat `{success:false, code, error}` or wrapped
68
+ `{status:true, data:{success:false, …}}` — which the unwrap boundary
69
+ deliberately passes through (envelope `status:false` is a different failure
70
+ and already rejects). Never hand-sniff these shapes; there is one reader:
71
+
72
+ - `readActionResult(body)` → `{ok, code, error, payload}`. `payload` merges
73
+ the envelope with the action dict (action fields win), so one-shot
74
+ secrets/counters read from one place whichever direction they rode.
75
+ Payload `status:false` (the `revoke_sessions` spelling) also refuses;
76
+ bodies with no flag resolve `ok: true`.
77
+ - `mojoAction(endpoint, id, action, payload?)` — the raw-path primitive:
78
+ POSTs `{[action]: payload ?? true}` to `endpoint/<id>` under
79
+ `withFreshAuth`, normalizes the reply, and **REJECTS with
80
+ `ActionRefusedError`** (a `MojoError` with `status: 200`, `errorCode` =
81
+ the refusal code, `message` = the server's text) on an inside-the-200
82
+ refusal. Returns the `ActionResult` on success.
83
+
84
+ Which to use: **model-bound → `defineModel(...).useAction`** (same
85
+ normalizer + cache maintenance; see defineModel.md "Action refusals" for
86
+ `refusal: 'reject' | 'return'`); **raw one-off action POST →
87
+ `mojoAction`**; **never** parse `success`/`status` off an action body at a
88
+ call site. Diagnostics whose flag is a result datum (connection testers)
89
+ either declare `refusal:'return'` on the model or stay as bespoke raw
90
+ readers with a comment.
91
+
92
+ ## Endpoint scoping — the middle tier
93
+
94
+ The model layer scopes reliably; everything hand-rolled with `useQuery` +
95
+ `mojoCall` historically forgot the scope param — the largest bug class in the
96
+ first consumer audit (eight files, every one a raw read or verb missing its
97
+ `group`). The middle tier makes the raw path as safe as the blessed one:
98
+ apps *declare* their scoped endpoint families once, and every helper —
99
+ plus a dev tripwire in the transport itself — enforces the declaration.
100
+
101
+ ### `registerEndpointScope(prefix | prefix[], {key?, required?, format?})`
102
+
103
+ App-side, at boot (before the first fetch). Longest registered prefix wins;
104
+ re-registering a prefix replaces it. `key` defaults to `'group'`, `required`
105
+ defaults to `true`; `format` maps the group id to the wire value for
106
+ spellings like `account=group-<pk>`:
107
+
108
+ ```ts
109
+ registerEndpointScope('/api/wallet/', { key: 'group' });
110
+ registerEndpointScope('/api/mojoverify/', { key: 'group' });
111
+ registerEndpointScope('/api/store/order', { key: 'gid' });
112
+ registerEndpointScope('/api/billing/', { key: 'account', format: (id) => `group-${id}` });
113
+ ```
114
+
115
+ The base admin portal registers none — it is deliberately no-group. A
116
+ `required` scope that cannot be resolved fails loudly (throw or disabled
117
+ query, below); `required: false` families only inject when a group is active.
118
+
119
+ ### Where the group id comes from
120
+
121
+ `GroupProvider` **owns** the active group — context stays the React-side
122
+ source of truth — and mirrors its *resolved* group into a module-level
123
+ signal (`setActiveGroupSignal` / `getActiveGroupId`) so plain functions can
124
+ read it. Do not read `localStorage.active_group_id` yourself instead: the
125
+ URL `?group=` param BEATS the stored id (group.tsx resolution order), and
126
+ only the provider computes that resolution — the signal always reflects it.
127
+ An explicit `group` option on any helper beats the signal.
128
+
129
+ ### `useScopedQuery<T>(path, params?, {read?, group?, ...queryOpts})`
130
+
131
+ `useQuery` over a scoped GET. The registered spelling is injected into the
132
+ request params, and the group id rides the query key —
133
+ `[path, params, gid]` — so a brand switch can never serve another brand's
134
+ cached rows, while invalidation by `[path]` prefix keeps working. Keep that
135
+ key discipline if you write a raw scoped query by hand.
136
+
137
+ When a REQUIRED scope has no active group, the query renders **disabled** —
138
+ and a disabled query has *neither data nor error*. Do not draw an empty box
139
+ or a spinner that never resolves: state the wait honestly at the call site
140
+ ("select a brand to load …").
141
+
142
+ ### `mojoScopedCall` / `mojoRpc`
143
+
144
+ - `mojoScopedCall(path, opts)` — `mojoCall` with the registered scope
145
+ injected: into `params` for parameter-only requests, into the `body` when
146
+ a body is present. Throws in dev when a REQUIRED scope cannot be resolved
147
+ — an unscoped write to a scoped family must never leave the machine.
148
+ - `mojoRpc<T>(path, body, opts?)` — the scoped RPC verb: POST `body` under
149
+ `withFreshAuth` with the scope injected; resolves `env.data as T`.
150
+ Action-shaped `{key: payload}` saves belong to `mojoAction`, not here.
151
+
152
+ ### `mojoAction` body injection
153
+
154
+ `mojoAction(endpoint, id, action, payload?)` consults the same registry:
155
+ for a scope-registered family with an active group, the scope key is
156
+ injected into the action POST body alongside `{[action]: payload}` — the
157
+ wallet-verbs class: actions the backend refuses for brand-scoped operators
158
+ without `group`. With nothing registered (the base admin) the injection is
159
+ inert.
160
+
161
+ ### The dev tripwire — and `unscoped: true`
162
+
163
+ `assertScoped` runs inside the unwrap boundary on **every** dev request: a
164
+ registered-REQUIRED path whose params *and* body both lack the scope key
165
+ throws before the request leaves the machine, naming the registration and
166
+ the fix. So even a raw `mojoCall` that bypasses every helper still fails at
167
+ the desk, not in production data. Passing the scope explicitly satisfies it;
168
+ for the rare genuinely-global call to a scoped family, mark it
169
+ `{ unscoped: true }` — an explicit, greppable opt-out. Production builds
170
+ skip the check (`import.meta.env.DEV` only); it is a development tripwire,
171
+ not a runtime gate.
172
+
64
173
  ## Mock admin contracts
65
174
 
66
175
  The mock carries measured GroupView/admin transports, not UI-shaped
package/docs/dataview.md CHANGED
@@ -126,7 +126,7 @@ grids stops being readable.
126
126
  | `email` | `<a href="mailto:…">` |
127
127
  | `url` / `file` | `<a target="_blank" rel="noopener noreferrer">` + external-link icon |
128
128
  | `phone` | `<a href="tel:…">` around `fmt.phone` (digits + `+` only in the href) |
129
- | `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer → cents, fractional → major units** |
129
+ | `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer (or integer-string) → cents; fractional (number or string) → major units** |
130
130
  | `filesize` | `fmt.filesize` (decimal KB/MB/GB) |
131
131
  | `percent` | `fmt.percent`; **`\|v\| ≤ 1` is scaled ×100**, anything larger is already a percentage |
132
132
  | `number` | `count`/`total`/`followers`/`views` → `fmt.compact` at ≥1000 else `fmt.number`; `score`/`rating` → `fmt.number(v, 1)` when fractional; `version` / the word `id` → verbatim; else `fmt.number` |
@@ -182,8 +182,11 @@ Truncated text keeps the full value on a `title` attribute.
182
182
 
183
183
  - **Money is CENTS when it's an integer.** django-mojo stores minor units,
184
184
  so `plan_amount: 129900` → `$1,299.00` while `overage_price: 4.75` →
185
- `$4.75`. A record that breaks that convention needs an explicit
186
- `format`.
185
+ `$4.75` — and wire decimal STRINGS classify the same way, so
186
+ `overage_price: '4.75'` (the DecimalField shape) is also `$4.75`. A
187
+ whole-dollar decimal string (`'12.00'`) parses to an integer and reads as
188
+ cents (`$0.12`) — inherently ambiguous; a record that breaks the
189
+ convention needs an explicit `format`.
187
190
  - **Percent auto-scales below 1.** `error_rate: 0.0182` → `2%`;
188
191
  `error_rate: 12` → `12%`. A ratio that legitimately exceeds 1 needs an
189
192
  explicit `format`.
@@ -55,16 +55,63 @@ action-specific payload. **Which one is DECLARED, never sniffed:**
55
55
 
56
56
  - `response: 'row'` (default) → `ActionOutcome.row` is the refreshed record
57
57
  (written through to the cache).
58
- - `response: 'payload'` → `row` is null; read `outcome.body` (e.g.
59
- `body.message` for `revoke_sessions` — toast the server's text).
58
+ - `response: 'payload'` → `row` is null; read `outcome.result.payload`
59
+ (e.g. `result.payload.message` for `revoke_sessions` — toast the
60
+ server's text). `outcome.body` remains the raw unwrapped envelope.
60
61
 
61
62
  Invoke: `await disableAction.mutateAsync({ id, payload: { reason: 'abuse' } })`.
62
63
  `payload` defaults to `{}`; handlers treat the value as a dict. Errors
63
64
  (e.g. `"reason must be one of: abuse, admin"`) REJECT with the server's
64
65
  message.
65
66
 
67
+ ## Action refusals
68
+
69
+ django-mojo action handlers can refuse **inside an HTTP 200**. The reply
70
+ reaches the client in one of two wire shapes:
71
+
72
+ ```jsonc
73
+ {"success": false, "code": "ALREADY_DISABLED", "error": "…"} // flat (JsonResponse verbatim)
74
+ {"status": true, "data": {"success": false, "code": "…", "error": "…"}} // wrapped (save-and-respond)
75
+ ```
76
+
77
+ This is NOT the envelope failure: envelope-level `status:false` already
78
+ rejects at the unwrap boundary (`MojoError`). The action-refusal layer sits
79
+ above it and is handled by `useAction` automatically:
80
+
81
+ - **`refusal: 'reject'` (default)** — a `success:false` (or payload
82
+ `status:false`) reply REJECTS with **`ActionRefusedError`**, so a refusal
83
+ can never read as done. The error extends `MojoError` with `status: 200`
84
+ (the HTTP layer succeeded — the HANDLER said no), `message` = the server's
85
+ `error` text, `errorCode` = the semantic refusal `code`, and
86
+ `result`/`data` = the merged payload (may hold evidence such as the
87
+ current row state). Call sites need only their ordinary
88
+ `catch (err) { toast.error(err.message) }`.
89
+ - **`refusal: 'return'`** — declare this when the flag is a DIAGNOSTIC
90
+ RESULT, not a refusal (e.g. a connection test answering
91
+ `{success, key_count}`). The mutation resolves either way; read
92
+ `outcome.result` (`{ok, code, error, payload}`). Declared, never sniffed.
93
+
94
+ `ActionOutcome.result` is the normalized reply for every action:
95
+ `result.payload` merges the envelope with the action dict (action fields
96
+ win), so one-shot secrets and counters read from one place regardless of
97
+ which wire shape arrived. For `refusal:'reject'` actions `result.ok` is
98
+ always `true` on the resolved path — a refusal already rejected.
99
+
100
+ Oddball: `User.revoke_sessions` spells the flag `status` inside its payload
101
+ (`{status: true, message}` on success). Only `status === false` refuses, so
102
+ its truthy success value — and payloads with no flag at all — resolve
103
+ normally.
104
+
66
105
  ## Pitfalls
67
106
 
107
+ **FK fields are graph-shaped.** The backend controls graph definitions, so
108
+ an FK field your UI reads as a scalar (`row.currency === "GC"`) can start
109
+ arriving expanded (`{id, code, name, …}`) — or as the bare pk — when a
110
+ graph changes. Render such fields through `fmt.code(value)` or a `render`
111
+ callback that handles all three shapes; never hand the raw field to JSX.
112
+ The shared primitives degrade instead of crashing (`safeNode`/`RenderGuard`),
113
+ but app JSX outside them is unguarded.
114
+
68
115
  - Don't build a second store around these hooks — pages read query state.
69
116
  - The action-name union isn't typed; an undeclared name throws in dev the
70
117
  first time the hook renders (fail loud, not a 404 later).
@@ -54,7 +54,7 @@ the type checker and was the source's silent-failure vector) and the
54
54
  | Fn | Notes |
55
55
  |---|---|
56
56
  | `fmt.number(v, decimals=0, fallback='—')` | thousands separators: `1234567` → `1,234,567`. *Deviation:* source defaulted to 2 decimals; portal columns are counts |
57
- | `fmt.currency(v, code='USD', {unit, decimals, fallback})` | `129900` → `$1,299.00`. **Input is CENTS by default** — django-mojo stores money as integer minor units (`cents_to_currency`) so it never touches a float; pass `{unit:'major'}` for a dollars value or a zero-decimal currency (JPY). *Deviation:* arg 2 is an ISO **code**, not a symbol — the source's own callers passed `currency("EUR")` and got `EUR1,299.00`. An unknown code warns once and falls back to USD |
57
+ | `fmt.currency(v, code='USD', {unit, decimals, fallback})` | `129900` → `$1,299.00`. **Input is CENTS by default** — django-mojo stores money as integer minor units (`cents_to_currency`) so it never touches a float; pass `{unit:'major'}` for a dollars value or a zero-decimal currency (JPY). *Deviation:* arg 2 is an ISO **code**, not a symbol — the source's own callers passed `currency("EUR")` and got `EUR1,299.00`. An unknown code warns once and falls back to USD. **End-to-end rule: ALL money crosses the wire as integer minor units — format at the edge, never pre-convert.** Troubleshooting: amounts 100× small = you passed dollars; 100× large = you passed cents with `unit:'major'` |
58
58
  | `fmt.percent(v, decimals=0, multiply=true, fallback='—')` | `0.856` → `86%`; `multiply=false` when the value is already a percentage |
59
59
  | `fmt.compact(v, decimals=1, fallback='—')` | `1234` → `1.2K`, `3400000` → `3.4M`. Uppercase K/M/B (source casing); caps at B, so `1e12` reads `1000.0B`. Below 1,000 the raw number passes through unseparated |
60
60
  | `fmt.filesize(v, binary=false, decimals=1, fallback='—')` | `1536` → `1.5 KB`; `binary` switches to 1024/`KiB`. Whole bytes stay integral (`999 B`); caps at TB. Negatives degrade readably (`-500 B`) |
@@ -69,6 +69,7 @@ the type checker and was the source's silent-failure vector) and the
69
69
  | `fmt.truncateFront(v, length=8, prefix='...', fallback='—')` | keeps only the TAIL: `...2eZvKY` |
70
70
  | `fmt.mask(v, char='*', showLast=4, fallback='—')` | all-but-last-4: `************1111`. Values no longer than `showLast` return intact. *Fixes a source bug:* `showLast:0` masked the string and re-appended it whole (`slice(-0)` is `slice(0)`) |
71
71
  | `fmt.slug(v, separator='-')` | **fallback is `''`**, not `—` (a slug of nothing is nothing). *Deviations:* accents fold to ASCII (`Café` → `cafe`, matching django-mojo's `strip_accents`) instead of being dropped (`caf`); the separator is regex-escaped — the source interpolated it raw, so `slug(v,'.')` misbehaved and `slug(v,'')` threw on an empty quantifier |
72
+ | `fmt.code(v, fallback='')` | **FK-safe display for coded wire fields.** django-mojo graphs expand FK fields per-graph — the same field is `"GC"` on one graph, `{id, code, name, …}` on another, and the bare pk when the graph omits it. `code()` renders every shape: string → itself, expanded row → `.code` ?? `.name`, pk number → `"3"`. **Fallback is `''`** (like `slug`) so it composes into template strings; cells that want `—` pass it explicitly. An object with no usable code/name warns once and returns the fallback. *Deviation from the wmx-admin-v2 `codeOf` source:* a pk number renders as its digits, not `''` — an unexpanded FK is data |
72
73
  | `fmt.phone(v, fallback='—')` | **DISPLAY ONLY.** E.164 (`+15555550142`) stays the wire shape — django-mojo REJECTS pretty formats on save, so never round-trip this into a field. US 10/11-digit → `(555) 555-0142` / `+1 (555) 555-0142`; anything else (a real international number) is returned trimmed and unchanged. *Deviation:* the source stripped the leading `+`, turning valid E.164 into digit soup. No `<a href="tel:">` — links are components |
73
74
 
74
75
  ### Duration + booleans
@@ -85,6 +86,21 @@ the type checker and was the source's silent-failure vector) and the
85
86
  | `fmt.initials(name)` | 1–2 letters; null-safe (`'?'` for empty — live rows carry `display_name: null`) |
86
87
  | `fmt.inferTone(value)` | status text → Badge tone ('active'→success, 'banned'→danger, booleans, …) |
87
88
 
89
+ ### FK fields change shape per graph — render them through `fmt.code`
90
+
91
+ A raw object rendered as a React child crashes the page (`Objects are not
92
+ valid as a React child`), and django-mojo makes that a live hazard: the
93
+ backend controls graph definitions, so a field your column renders as a
94
+ string can start arriving as an expanded row object without any frontend
95
+ change. wmx-admin-v2 white-screened a production table exactly this way.
96
+ Any FK-ish field (`currency`, `group`, `owner`, …) rendered in JSX goes
97
+ through `fmt.code(value)` — or a `render` callback that handles both
98
+ shapes. The shared primitives (ModelTable cells, DetailView sections and
99
+ badges, the card formatter hooks) degrade to a readable value plus one
100
+ `console.warn` instead of crashing (see `safeNode`/`RenderGuard` in the
101
+ ModelTable doc), but app-composed JSX outside those paths is on its own —
102
+ use the formatter.
103
+
88
104
  Formatters NEVER throw on bad input — they degrade (rule: a formatter is
89
105
  the last thing allowed to take down a row). Every one is null/undefined-safe,
90
106
  parses strictly (`"12 items"` is NOT 12 — it degrades, unlike the source's
@@ -72,6 +72,49 @@ The infrastructure entry installs the optional managed-DNS adapter
72
72
  synchronously, which makes Email-first navigation safe even before any DNS page
73
73
  chunk has loaded. `admin/core` performs neither DNS nor field registration.
74
74
 
75
+ ### Flat sections (`presentation: 'flat'`)
76
+
77
+ A section whose children should render as FLAT top-level sidebar rows under
78
+ labeled group dividers — no accordion (the persona-lens shape: a Settings
79
+ lens whose sidebar reads Brand / Access / Activity as divider groups):
80
+
81
+ ```ts
82
+ {
83
+ id: 'settings', label: 'Settings', icon: 'bi-gear', route: '/settings',
84
+ presentation: 'flat', group: 'Brand', // labels the hoisted home row
85
+ children: [
86
+ { label: 'Brand profile', route: '/settings/brand', group: 'Brand' },
87
+ { label: 'Members', route: '/settings/members', group: 'Access' },
88
+ // a nested child stays an accordion among the flat rows
89
+ { label: 'Reports', group: 'Access', children: [/* … */] },
90
+ ],
91
+ }
92
+ ```
93
+
94
+ `SidebarNav` applies the exported pure `flattenMenuItems(items, ctx)`
95
+ automatically; flag-less menus pass through by identity. Semantics:
96
+
97
+ - **Divider on group CHANGE** (run semantics) — a repeated non-consecutive
98
+ label is two runs. A registry divider immediately followed by the first
99
+ group divider collapses (later wins).
100
+ - **The section's `route` hoists first** as its home row, `exact`-matched so
101
+ it never prefix-lights over deep routes. The label stays the section's own
102
+ (no auto-"Overview" — name it what you mean).
103
+ - **Nested children hoist as accordions** and RESET the run, so the next
104
+ flat row re-emits its divider.
105
+ - **Icons fall back to the section's**; a child's `group` label joins its
106
+ search `keywords`.
107
+ - **Gates compose section ∧ child** — a child's own `permissions` never
108
+ bypass the section gate (stricter than the wmx source, deliberately: it
109
+ matches the admin nav's clause idiom). An invisible flat parent drops its
110
+ whole section.
111
+ - **Flat is presentation only.** Route resolution (`resolveActiveMenu`)
112
+ walks the original tree, so active-menu selection cannot disagree with
113
+ what renders. Rail density is an authoring concern — nest a long group.
114
+
115
+ The personas layer's `sectionMenuEntries` still pre-flattens its own menus;
116
+ converging it onto flat sections is a named follow-up.
117
+
75
118
  ## Permissions (A2) — client gates, server authority
76
119
 
77
120
  `Me.permissions` is a dict; truthy `true` OR `1` grants. Semantics
package/docs/params.md CHANGED
@@ -34,6 +34,18 @@ unless `defaults.sort`), `page` (1-based), `size`.
34
34
  Every write goes through the URL (`replace`), so table state deep-links and
35
35
  survives reloads by construction.
36
36
 
37
+ ## `excludeKeys` — the fixedParams scrub
38
+
39
+ `useTableParams(defaults, defaultFilters, excludeKeys?)`: keys in the set
40
+ never become filter state — dropped from the URL-derived filters memo, the
41
+ `defaultFilters` seed, and `applySaved`'s persisted filters. ModelTable
42
+ passes its `fixedParams` keys here so locked scope cannot re-enter as a
43
+ removable pill via a bookmark or a stale persisted view. This does not
44
+ bend the store's rule: the store remains the single source of truth for
45
+ table STATE (what the user did); `fixedParams` is table IDENTITY (what
46
+ the table is) and deliberately lives outside it, merged onto the wire
47
+ after normalization. Pass a render-stable Set — it is a memo dependency.
48
+
37
49
  ## `registerNonFilterParams(...keys)` — params the PAGE owns
38
50
 
39
51
  Everything in the query string that isn't `search/sort/page/size` is treated