portal-mojo 0.1.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/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- package/src/ui/ui.tsx +45 -0
package/docs/markdown.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# markdown — server-rendered markdown + the trust model
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import { MarkdownView } from 'portal-mojo/ui';
|
|
5
|
+
import {
|
|
6
|
+
renderMarkdown, peekMarkdown, clearMarkdownCache,
|
|
7
|
+
sanitizeMarkdownHtml, safeLinkUrl, safeImgUrl,
|
|
8
|
+
MarkdownFallback, markdownToNodes, markdownToHtml,
|
|
9
|
+
MARKDOWN_RENDER_ENDPOINT, MAX_MARKDOWN_BYTES, type SanitizeDrop,
|
|
10
|
+
} from 'portal-mojo/client';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
django-mojo owns the markdown renderer. The portal asks it instead of
|
|
14
|
+
shipping a second one that drifts from the docs system — and keeps a
|
|
15
|
+
dependency-free client renderer behind it so a missing endpoint, an offline
|
|
16
|
+
tab or a cold first frame still shows the document.
|
|
17
|
+
|
|
18
|
+
## `<MarkdownView>`
|
|
19
|
+
|
|
20
|
+
| Prop | Default | Meaning |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `source` | — | The markdown text. |
|
|
23
|
+
| `renderer` | `'server'` | `'server'` calls `/api/docit/render` and sanitizes the answer; `'client'` never touches the wire (previews of text being typed; feeds that must not fan out one request per item). |
|
|
24
|
+
| `debounceMs` | `0` | Wait this long after `source` stops changing before calling the server. Set it for text that grows token by token (a streaming assistant reply) so one reply is one request. |
|
|
25
|
+
| `className` | — | Extra classes on the wrapper. `md` is always present. |
|
|
26
|
+
|
|
27
|
+
Renders one `<div class="md">`. Styling lives in the consuming app —
|
|
28
|
+
`apps/portal/src/theme/markdown.css`, tokens only, both themes; both render
|
|
29
|
+
paths emit the same element set, so one stylesheet dresses both.
|
|
30
|
+
|
|
31
|
+
### No loading jump, by construction
|
|
32
|
+
|
|
33
|
+
1. Source already rendered? The module cache answers **synchronously** — the
|
|
34
|
+
server HTML is on the first frame, no request, no transition.
|
|
35
|
+
2. Otherwise the **client fallback paints immediately** (real content at
|
|
36
|
+
roughly the right height, `aria-busy`, class `md-pending`), and the server
|
|
37
|
+
HTML swaps in under the same wrapper when it lands.
|
|
38
|
+
3. If the render fails, the fallback simply stays, with one `console.warn`
|
|
39
|
+
saying why.
|
|
40
|
+
|
|
41
|
+
There is no spinner and no empty state at any point. `md-pending` fades in
|
|
42
|
+
after a 250 ms delay, so a fast answer never flickers and a slow one never
|
|
43
|
+
moves.
|
|
44
|
+
|
|
45
|
+
## Wire contract
|
|
46
|
+
|
|
47
|
+
`mojo/apps/docit/rest/render.py` — read, not assumed:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
POST /api/docit/render {markdown: string} @requires_auth
|
|
51
|
+
→ {status: true, code: 200, data: {html: string}}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The handler returns a bare dict, so `mojo/decorators/http.py` wraps it into
|
|
55
|
+
the standard envelope — unwrapped at the **one boundary** (`mojoCall`). Do
|
|
56
|
+
not reintroduce web-mojo's `resp.data.data.html || resp.data.html` sniff; it
|
|
57
|
+
existed in three copies there and is on the do-not-recreate list.
|
|
58
|
+
|
|
59
|
+
Errors: `400` markdown field is required · `405` non-POST · `413` over
|
|
60
|
+
`MAX_MARKDOWN_BYTES` (400 000 bytes; `renderMarkdown` rejects locally rather
|
|
61
|
+
than spend a round trip on a guaranteed 413).
|
|
62
|
+
|
|
63
|
+
The server runs mistune with `hard_wrap=True` (a single newline is a `<br>`)
|
|
64
|
+
and `escape=True` (raw HTML in the SOURCE becomes text). Its plugin set adds
|
|
65
|
+
tables, task lists, strikethrough, footnotes, abbr, mark, math, spoiler and
|
|
66
|
+
pygments syntax highlighting.
|
|
67
|
+
|
|
68
|
+
The mock (`client/mock.ts`) answers the same route with the same envelope and
|
|
69
|
+
the same error codes, running the client parser as its renderer — so mock and
|
|
70
|
+
live differ in fidelity, never in shape. **The mock's HTML goes through the
|
|
71
|
+
sanitizer exactly like a real backend's. There is no trusted lane.**
|
|
72
|
+
|
|
73
|
+
## THE TRUST MODEL
|
|
74
|
+
|
|
75
|
+
> **Server HTML is untrusted.** django-mojo escapes raw HTML in the markdown
|
|
76
|
+
> source, but a portal must not be one backend regression away from stored
|
|
77
|
+
> XSS. Nothing is trusted because of where it came from.
|
|
78
|
+
|
|
79
|
+
Two render paths, two different guarantees:
|
|
80
|
+
|
|
81
|
+
### Path A — server HTML → `sanitizeMarkdownHtml` → `dangerouslySetInnerHTML`
|
|
82
|
+
|
|
83
|
+
`MarkdownView` is the **only** sanctioned `dangerouslySetInnerHTML` in
|
|
84
|
+
portal-mojo, and the only string it can ever receive is the return value of
|
|
85
|
+
`renderMarkdown`, which sanitizes *before it resolves*. Unsanitized server
|
|
86
|
+
HTML never leaves `client/markdown.ts`, so no caller can hold it.
|
|
87
|
+
|
|
88
|
+
The sanitizer (`client/markdown-sanitize.ts`):
|
|
89
|
+
|
|
90
|
+
1. Parses with `DOMParser('text/html')` — an **inert** document: no script
|
|
91
|
+
runs, no image loads, no `onerror` fires. (Assigning to a live element's
|
|
92
|
+
`innerHTML` would already have fetched `<img src=x onerror=…>`.)
|
|
93
|
+
2. Walks the tree. Every element is one of three things:
|
|
94
|
+
- **allowlisted** → kept, attributes then scrubbed to an allowlist
|
|
95
|
+
- **dangerous** → removed **with its whole subtree**
|
|
96
|
+
- **anything else** → **unwrapped**: the element goes, its (already
|
|
97
|
+
sanitized) children stay. Text is never lost — which is what keeps a
|
|
98
|
+
pygments `<div class=highlight><span class=k>` block readable after its
|
|
99
|
+
markup is stripped.
|
|
100
|
+
3. Serializes with the browser's own serializer (`body.innerHTML`).
|
|
101
|
+
|
|
102
|
+
| | Set |
|
|
103
|
+
|---|---|
|
|
104
|
+
| **Kept** | `p br hr h1-h6 ul ol li strong em del code pre blockquote a img table thead tbody tr th td input mark sup sub` |
|
|
105
|
+
| **Removed with subtree** | `script style iframe object embed applet noscript template link meta base title frame frameset form button select textarea svg math` — plus any element outside the XHTML namespace |
|
|
106
|
+
| **Unwrapped** | everything else (`div span section sup-wrappers …`) |
|
|
107
|
+
| **Attributes kept** | `a[href]`, `img[src alt]`, `input[type checked disabled]`, and `class` — **`md-` prefixed values only** |
|
|
108
|
+
| **Attributes dropped** | every other attribute, which is how `on*` handlers, `style`, `srcset`, `formaction`, `xlink:href`, `id` and `data-*` all go |
|
|
109
|
+
|
|
110
|
+
Value rules on top of the attribute allowlist:
|
|
111
|
+
|
|
112
|
+
- `a[href]` — `http:` / `https:` / `mailto:` / `#fragment` only. Rejected
|
|
113
|
+
hrefs are removed but the **link text stays** (never render nothing).
|
|
114
|
+
`http(s)` links are rewritten with `target="_blank" rel="noopener noreferrer"`.
|
|
115
|
+
- `img[src]` — `http:` / `https:` only. `data:image/svg+xml` is a script
|
|
116
|
+
vector, not an image. An image with no usable `src` is dropped entirely.
|
|
117
|
+
- `input` — must be `type=checkbox` (task lists); anything else is a phishing
|
|
118
|
+
surface and is dropped. `disabled` is forced on, `checked` is preserved.
|
|
119
|
+
- URL vetting **normalizes first, then allowlists**: control and zero-width
|
|
120
|
+
characters are stripped (that is how `java	script:` is smuggled) and
|
|
121
|
+
only then is the scheme matched. Unrecognized or scheme-less → dropped.
|
|
122
|
+
|
|
123
|
+
### Path B — the client fallback → React elements
|
|
124
|
+
|
|
125
|
+
`MarkdownFallback` / `markdownToNodes` emit React elements directly. No HTML
|
|
126
|
+
string is produced and `innerHTML` is never touched, so there is nothing for
|
|
127
|
+
a sanitizer to do: the only way a `<script>` in the source could become a
|
|
128
|
+
script is if React rendered one, and React only renders the elements that
|
|
129
|
+
file names. Text is escaped by React itself.
|
|
130
|
+
|
|
131
|
+
**The one thing React does not make safe is URLs** — it will happily render
|
|
132
|
+
`href="javascript:…"`. Both link and image URLs on this path go through the
|
|
133
|
+
*same* `safeLinkUrl` / `safeImgUrl` used by the sanitizer; a rejected link
|
|
134
|
+
degrades to plain text, a rejected image to its alt text.
|
|
135
|
+
|
|
136
|
+
### Consequences worth knowing
|
|
137
|
+
|
|
138
|
+
- Server syntax highlighting **survives as text, not colour**: pygments
|
|
139
|
+
classes are not `md-` prefixed, so they are stripped and the wrappers
|
|
140
|
+
unwrapped. Shipping a monokai stylesheet would fight both themes.
|
|
141
|
+
- Table alignment from the server is lost (`style="text-align:…"` is an
|
|
142
|
+
attribute drop).
|
|
143
|
+
- **Relative links and images are dropped** — inside a hash-routed portal
|
|
144
|
+
they resolve against the current route, which is never what the author
|
|
145
|
+
meant. Use absolute `https://` URLs in markdown that the portal renders.
|
|
146
|
+
- `mark`, `sup` and `sub` are on the allowlist beyond the base set because
|
|
147
|
+
the backend's plugin list emits them and all three are inert.
|
|
148
|
+
|
|
149
|
+
## `renderMarkdown(source)`
|
|
150
|
+
|
|
151
|
+
Returns the **sanitized** HTML for `source`.
|
|
152
|
+
|
|
153
|
+
- **REJECTS on any failure** — endpoint missing, offline, 401, oversized
|
|
154
|
+
body, a response without `html`, no DOM to sanitize with. It never resolves
|
|
155
|
+
a fallback as success: the fallback path renders React *elements*, so a
|
|
156
|
+
string could not say which renderer produced it. The fallback decision
|
|
157
|
+
belongs to the component.
|
|
158
|
+
- **Cached** in a module-level `Map` keyed by an FNV-1a hash of the source
|
|
159
|
+
plus its length. Every entry stores its source and is verified on hit, so a
|
|
160
|
+
hash collision costs a re-render, never a wrong document. 200 entries,
|
|
161
|
+
oldest evicted.
|
|
162
|
+
- **Single-flight**: two mounts of the same message are one request. A chat
|
|
163
|
+
feed re-rendering 50 cached messages is zero.
|
|
164
|
+
|
|
165
|
+
`peekMarkdown(source)` reads the cache synchronously (`null` on a miss) —
|
|
166
|
+
that is what lets a cache hit paint on the first frame.
|
|
167
|
+
`clearMarkdownCache()` drops it.
|
|
168
|
+
|
|
169
|
+
## Files
|
|
170
|
+
|
|
171
|
+
| File | Job |
|
|
172
|
+
|---|---|
|
|
173
|
+
| `ui/MarkdownView.tsx` | the component; the one `dangerouslySetInnerHTML` |
|
|
174
|
+
| `client/markdown.ts` | the wire call, cache, single-flight, size guard; public re-exports |
|
|
175
|
+
| `client/markdown-sanitize.ts` | the allowlist sanitizer — the security boundary |
|
|
176
|
+
| `client/markdown-parse.ts` | markdown → block AST, plus the HTML emitter the **mock** uses |
|
|
177
|
+
| `client/markdown-fallback.tsx` | AST → React elements (the fallback path) |
|
|
178
|
+
| `apps/portal/src/theme/markdown.css` | `.md` styling, tokens only, both themes |
|
|
179
|
+
|
|
180
|
+
## Fallback coverage
|
|
181
|
+
|
|
182
|
+
One parser feeds both the React emitter and the mock's HTML emitter, so what
|
|
183
|
+
you see under the mock is what the fallback renders live: headings,
|
|
184
|
+
paragraphs (hard-wrap `<br>`), `hr`, fenced code, blockquotes, nested and
|
|
185
|
+
task lists, GFM tables (including escaped `\|`), bold/italic/bold-italic
|
|
186
|
+
(`*` and `_`, with `snake_case_words` left alone), inline code,
|
|
187
|
+
strikethrough, links, images, `<autolinks>` and bare URLs.
|
|
188
|
+
|
|
189
|
+
Server-only, and degrading to their literal text on the fallback path:
|
|
190
|
+
footnotes, math, abbr, spoiler, syntax highlighting.
|
|
191
|
+
|
|
192
|
+
## Pitfalls
|
|
193
|
+
|
|
194
|
+
- **Do not add a second `dangerouslySetInnerHTML`.** If you need to show
|
|
195
|
+
rendered markdown, use `MarkdownView`; if you need to show what the
|
|
196
|
+
sanitizer produced, show it as text (the playground demo does).
|
|
197
|
+
- **Do not pass user HTML to `MarkdownView`** — pass *markdown*. Raw HTML in
|
|
198
|
+
the source is escaped to text by both renderers, deliberately.
|
|
199
|
+
- Don't call `renderMarkdown` in a render body; it is a promise. The
|
|
200
|
+
component already owns the lifecycle.
|
|
201
|
+
- One request per distinct source, not per component: with a streaming
|
|
202
|
+
source set `debounceMs`, or render `renderer="client"` while it streams and
|
|
203
|
+
switch to `'server'` once it settles.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# menus & access — global Admin, product menus, permissions, group context
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { registerMenus, setDefaultMenu, SidebarNav, Guarded, RequiresGroup, GroupSwitcher } from 'portal-mojo/ui';
|
|
5
|
+
import { useMe, useCan, useAuthSnapshot, useActiveGroup, GroupProvider, hasPermission, registerPermissionCategories } from 'portal-mojo/client';
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
## Menu registry + SidebarNav (A4)
|
|
9
|
+
|
|
10
|
+
Menus are DATA registered once (`registerMenus([...])`); the active menu is
|
|
11
|
+
**derived** from (route, active group, me) — no mutable menu state.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
registerMenus([
|
|
15
|
+
{ name: 'admin', scope: 'global', presentation: 'accordion', items: [
|
|
16
|
+
{ divider: 'Main' },
|
|
17
|
+
{ id: 'identity', label: 'Identity & Access', icon: 'bi-people', children: [
|
|
18
|
+
{ label: 'Users', route: '/users', permissions: 'view_users' },
|
|
19
|
+
]},
|
|
20
|
+
]},
|
|
21
|
+
{ name: 'project', scope: 'group', groupKind: 'any', items: [ /* product menu */ ] },
|
|
22
|
+
]);
|
|
23
|
+
setDefaultMenu('admin');
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Resolution: route containment in registration order (group menus eligible
|
|
27
|
+
only under a kind-matched active group) → defaultMenu → first non-group
|
|
28
|
+
visible. Items support `permissions` (any-of) ∧ `requiresGroupKind`;
|
|
29
|
+
dividers with no visible children drop; a parent lights when a child route
|
|
30
|
+
is active. `scope: 'admin' | 'group' | 'account' | 'global'` makes ownership
|
|
31
|
+
explicit; legacy `groupKind` menus still infer group scope. Static rendering
|
|
32
|
+
remains the default for compatibility. `presentation: 'accordion'` enables
|
|
33
|
+
searchable, single-open-section navigation. Give accordion parents stable
|
|
34
|
+
`id` values; `keywords` add non-rendered search terms, and
|
|
35
|
+
`permissionClauses` express AND-across / any-within checks when both a section
|
|
36
|
+
and its route have gates.
|
|
37
|
+
|
|
38
|
+
The standalone Admin app registers one global accordion menu and does not
|
|
39
|
+
mount `GroupProvider` or `GroupSwitcher`. Admin is system-wide: selecting a
|
|
40
|
+
group must never change which Admin pages or grants are visible. A product
|
|
41
|
+
portal owns its group-scoped menus and switcher. When it embeds Admin sections,
|
|
42
|
+
those routes still run inside a global-scope boundary so active-member grants
|
|
43
|
+
cannot satisfy `sys.*` or Admin section permission checks.
|
|
44
|
+
|
|
45
|
+
Until the identity bundle is fully consolidated, the app-local `/users` and
|
|
46
|
+
`/groups` routes must carry their own system-pinned route and menu clauses in
|
|
47
|
+
addition to nested control gates. A Group opened from `/groups` is inspected
|
|
48
|
+
route data only: it must not call `setActiveGroup`, add `?group=`, or treat an
|
|
49
|
+
active product membership as Admin authority.
|
|
50
|
+
|
|
51
|
+
`<SidebarNav collapsed onRequestExpand>` provides the compact icon rail.
|
|
52
|
+
Collapsed category buttons expose CSS tooltips and expand on click, Enter, or
|
|
53
|
+
Space. Accordion search matches labels plus `keywords` and reports its result
|
|
54
|
+
count through a live status region.
|
|
55
|
+
|
|
56
|
+
Admin section bundles contribute through `adminSectionsMenu`. Use
|
|
57
|
+
`{grouped: true, presentation: 'accordion'}` for the domain-grouped Admin
|
|
58
|
+
experience. Routes remain mount-relative; multiple embedded root sections
|
|
59
|
+
share one `/system` landing instead of emitting duplicate index routes.
|
|
60
|
+
|
|
61
|
+
## Permissions (A2) — client gates, server authority
|
|
62
|
+
|
|
63
|
+
`Me.permissions` is a dict; truthy `true` OR `1` grants. Semantics
|
|
64
|
+
(`hasPermission`):
|
|
65
|
+
- `is_superuser` → everything.
|
|
66
|
+
- system `admin` → full client-side grant (NOTE: the BACKEND does not
|
|
67
|
+
honor `admin` as a wildcard — only `is_superuser` and category rollup.
|
|
68
|
+
The client is deliberately more permissive; the server re-checks).
|
|
69
|
+
- category → granular rollup (`users` grants `view_users`… — one-way);
|
|
70
|
+
apps extend via `registerPermissionCategories({cat: [perms]})`.
|
|
71
|
+
- `sys.`-prefixed checks never consult the group member.
|
|
72
|
+
- Otherwise the ACTIVE GROUP MEMBER's permissions fold in (member `admin`
|
|
73
|
+
grants within the group only).
|
|
74
|
+
|
|
75
|
+
`useCan(permission | permissions[]) → {can, loading}`;
|
|
76
|
+
`<Guarded permission="view_admin" fallback?>…</Guarded>` renders nothing
|
|
77
|
+
(or fallback) without the grant — fail-closed.
|
|
78
|
+
|
|
79
|
+
## Group context (A3)
|
|
80
|
+
|
|
81
|
+
Product portals place `<GroupProvider>` at the app root. `useActiveGroup() → {group, member,
|
|
82
|
+
loading, setActiveGroup, clearActiveGroup}`. The active group id rides the
|
|
83
|
+
REAL `?group=` search param (hash-safe, deep-linkable) with localStorage
|
|
84
|
+
fallback; activating a group fetches the caller's member record
|
|
85
|
+
(`/api/group/<id>/member`) whose permissions feed `useCan`.
|
|
86
|
+
`GroupSwitcher` is the searchable tree picker (300ms server search,
|
|
87
|
+
embedded-parent rows, kind chips); `RequiresGroup` wraps pages that need a
|
|
88
|
+
group and shows the pick-a-group state otherwise. Do not install these in the
|
|
89
|
+
standalone Admin shell; they belong to group-scoped product navigation.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# MultiSelectDropdown — static-options checkbox dropdown
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import {
|
|
5
|
+
MultiSelectDropdown,
|
|
6
|
+
type MultiSelectOption,
|
|
7
|
+
type MultiSelectValue,
|
|
8
|
+
} from 'portal-mojo/ui';
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
A trigger that summarizes the picks over a checkbox menu that **stays open
|
|
12
|
+
while you tick**; `Done` closes it. The static-options sibling of
|
|
13
|
+
[`CollectionMultiSelect`](collection-multiselect.md) (same selection
|
|
14
|
+
contract, but over an `options` array instead of live model data, and folded
|
|
15
|
+
into a dropdown instead of an always-visible panel). Ported from web-mojo
|
|
16
|
+
`src/core/forms/inputs/MultiSelectDropdown.js`.
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
const [statuses, setStatuses] = useState<MultiSelectValue[]>(['open']);
|
|
20
|
+
|
|
21
|
+
<MultiSelectDropdown
|
|
22
|
+
options={[
|
|
23
|
+
{ value: 'new', label: 'New' },
|
|
24
|
+
{ value: 'open', label: 'Open' },
|
|
25
|
+
{ value: 'closed', label: 'Closed', disabled: true },
|
|
26
|
+
]}
|
|
27
|
+
value={statuses}
|
|
28
|
+
onChange={setStatuses}
|
|
29
|
+
label="Status"
|
|
30
|
+
required
|
|
31
|
+
/>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Props
|
|
35
|
+
|
|
36
|
+
| Prop | Behavior |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `options` | `Array<string \| MultiSelectOption>`. Bare strings normalize to `{ value: s, label: s }`. Option shape: `{ value: string \| number, label?, text?, disabled? }` — `label` wins, `text` is the source's alias (accepted, don't reach for it), else `String(value)`. An option with no `value` warns and is dropped. |
|
|
39
|
+
| `value` / `onChange` | **Controlled.** `value` takes an array, a bare scalar (coerced to one pick), or `null`/`undefined` (none). `onChange` always receives the full next **array**; every toggle is a commit. Values keep their caller-side types (a numeric `2` comes back as `2`); comparisons are normalized. |
|
|
40
|
+
| `label` / `required` / `help` / `error` | House field chrome (`.field-label` / `.field-help` / `.field-error`; `error` replaces `help`). The label is a `<div id>` the trigger points at with `aria-labelledby` — a `<label htmlFor>` cannot target a button. The trigger references **both** that id and its own, so the accessible name keeps the summary (`"Status, New, Open"`). |
|
|
41
|
+
| `placeholder` | Trigger text while nothing is selected, rendered muted. Default `'Select...'`. |
|
|
42
|
+
| `maxHeight` | px before the option list scrolls. Default 300. |
|
|
43
|
+
| `showSelectedLabels` | Summarize with labels (default true). `false` → always `"N selected"`. |
|
|
44
|
+
| `maxLabelsToShow` | Picks above this switch the trigger to `"N selected"`. Default 3. |
|
|
45
|
+
| `disabled` | Locks the trigger and every row. |
|
|
46
|
+
| `placement` | Menu placement against the trigger — `Popover`'s `PopoverPlacement`. Default `'bottom-start'`. |
|
|
47
|
+
| `id` / `className` | `id` lands on the trigger button; `className` on the field wrapper. |
|
|
48
|
+
|
|
49
|
+
Not ported: `name` (served only the legacy change-event payload and per-row
|
|
50
|
+
DOM id generation — React callers close over their own field), `getValue` /
|
|
51
|
+
`setValue` / `setOptions` / `clear` / `getFormValue` / `setFormValue` (the
|
|
52
|
+
whole imperative surface is the `value`/`onChange` pair), and `placeHolder`
|
|
53
|
+
(the source accepted both casings; one spelling survives).
|
|
54
|
+
|
|
55
|
+
## Trigger summarization
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
0 picks → placeholder, muted
|
|
59
|
+
≤ maxLabelsToShow and showSelectedLabels → labels, comma-joined
|
|
60
|
+
otherwise → "N selected"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Labels list in **selection order**, not option order (source parity) — the
|
|
64
|
+
summary reads back what the user did. A selected value matching no option
|
|
65
|
+
falls back to its own `String()` form **with a one-time `console.warn`**
|
|
66
|
+
(unknown-value rule) — the trigger never renders a blank slot.
|
|
67
|
+
|
|
68
|
+
## Menu
|
|
69
|
+
|
|
70
|
+
- Mounts in the shared [`Popover`](popover.md): portalled to the top layer,
|
|
71
|
+
so it escapes overflow containers and stacks above open native-`<dialog>`
|
|
72
|
+
modals. The menu width is measured from the trigger (the source's `w-100`)
|
|
73
|
+
at open and kept in sync by a `ResizeObserver` while open.
|
|
74
|
+
- The **whole row** is the click target. Toggling never closes the menu —
|
|
75
|
+
`Done`, Escape, and an outside click do.
|
|
76
|
+
- Per-option `disabled` refuses the toggle by click **and** by keyboard, and
|
|
77
|
+
arrow navigation skips the row.
|
|
78
|
+
- No options → a single `"No options available"` line and **no Done footer**
|
|
79
|
+
(source parity); close with Escape or an outside click.
|
|
80
|
+
|
|
81
|
+
## Keyboard
|
|
82
|
+
|
|
83
|
+
| Key | Effect |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `Enter` / `Space` / `ArrowDown` on the trigger | Open. Focus moves to the first enabled row — the menu is portalled to the end of `<body>`, so Tab from the trigger could never reach it. |
|
|
86
|
+
| `Tab` | Walks the rows in order, then the `Done` button (every enabled row is `tabIndex=0`, matching `CollectionMultiSelect`). |
|
|
87
|
+
| `ArrowDown` / `ArrowUp` | Move between **enabled** rows, wrapping. |
|
|
88
|
+
| `Home` / `End` | First / last enabled row. |
|
|
89
|
+
| `Space` / `Enter` on a row | Toggle (`preventDefault` — no page scroll). |
|
|
90
|
+
| `Escape` | Closes and returns focus to the trigger. Inside a modal the first Escape closes only the menu (`Popover`'s capture-phase handler). |
|
|
91
|
+
|
|
92
|
+
`Done` and Escape always hand focus back to the trigger. An outside click
|
|
93
|
+
only does when focus is still parked inside the menu — never fight the
|
|
94
|
+
element the user just clicked.
|
|
95
|
+
|
|
96
|
+
## Invariants
|
|
97
|
+
|
|
98
|
+
1. **One value pipeline.** The ROW is the checkbox widget
|
|
99
|
+
(`role="checkbox"` + `aria-checked`); the `<input type="checkbox">` is
|
|
100
|
+
display-only — `pointer-events: none`, `readOnly`, `aria-hidden`,
|
|
101
|
+
`tabIndex={-1}` — and renders straight from `value`. A row click and a
|
|
102
|
+
keyboard `Space` call the identical `toggle()`. There is no second event
|
|
103
|
+
source that could move the DOM without moving state.
|
|
104
|
+
2. **Identity never round-trips through DOM attributes.** Toggles close over
|
|
105
|
+
the option object; every comparison goes through one `String()`
|
|
106
|
+
normalization (`keyOf`). String and number values, and mixes, work by
|
|
107
|
+
construction.
|
|
108
|
+
3. `null`/`undefined` are the only "nothing selected" — `0` and `''` are
|
|
109
|
+
selections.
|
|
110
|
+
4. Both themes, tokens only: `apps/portal/src/theme/multiselect-dropdown.css`
|
|
111
|
+
(reuses `.tbl-check`, `.btn`, `.field-*`; the popover shell supplies the
|
|
112
|
+
surface/border/shadow).
|
|
113
|
+
|
|
114
|
+
## The bug this port kills
|
|
115
|
+
|
|
116
|
+
web-mojo tracked selection in a private `item.selected` flag driven **only by
|
|
117
|
+
row-click delegation**, then wrote `checkbox.checked` by hand — while each row
|
|
118
|
+
also contained a real focusable `<input type="checkbox">` and a row-wide
|
|
119
|
+
`<label for>`. Any toggle the delegation didn't originate (keyboard `Space`
|
|
120
|
+
on the focused checkbox; the label's synthesized click) moved the DOM without
|
|
121
|
+
moving the tracked selection, or moved it twice. The rendered boxes and the
|
|
122
|
+
value drifted apart, and the drift survived — the view re-rendered from the
|
|
123
|
+
same desynced flags.
|
|
124
|
+
|
|
125
|
+
Two smaller ones die with it:
|
|
126
|
+
|
|
127
|
+
- **Numeric values were emitted as strings.** The source read
|
|
128
|
+
`element.getAttribute('data-value')` (always a string) and pushed *that*
|
|
129
|
+
into `selectedValues`, then rebuilt rows with
|
|
130
|
+
`selectedValues.includes(optionValue)` against the raw numbers — so a
|
|
131
|
+
numeric option ticked once could never be seen as selected again.
|
|
132
|
+
- **Falsy scalars vanished.** The constructor dropped a non-array `value`
|
|
133
|
+
outright, and `setValue` coerced with a truthy test, so `0` and `''` were
|
|
134
|
+
silently discarded.
|
|
135
|
+
|
|
136
|
+
## Pitfalls
|
|
137
|
+
|
|
138
|
+
- The menu is `position: fixed` in the top layer. Don't try to clip, cover
|
|
139
|
+
or z-index it from an ancestor — see [popover.md](popover.md).
|
|
140
|
+
- `maxLabelsToShow={0}` is honored (always the count). The source's `|| 3`
|
|
141
|
+
quietly rewrote a 0.
|
|
142
|
+
- Duplicate values in `value` collapse on the next toggle of that row (the
|
|
143
|
+
removal filters every copy). Pass a de-duplicated array.
|
|
144
|
+
- Options are static and rendered in the order given — there is no search and
|
|
145
|
+
no client-side filtering. Reach for `CollectionMultiSelect` once the list is
|
|
146
|
+
server data or long enough to need search.
|
|
147
|
+
- **Cousin, not yet reconciled:** `FilterBar`'s `multiselect` filter type
|
|
148
|
+
renders its own checkbox list (`.check-list` / `.check-row`) inside the
|
|
149
|
+
filter modal, with the same job and a different implementation — see
|
|
150
|
+
`FilterForm` in `packages/portal-mojo/src/ui/FilterBar.tsx`. Collapsing the
|
|
151
|
+
two onto this component is a follow-up **decision** (the filter dialog
|
|
152
|
+
commits on submit, this one commits per toggle), deliberately not attempted
|
|
153
|
+
here. Change one, look at the other.
|
package/docs/params.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# params — the table-state store (single source of truth)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
useTableParams, PAGE_SIZES, registerNonFilterParams,
|
|
6
|
+
readTableState, writeTableState, clearTableState,
|
|
7
|
+
} from 'portal-mojo/client';
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
**THE architecture rule:** one flat params object owns search, sort,
|
|
11
|
+
filters and paging for a table. It is URL-synced (real search string, hash
|
|
12
|
+
router safe), drives the server query, the pills, preset matching, and
|
|
13
|
+
persistence. Components read it; nothing else holds table state.
|
|
14
|
+
|
|
15
|
+
## `useTableParams(defaults?) → TableParamsApi`
|
|
16
|
+
|
|
17
|
+
State: `search`, `sort` (`'field' | '-field' | ''`, default `'-created'`
|
|
18
|
+
unless `defaults.sort`), `page` (1-based), `size`.
|
|
19
|
+
|
|
20
|
+
`wire` — the params to hand to `mojoList`: `{start, size, sort?, search?,
|
|
21
|
+
...filters}` where `start = (page-1)*size`.
|
|
22
|
+
|
|
23
|
+
| Method | Semantics |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `setSearch(term)` | writes `search`, resets page 1 |
|
|
26
|
+
| `cycleSort(field)` | asc → desc (`-field`) → off |
|
|
27
|
+
| `setFilter(key, value)` | string \| string[] \| null. Arrays collapse: 1 value → `field=`, 2+ → `field__in=a,b`. A field never carries both forms. null clears |
|
|
28
|
+
| `setDateRange(field, start, end)` | writes the `dr_field/dr_start/dr_end` triple — ONE active range by construction |
|
|
29
|
+
| `removeFilter(f)` / `clearFilters()` | pills & clear-all |
|
|
30
|
+
| `applyPreset(params)` / `presetActive(params)` | mutually-exclusive bundles; active state is DERIVED by matching, never stored |
|
|
31
|
+
| `setPage(n)` / `setSize(n)` | paging (`PAGE_SIZES = [5,10,25,50,100]`) |
|
|
32
|
+
| `applySaved(saved)` | one-shot rehydrate from a persistence blob (below) |
|
|
33
|
+
|
|
34
|
+
Every write goes through the URL (`replace`), so table state deep-links and
|
|
35
|
+
survives reloads by construction.
|
|
36
|
+
|
|
37
|
+
## `registerNonFilterParams(...keys)` — params the PAGE owns
|
|
38
|
+
|
|
39
|
+
Everything in the query string that isn't `search/sort/page/size` is treated
|
|
40
|
+
as a Django lookup. That is deliberate (any `field__lookup=` deep-links with
|
|
41
|
+
no client schema), but it means a **host page's own query param becomes a
|
|
42
|
+
filter**: `#/components?demo=filters` shipped `demo=filters` to
|
|
43
|
+
`/api/group` — zero rows on the mock, `FieldError` on a live backend — and
|
|
44
|
+
kept the "All" preset from ever matching.
|
|
45
|
+
|
|
46
|
+
Declare such keys once, at module load, before any table renders:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
registerNonFilterParams('demo'); // the component playground's rail key
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Registered keys are excluded from `filters`, `wire` and the pills, and are
|
|
53
|
+
**re-emitted on every write** (each write rebuilds the whole query string,
|
|
54
|
+
so without this they would silently vanish the first time a filter changed).
|
|
55
|
+
Do not register a name that is also a model field.
|
|
56
|
+
|
|
57
|
+
## Persistence blob (used by ModelTable's `persistState`)
|
|
58
|
+
|
|
59
|
+
`readTableState/writeTableState/clearTableState(key)` store
|
|
60
|
+
`{v: 2, sort?, size?, search?, filters?, hidden?}` at
|
|
61
|
+
`localStorage["mojo:tableview:" + key]`.
|
|
62
|
+
|
|
63
|
+
Precedence on restore: **URL > saved > defaults** per key — except `size`,
|
|
64
|
+
a per-user viewing preference the URL merely echoes, where **saved wins**.
|
|
65
|
+
Corrupt or other-version blobs are discarded AND cleared. `sort: ''`
|
|
66
|
+
(deliberately un-sorted) is not persisted — the default sort returns.
|
|
67
|
+
|
|
68
|
+
## Pitfalls
|
|
69
|
+
|
|
70
|
+
- Values are strings on the wire — booleans as `'true'/'false'`.
|
|
71
|
+
- Never mutate `wire`; it's derived.
|
|
72
|
+
- Don't store table state anywhere else (component state, context, redux
|
|
73
|
+
patterns) — that's the exact drift this store exists to prevent.
|
package/docs/popover.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Popover — the anchored top-layer primitive
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { Popover, type PopoverPlacement, type PopoverCloseReason } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
The shared surface every dropdown-style control mounts in — date/time
|
|
8
|
+
pickers, autocomplete, multi-selects, kebab menus. Ported from web-mojo's
|
|
9
|
+
`CalendarPopover.js`. Portal-mounted so it escapes overflow/clipping
|
|
10
|
+
containers; anchor-positioned; closes on outside-mousedown and Escape;
|
|
11
|
+
repositions on any scroll (capture phase), resize, and anchor/content
|
|
12
|
+
size change.
|
|
13
|
+
|
|
14
|
+
```tsx
|
|
15
|
+
const btnRef = useRef<HTMLButtonElement>(null);
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
|
|
18
|
+
<button ref={btnRef} onClick={() => setOpen(v => !v)}>Pick…</button>
|
|
19
|
+
<Popover anchorRef={btnRef} open={open} onClose={() => setOpen(false)}>
|
|
20
|
+
{/* content brings its own padding/width — the shell is bare */}
|
|
21
|
+
</Popover>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## API
|
|
25
|
+
|
|
26
|
+
| Prop | Type | Notes |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `anchorRef` | `RefObject<HTMLElement \| null>` | Trigger element. Re-read on every reposition — swap `.current` to re-point the popover (one instance can serve many triggers). |
|
|
29
|
+
| `open` | `boolean` | Controlled. The popover never closes itself. |
|
|
30
|
+
| `onClose` | `(reason: 'outside' \| 'escape') => void` | Flip `open` false here. The reason lets pickers commit-on-outside vs revert-on-escape. |
|
|
31
|
+
| `placement?` | `'bottom-start' \| 'bottom-end' \| 'top-start' \| 'top-end'` | Default `bottom-start`. Unknown values fall back to the default WITH a `console.warn`. |
|
|
32
|
+
| `gap?` | `number` | px between anchor and popover, default 6. |
|
|
33
|
+
| …rest | `HTMLAttributes<HTMLDivElement>` | Spread onto the shell (`id`, `aria-*`, `style`, …). `role` defaults to `"dialog"`, overridable. `className` merges after `mojo-popover`. |
|
|
34
|
+
|
|
35
|
+
Children are `ReactNode` (never HTML strings — house rule). There is no
|
|
36
|
+
imperative reposition handle: layout effects + capture-phase scroll +
|
|
37
|
+
resize + `ResizeObserver` (on both popover and anchor) cover every
|
|
38
|
+
reposition trigger, including async content growth.
|
|
39
|
+
|
|
40
|
+
The source's `portal: false` inline mode is deliberately not an option:
|
|
41
|
+
in React, "inline" is just rendering the content where you want it (an
|
|
42
|
+
inline calendar renders `<Calendar/>` directly). `Popover` exists only
|
|
43
|
+
for the anchored, floating case.
|
|
44
|
+
|
|
45
|
+
## The dialog problem (why this is not a z-indexed div)
|
|
46
|
+
|
|
47
|
+
portal-mojo modals are native `<dialog showModal>` — members of the
|
|
48
|
+
browser **top layer**, which paints above the whole document regardless of
|
|
49
|
+
z-index. A body-portaled `z-index: 10000` div (web-mojo's approach)
|
|
50
|
+
renders UNDER an open modal, so a picker inside a modal form would be
|
|
51
|
+
invisible.
|
|
52
|
+
|
|
53
|
+
**Decision:** the popover joins the top layer itself via the HTML Popover
|
|
54
|
+
API — the shell carries `popover="manual"` and is shown with
|
|
55
|
+
`showPopover()`. Top-layer members paint in promotion order, so a popover
|
|
56
|
+
opened after the modal stacks above it. `manual` (never `auto`) opts out
|
|
57
|
+
of UA light-dismiss: close behavior stays this component's own
|
|
58
|
+
outside-mousedown + Escape, identical semantics to the web-mojo source.
|
|
59
|
+
|
|
60
|
+
**Fallback** (browsers without the API — pre-2024): a plain portaled div.
|
|
61
|
+
When the anchor sits inside an open `<dialog>`, the portal target becomes
|
|
62
|
+
that dialog instead of `document.body` — a descendant paints with its
|
|
63
|
+
top-layer host, and `position: fixed` children are not clipped by ancestor
|
|
64
|
+
overflow — so pickers-in-modals still work there too.
|
|
65
|
+
|
|
66
|
+
## Positioning contract
|
|
67
|
+
|
|
68
|
+
- The shell is `position: fixed` in both paths; all math is
|
|
69
|
+
**viewport-relative** (deliberate translation from the source's
|
|
70
|
+
`absolute` + `scrollX/Y` — the top layer does not scroll with the page,
|
|
71
|
+
so page coordinates would be wrong there).
|
|
72
|
+
- Placement math, then a horizontal viewport clamp (8px inset), then a
|
|
73
|
+
vertical flip: any placement that would overflow the bottom flips above
|
|
74
|
+
the anchor when there is room — the source's exact rule.
|
|
75
|
+
- Reposition triggers: open, placement/gap change, any capture-phase
|
|
76
|
+
scroll, window resize, popover or anchor resize.
|
|
77
|
+
|
|
78
|
+
## Focus
|
|
79
|
+
|
|
80
|
+
Opening NEVER moves focus — `showPopover()` only focuses `autofocus`
|
|
81
|
+
content, and the shell has none. Typing in an anchored input keeps
|
|
82
|
+
working (the autocomplete precondition). Keeping input focus while
|
|
83
|
+
clicking options (`preventDefault` on option `mousedown`) is the
|
|
84
|
+
consuming control's job, not the shell's.
|
|
85
|
+
|
|
86
|
+
## Escape layering
|
|
87
|
+
|
|
88
|
+
The Escape listener runs in the **capture phase** and calls
|
|
89
|
+
`preventDefault()` + `stopPropagation()`: with the anchor inside an open
|
|
90
|
+
modal, the first Escape closes only the popover (preventing the dialog's
|
|
91
|
+
cancel), the second closes the modal. (Deviation from source, which
|
|
92
|
+
predates native `<dialog>`: it neither prevented nor stopped — carrying
|
|
93
|
+
that here would close both layers on one keypress.)
|
|
94
|
+
|
|
95
|
+
Outside-mousedown does NOT stop propagation: the click still activates
|
|
96
|
+
whatever was under it (click a second trigger → this popover closes and
|
|
97
|
+
that one opens). Clicks on the anchor are never "outside" — the anchor's
|
|
98
|
+
own click handler owns its toggle. The listener attach is deferred one
|
|
99
|
+
tick so the interaction that opened the popover can't immediately close
|
|
100
|
+
it (source parity).
|
|
101
|
+
|
|
102
|
+
## Styling
|
|
103
|
+
|
|
104
|
+
`.mojo-popover` lives in `apps/portal/src/theme/popover.css` — tokens
|
|
105
|
+
only, both themes. It neutralizes the UA `[popover]` stylesheet
|
|
106
|
+
(`inset: 0` centering, `margin: auto`, border, padding, Canvas colors)
|
|
107
|
+
and restores the mission-control look (`.filter-menu` family: `--surface`
|
|
108
|
+
bg, `--line` border, radius 10, `--shadow-pop`).
|
|
109
|
+
|
|
110
|
+
The shell is **unpadded and unsized** (`width: max-content`): content
|
|
111
|
+
brings its own padding, width, and scrolling. (The web-mojo source shell
|
|
112
|
+
baked in 20px padding + 260px min-width for its one tenant, the calendar
|
|
113
|
+
— the calendar port carries those itself.)
|
|
114
|
+
|
|
115
|
+
## Pitfalls
|
|
116
|
+
|
|
117
|
+
- **Don't wrap the shell in `overflow: hidden` styling assumptions** —
|
|
118
|
+
it's in the top layer; nothing ancestral clips or covers it. Anything
|
|
119
|
+
that must paint ABOVE an open popover has to join the top layer later.
|
|
120
|
+
- **Toasts render under open modals** (`.toast-host` is z-index 60, not
|
|
121
|
+
top layer). Don't signal from popover content inside a modal via toast;
|
|
122
|
+
show it in the modal body.
|
|
123
|
+
- **One value pipeline:** the popover is presentation only. Commit
|
|
124
|
+
semantics (`change` on select/Enter/blur, never per keystroke) belong
|
|
125
|
+
to the consuming control.
|
|
126
|
+
- The opening interaction can be `click` or `mousedown`-driven; both are
|
|
127
|
+
safe. But if you open programmatically and simultaneously flip other
|
|
128
|
+
document state, remember listeners attach a tick later.
|
|
129
|
+
- Placement flips only bottom→top (per source). If content can be taller
|
|
130
|
+
than the viewport, cap and scroll it in YOUR content styles.
|