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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# grouping helpers + formatters
|
|
2
|
+
|
|
3
|
+
## groupBy* helpers
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { groupByDay, groupByField, groupByRecency, groupByBoolean } from 'portal-mojo/ui';
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Each returns `{groupBy, groupHeaderLabel}` — spread straight into
|
|
10
|
+
ModelTable props: `{...groupByRecency<User>('last_activity')}`. Keys are
|
|
11
|
+
STABLE bucket ids (sort-ordered where it matters); labels format
|
|
12
|
+
separately. A null key = ungrouped tail (no header; the prior section
|
|
13
|
+
continues). All date helpers parse through `date/fns detectTemporal`, so
|
|
14
|
+
they accept **every** shape django-mojo emits: epoch seconds, epoch
|
|
15
|
+
milliseconds, either as a numeric string (JSONField metadata routinely
|
|
16
|
+
carries these), `'YYYY-MM-DD'`, `'YYYY-MM'`, `'YYYY'`, full ISO with `Z` or
|
|
17
|
+
an offset, IANA-tailed wall times, and `Date`. A 4-digit numeric string is
|
|
18
|
+
read as a **year**, never an epoch. Anything unrecognizable degrades to the
|
|
19
|
+
fallback — a formatter never throws.
|
|
20
|
+
|
|
21
|
+
| Helper | Buckets |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `groupByDay(field \| accessor)` | local `YYYY-MM-DD`; labels Today / Yesterday / Apr 25 / Dec 19, 2025 |
|
|
24
|
+
| `groupByRecency(field)` | Today / Yesterday / This week / This month / Earlier this year / Older — keys sort so `-date` order reads top-down |
|
|
25
|
+
| `groupByField(field, {labels?, format?, fallback?})` | categorical on `String(raw)`; `labels` wins over `format`; `fallback` names the empty bucket (default: tail). `0`/`false` DO bucket |
|
|
26
|
+
| `groupByBoolean(field, {trueLabel?, falseLabel?})` | binary; string-false forms (`'false','0','no','off'`) coerce false; missing → tail |
|
|
27
|
+
|
|
28
|
+
Align the grouped field with the table's sort — grouping runs in render
|
|
29
|
+
order, so a mismatched sort repeats headers per key change (defined
|
|
30
|
+
behavior).
|
|
31
|
+
|
|
32
|
+
## fmt
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
import { fmt } from 'portal-mojo/ui';
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The complete `DataFormatter.js` FUNCTION set, typed. Deliberately absent:
|
|
39
|
+
the **pipe-string parser** (`"filesize|muted"` — do-not-recreate: it defeats
|
|
40
|
+
the type checker and was the source's silent-failure vector) and the
|
|
41
|
+
**HTML-emitting** formatters (badge/avatar/linkify/clipboard/tooltip/status)
|
|
42
|
+
— those are components, not formatters.
|
|
43
|
+
|
|
44
|
+
### Dates
|
|
45
|
+
|
|
46
|
+
| Fn | Notes |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `fmt.date(v, fallback='—')` / `fmt.datetime(v, fallback='—')` | `MM/DD/YYYY` / `Mon D, YYYY h:mm` — accepts **epoch seconds** (the django-mojo wire shape), ms, ISO, Date |
|
|
49
|
+
| `fmt.relative(v, fallback='Never')` | "3 weeks ago" style |
|
|
50
|
+
| `fmt.relativeShort(v, fallback='—')` | compact age for chips: `now` / `5h` / `3d` / `3w` / `2mo` / `1y`. **Magnitude only** — a future date reads the same as a past one (use `relative` when direction matters). Source boundaries kept (`>`, not `>=`): 7d is `7d`, 8d is `1w` |
|
|
51
|
+
|
|
52
|
+
### Numbers
|
|
53
|
+
|
|
54
|
+
| Fn | Notes |
|
|
55
|
+
|---|---|
|
|
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 |
|
|
58
|
+
| `fmt.percent(v, decimals=0, multiply=true, fallback='—')` | `0.856` → `86%`; `multiply=false` when the value is already a percentage |
|
|
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
|
+
| `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`) |
|
|
61
|
+
| `fmt.ordinal(v, suffixOnly=false, fallback='—')` | `3` → `3rd`, `11` → `11th`. *Deviation:* the suffix is computed on the absolute value, so `-1` → `-1st` (source made every negative `th`) |
|
|
62
|
+
|
|
63
|
+
### Strings
|
|
64
|
+
|
|
65
|
+
| Fn | Notes |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `fmt.truncate(v, length=50, suffix='...', fallback='—')` | cuts to `length` **then** appends the suffix, so the result runs longer than `length` (source behavior) |
|
|
68
|
+
| `fmt.truncateMiddle(v, size=8, replace='***', fallback='—')` | keeps both ends: `d41d***427e` — ids stay recognizable. `size` is the TOTAL kept, split in half |
|
|
69
|
+
| `fmt.truncateFront(v, length=8, prefix='...', fallback='—')` | keeps only the TAIL: `...2eZvKY` |
|
|
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
|
+
| `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.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
|
+
### Duration + booleans
|
|
75
|
+
|
|
76
|
+
| Fn | Notes |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `fmt.duration(v, unit='ms', {short, precision, fallback})` | `8040000` → `2h 14m`; `duration(8040,'s')` is the same. `unit` ∈ `ms\|s\|m\|h\|d` (unknown → `ms` + one `console.warn`). `precision` (default 2) caps the unit count; sub-second values report `500ms`. *Deviations:* short form is the DEFAULT (source: long — get it with `{short:false}` → `2 hours 14 minutes`) and its parts are space-separated (source: `2h14m`) |
|
|
79
|
+
| `fmt.yesNo(v, {yes, no, fallback})` | `Yes`/`No`; string-false forms (`'false','0','no','off'`) and empty arrays/objects read false — the same coercion table as `groupByBoolean`. **null/undefined/`''` → `—`**: unset is not the same answer as "No" |
|
|
80
|
+
|
|
81
|
+
### Tone
|
|
82
|
+
|
|
83
|
+
| Fn | Notes |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `fmt.initials(name)` | 1–2 letters; null-safe (`'?'` for empty — live rows carry `display_name: null`) |
|
|
86
|
+
| `fmt.inferTone(value)` | status text → Badge tone ('active'→success, 'banned'→danger, booleans, …) |
|
|
87
|
+
|
|
88
|
+
Formatters NEVER throw on bad input — they degrade (rule: a formatter is
|
|
89
|
+
the last thing allowed to take down a row). Every one is null/undefined-safe,
|
|
90
|
+
parses strictly (`"12 items"` is NOT 12 — it degrades, unlike the source's
|
|
91
|
+
`parseFloat`), and formats at a fixed `en-US` locale rather than the
|
|
92
|
+
browser's. The trailing `fallback` param (or `opts.fallback`) overrides the
|
|
93
|
+
`—`; pass `''` when composing strings rather than filling a cell.
|
|
94
|
+
|
|
95
|
+
Live gallery: **Develop → Components → Formatters**.
|
|
96
|
+
|
|
97
|
+
`Badge`: `<Badge tone="success|warning|danger|info|muted|primary">` from
|
|
98
|
+
`portal-mojo/ui` — the standard status chip.
|
package/docs/idioms.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# UX idioms — armed button · undo toast · progress toast
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { ArmedButton } from 'portal-mojo/ui'; // component
|
|
5
|
+
import { toast, undoToast, progressToast } from 'portal-mojo/ui';
|
|
6
|
+
// toast.undo === undoToast · toast.progress === progressToast
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Ported from maestro's workspaces portal (`workspaces/js/dom.js`), these three
|
|
10
|
+
are the house grammar for **action feedback by reversibility**:
|
|
11
|
+
|
|
12
|
+
| The action is… | Use | Never |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| Irreversible, no input needed | `<ArmedButton>` two-step | a confirm dialog |
|
|
15
|
+
| Irreversible, needs input (a reason) | `formModal` flow | armed button (it can't collect input) |
|
|
16
|
+
| Reversible | act IMMEDIATELY + `toast.undo` | a confirm dialog |
|
|
17
|
+
| Long-running | `toast.progress` handle | fire-and-forget with a success toast at the end |
|
|
18
|
+
|
|
19
|
+
Live demo: portal → Develop → Components → **UX idioms**. First real
|
|
20
|
+
consumers: ApiKeysPage (revoke = armed, disable = undo), UserDetail
|
|
21
|
+
(revoke-all-sessions = armed).
|
|
22
|
+
|
|
23
|
+
## ArmedButton
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
<ArmedButton
|
|
27
|
+
label="Revoke key"
|
|
28
|
+
armedLabel="Click again — token dies now"
|
|
29
|
+
onConfirm={() => revoke.mutateAsync({ id })} // may be async
|
|
30
|
+
icon="bi-trash" // optional, resting state only
|
|
31
|
+
className="btn-compact" // appended to `btn armed-btn`
|
|
32
|
+
disarmMs={6000} // auto-disarm window (source parity: 6s)
|
|
33
|
+
title="…" // defaults to string labels
|
|
34
|
+
disabled={false}
|
|
35
|
+
/>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Behavior contract (dom.js `armedButton` parity):
|
|
39
|
+
|
|
40
|
+
- First click **arms**: danger fill, warning icon, `armedLabel` shown. The
|
|
41
|
+
armed label MUST name the blast radius ("Click again — signs out every
|
|
42
|
+
device"), not repeat the verb.
|
|
43
|
+
- Second click disarms first, then awaits `onConfirm`. Re-clicks while the
|
|
44
|
+
promise is pending are ignored (button disables).
|
|
45
|
+
- **Auto-disarm** after `disarmMs`; `Escape` disarms; unmount disarms (state
|
|
46
|
+
dies with the node — a re-render that drops the button can never leave a
|
|
47
|
+
live trigger). Fail-safe cost is one extra click, never an unintended fire.
|
|
48
|
+
- Clicks `stopPropagation()` — safe inside clickable rows / expand rows.
|
|
49
|
+
|
|
50
|
+
Styling: `apps/portal/src/theme/idioms.css` — `.armed-btn` (rest) /
|
|
51
|
+
`.armed-btn.is-armed` (armed, pulsing; static under
|
|
52
|
+
`prefers-reduced-motion`). Tokens only, both themes; the dark theme swaps
|
|
53
|
+
the armed text color for contrast against the light --bad.
|
|
54
|
+
|
|
55
|
+
## toast.undo / undoToast
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const handle = toast.undo('Key disabled', () => reenable(), { timeout: 6000 });
|
|
59
|
+
handle.dismiss(); // optional early resolution
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- **The action has already happened** when the toast shows — do the mutation
|
|
63
|
+
first, then offer Undo. This replaces `confirm()` for reversible actions.
|
|
64
|
+
- Clicking **Undo** dismisses at once and calls `onUndo`. Otherwise the card
|
|
65
|
+
auto-dismisses after `timeout` (default 6000ms) and the action stands.
|
|
66
|
+
- `onUndo` typically fires the inverse mutation; surface ITS failure with a
|
|
67
|
+
normal `toast.error` — the undo toast itself is done by then.
|
|
68
|
+
|
|
69
|
+
## toast.progress / progressToast
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
const p = toast.progress('Uploading export.csv…', { onCancel: () => abort() });
|
|
73
|
+
p.update(42); // clamped + rounded to 0–100
|
|
74
|
+
p.finalizing('Finishing upload'); // → 100% + spinner, cancel hidden; still unsettled
|
|
75
|
+
p.done('Uploaded'); // → 100% + ✓, auto-removes after 1.4s
|
|
76
|
+
p.fail('Cancelled'); // → red + ✕, auto-removes after 5s
|
|
77
|
+
p.remove(); // immediate removal
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- **Persistent**: never auto-dismisses while active — the caller drives it to
|
|
81
|
+
`done` / `fail` / `remove`. After the first of those, the handle is settled
|
|
82
|
+
and later calls no-op.
|
|
83
|
+
- `onCancel` renders a ✕ **only while active**. Clicking it calls `onCancel`
|
|
84
|
+
and nothing else — the CALLER aborts its operation and then settles the
|
|
85
|
+
toast (usually `fail('Cancelled')`). Cancel is a request, not a dismissal.
|
|
86
|
+
- `finalizing()` is additive and non-settling: it shows a spinner at 100% and
|
|
87
|
+
removes cancellation while an authoritative server read or consumer
|
|
88
|
+
callback completes. The caller must still finish with `done` or `fail`.
|
|
89
|
+
- Basic toasts stay capped at 5; undo/progress cards are exempt from the cap
|
|
90
|
+
(evicting a live progress bar would orphan the operation's only indicator).
|
|
91
|
+
|
|
92
|
+
## Invariants
|
|
93
|
+
|
|
94
|
+
- All three render through the one `<ToastHost />` / store — mount nothing
|
|
95
|
+
new. Every existing `toast.success/error/info/warning` behavior (3.5s life,
|
|
96
|
+
newest-last, cap of 5) is byte-for-byte unchanged.
|
|
97
|
+
- Handles are safe to call after settlement/dismissal — every method no-ops
|
|
98
|
+
rather than throwing or resurrecting a card.
|
|
99
|
+
- `ArmedButton` is a plain `<button>` — no portal, no dialog; it composes
|
|
100
|
+
with rows, expand panels and modals without z-index ceremony.
|
|
101
|
+
|
|
102
|
+
## Pitfalls
|
|
103
|
+
|
|
104
|
+
- Don't pair `ArmedButton` with a follow-up `modal.confirm` — the arming IS
|
|
105
|
+
the confirmation; stacking both is the friction the idiom removes.
|
|
106
|
+
- Don't `toast.undo` an action you haven't performed yet ("undo" that
|
|
107
|
+
actually means "cancel a pending thing" is a lie — use the progress
|
|
108
|
+
toast's cancel for that).
|
|
109
|
+
- Don't reuse a settled progress handle for a second operation — mint a new
|
|
110
|
+
one per run.
|
|
111
|
+
- The undo window is UX grace, not a transaction: if the inverse mutation
|
|
112
|
+
can fail, handle its rejection (the demo and ApiKeysPage show the shape).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Image editor
|
|
2
|
+
|
|
3
|
+
Import from `portal-mojo/ui`:
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { ImageEditor, imageEditorModal, type ImageEditorResult } from 'portal-mojo/ui';
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`ImageEditor` is the dependency-free crop/transform/filter editor. It accepts a
|
|
10
|
+
controlled `source` (`Blob`, `File`, or URL), reports only through `onSave`, and
|
|
11
|
+
never triggers a browser file download. A URL source is fetched before decode:
|
|
12
|
+
same-origin requests include same-origin credentials, while cross-origin URLs
|
|
13
|
+
remain subject to CORS and receive no credentials. Source/capability URLs are
|
|
14
|
+
never echoed in editor errors. `imageEditorModal(source, options)` is the
|
|
15
|
+
awaitable native dialog form: it resolves an `ImageEditorResult` on save and
|
|
16
|
+
`null` on Cancel, Escape, backdrop dismissal, or owner-signal abort.
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
interface ImageEditorResult {
|
|
20
|
+
blob: Blob; // image/png, alpha preserved
|
|
21
|
+
filename: string;
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
cropData: CropData | null; // logical pixels at the crop's input stage
|
|
25
|
+
operations: readonly ImageEditorOperation[];
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
const result = await imageEditorModal(file, {
|
|
31
|
+
startMode: 'crop',
|
|
32
|
+
filename: file.name,
|
|
33
|
+
crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } },
|
|
34
|
+
});
|
|
35
|
+
if (result) upload(new File([result.blob], result.filename, { type: 'image/png' }));
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Options are `filename`, `startMode`, `modes`, `crop`, `initialOperations`,
|
|
39
|
+
`maxHistory` (1–20), and `saveText`. Modal use additionally accepts `title`,
|
|
40
|
+
`size` (`sm`, `md`, or `lg`), and an `AbortSignal` as `signal`; abort resolves
|
|
41
|
+
the modal with `null`. Embedded use additionally supplies `onSave`, optional
|
|
42
|
+
`onCancel`, `disabled`, and `onBusyChange`.
|
|
43
|
+
|
|
44
|
+
## Low-level exports
|
|
45
|
+
|
|
46
|
+
The framework-free math and raster contracts are intentionally public from the
|
|
47
|
+
same `portal-mojo/ui` subpath for non-React composition and deterministic
|
|
48
|
+
verification:
|
|
49
|
+
|
|
50
|
+
- Geometry/state types include `ImageSize`, `ImagePoint`, `ImageRect`,
|
|
51
|
+
`CropData`, `CropOptions`, `TransformState`, `FilterState`,
|
|
52
|
+
`ImageEditorOperation`, and `ImageEditorSnapshot`. The corresponding defaults,
|
|
53
|
+
limits, crop handles, presets, and filter order are exported constants.
|
|
54
|
+
- Geometry helpers cover canvas/image mapping, crop initialize/constrain/move/
|
|
55
|
+
resize, transform clamp/rotate/zoom/output sizing, filter composition,
|
|
56
|
+
operation output/crop inspection, and immutable history commits.
|
|
57
|
+
- Raster helpers expose `PixelSurface`, `filterPixels`,
|
|
58
|
+
`applyOperationsToPixels`, `decodeImageSource`, `drawPixelSurface`,
|
|
59
|
+
`pixelSurfaceToBlob`, and `renderImageOperations`. `decodeImageSource`, canvas
|
|
60
|
+
drawing, and Blob encoding require browser APIs; callers that retain a
|
|
61
|
+
returned `DecodedImage` must call its `dispose()`.
|
|
62
|
+
|
|
63
|
+
These helpers operate on logical pixels and do not mount UI, upload Files,
|
|
64
|
+
persist state, or initiate a browser download. `decodeImageSource` is the one
|
|
65
|
+
exception that performs the URL fetch described above.
|
|
66
|
+
|
|
67
|
+
## Composition and history
|
|
68
|
+
|
|
69
|
+
Operations are immutable and chronological. Transform creates a logical
|
|
70
|
+
full-resolution output canvas; pan is measured in logical image pixels and is
|
|
71
|
+
unchanged by a preview resize. Crop consumes the current working bitmap in
|
|
72
|
+
source-pixel coordinates and either retains its natural crop dimensions or
|
|
73
|
+
emits exact `cropAndScale` dimensions. Filters consume the current full working
|
|
74
|
+
bitmap after every prior operation. The preview's CSS size and device-pixel
|
|
75
|
+
ratio never participate in export.
|
|
76
|
+
|
|
77
|
+
Apply commits one snapshot. Switching modes commits only a dirty mode. Pointer
|
|
78
|
+
drags and slider movement update one draft, so they coalesce into one history
|
|
79
|
+
entry. Undo/redo crosses committed operations, a divergent commit truncates
|
|
80
|
+
redo, Reset returns to the decoded source, and at most 20 snapshots are kept.
|
|
81
|
+
|
|
82
|
+
## Source-derived invariants
|
|
83
|
+
|
|
84
|
+
- Crop defaults to the centered 80% box; the minimum is 50 source pixels.
|
|
85
|
+
- Transform scale is clamped to 0.1–5 with the legacy 0.02 step; rotations
|
|
86
|
+
normalize to 0–359 degrees.
|
|
87
|
+
- Filter defaults, preset values, and application order are carried from
|
|
88
|
+
web-mojo: brightness → contrast → saturation → hue → blur → grayscale →
|
|
89
|
+
sepia. Unknown modes/presets warn and fall back.
|
|
90
|
+
- Invalid/non-finite ratios, dimensions, histories, and operations reject at
|
|
91
|
+
the React boundary. Decode/CORS/encoding failures use generic errors that do
|
|
92
|
+
not echo source or capability URLs and retain the prior decoded source and
|
|
93
|
+
history.
|
|
94
|
+
- Owned object URLs and image bitmaps are released on replacement/unmount;
|
|
95
|
+
async decode/save work is generation guarded. PNG encoding is explicit and
|
|
96
|
+
preserves alpha. Crop overlays never enter the output bitmap.
|
|
97
|
+
|
|
98
|
+
For image relations, `ImageField` can opt into `edit={{ crop: ... }}`. Editing
|
|
99
|
+
runs before the existing upload queue: Cancel or editor failure keeps the
|
|
100
|
+
selected original available, and no File id or owner attachment exists until
|
|
101
|
+
the edited PNG File enters that queue.
|
package/docs/loading.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Loaders — spinner · busy overlay · view loader · inline · Busy
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
Spinner, ViewLoader, LoadingState, InlineLoader, Busy, LoadingOverlay,
|
|
6
|
+
busy, busyWhile, closeAllBusy, useDelayedFlag,
|
|
7
|
+
LOADING_DELAY_MS, BUSY_MIN_VISIBLE_MS, BUSY_TIMEOUT_MS,
|
|
8
|
+
} from 'portal-mojo/ui';
|
|
9
|
+
// LoadingState === ViewLoader (alias — reads better in a route's early return)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The "we're fetching / working" family. It **complements the skeleton
|
|
13
|
+
silhouette**, which stays the default for anything whose shape is already
|
|
14
|
+
known (see the decision table below). Live demo: portal → Develop →
|
|
15
|
+
Components → **Loaders**. Styling: `apps/portal/src/theme/loading.css` —
|
|
16
|
+
tokens only, both themes.
|
|
17
|
+
|
|
18
|
+
Ported from web-mojo `src/core/views/feedback/BusyIndicator.js` (its only
|
|
19
|
+
loading UI: a singleton frosted overlay behind `Modal.loading()` /
|
|
20
|
+
`app.showLoading()`, used by ~30 admin call sites). Reference counting, the
|
|
21
|
+
newest-message-wins label and the 30s watchdog are kept; the z-index
|
|
22
|
+
arithmetic, the hardcoded light-theme palette and the show-on-the-same-tick
|
|
23
|
+
behavior are not.
|
|
24
|
+
|
|
25
|
+
## The anti-flash rule (the design idea)
|
|
26
|
+
|
|
27
|
+
> **An indicator that appears for 80ms is worse than none.** It reads as a
|
|
28
|
+
> glitch, and it makes fast software look broken.
|
|
29
|
+
|
|
30
|
+
Every loader here is gated by `useDelayedFlag`:
|
|
31
|
+
|
|
32
|
+
- nothing renders until the work has run **`LOADING_DELAY_MS` (200ms)** —
|
|
33
|
+
work that finishes inside that window produces no indicator at all;
|
|
34
|
+
- the overlay, once up, stays at least **`BUSY_MIN_VISIBLE_MS` (400ms)**, so
|
|
35
|
+
it can't blink out either.
|
|
36
|
+
|
|
37
|
+
Both are per-call overridable (`delay`, `minVisible`). `delay: 0` opts out —
|
|
38
|
+
correct for demos and for a route that is *known* to be slow.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const show = useDelayedFlag(query.isFetching); // 200ms default
|
|
42
|
+
const show = useDelayedFlag(save.isPending, 400); // number = delay
|
|
43
|
+
const show = useDelayedFlag(active, { delay: 150, minVisible: 500 });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Skeleton vs loader — which one
|
|
47
|
+
|
|
48
|
+
| Situation | Use |
|
|
49
|
+
|---|---|
|
|
50
|
+
| Table rows, detail grids, card lists — **shape known** | **Skeleton** (`.skel` silhouette: `skel-avatar`, `skel-stack`, `skel-w-60`, `skel-pill`) |
|
|
51
|
+
| A panel or route with nothing to show yet, shape unknown | `<ViewLoader label="Loading members…" />` |
|
|
52
|
+
| One row / cell / field resolving on its own | `<InlineLoader />` |
|
|
53
|
+
| A form or card whose own mutation is in flight | `<Busy active={save.isPending}>` |
|
|
54
|
+
| Work the user must wait out (export, long save, batch) | `busy()` / `<LoadingOverlay>` |
|
|
55
|
+
| Long work with a real percentage | `toast.progress` — see [idioms.md](./idioms.md) |
|
|
56
|
+
| Background refresh of data already on screen | **Nothing** (or the toolbar refresh spin) |
|
|
57
|
+
|
|
58
|
+
Rules of thumb: a skeleton **promises a shape**, so never render one for
|
|
59
|
+
data whose shape you can't predict (a report, an export, a search that may
|
|
60
|
+
return nothing). A loader **says work is happening**, so never render one
|
|
61
|
+
over content that is already on screen and still valid — dim it (`<Busy>`)
|
|
62
|
+
or leave it alone.
|
|
63
|
+
|
|
64
|
+
ModelTable, CollectionMultiSelect and DetailView already use skeletons and
|
|
65
|
+
should keep doing so. Nothing in this family replaces them.
|
|
66
|
+
|
|
67
|
+
## Spinner
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<Spinner /> {/* md, decorative */}
|
|
71
|
+
<Spinner size="xs" /> {/* xs | sm | md | lg, or a pixel number */}
|
|
72
|
+
<Spinner size={44} />
|
|
73
|
+
<Spinner size="sm" label="Refreshing" /> {/* the ONLY indication → named */}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Prop | Type | Notes |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `size?` | `'xs' \| 'sm' \| 'md' \| 'lg' \| number` | 12 / 15 / 20 / 30px, or an explicit diameter. Unknown value → `'md'` **with a `console.warn`** (once per bad value). |
|
|
79
|
+
| `label?` | `string` | Omit when visible text sits beside it (then it renders `aria-hidden`). Pass it only for an icon-only affordance. |
|
|
80
|
+
| `className?` | `string` | Appended after `mojo-spinner …`. |
|
|
81
|
+
|
|
82
|
+
The ring is drawn in `currentColor`, so it inherits its context: white inside
|
|
83
|
+
`.btn-primary`, `--accent` inside a loader card, `--mute` in a table cell. No
|
|
84
|
+
color prop, and none needed.
|
|
85
|
+
|
|
86
|
+
## busy() — the blocking full-screen overlay
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
const b = busy('Rebuilding index…');
|
|
90
|
+
try { await rebuild(); } finally { b.close(); }
|
|
91
|
+
|
|
92
|
+
// Preferred — a throw can never strand the overlay:
|
|
93
|
+
await busyWhile('Preparing export…', () => mojoDownload(endpoint, params, 'csv'));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| API | Signature |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `busy` | `(label?: string, opts?: BusyOptions) => BusyHandle` |
|
|
99
|
+
| `busyWhile` | `<T>(label: string, run: () => Promise<T> \| T, opts?: BusyOptions) => Promise<T>` |
|
|
100
|
+
| `closeAllBusy` | `() => void` — escape hatch for an error boundary / hard reset |
|
|
101
|
+
| `BusyOptions` | `{ delay?: number; minVisible?: number; timeout?: number }` |
|
|
102
|
+
| `BusyHandle` | `{ close(): void; setLabel(label: string): void; isActive(): boolean }` |
|
|
103
|
+
|
|
104
|
+
- **Imperative on purpose**, matching `modal.*` / `toast.*`: call it from any
|
|
105
|
+
handler, or from non-component code (client interceptors, route guards).
|
|
106
|
+
There is **no host component to mount** — nothing to wire into `App.tsx`.
|
|
107
|
+
- **Reference-counted by ticket.** Nested calls compose; the newest live
|
|
108
|
+
ticket's label is displayed, and closing it falls back to the previous
|
|
109
|
+
label rather than leaving stale text. `close()` is idempotent.
|
|
110
|
+
- **Watchdog:** a ticket left open longer than `timeout` (default 30s)
|
|
111
|
+
force-closes with a `console.error` naming the label. A caller that throws
|
|
112
|
+
outside a `finally` can never wedge the app behind an overlay nobody can
|
|
113
|
+
dismiss. `timeout: 0` disables it for a legitimately unbounded operation.
|
|
114
|
+
- `<LoadingOverlay active label />` is the declarative binding to the same
|
|
115
|
+
overlay (renders nothing into the React tree). Changing `label` swaps the
|
|
116
|
+
text **without restarting the delay**.
|
|
117
|
+
|
|
118
|
+
### Why a `<dialog>`, and what "blocking" means
|
|
119
|
+
|
|
120
|
+
The overlay is a native `<dialog>` opened with `showModal()` — a member of
|
|
121
|
+
the browser **top layer**, exactly the reasoning behind
|
|
122
|
+
[Popover](./popover.md):
|
|
123
|
+
|
|
124
|
+
- it paints **above open modals and drawers** with no z-index arithmetic
|
|
125
|
+
(web-mojo needed `ModalView.getFullscreenAwareZIndex().modal + 1000`);
|
|
126
|
+
- the rest of the document goes **inert** — pointer *and* keyboard. A
|
|
127
|
+
`pointer-events: none` scrim leaves <kbd>Tab</kbd> working, which is not
|
|
128
|
+
blocking, it just looks like it;
|
|
129
|
+
- **Escape is swallowed** (`cancel` → `preventDefault`): dismissible only by
|
|
130
|
+
the caller, because the operation keeps running either way;
|
|
131
|
+
- focus returns to the pre-open element on close (native `<dialog>`
|
|
132
|
+
behavior), so a save started from a toolbar button lands you back on it.
|
|
133
|
+
|
|
134
|
+
A modal opened *after* the overlay stacks above it (top-layer promotion
|
|
135
|
+
order) — that is deliberate: an error modal raised mid-operation must be
|
|
136
|
+
readable.
|
|
137
|
+
|
|
138
|
+
## ViewLoader / LoadingState
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
if (query.isPending) return <ViewLoader label="Loading members…" />;
|
|
142
|
+
|
|
143
|
+
<ViewLoader size="lg" label="Loading dashboard…" hint="First load builds the cache." />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Prop | Type | Notes |
|
|
147
|
+
|---|---|---|
|
|
148
|
+
| `label?` | `string` | Default `'Loading…'`. Say **what** — "Loading members…" beats "Loading…". |
|
|
149
|
+
| `hint?` | `ReactNode` | Second line for the honestly slow. |
|
|
150
|
+
| `size?` | `'sm' \| 'md' \| 'lg'` | Reserved height 88 / 168 / 280px + spinner size. Unknown → `'md'` with a `console.warn`. |
|
|
151
|
+
| `delay?` | `number` | Default 200ms. |
|
|
152
|
+
| `className?` | `string` | |
|
|
153
|
+
|
|
154
|
+
Before the delay elapses it renders an **empty box of the same height**
|
|
155
|
+
(`aria-hidden`), not `null`: a fast panel pops in with no layout jump, and a
|
|
156
|
+
slow one doesn't shove content down when the spinner appears.
|
|
157
|
+
|
|
158
|
+
## InlineLoader
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
<InlineLoader /> {/* "Loading…" */}
|
|
162
|
+
<InlineLoader label="checking sessions…" />
|
|
163
|
+
<InlineLoader label="" /> {/* bare spinner, still announced */}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Small inline row: `label?`, `delay?`, `className?`. Renders `null` before the
|
|
167
|
+
delay (inline text needs no reserved height).
|
|
168
|
+
|
|
169
|
+
## Busy — dim + disable a region
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
<Busy active={save.isPending} label="Saving…">
|
|
173
|
+
<SchemaForm … />
|
|
174
|
+
</Busy>
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
| Prop | Type | Notes |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `active` | `boolean` | A mutation's `isPending`. |
|
|
180
|
+
| `label?` | `string` | Caption under the veil spinner. |
|
|
181
|
+
| `delay?` | `number` | Anti-flash for the **dim only**. |
|
|
182
|
+
| `spinner?` | `boolean` | Default true; false = bare dim. |
|
|
183
|
+
| `className?` | `string` | Appended after `mojo-dim`. |
|
|
184
|
+
|
|
185
|
+
**Blocking is immediate, dimming is delayed.** The content wrapper gets the
|
|
186
|
+
native `inert` attribute the instant `active` goes true — no double-submits,
|
|
187
|
+
no tabbing into a form mid-save — while the visual dim waits out the
|
|
188
|
+
anti-flash window so a 60ms save doesn't strobe the panel. That split is the
|
|
189
|
+
whole reason this wrapper exists; `pointer-events: none` alone would leave
|
|
190
|
+
the keyboard path open.
|
|
191
|
+
|
|
192
|
+
**Focus survives it.** Going inert blurs whatever was focused inside (the
|
|
193
|
+
field the user was in when they hit save), so `Busy` remembers that element
|
|
194
|
+
and restores focus when `active` goes false — but only if focus is still
|
|
195
|
+
parked on `<body>`, i.e. nothing else claimed it. If the user deliberately
|
|
196
|
+
clicked away mid-save, they keep where they went.
|
|
197
|
+
|
|
198
|
+
## Accessibility contract
|
|
199
|
+
|
|
200
|
+
- `Spinner` is `role="img"` + `aria-label` when named, `aria-hidden`
|
|
201
|
+
otherwise — **never** a live region. Nesting live regions double-announces.
|
|
202
|
+
- The overlay dialog carries `aria-busy="true"` and an `aria-label` mirroring
|
|
203
|
+
the label; the label element inside is `role="status"`, so a mid-flight
|
|
204
|
+
`setLabel('Finalizing…')` is announced politely.
|
|
205
|
+
- `ViewLoader` and `InlineLoader` are `role="status"`; `ViewLoader` adds
|
|
206
|
+
`aria-busy="true"`. Its pre-delay placeholder is `aria-hidden` (nothing to
|
|
207
|
+
announce yet).
|
|
208
|
+
- `Busy` sets `aria-busy` on the wrapper and `inert` on the content, which
|
|
209
|
+
also removes it from the accessibility tree while the mutation runs — and
|
|
210
|
+
restores focus to the element the inert switch blurred (see above). Its
|
|
211
|
+
veil is `pointer-events: none`: `inert` already stopped the clicks, and a
|
|
212
|
+
veil that swallowed them would break text selection after release.
|
|
213
|
+
- **Reduced motion:** the spinner does not spin. `theme.css` kills every
|
|
214
|
+
animation with a universal `!important` rule — right for a skeleton
|
|
215
|
+
(shimmer stops), wrong for a spinner (the ring would freeze mid-rotation
|
|
216
|
+
with one colored quarter and read as a stuck control). `loading.css`
|
|
217
|
+
therefore **substitutes** a uniform ring on a slow opacity pulse, with
|
|
218
|
+
`!important` so it out-ranks that universal rule (among important
|
|
219
|
+
declarations the more specific selector wins).
|
|
220
|
+
|
|
221
|
+
## Pitfalls
|
|
222
|
+
|
|
223
|
+
- **Don't overlay a background refresh.** The blocking overlay is for work
|
|
224
|
+
the user must wait out. Data already on screen stays interactive; use
|
|
225
|
+
`<Busy>`, a skeleton, or the toolbar refresh spin.
|
|
226
|
+
- **Don't hand-roll `busy()` without a `finally`.** Use `busyWhile` — the
|
|
227
|
+
watchdog exists because that mistake was made in web-mojo, not
|
|
228
|
+
hypothetically.
|
|
229
|
+
- **Don't nest a `<LoadingOverlay>` per component.** One overlay serves the
|
|
230
|
+
app; several tickets are fine, several *hosts* are not a thing (there is no
|
|
231
|
+
host).
|
|
232
|
+
- **Don't use a loader where a progress toast belongs.** If you have real
|
|
233
|
+
percentages (uploads, multi-file exports), `toast.progress` keeps the app
|
|
234
|
+
usable; a blocking overlay for 40 seconds is hostile.
|
|
235
|
+
- **Don't add `delay={0}` to make it "feel responsive."** It does the
|
|
236
|
+
opposite — you get the flash the delay exists to remove. `delay: 0` is for
|
|
237
|
+
demos and known-slow routes.
|
|
238
|
+
- **The overlay is `dialog[open]`.** ModelTable's `autoRefresh` skips ticks
|
|
239
|
+
while any `dialog[open]` exists, so auto-refresh politely pauses during a
|
|
240
|
+
busy operation. That is intended; don't "fix" it.
|
|
241
|
+
- **Toasts render under the overlay** (`.toast-host` is z-index 60, the
|
|
242
|
+
overlay is in the top layer). Toast *after* closing the handle, not during.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# LocationClient + AddressField
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { LocationClient } from 'portal-mojo/client';
|
|
5
|
+
import { AddressField } from 'portal-mojo/ui';
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
`LocationClient` is the typed adapter for django-mojo's six public location
|
|
9
|
+
endpoints. It calls `mojoCall` directly—there is no Query cache—and parses the
|
|
10
|
+
backend's intentionally mixed response shapes without performing a second
|
|
11
|
+
envelope unwrap.
|
|
12
|
+
|
|
13
|
+
| Method | Wire contract |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `validateAddress(address)` | `POST /api/location/address/validate`; reads nested `data` |
|
|
16
|
+
| `autocomplete(input, options?)` | `GET /api/location/address/suggestions`; reads top-level `success/data/size/count` |
|
|
17
|
+
| `placeDetails(placeId)` | `GET /api/location/address/place-details`; reads top-level `address` |
|
|
18
|
+
| `geocode(address)` | `POST /api/location/address/geocode`; reads top-level coordinates/components |
|
|
19
|
+
| `reverseGeocode({lat,lng})` | `GET /api/location/address/reverse-geocode` |
|
|
20
|
+
| `timezone({lat,lng,timestamp?})` | `GET /api/location/timezone` |
|
|
21
|
+
|
|
22
|
+
## Provider session and races
|
|
23
|
+
|
|
24
|
+
The first suggestions request deliberately omits `session_token`; django-mojo
|
|
25
|
+
creates it. The client accepts that response token into a JavaScript private
|
|
26
|
+
field, reuses it on later suggestions and place-details GETs, then destroys it
|
|
27
|
+
when selection ends the session. There is no token getter or input prop.
|
|
28
|
+
|
|
29
|
+
Every request captures a monotonically increasing generation. A later request,
|
|
30
|
+
`cancelPending()`, `reset()`, `replaceTransport()`, `dispose()`, unmount, or
|
|
31
|
+
upstream-client change makes older completions reject with
|
|
32
|
+
`StaleLocationRequestError`. Use `isStaleLocationRequest(error)` when a UI
|
|
33
|
+
should silently discard that expected outcome.
|
|
34
|
+
|
|
35
|
+
Never put the session token in TanStack Query, router/search params, form
|
|
36
|
+
values, storage, application logs, or mock history. The mock exposes only
|
|
37
|
+
derived booleans/counts such as `has_session_token` and `input_length`.
|
|
38
|
+
|
|
39
|
+
## AddressField
|
|
40
|
+
|
|
41
|
+
`AddressField` is controlled and commit-only. Keystrokes stay in a private
|
|
42
|
+
draft. Raw text commits on Enter/Tab/blur/outside; Escape restores the
|
|
43
|
+
controlled value. Selecting a suggestion privately fetches place details and
|
|
44
|
+
only then commits. A details failure drops the provider description and
|
|
45
|
+
restores the prior value.
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
<AddressField
|
|
49
|
+
fieldName="address1"
|
|
50
|
+
value={values.address1}
|
|
51
|
+
fields={{ city: 'city', state_code: 'state', postal_code: 'postal_code' }}
|
|
52
|
+
onCommit={(address1) => setValues(v => ({ ...v, address1 }))}
|
|
53
|
+
onPatch={(patch) => setValues(v => ({ ...v, ...patch }))}
|
|
54
|
+
/>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
For schema-driven forms, use `type: 'address'`:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const fields: Field[] = [{
|
|
61
|
+
name: 'address1', type: 'address', label: 'Street address',
|
|
62
|
+
addressFields: {
|
|
63
|
+
city: 'city', state_code: 'state', postal_code: 'postal_code',
|
|
64
|
+
latitude: 'latitude', longitude: 'longitude',
|
|
65
|
+
},
|
|
66
|
+
} /* plus every destination as a declared Field */];
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`address1` defaults to the address field's own name. Other destinations are
|
|
70
|
+
opt-in. SchemaForm and FormView discard undeclared destinations. FormView
|
|
71
|
+
queues the accepted patch as one autosave batch; a server failure rolls every
|
|
72
|
+
field in that batch back to its shared server snapshot.
|
|
73
|
+
|
|
74
|
+
The suggestion list uses the shared `Popover`, which is the sole owner of
|
|
75
|
+
outside-mousedown and Escape dismissal. Styles live in
|
|
76
|
+
`theme/location-address.css` and use design tokens in both themes.
|