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.
- package/docs/DetailView.md +24 -3
- package/docs/ModelTable.md +44 -0
- package/docs/README.md +1 -0
- package/docs/charts.md +7 -0
- package/docs/client.md +109 -0
- package/docs/dataview.md +6 -3
- package/docs/defineModel.md +49 -2
- package/docs/grouping-and-fmt.md +17 -1
- package/docs/menus-and-access.md +43 -0
- package/docs/params.md +12 -0
- package/docs/personas.md +212 -0
- package/docs/realtime.md +40 -0
- package/docs/route-error.md +64 -0
- package/package.json +2 -1
- package/src/admin/core/index.ts +18 -3
- package/src/admin/identity/users/sections/actions.tsx +14 -5
- package/src/admin/jobs/JobDetail.tsx +4 -2
- package/src/admin/network/BlockedIPsPage.tsx +9 -6
- package/src/admin/network/FirewallLogPage.tsx +3 -1
- package/src/admin/phonehub/api.ts +3 -0
- package/src/admin/storage/models.ts +6 -0
- package/src/client/action-result.ts +95 -0
- package/src/client/active-group.ts +29 -0
- package/src/client/client.ts +8 -0
- package/src/client/endpoint-scope.ts +79 -0
- package/src/client/group.tsx +9 -0
- package/src/client/index.ts +5 -0
- package/src/client/mock.ts +7 -1
- package/src/client/model.ts +26 -2
- package/src/client/params.ts +16 -4
- package/src/client/persist.ts +54 -0
- package/src/client/realtime.ts +13 -0
- package/src/client/scoped.ts +93 -0
- package/src/personas/PersonaProvider.tsx +151 -0
- package/src/personas/audit.ts +73 -0
- package/src/personas/index.ts +13 -0
- package/src/personas/presets.ts +72 -0
- package/src/personas/registry.ts +111 -0
- package/src/personas/sections.tsx +202 -0
- package/src/ui/DetailView.tsx +30 -6
- package/src/ui/ModelTable.tsx +75 -9
- package/src/ui/RouteError.tsx +73 -0
- package/src/ui/SidebarNav.tsx +36 -5
- package/src/ui/active-persona.ts +34 -0
- package/src/ui/detail/DataView.tsx +10 -4
- package/src/ui/detail/KnownFieldsCard.tsx +13 -1
- package/src/ui/fixed-params.ts +58 -0
- package/src/ui/format.ts +36 -11
- package/src/ui/index.ts +3 -0
- package/src/ui/menu-registry.ts +128 -9
- package/src/ui/route-error.ts +60 -0
- package/src/ui/safe-node.tsx +88 -0
- package/src/ui/shell.ts +2 -0
- package/src/ui/warn-once.ts +12 -0
package/docs/DetailView.md
CHANGED
|
@@ -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
|
|
package/docs/ModelTable.md
CHANGED
|
@@ -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
|
|
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
|
|
186
|
-
`
|
|
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`.
|
package/docs/defineModel.md
CHANGED
|
@@ -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.
|
|
59
|
-
`
|
|
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).
|
package/docs/grouping-and-fmt.md
CHANGED
|
@@ -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
|
package/docs/menus-and-access.md
CHANGED
|
@@ -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
|