portal-mojo 0.1.0 → 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/README.md +42 -1
- package/docs/DetailView.md +24 -3
- package/docs/ModelTable.md +44 -0
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +14 -7
- 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/image-editor.md +13 -0
- package/docs/menus-and-access.md +57 -0
- package/docs/params.md +12 -0
- package/docs/personas.md +212 -0
- package/docs/realtime.md +44 -2
- package/docs/route-error.md +64 -0
- package/package.json +15 -2
- package/src/admin/assistant/AssistantLauncher.tsx +23 -0
- package/src/admin/assistant/AssistantPanel.tsx +3 -2
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/index.ts +1 -11
- package/src/admin/assistant/launchers.tsx +5 -11
- package/src/admin/assistant/pages.tsx +1 -1
- package/src/admin/assistant/permissions.ts +1 -0
- package/src/admin/assistant/streaming.ts +69 -41
- package/src/admin/bouncer/index.ts +1 -28
- package/src/admin/bouncer/models.ts +1 -1
- package/src/admin/bouncer/signatures.tsx +1 -1
- package/src/admin/cloudwatch/CloudWatchChart.tsx +1 -1
- package/src/admin/cloudwatch/data.ts +1 -1
- package/src/admin/cloudwatch/index.ts +1 -11
- package/src/admin/core/index.ts +230 -0
- package/src/admin/credentials/group-api-keys.tsx +1 -1
- package/src/admin/credentials/models.ts +1 -1
- package/src/admin/credentials/webhook-subscriptions.tsx +1 -1
- package/src/admin/dashboard/AdminDashboardPage.tsx +9 -18
- package/src/admin/dashboard/data.ts +1 -1
- package/src/admin/dashboard/index.ts +1 -0
- package/src/admin/dns/CertificateDetail.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +1 -1
- package/src/admin/dns/CertificatesPage.tsx +1 -1
- package/src/admin/dns/DnsRecordsPanel.tsx +1 -1
- package/src/admin/dns/DomainCertificatesSection.tsx +1 -1
- package/src/admin/dns/DomainDetail.tsx +1 -1
- package/src/admin/dns/DomainOnboardingDialog.tsx +1 -1
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/ProviderCredentialsPage.tsx +1 -1
- package/src/admin/dns/RegistrantContactPage.tsx +1 -1
- package/src/admin/dns/api.ts +1 -1
- package/src/admin/dns/index.ts +2 -59
- package/src/admin/dns/models.ts +1 -1
- package/src/admin/dns/setup.ts +27 -0
- package/src/admin/domains/assistant.ts +13 -0
- package/src/admin/domains/communications.ts +35 -0
- package/src/admin/domains/identity.ts +24 -0
- package/src/admin/domains/infrastructure.ts +24 -0
- package/src/admin/domains/observability.ts +28 -0
- package/src/admin/domains/operations.ts +18 -0
- package/src/admin/domains/security.ts +55 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +1 -1
- package/src/admin/identity/members/MemberDetail.tsx +1 -1
- package/src/admin/identity/members/MembersPage.tsx +1 -1
- package/src/admin/identity/members/index.ts +1 -17
- package/src/admin/identity/members/member-flows.tsx +1 -1
- package/src/admin/identity/members/models.ts +1 -1
- package/src/admin/identity/users/UserDetail.tsx +1 -1
- package/src/admin/identity/users/UsersPage.tsx +1 -1
- package/src/admin/identity/users/index.ts +1 -16
- package/src/admin/identity/users/models.ts +1 -1
- package/src/admin/identity/users/sections/LoginsSection.tsx +1 -1
- package/src/admin/identity/users/sections/NotificationsSection.tsx +1 -1
- package/src/admin/identity/users/sections/PersonalSection.tsx +1 -1
- package/src/admin/identity/users/sections/actions.tsx +15 -6
- package/src/admin/identity/users/sections/permission-catalog.ts +1 -1
- package/src/admin/identity/users/sections/queries.ts +1 -1
- package/src/admin/identity/users/sections/shared.tsx +1 -1
- package/src/admin/incidents/EventsPage.tsx +1 -1
- package/src/admin/incidents/IncidentDetail.tsx +1 -1
- package/src/admin/incidents/IncidentsPage.tsx +1 -1
- package/src/admin/incidents/index.ts +1 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/index.ts +5 -364
- package/src/admin/jobs/JobDetail.tsx +5 -3
- package/src/admin/jobs/JobsTablePage.tsx +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +2 -2
- package/src/admin/jobs/ScheduledTaskDetail.tsx +1 -1
- package/src/admin/jobs/ScheduledTasksPage.tsx +1 -1
- package/src/admin/jobs/columns.tsx +1 -1
- package/src/admin/jobs/control.ts +1 -1
- package/src/admin/jobs/index.ts +1 -39
- package/src/admin/jobs/models.ts +1 -1
- package/src/admin/jobs/queries.ts +1 -1
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +1 -1
- package/src/admin/jobs/sections/JobOperationsSection.tsx +1 -1
- package/src/admin/messaging/EmailDomainWizard.tsx +1 -1
- package/src/admin/messaging/PublicMessagesPage.tsx +1 -1
- package/src/admin/messaging/api.ts +1 -1
- package/src/admin/messaging/data.ts +1 -1
- package/src/admin/messaging/index.ts +1 -9
- package/src/admin/messaging/models.ts +1 -1
- package/src/admin/messaging/push/PushPage.tsx +1 -1
- package/src/admin/messaging/push/api.ts +1 -1
- package/src/admin/messaging/push/index.ts +1 -4
- package/src/admin/messaging/push/models.ts +1 -1
- package/src/admin/monitoring/MetricsExplorerPage.tsx +1 -1
- package/src/admin/monitoring/MetricsSourcePicker.tsx +1 -1
- package/src/admin/monitoring/index.ts +1 -41
- package/src/admin/monitoring/metrics-explorer-client.ts +2 -2
- package/src/admin/monitoring/models.ts +1 -1
- package/src/admin/network/BlockedIPsPage.tsx +10 -7
- package/src/admin/network/BlockedIpDetail.tsx +1 -1
- package/src/admin/network/FirewallLogPage.tsx +3 -1
- package/src/admin/network/IPSetDetail.tsx +1 -1
- package/src/admin/network/IPSetsPage.tsx +1 -1
- package/src/admin/network/geofence/BlocksTab.tsx +1 -1
- package/src/admin/network/geofence/ExemptionsTab.tsx +1 -1
- package/src/admin/network/geofence/GeofencingPage.tsx +1 -1
- package/src/admin/network/geofence/RulesTab.tsx +1 -1
- package/src/admin/network/geofence/geofence-data.ts +3 -21
- package/src/admin/network/geofence/permissions.ts +13 -0
- package/src/admin/network/index.ts +1 -41
- package/src/admin/network/models.ts +3 -9
- package/src/admin/network/permissions.ts +8 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +3 -3
- package/src/admin/phonehub/api.ts +4 -1
- package/src/admin/phonehub/data.ts +1 -1
- package/src/admin/phonehub/index.ts +1 -4
- package/src/admin/phonehub/models.ts +1 -1
- package/src/admin/public/assistant-launcher.ts +1 -0
- package/src/admin/public/assistant.ts +2 -0
- package/src/admin/public/communications.ts +4 -0
- package/src/admin/public/identity.ts +3 -0
- package/src/admin/public/infrastructure.ts +4 -0
- package/src/admin/public/observability.ts +4 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/public/security.ts +7 -0
- package/src/admin/registry.ts +40 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +1 -1
- package/src/admin/rules/RuleSetsPage.tsx +1 -1
- package/src/admin/rules/index.ts +1 -6
- package/src/admin/rules/models.ts +1 -1
- package/src/admin/security/devices/LoginEventDetail.tsx +1 -1
- package/src/admin/security/devices/LoginLocationsPage.tsx +1 -1
- package/src/admin/security/devices/UserDeviceDetail.tsx +1 -1
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +1 -1
- package/src/admin/security/devices/index.ts +1 -22
- package/src/admin/security/devices/models.ts +1 -1
- package/src/admin/security/geoip/GeoIpCachePage.tsx +1 -1
- package/src/admin/security/geoip/GeoIpDossier.tsx +1 -1
- package/src/admin/security/geoip/geoip-forms.ts +1 -1
- package/src/admin/security/geoip/index.ts +1 -17
- package/src/admin/security/geoip/models.ts +1 -1
- package/src/admin/security/index.ts +1 -23
- package/src/admin/security/models.ts +1 -1
- package/src/admin/security/tickets.tsx +1 -1
- package/src/admin/settings/SettingDetail.tsx +1 -1
- package/src/admin/settings/SettingEditor.tsx +1 -1
- package/src/admin/settings/index.ts +1 -13
- package/src/admin/settings/model.ts +1 -1
- package/src/admin/shortlinks/index.ts +1 -12
- package/src/admin/shortlinks/models.ts +1 -1
- package/src/admin/storage/BackendsPage.tsx +1 -1
- package/src/admin/storage/FileManagerDetail.tsx +1 -1
- package/src/admin/storage/FileView.tsx +1 -1
- package/src/admin/storage/FilesPage.tsx +1 -1
- package/src/admin/storage/index.ts +1 -18
- package/src/admin/storage/models.ts +9 -4
- package/src/admin/storage/permissions.ts +3 -0
- package/src/admin/storage/storage-dialogs.tsx +1 -1
- package/src/client/action-result.ts +95 -0
- package/src/client/active-group.ts +29 -0
- package/src/client/client.ts +15 -1
- 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 +17 -12
- 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-mock.ts +17 -13
- package/src/client/realtime.ts +18 -4
- package/src/client/runtime.ts +21 -0
- package/src/client/scoped.ts +93 -0
- package/src/client/upload.ts +6 -3
- 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/image-editor/ImageEditor.tsx +21 -22
- package/src/ui/image-editor/canvas.ts +82 -11
- package/src/ui/image-editor/math.ts +78 -0
- 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 +13 -0
- package/src/ui/warn-once.ts +12 -0
package/README.md
CHANGED
|
@@ -13,12 +13,53 @@ Live demos for everything: run `npm run dev:showcase` → **Develop → Componen
|
|
|
13
13
|
|
|
14
14
|
## Subpath surfaces
|
|
15
15
|
|
|
16
|
+
The `client/runtime`, `ui/shell`, `admin/registry`, and Assistant launcher
|
|
17
|
+
subpaths are stable, narrow package boundaries for first-party portal shells
|
|
18
|
+
and advanced hosts. They intentionally expose only the runtime pieces needed
|
|
19
|
+
at application startup; use the broader compatibility surfaces when those
|
|
20
|
+
additional exports are required.
|
|
21
|
+
|
|
16
22
|
| Subpath | Surface |
|
|
17
23
|
|---|---|
|
|
18
24
|
| `portal-mojo/client` | Typed django-mojo protocol layer: envelope unwrap at exactly one boundary (a failed save **rejects**), `start`/`size` paging, `'-field'` sort, Django lookups, the URL-synced `useTableParams` store (single source of truth for table state), TanStack Query hooks. Auth client: password / magic-link / passkey login, forgot/reset, cross-origin handoff (`?auth_code=` scrub-before-network), single-flight refresh + pre-request gate (synthetic-401 reject, `/api/token/refresh` recursion guard), `X-Mojo-UID` device header, `getAuthSnapshot`/`subscribeAuth` for React. Auth-challenged realtime transport: `RealtimeProvider`/`RealtimeClient`, refcounted topics, typed event projection, and a deterministic mock. Boot auth with `initAuth()`. The in-memory HTTP mock transport lives here too — it is the wire contract's executable spec and evolves in lockstep with the client (any seeded active user logs in with email + `"mojo"`). |
|
|
25
|
+
| `portal-mojo/client/runtime` | Stable, narrow client boundary for first-party application runtime code. It omits eager mock-test controls; the mock transport itself loads on the first mock request. |
|
|
19
26
|
| `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
|
|
27
|
+
| `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, modal/toast hosts, password helpers, and formatters. |
|
|
20
28
|
| `portal-mojo/charts` | Dependency-free SVG charts: `SeriesChart` (line/bar/area, stacked bars, legend toggle, crosshair tooltip) and `MetricsChart` (granularity/range/type control bar for `/api/metrics/fetch`). |
|
|
21
|
-
| `portal-mojo/admin` |
|
|
29
|
+
| `portal-mojo/admin/core` | Side-effect-light Admin contracts and host: `AdminSection`, the discriminated `AdminRoute` XOR, `adminSectionRoutes`, `adminSectionsMenu`, and `AdminLazyPage`. |
|
|
30
|
+
| `portal-mojo/admin/registry` | Stable, narrow built-in `ADMIN_SECTIONS` roster for first-party hosts, without broad page barrels. It installs the optional Email→DNS adapter synchronously so communications routes work before DNS navigation. |
|
|
31
|
+
| `portal-mojo/admin/assistant/launcher` | Stable, narrow shell launcher boundary without the Assistant page/data barrel. |
|
|
32
|
+
| `portal-mojo/admin/{identity,security,observability,operations,infrastructure,communications,assistant}` | Narrow domain APIs and registry contributions. Built-in routes use leaf-module `loadComponent` loaders; importing `infrastructure` synchronously installs the optional Email→DNS adapter. |
|
|
33
|
+
| `portal-mojo/admin` | Compatibility aggregate containing every historical named export and the stable `ADMIN_SECTIONS` roster. It intentionally installs the DNS adapter and Rule field registration synchronously. New code should prefer `admin/core` plus the domains it consumes. |
|
|
34
|
+
|
|
35
|
+
### Admin route contract
|
|
36
|
+
|
|
37
|
+
Routes are an explicit XOR. A host-owned page stays synchronous; a packaged
|
|
38
|
+
page supplies a separately named loader. The host never guesses from whether a
|
|
39
|
+
value is callable.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
const extension: AdminSection = {
|
|
43
|
+
id: 'billing', title: 'Billing', icon: 'bi-receipt',
|
|
44
|
+
permissions: ['sys.billing'],
|
|
45
|
+
routes: [{ path: '', component: BillingHome }],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const packaged: AdminSection = {
|
|
49
|
+
id: 'audit', title: 'Audit', icon: 'bi-journal-text',
|
|
50
|
+
permissions: ['sys.view_logs'],
|
|
51
|
+
routes: [{
|
|
52
|
+
path: '',
|
|
53
|
+
loadComponent: () => import('./AuditPage').then(({ AuditPage }) => ({ default: AuditPage })),
|
|
54
|
+
}],
|
|
55
|
+
};
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Lazy loading, chunk-error recovery, and retry render inside section and route
|
|
59
|
+
permission guards, so a denied route never invokes its loader. Routes stay
|
|
60
|
+
mount-relative: pass no mount for `#/…`, or `{mount: '/system'}` for the same
|
|
61
|
+
sections under `#/system/…`. Root denial and first-visible fallback semantics
|
|
62
|
+
are identical in both forms.
|
|
22
63
|
|
|
23
64
|
## What the consuming app must provide
|
|
24
65
|
|
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/admin-assistant.md
CHANGED
|
@@ -9,20 +9,22 @@ Import from `portal-mojo/admin`.
|
|
|
9
9
|
- An owned, text-only conversation uses realtime only when the shared transport
|
|
10
10
|
is authenticated and the caller locally has `sys.view_admin`, matching the
|
|
11
11
|
WebSocket handler's narrower server check. The client sends exactly
|
|
12
|
-
`{type:'assistant_message',message,conversation_id?}
|
|
12
|
+
`{type:'assistant_message',message,request_id,conversation_id?}`, with a fresh
|
|
13
|
+
canonical UUID per turn. A caller with only
|
|
13
14
|
`sys.assistant`, any send with attachments, an unavailable socket, and a
|
|
14
15
|
foreign inspect-only conversation stay on REST. Messages are never
|
|
15
16
|
auto-resubmitted across paths.
|
|
16
17
|
- `POST /api/assistant` sends `{message, conversation_id?, attachments?}`.
|
|
17
18
|
`attachments` is omitted for text-only sends or contains 1–5 unique positive
|
|
18
19
|
ids from completed authoritative queue references.
|
|
19
|
-
- Realtime
|
|
20
|
+
- Realtime accepts these Assistant event shapes only as exact direct frames;
|
|
21
|
+
topic-wrapped or broadcast Assistant-shaped traffic is ignored. It positively
|
|
22
|
+
projects only `assistant_thinking`, `assistant_text`,
|
|
20
23
|
`assistant_tool_call`, `assistant_plan`, `assistant_plan_update`,
|
|
21
|
-
`assistant_response`, and `assistant_error
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
uncorrelated server error terminates the active turn. Tool state retains
|
|
24
|
+
`assistant_response`, and `assistant_error`. Thinking, text, tool, plan,
|
|
25
|
+
update, response, and error events require the exact `request_id` sent for
|
|
26
|
+
the active turn. Non-error events also require a positive `conversation_id`;
|
|
27
|
+
an error may omit only the conversation id. Tool state retains
|
|
26
28
|
bounded name/status/count only; raw input and the terminal `tool_calls_made`
|
|
27
29
|
list never enter UI state, caches, logs, or mock observations. Plans omit
|
|
28
30
|
tool inputs. Terminal duplication is checked only with the backend's
|
|
@@ -37,6 +39,11 @@ Import from `portal-mojo/admin`.
|
|
|
37
39
|
reconnect; locally observed permission loss immediately clears this
|
|
38
40
|
consumer's transient stream/subscriptions without disabling the shared
|
|
39
41
|
provider.
|
|
42
|
+
- Direct-source binding and the echoed `request_id` jointly isolate concurrent
|
|
43
|
+
turns, including turns in the same conversation. Missing or mismatched IDs
|
|
44
|
+
are ignored; if no correlated acknowledgement arrives within ten seconds,
|
|
45
|
+
the outcome is marked unknown, the list is refreshed, and the send is never
|
|
46
|
+
retried automatically.
|
|
40
47
|
- `POST /api/assistant/context` sends only `{model, pk}`. The returned conversation id is immediately fetched from `/api/assistant/conversation/<id>?graph=detail`; the client never synthesizes or reposts context text.
|
|
41
48
|
- Conversation and Skill lists/details/deletes are imperative and component-local. They do not use `defineModel`, Query cache, `ModelTable`, a `RecordFeed` adapter, persistence, or exports.
|
|
42
49
|
- A foreign conversation visible to an administrator is inspect-only. Only `conversation.user.id === me.id` enables continuation.
|
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/image-editor.md
CHANGED
|
@@ -64,6 +64,19 @@ These helpers operate on logical pixels and do not mount UI, upload Files,
|
|
|
64
64
|
persist state, or initiate a browser download. `decodeImageSource` is the one
|
|
65
65
|
exception that performs the URL fetch described above.
|
|
66
66
|
|
|
67
|
+
## Browser-work limits
|
|
68
|
+
|
|
69
|
+
The public low-level constants make the editor's availability boundary explicit:
|
|
70
|
+
`MAX_IMAGE_SOURCE_BYTES` is 25,000,000 bytes, `MAX_IMAGE_DIMENSION` is 8,192
|
|
71
|
+
pixels, `MAX_IMAGE_PIXELS` is 16,000,000 pixels per decoded/intermediate/final
|
|
72
|
+
surface, and `MAX_IMAGE_EDITOR_OPERATIONS` is 20. Every operation output is
|
|
73
|
+
checked before raster allocation. Initial transforms and filters must remain
|
|
74
|
+
inside the same ranges as the controls (scale 0.1–5; brightness, contrast, and
|
|
75
|
+
saturation 0–200; hue 0–360; blur 0–10; grayscale and sepia 0–100). URL bodies
|
|
76
|
+
are streamed to the byte ceiling, and source replacement or unmount aborts the
|
|
77
|
+
fetch and `<img>` fallback where the browser permits; `createImageBitmap` itself
|
|
78
|
+
is not abortable, so a stale result is closed immediately on resolution.
|
|
79
|
+
|
|
67
80
|
## Composition and history
|
|
68
81
|
|
|
69
82
|
Operations are immutable and chronological. Transform creates a logical
|
package/docs/menus-and-access.md
CHANGED
|
@@ -58,6 +58,63 @@ Admin section bundles contribute through `adminSectionsMenu`. Use
|
|
|
58
58
|
experience. Routes remain mount-relative; multiple embedded root sections
|
|
59
59
|
share one `/system` landing instead of emitting duplicate index routes.
|
|
60
60
|
|
|
61
|
+
Import the route/menu machinery from `portal-mojo/admin/core`, then import only
|
|
62
|
+
the registry domains the shell installs. The stable domains are `identity`,
|
|
63
|
+
`security`, `observability`, `operations`, `infrastructure`, `communications`,
|
|
64
|
+
and `assistant`. `portal-mojo/admin` remains the compatibility aggregate and
|
|
65
|
+
retains every historical export, but deliberately performs the legacy DNS and
|
|
66
|
+
Rule-field setup at import time.
|
|
67
|
+
|
|
68
|
+
Built-in routes use `loadComponent`; external sections may use `component`.
|
|
69
|
+
These properties are mutually exclusive. Loading and retryable error states
|
|
70
|
+
live inside both permission guards, so access denial performs no page import.
|
|
71
|
+
The infrastructure entry installs the optional managed-DNS adapter
|
|
72
|
+
synchronously, which makes Email-first navigation safe even before any DNS page
|
|
73
|
+
chunk has loaded. `admin/core` performs neither DNS nor field registration.
|
|
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
|
+
|
|
61
118
|
## Permissions (A2) — client gates, server authority
|
|
62
119
|
|
|
63
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
|