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/feedback.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# feedback — awaitable modals + toasts
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { modal, formModal, toast, ModalHost, ToastHost } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Mount `<ModalHost />` and `<ToastHost />` once in the app shell. Modals are
|
|
8
|
+
native `<dialog>` — stacking, backdrop and Escape come free; every opener
|
|
9
|
+
is a Promise that resolves when its dialog closes.
|
|
10
|
+
|
|
11
|
+
## modal
|
|
12
|
+
|
|
13
|
+
| Call | Resolves |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `modal.confirm({title, message, confirmText, danger?})` | `boolean` |
|
|
16
|
+
| `modal.open<T>((close) => <JSX/>)` | `T \| null` — generic surface; call `close(value)` |
|
|
17
|
+
| `modal.detail((close) => <DetailView …/>)` | detail-sized shell for record views |
|
|
18
|
+
| `modal.drawer<T>({title, content\|render, …})` | `T \| null` — right slide-over (below) |
|
|
19
|
+
| `formModal({title, fields, initial?, submitText?, intro?})` | `FormData \| null` (see forms.md) |
|
|
20
|
+
|
|
21
|
+
Patterns:
|
|
22
|
+
- Danger confirms: `danger: true` styles the confirm button destructively.
|
|
23
|
+
- Batch prepare: open a `formModal` INSIDE a flow to collect once-per-batch
|
|
24
|
+
input; resolve null to cancel the whole thing.
|
|
25
|
+
- Stacked dialogs are fine (disable-reason form over a detail modal).
|
|
26
|
+
|
|
27
|
+
## modal.drawer
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const picked = await modal.drawer<string>({
|
|
31
|
+
eyebrow: 'Source IP', title: '198.51.100.24',
|
|
32
|
+
meta: [{ icon: 'bi-clock', text: 'Last 30 days' }, '412 events'],
|
|
33
|
+
width: 'wide',
|
|
34
|
+
render: (close) => <button onClick={() => close('approved')}>Approve</button>,
|
|
35
|
+
});
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Right-anchored slide-over on the SAME `<dialog showModal>` substrate as the
|
|
39
|
+
rest of the manager — backdrop, Escape and stacking are native, so a drawer
|
|
40
|
+
opened from an open modal simply takes the top layer. Header shape (eyebrow ·
|
|
41
|
+
title · meta) is web-mojo's `Modal.drawer()` carried over.
|
|
42
|
+
|
|
43
|
+
| Option | Meaning |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `title` / `eyebrow` / `meta` | Header: big line, uppercase tag above it, subtitle row (`string` or `{icon, text}`) |
|
|
46
|
+
| `content` | Static body ReactNode — ignored when `render` is given |
|
|
47
|
+
| `render(close)` | Body that owns the result; `close(value)` resolves the await |
|
|
48
|
+
| `width` | `480px` default · `'wide'` → `720px` · a number → px (min 280). Always clamped to the viewport (`min(w, 100%)`); full-bleed under 560px |
|
|
49
|
+
| `dismissable` | Default `true`. Governs Escape + backdrop + the header X together — no half-states |
|
|
50
|
+
|
|
51
|
+
- **Resolution:** `close(value)` → `value`; Escape / backdrop / X → `null`.
|
|
52
|
+
The promise settles AFTER the slide-out (~200ms), so `await` then toast
|
|
53
|
+
reads in the right order. First close wins; later ones are ignored.
|
|
54
|
+
- **Exit animation:** close adds `.mojo-drawer-closing`, the manager waits out
|
|
55
|
+
the keyframes, THEN drops the item (unmount calls `dialog.close()`). Under
|
|
56
|
+
`prefers-reduced-motion: reduce` the wait is 0 and the keyframes are off.
|
|
57
|
+
- **`dismissable: false` requires a `render`** that can close the drawer —
|
|
58
|
+
otherwise there is no exit at all, so it falls back to dismissable with a
|
|
59
|
+
`console.warn` (house rule: degenerate input → default + warn).
|
|
60
|
+
- An unknown `width` warns and falls back to 480px.
|
|
61
|
+
- Styles: `apps/portal/src/theme/drawer.css` (`.mojo-drawer`, `.drawer-panel`,
|
|
62
|
+
`.drawer-head/-eyebrow/-title/-meta`, `.drawer-body`) — tokens only, both
|
|
63
|
+
themes. `DRAWER_EXIT_MS` in `modal.tsx` must match the closing keyframes.
|
|
64
|
+
|
|
65
|
+
**Boundary — this drawer is TRANSIENT.** It is a modal, top-layer, awaitable
|
|
66
|
+
surface for drill-downs and one-off flows: it blocks the page, it resolves,
|
|
67
|
+
it is gone. The persistent shell right-panel slot (tickets / assistant) is a
|
|
68
|
+
different thing — a non-modal layout region that lives alongside content and
|
|
69
|
+
holds state across navigation. Do not build that with `modal.drawer`, and do
|
|
70
|
+
not grow `modal.drawer` toward it.
|
|
71
|
+
|
|
72
|
+
## toast
|
|
73
|
+
|
|
74
|
+
`toast.success(msg)` / `.error(msg)` / `.info(msg)` / `.warning(msg)` —
|
|
75
|
+
bottom-right cards, max 5, 3.5s. Conventions:
|
|
76
|
+
- Server failures: toast the server's message verbatim
|
|
77
|
+
(`err instanceof Error ? err.message : 'Fallback'`).
|
|
78
|
+
- Batch partial results use `warning` (`"Disable: 3 succeeded, 2 failed"`).
|
|
79
|
+
- `response: 'payload'` actions toast the SERVER's payload message
|
|
80
|
+
(`outcome.body.message`), not client copy.
|
|
81
|
+
|
|
82
|
+
## Pitfall
|
|
83
|
+
|
|
84
|
+
An open `<dialog>` pauses ModelTable's autoRefresh by design — don't build
|
|
85
|
+
alternative modal systems that bypass `<dialog>` or that interlock breaks.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# File upload UX
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import {
|
|
5
|
+
AttachmentQueue, FileDropZone, FilePicker, UploadQueue, useUploadQueue,
|
|
6
|
+
validateFileSelection,
|
|
7
|
+
} from 'portal-mojo/ui';
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The UI layer composes the imperative transport documented in
|
|
11
|
+
[`client.md`](client.md). It does not replace the server contract: browser
|
|
12
|
+
validation is convenience only, and every accepted file still passes through
|
|
13
|
+
django-mojo's initiate → byte transfer → authoritative reconciliation flow.
|
|
14
|
+
|
|
15
|
+
Live examples: portal → Develop → Components → **File upload**. The Showcase
|
|
16
|
+
transport and progress fixture live only in its demo module; package consumers
|
|
17
|
+
use the real client by default.
|
|
18
|
+
|
|
19
|
+
## Selection controls
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
const selected = ({ accepted, rejected }: FileSelectionResult) => {
|
|
23
|
+
queue.add(accepted);
|
|
24
|
+
rejected.forEach((item) => toast.warning(item.message));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
<FileDropZone
|
|
28
|
+
accept={['image/*', '.pdf']}
|
|
29
|
+
maxFileSize={10_000_000}
|
|
30
|
+
maxFiles={4}
|
|
31
|
+
onSelection={selected}
|
|
32
|
+
/>
|
|
33
|
+
|
|
34
|
+
<FilePicker accept=".csv,text/csv" multiple onSelection={selected}>
|
|
35
|
+
Import CSV
|
|
36
|
+
</FilePicker>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`validateFileSelection(files, rules)` is pure. It returns ordered `accepted`
|
|
40
|
+
and `rejected` arrays and never uploads. `accept` supports exact MIME types,
|
|
41
|
+
MIME wildcards, and extensions. `maxFileSize` is bytes; `maxFiles` applies to
|
|
42
|
+
one picker/drop gesture.
|
|
43
|
+
|
|
44
|
+
Both controls are native buttons. The hidden input is a sibling, so the DOM
|
|
45
|
+
does not nest interactive controls. The picker clears its input after every
|
|
46
|
+
change, allowing the same file to be chosen twice in separate gestures.
|
|
47
|
+
`FileDropZone` also installs a reference-counted window guard while mounted:
|
|
48
|
+
file drags cannot navigate the tab when dropped outside the zone, while text,
|
|
49
|
+
links, and other non-file drags retain normal browser behavior.
|
|
50
|
+
|
|
51
|
+
`useFileDrop(options)` exposes the same behavior for a custom native control:
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
const { isDragActive, dropProps } = useFileDrop({ onSelection: selected });
|
|
55
|
+
return <button type="button" {...dropProps}>{isDragActive ? 'Drop' : 'Attach'}</button>;
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Queue
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
const queue = useUploadQueue({
|
|
62
|
+
consumerKey: 'ticket-attachments',
|
|
63
|
+
destination: { use: 'support', groupId },
|
|
64
|
+
async onComplete(file, { signal }) {
|
|
65
|
+
await attachFileToTicket(file.id, { signal });
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
<FileDropZone onSelection={({ accepted }) => queue.add(accepted)} />
|
|
70
|
+
<UploadQueue queue={queue} />
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Defaults are concurrency **3** and capacity **6**. Files enter a FIFO and the
|
|
74
|
+
transport task is not created until a slot opens; selecting six files never
|
|
75
|
+
starts six initiate requests. The queue belongs to the mounted component—no
|
|
76
|
+
local/session persistence and no TanStack Query cache. StrictMode's probe
|
|
77
|
+
unmount is tolerated, but a real unmount aborts tasks and callbacks, removes
|
|
78
|
+
progress cards, unsubscribes observers, and drops retained `File` references.
|
|
79
|
+
An authenticated queue also disposes immediately if its auth UID changes or
|
|
80
|
+
the session signs out.
|
|
81
|
+
|
|
82
|
+
The queue deduplicates on consumer + normalized destination + file metadata.
|
|
83
|
+
Use a stable `consumerKey` for the owning form/view. `destination` accepts
|
|
84
|
+
`fileManagerId`, `groupId`, and the django-mojo `use` selector. Two consumers
|
|
85
|
+
or two destinations may intentionally queue the same local file.
|
|
86
|
+
|
|
87
|
+
The public snapshot is stable between transitions and deliberately inert. It
|
|
88
|
+
contains safe filename/type/size, byte counts, status, sanitized failures, and
|
|
89
|
+
safe completed File references. It never contains browser `File` objects,
|
|
90
|
+
transport tasks, callbacks, capabilities, abort controllers, toast handles,
|
|
91
|
+
raw errors, or auth data.
|
|
92
|
+
|
|
93
|
+
`AttachmentQueue` is the shared reference-only composition used by record
|
|
94
|
+
notes and Assistant chat. It adds picker/drop UI to the same queue and emits
|
|
95
|
+
only completed `{id,filename,content_type,category}` references after checking
|
|
96
|
+
the authoritative lifecycle result has a positive manager id and the exact
|
|
97
|
+
expected group. It never emits browser Files, deletes a File, or turns a failed
|
|
98
|
+
or uncertain upload into an attachable reference.
|
|
99
|
+
|
|
100
|
+
Admin Files composes this primitive in `FileUploadSurface`: Add File and a
|
|
101
|
+
drag-only whole-page overlay share an explicit policy-backed destination modal,
|
|
102
|
+
while the queue remains mounted outside it. The surface fabricates neither
|
|
103
|
+
progress nor reconciliation timers.
|
|
104
|
+
|
|
105
|
+
## Outcome truth and actions
|
|
106
|
+
|
|
107
|
+
- **Cancel** is a request. An active item becomes `cancelling`; it becomes
|
|
108
|
+
`uncertain` if django-mojo cannot prove the remote state. The UI never calls
|
|
109
|
+
an initiated transfer “cancelled” merely because `AbortController` fired.
|
|
110
|
+
- **Retry** explicitly replays a retryable failed/id-less-uncertain task.
|
|
111
|
+
- **Recover** appears only when the task has a known File id and invokes the
|
|
112
|
+
transport's reconciliation-only recovery—no duplicate byte transfer.
|
|
113
|
+
- **Finalizing** begins after upload truth is `completed` and while
|
|
114
|
+
`onComplete` runs. It is non-cancellable and releases the transfer slot.
|
|
115
|
+
- A rejected `onComplete` is `completed-warning`, not upload failure. “Retry
|
|
116
|
+
follow-up” reruns only the callback using the already-completed safe File
|
|
117
|
+
reference; it never replays the bytes.
|
|
118
|
+
- Remove is immediate for queued/settled items. Removing an active item first
|
|
119
|
+
requests cancellation and only forgets it after transport truth settles.
|
|
120
|
+
|
|
121
|
+
Every active attempt owns exactly one `toast.progress` card. Its displayed
|
|
122
|
+
percentage changes only when real byte events cross an integer percentage.
|
|
123
|
+
Reconciliation/callback work uses the additive non-cancellable `finalizing`
|
|
124
|
+
state, then settles to success, failure, or completed-with-warning.
|
|
125
|
+
|
|
126
|
+
## Headless use and verification
|
|
127
|
+
|
|
128
|
+
`createUploadQueue()` exposes the same external-store controller for code that
|
|
129
|
+
does not need the hook. `UploadQueue` is presentational and accepts any
|
|
130
|
+
controller with the documented snapshot/actions, which also keeps Showcase
|
|
131
|
+
fixtures out of production transport code.
|
|
132
|
+
|
|
133
|
+
Run the focused executable contract:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
npm run verify:upload-ux
|
|
137
|
+
npm run verify:form-uploads
|
|
138
|
+
npm run verify:record-attachments
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
It covers pure validation, lazy concurrency, capacity, stable/safe snapshots,
|
|
142
|
+
consumer/destination dedupe, real progress updates, callback-only retry,
|
|
143
|
+
truthful cancellation, known-id recovery, and byte-identical Portal/Showcase
|
|
144
|
+
token CSS. The form contract adds strict relation normalization, explicit
|
|
145
|
+
attach states, owner-response reconciliation, orphan retention, safe previews,
|
|
146
|
+
avatar #1488 semantics, and cache-boundary sanitization.
|
package/docs/forms.md
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# forms — the Field language, SchemaForm, formModal, FormView (autosave)
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import {
|
|
5
|
+
SchemaForm, SchemaSelect, formModal,
|
|
6
|
+
FormView, FileField, ImageField, registerFormTabs, getFormTabs,
|
|
7
|
+
type Field, type FormData, type FormTab,
|
|
8
|
+
} from 'portal-mojo/ui';
|
|
9
|
+
import { useFormAutosave, resolveShowWhen } from 'portal-mojo/ui'; // the machine, standalone
|
|
10
|
+
// Field/FormData/ModelForm/FieldValues/ShowWhen TYPES also export from
|
|
11
|
+
// 'portal-mojo/client' — model definitions carry form configs as plain data.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Two surfaces over ONE field language:
|
|
15
|
+
|
|
16
|
+
- **SchemaForm / formModal** — submit-style (create dialogs, action forms).
|
|
17
|
+
- **FormView** — the inline-autosave surface bound to a server row via
|
|
18
|
+
`defineModel`. **No save buttons**; edits save themselves.
|
|
19
|
+
|
|
20
|
+
## The `Field` language (data, not JSX)
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
interface Field {
|
|
24
|
+
name: string; // wire key; DOTTED names read/write nested dicts
|
|
25
|
+
type: FieldType; // 6 builtins + every registry type (below) + (string & {})
|
|
26
|
+
label: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
required?: boolean; // SchemaForm submit validation
|
|
29
|
+
help?: string;
|
|
30
|
+
columns?: 6 | 12; // grid halves / full row
|
|
31
|
+
options?: FieldOption[]; // select/multiselect/combo: {value, label, description?, disabled?}
|
|
32
|
+
showWhen?: ShowWhen; // conditional visibility (both surfaces)
|
|
33
|
+
schema?: ZodType; // per-field zod validation (FormView commits)
|
|
34
|
+
disabled?: boolean; // registry types
|
|
35
|
+
accept?: string | string[]; maxFileSize?: number; // file/image selection UX
|
|
36
|
+
uploadDestination?: { fileManagerId?; groupId?; use? };
|
|
37
|
+
onUploadOrphan?: (fileId: number) => void;
|
|
38
|
+
// …plus the registry-type props (precision, outputFormat, presets,
|
|
39
|
+
// model/endpoint, timezone, maxTags, …) — see "Field-type registry".
|
|
40
|
+
}
|
|
41
|
+
type FormData = Record<string, FieldValue>; // SchemaForm values
|
|
42
|
+
type FieldValue = string | number | boolean | null | Array<string | number>;
|
|
43
|
+
type FieldValues = Record<string, FieldValue>; // FormView values
|
|
44
|
+
type ShowWhen = { field; value; negate? } | ((values: FieldValues) => boolean);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Builtins** — rendered inline by both surfaces, byte-for-byte the pre-B4
|
|
48
|
+
behavior: `text` `email` `tel` `select` `switch` `textarea`. Every other
|
|
49
|
+
type resolves through the **field-type registry** (below).
|
|
50
|
+
|
|
51
|
+
`ModelForm = { title, fields, submitText? }` — the shape `defineModel`
|
|
52
|
+
carries (`UserModel.forms.create`) and `formModal` renders.
|
|
53
|
+
|
|
54
|
+
### showWhen (both surfaces, one pipeline: `resolveShowWhen`)
|
|
55
|
+
|
|
56
|
+
Declarative rule = web-mojo FormBuilder semantics: the controlling field's
|
|
57
|
+
current value is `String()`-coerced and matched against `value`
|
|
58
|
+
(scalar or list); `negate` flips. Or pass a predicate over the live values.
|
|
59
|
+
**Hidden fields don't submit/save, and their pending edits + errors clear.**
|
|
60
|
+
|
|
61
|
+
### Per-field zod (`schema`)
|
|
62
|
+
|
|
63
|
+
Validated at COMMIT against the display value (string for text-ish types,
|
|
64
|
+
boolean for switch). A failing parse **blocks the save of that field only** —
|
|
65
|
+
the first issue's message shows in the field's error slot; other fields keep
|
|
66
|
+
saving. The server stays authoritative: values zod allows can still be
|
|
67
|
+
rejected server-side (and then revert — below).
|
|
68
|
+
|
|
69
|
+
## SchemaForm
|
|
70
|
+
|
|
71
|
+
`<SchemaForm fields initial? submitText? onSubmit onCancel? />` — fully
|
|
72
|
+
controlled; validates required + email format on visible fields; async
|
|
73
|
+
`onSubmit` gets a busy state and surfaces thrown errors as the form alert.
|
|
74
|
+
showWhen-hidden fields don't render, don't validate, and are stripped from
|
|
75
|
+
the submitted payload.
|
|
76
|
+
|
|
77
|
+
When file/image fields are present, submit, cancel, wizard steps/tabs, and
|
|
78
|
+
modal dismissal lock while bytes are active. `onSubmit`/`onFinish` must return
|
|
79
|
+
the authoritative saved owner row so the field can compare its returned
|
|
80
|
+
relation to the requested id/null. A rejection or mismatch keeps the completed
|
|
81
|
+
candidate available for attachment-only retry; it never replays bytes.
|
|
82
|
+
|
|
83
|
+
**Select semantics (the rule-4 fix):** display always equals state. While
|
|
84
|
+
the value is `''` (unset) the select shows a real disabled placeholder; an
|
|
85
|
+
initial value not among the options warns once and shows the placeholder.
|
|
86
|
+
`SchemaSelect` is exported — FormView renders selects through the same
|
|
87
|
+
component. One select implementation, one value pipeline.
|
|
88
|
+
|
|
89
|
+
## formModal
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const data = await formModal({ ...UserModel.forms.create!, initial?, intro? });
|
|
93
|
+
if (!data) return; // cancelled → null
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## FormView — inline autosave
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<FormView
|
|
100
|
+
model={UserModel} // Pick<ModelDef, 'useSave'> — the batch POST
|
|
101
|
+
row={user} // current server row (snapshot + revert target)
|
|
102
|
+
fields={PROFILE_FIELDS} // flat fields, and/or:
|
|
103
|
+
tabs="user.permissions" // registry name — or inline FormTab[]
|
|
104
|
+
debounceMs={300} // batch window (default 300)
|
|
105
|
+
savedFlashMs={1500} // saved-check duration (default 1500)
|
|
106
|
+
onSaved={({ changes, fields, row }) => …}
|
|
107
|
+
onSaveError={({ changes, fields, error }) => …}
|
|
108
|
+
onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
|
|
109
|
+
/>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### The autosave lifecycle (the machine: `useFormAutosave`)
|
|
113
|
+
|
|
114
|
+
1. **Commit, never keystroke.** Text/textarea update the draft per keystroke
|
|
115
|
+
(dirty dot) but COMMIT on blur — text also on Enter. Selects and switches
|
|
116
|
+
commit on change. This is by construction: web-mojo's per-keystroke
|
|
117
|
+
`input` autosave saved partial text (the ComboBox "Amer" leak).
|
|
118
|
+
2. **300ms batch.** Each accepted commit (re)arms one window; commits inside
|
|
119
|
+
it coalesce. The batch POSTs **one save with only the changed fields**
|
|
120
|
+
(change-detection against the server snapshot: trimmed-string compare,
|
|
121
|
+
strict boolean for switches — `42` vs `'42'` and `null` vs `''` are not
|
|
122
|
+
changes).
|
|
123
|
+
3. **Serialized.** While a POST is in flight, further commits queue the next
|
|
124
|
+
batch; completion re-arms the window. Never two saves interleaved for one
|
|
125
|
+
form. (web-mojo's `isSaving` guard silently dropped the queue — fixed.)
|
|
126
|
+
4. **Indicators** ride the label row: dirty dot → spinner (queued + in
|
|
127
|
+
flight) → saved check (~1.5s) → idle. An **error pins** on the field with
|
|
128
|
+
the zod/server message until that field's next successful save.
|
|
129
|
+
5. **Revert on fail.** A rejected batch restores every batch field to the
|
|
130
|
+
**server snapshot** and toasts the server's message verbatim — a failed
|
|
131
|
+
value never sticks (`Model.save` resolve-on-failure heritage ends here;
|
|
132
|
+
`useSave` REJECTS).
|
|
133
|
+
6. **Reconcile.** The success row (and any `row` prop change — refetch,
|
|
134
|
+
another form's save) becomes the new snapshot; untouched fields follow it.
|
|
135
|
+
Server normalization is visible: save `555 010 0142`, the field comes back
|
|
136
|
+
`+15550100142`.
|
|
137
|
+
|
|
138
|
+
### `file` / `image` relations
|
|
139
|
+
|
|
140
|
+
Both registry bindings use the exported controlled `FileField`/`ImageField`
|
|
141
|
+
over the one shared `UploadQueue` + client transport. Their public value is
|
|
142
|
+
only a positive numeric File id or `null`; expanded relation objects normalize
|
|
143
|
+
to their positive numeric id before generic string coercion, while booleans,
|
|
144
|
+
numeric strings, non-positive numbers, and ambiguous objects normalize to
|
|
145
|
+
null and are never posted as File ids.
|
|
146
|
+
|
|
147
|
+
The explicit UI states are keep, clear, replacement-in-progress,
|
|
148
|
+
replacement-failed, completed-awaiting-attach, and attach-failed. The stored
|
|
149
|
+
relation remains the visible/revert baseline until upload completion and an
|
|
150
|
+
authoritative owner save both succeed. FormView receives the exact sent File
|
|
151
|
+
value plus saved row from its autosave mutation and reconciles only that File
|
|
152
|
+
field: a result for an older candidate is ignored, while an exact returned
|
|
153
|
+
id/null means attached; reject or mismatch restores the prior server value but
|
|
154
|
+
retains the completed candidate for “Retry attachment.” Remove reports the
|
|
155
|
+
candidate once through `onUploadOrphan`; Clear posts null. Neither action
|
|
156
|
+
deletes File.
|
|
157
|
+
|
|
158
|
+
ImageField previews a newly selected image with a component-local object URL
|
|
159
|
+
and revokes it on replace/settle/unmount. Stored preview URLs are resolved by
|
|
160
|
+
an imperative mounted File detail read, scheme-checked, rendered with
|
|
161
|
+
`referrerPolicy="no-referrer"`, and never cached, persisted, logged, or copied
|
|
162
|
+
into errors. Queue work is generation-guarded, cancelled on unmount/auth loss,
|
|
163
|
+
and an active task is cancelled before removal.
|
|
164
|
+
|
|
165
|
+
Image editing is opt-in. Direct use passes `edit` to `ImageField`; schema data
|
|
166
|
+
passes `imageEdit` on an `image` field. The value is `true` for defaults or the
|
|
167
|
+
`imageEditorModal` options (`startMode`, `modes`, `crop`, `maxHistory`, and
|
|
168
|
+
labels). `requireEdit` (schema: `imageEditRequired`) removes the Use original
|
|
169
|
+
escape hatch while still retaining the selection for retry/discard. The editor opens before upload initiation. Cancel and decode/encode
|
|
170
|
+
failure retain the selected original locally with Edit/Use original/Discard
|
|
171
|
+
choices; no File row, relation id, or owner save exists yet. Save converts the
|
|
172
|
+
explicit PNG Blob into the exact File fed to the existing queue. Editing joins
|
|
173
|
+
transfer in `onPendingChange`, so owning forms and dialogs keep their existing
|
|
174
|
+
dismiss/submit lock.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
{
|
|
178
|
+
name: 'avatar', type: 'image', label: 'Avatar',
|
|
179
|
+
imageEdit: {
|
|
180
|
+
startMode: 'crop',
|
|
181
|
+
crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } },
|
|
182
|
+
},
|
|
183
|
+
imageEditRequired: true,
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
All machine state lives in one reducer (draft, server snapshot, per-field
|
|
188
|
+
status, pending batch, in-flight flag); the two timers only dispatch.
|
|
189
|
+
|
|
190
|
+
### Dotted names — the nested-value wire contract
|
|
191
|
+
|
|
192
|
+
`name: 'permissions.manage_users'` reads `row.permissions.manage_users`
|
|
193
|
+
(loose truthy: `true` or `1`) and saves as a **partial dict**:
|
|
194
|
+
`{permissions: {manage_users: true}}`. django-mojo **merges** dict bodies
|
|
195
|
+
into JSONFields (`rest.py on_rest_update_jsonfield` → `objict.merge_dicts`)
|
|
196
|
+
— never send the whole dict, never fear clobbering sibling keys. The mock
|
|
197
|
+
mirrors the merge exactly.
|
|
198
|
+
|
|
199
|
+
### Permission tabsets — a registry, not mutated arrays
|
|
200
|
+
|
|
201
|
+
```ts
|
|
202
|
+
registerFormTabs('user.permissions', [
|
|
203
|
+
{ key: 'standard', label: 'Standard', fields: [...] },
|
|
204
|
+
{ key: 'users', label: 'Users', permissions: ['users', 'manage_users'], fields: [...] },
|
|
205
|
+
]);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
- Registering the same name again **appends new keys / replaces same keys**
|
|
209
|
+
(an app adds its tab beside the framework's — web-mojo
|
|
210
|
+
`registerPermissions` semantics without `arr.length = 0; arr.push(…)`).
|
|
211
|
+
- `getFormTabs(name)` returns a snapshot copy; mounted FormViews re-render
|
|
212
|
+
on registration (subscription).
|
|
213
|
+
- Tab `permissions` resolve **fail-closed like `<Guarded>`** against `me` +
|
|
214
|
+
the active-group member (`hasPermission`): hidden while loading, while
|
|
215
|
+
anonymous, and on failure. The active tab self-heals to the first visible.
|
|
216
|
+
- A tab whose field holds a pinned error shows a red dot.
|
|
217
|
+
|
|
218
|
+
The machine spans ALL tabs' fields — a batch may carry fields from several
|
|
219
|
+
tabs; switching tabs loses nothing.
|
|
220
|
+
|
|
221
|
+
## Field-type registry (board #1278)
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import {
|
|
225
|
+
registerFieldType, resolveFieldRenderer, registeredFieldTypes,
|
|
226
|
+
warnUnknownFieldType, // the shared unknown-type warn
|
|
227
|
+
fieldToWire, wireToField, // the epoch boundary (field-wire)
|
|
228
|
+
emptyFieldValue, fieldPrecision,
|
|
229
|
+
type RegistryFieldProps, type FieldTypeRenderer,
|
|
230
|
+
} from 'portal-mojo/ui';
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`ui/field-registry` maps every non-builtin `Field.type` string to a renderer
|
|
234
|
+
that binds the Field def + the form's controlled value/commit pipeline to
|
|
235
|
+
the real component — web-mojo's `INPUT_TYPES`/`createInput` +
|
|
236
|
+
FormBuilder's field-type switch, carried as a registry (also the FormPlugins
|
|
237
|
+
replacement: apps extend with `registerFieldType`). **Both surfaces consult
|
|
238
|
+
it**: SchemaForm and FormView render any registered type from the same
|
|
239
|
+
schema, with the same commit semantics (every registry component fires on
|
|
240
|
+
COMMIT, so each change is one autosave gesture).
|
|
241
|
+
|
|
242
|
+
### The registry API
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
// Renderers are function components — they may hold hooks/state.
|
|
246
|
+
type FieldTypeRenderer = ComponentType<RegistryFieldProps>;
|
|
247
|
+
interface RegistryFieldProps {
|
|
248
|
+
field: Field;
|
|
249
|
+
value: FieldValue; // form-state value (WIRE-shaped — below)
|
|
250
|
+
invalid?: boolean; // paint the error border (text is the surface's)
|
|
251
|
+
disabled?: boolean;
|
|
252
|
+
commit: (value: FieldValue) => void; // THE change pipeline, once per commit
|
|
253
|
+
commitPatch: (patch: FieldValues) => void; // one declared-field transaction
|
|
254
|
+
}
|
|
255
|
+
registerFieldType('rating', RatingField); // add / replace (HMR-safe)
|
|
256
|
+
registerFieldType(['a', 'b'], SharedField); // aliases share one renderer
|
|
257
|
+
resolveFieldRenderer('rating'); // renderer | null
|
|
258
|
+
registeredFieldTypes(); // names, registration order
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Names match **exactly** (no case folding): a typo'd type must fall through
|
|
262
|
+
to the loud fallback, not silently half-match.
|
|
263
|
+
|
|
264
|
+
**Unknown-type rule** (house rule 4): a type with no renderer renders a
|
|
265
|
+
**text input** and `console.warn`s ONCE per (field, type) — never "render
|
|
266
|
+
nothing". Both surfaces enforce it (`warnUnknownFieldType` carries the
|
|
267
|
+
shared warn-once set).
|
|
268
|
+
|
|
269
|
+
### The value-shape table
|
|
270
|
+
|
|
271
|
+
**State holds the WIRE shape.** The registry renderer converts at its own
|
|
272
|
+
edge — render through `wireToField(field, value)`, commit through
|
|
273
|
+
`fieldToWire(field, value)` — so SchemaForm's submit payload and FormView's
|
|
274
|
+
autosave batches carry wire-ready values without either surface knowing
|
|
275
|
+
dates exist. This is the ONE conversion point.
|
|
276
|
+
|
|
277
|
+
| `Field.type` (aliases) | Component | Control (what the picker holds) | State = wire (what the save body carries) | Empty |
|
|
278
|
+
|---|---|---|---|---|
|
|
279
|
+
| `tag`, `tags` | TagInput | chip list | **CSV string** `'a,b'` (django-mojo models split it) | `''` |
|
|
280
|
+
| `multiselect` | MultiSelectDropdown | checked options | **array** of option values | `[]` |
|
|
281
|
+
| `collection` | CollectionSelect | one picked row | the row's **id** (`string \| number`); `null` clears | `null` |
|
|
282
|
+
| `collectionmultiselect`, `collection-multiselect` | CollectionMultiSelect | checked rows | **array of ids** | `[]` |
|
|
283
|
+
| `combo`, `combobox`, `autocomplete` | ComboBox | committed option/text | the committed value (`string \| number`; free text iff `allowCustom`) | `''` |
|
|
284
|
+
| `address` | AddressField | private draft + provider suggestions | raw committed address, or one atomic mapped details patch | `''` |
|
|
285
|
+
| `datepicker`, `monthpicker`, `yearpicker` | DatePicker | canonical `YYYY-MM-DD` / `YYYY-MM` / `YYYY` | the shape it read (see below); **epoch seconds** at UTC midnight by default; `outputFormat` overrides | `null` |
|
|
286
|
+
| `daterange`, `monthrange`, `yearrange` | DateRangePicker | canonical `[start, end]` pair | as above, per element — **`[startEpoch, endEpoch]`** by default | `null` |
|
|
287
|
+
| `timepicker` | TimePicker (+ the REAL TimezoneSelect in its slot) | HH:MM (+ zone) | serialized **string** — `'iso'` (default) `'14:30-07:00'`, `'iana'` `'14:30 America/…'`; times are never epochs | `''` |
|
|
288
|
+
| `datetimepicker` | DateTimePicker | `'YYYY-MM-DD HH:MM'` local | the shape it read; **epoch seconds** (exact instant) by default, ISO when the column spoke ISO | `null` |
|
|
289
|
+
| `timezone` | TimezoneSelect | IANA zone | the IANA **string** | `''` (picker displays the local zone; `resolveTimezone` computes that effective default when a form must post it) |
|
|
290
|
+
|
|
291
|
+
Alias precision mapping (web-mojo `PRECISION_ALIASES`): `monthpicker`/
|
|
292
|
+
`monthrange` → `'month'`, `yearpicker`/`yearrange` → `'year'`; an
|
|
293
|
+
**explicit `Field.precision` wins** over the alias (`createInput` parity).
|
|
294
|
+
|
|
295
|
+
Registry-type Field props (all optional, additive): `precision`,
|
|
296
|
+
`outputFormat`, `displayFormat`, `min`/`max`, `presets`, `months`,
|
|
297
|
+
`separator` (tag CSV joiner / daterange display), `timeFormat`, `step`,
|
|
298
|
+
`timezone`/`timezones`, `model`/`endpoint`, `labelField`/`valueField`,
|
|
299
|
+
`maxItems`, `emptyFetch`, `debounceMs`, `requiresActiveGroup`,
|
|
300
|
+
`defaultParams`, `enableSearch`, `maxTags`, `allowDuplicates`,
|
|
301
|
+
`allowCustom`, `showDescription`, `maxSuggestions`, `addressFields`,
|
|
302
|
+
`country`, `minChars`, `disabled`. Component
|
|
303
|
+
knobs a Field doesn't carry (locale, firstDay, disabledDates, …) are
|
|
304
|
+
reachable only by using the component directly.
|
|
305
|
+
|
|
306
|
+
### The epoch-seconds save boundary (`ui/field-wire`)
|
|
307
|
+
|
|
308
|
+
The measured django-mojo contract (serializer.py:380-389 +
|
|
309
|
+
rest.py:1888-1892 / `dates.parse_datetime`): **DateTimeField serializes OUT
|
|
310
|
+
as epoch seconds** and the save path parses epochs back in; DateField
|
|
311
|
+
serializes OUT as `'YYYY-MM-DD'` but its save path ALSO accepts epochs
|
|
312
|
+
(naive/UTC-parsed — a UTC-midnight epoch lands on the same day). Hence:
|
|
313
|
+
|
|
314
|
+
- `wireToField(field, raw)` — accepts **every** shape django-mojo emits
|
|
315
|
+
(epoch seconds, epoch **milliseconds**, either of those as numeric
|
|
316
|
+
strings, `'YYYY-MM-DD'`, full ISO, `Date`), so rows mixing `last_login`
|
|
317
|
+
epochs and `dob` strings flow through one path. Detection is
|
|
318
|
+
`date/fns detectTemporal`; unparseable stored values warn once and read
|
|
319
|
+
as empty.
|
|
320
|
+
- `fieldToWire(field, control)` — **answers in the shape the field last
|
|
321
|
+
read**. A column that arrived as `'YYYY-MM-DD'` saves back as
|
|
322
|
+
`'YYYY-MM-DD'`; one that arrived as ISO saves ISO; one that arrived as
|
|
323
|
+
epoch milliseconds saves milliseconds; everything else (and any field
|
|
324
|
+
never read — a fresh record) saves **epoch seconds**, the DateTimeField
|
|
325
|
+
contract. Canonical strings become UTC midnight for date-only
|
|
326
|
+
precisions; datetimes honor an explicit offset/IANA zone, else the
|
|
327
|
+
browser's local wall time.
|
|
328
|
+
|
|
329
|
+
This auto-detection exists because the wire shape is per-COLUMN, not
|
|
330
|
+
global: emitting epochs for everything silently rewrote a DateField's
|
|
331
|
+
type on every save and turned ISO metadata values into numbers.
|
|
332
|
+
`Field.outputFormat` (`'epoch' | 'date' | 'iso'`) remains the explicit
|
|
333
|
+
override and always wins — set it when a column's shape must not be
|
|
334
|
+
inferred. The remembered shape is keyed by the Field **object**, so
|
|
335
|
+
module-level schema constants (the normal case) carry it correctly and
|
|
336
|
+
two forms sharing a field name never collide.
|
|
337
|
+
- **`dr_field/dr_start/dr_end` filter params are NOT this boundary** — they
|
|
338
|
+
stay `YYYY-MM-DD` strings and belong to FilterBar/params (rest.py parses
|
|
339
|
+
dr_* itself). Untouched.
|
|
340
|
+
|
|
341
|
+
Every other type passes through both functions unchanged — its control
|
|
342
|
+
shape IS its wire shape, per the table.
|
|
343
|
+
|
|
344
|
+
### Kitchen sink
|
|
345
|
+
|
|
346
|
+
The playground's **Field registry kitchen sink** section renders EVERY
|
|
347
|
+
registered type (all aliases) from one schema on both surfaces — FormView
|
|
348
|
+
autosave against the mock user (saves land in `metadata.*`, live
|
|
349
|
+
control-vs-wire panel per field) and SchemaForm submit (exact payload
|
|
350
|
+
shown) — plus the explicit-precision override, `outputFormat:'date'`, the
|
|
351
|
+
#1273 seam, and the deliberate unknown type.
|
|
352
|
+
(`apps/portal/src/pages/components/demos-kitchen-sink.tsx`)
|
|
353
|
+
|
|
354
|
+
## Pitfalls
|
|
355
|
+
|
|
356
|
+
`SchemaForm` and `FormWizard` share one controlled submit-state/rendering core. SchemaForm intentionally keeps its original lazy initialization and required/email-only submit contract; FormWizard adds cross-section validation, focus, reset, and roster reconciliation. `FormView` does **not** use that submit store: it retains the one autosave reducer across flat fields and all tabs. See [tabs-and-form-wizard.md](tabs-and-form-wizard.md).
|
|
357
|
+
|
|
358
|
+
- Never uncontrolled inputs, never a second value pipeline — the
|
|
359
|
+
buttongroup/checklistdropdown bug class stays impossible only while this
|
|
360
|
+
holds.
|
|
361
|
+
- Phone fields: django-mojo runs `set_phone_number` on save and wants E.164
|
|
362
|
+
(`+15555550142`); pretty/short formats REJECT with
|
|
363
|
+
`Invalid phone number: <value>` — surface it verbatim (FormView does).
|
|
364
|
+
The mock enforces the same rule.
|
|
365
|
+
- Keep the client `schema` **looser or equal** to the server: client zod is
|
|
366
|
+
UX, the reject/revert path is the safety net — don't try to out-validate
|
|
367
|
+
the backend.
|
|
368
|
+
- FormView needs a LOADED row — gate on `useOne`'s `isPending` first.
|
|
369
|
+
- The rich field set (collection pickers, tags, dates, …) lives in the
|
|
370
|
+
field-type registry above — extend THIS language via `registerFieldType`;
|
|
371
|
+
don't fork it. New renderers must fire on COMMIT only and keep state
|
|
372
|
+
wire-shaped (convert in the renderer, like the date bindings do).
|
|
373
|
+
- `commitPatch` is for one provider-driven gesture, such as address details.
|
|
374
|
+
Both form surfaces filter it to declared field names. FormView validates
|
|
375
|
+
and queues the whole accepted patch as one batch, so server failure rolls
|
|
376
|
+
every member back together.
|
|
377
|
+
- `daterange` here is ONE field name whose value is a `[start, end]` pair —
|
|
378
|
+
NOT web-mojo's `startName`/`endName` twin wire keys. A model with two
|
|
379
|
+
separate date COLUMNS should use two `datepicker` fields.
|
|
380
|
+
- `timepicker`'s `outputFormat:'object'` (component-level) is not a form
|
|
381
|
+
shape — form state is JSON-scalar/array; the registry always serializes
|
|
382
|
+
to the string forms.
|
|
383
|
+
|
|
384
|
+
## Simple-types parity audit
|
|
385
|
+
|
|
386
|
+
Every `case` in web-mojo `FormBuilder.js`'s field-type switch (the ~45-type
|
|
387
|
+
FieldTypes list), vs this package. **Gaps are recorded, not built** — the
|
|
388
|
+
orchestrator files follow-ups.
|
|
389
|
+
|
|
390
|
+
| FormBuilder case | SchemaForm/FormView | Registry | Status / note |
|
|
391
|
+
|---|---|---|---|
|
|
392
|
+
| `text` | builtin | — | OK |
|
|
393
|
+
| `email` | builtin (+ format validation) | — | OK |
|
|
394
|
+
| `password` | — | — | **GAP** — C3 auth pages own password UX (strength/generator, board #1259) |
|
|
395
|
+
| `number` | — | — | **GAP** — obvious next builtin (min/max/step props already exist on Field) |
|
|
396
|
+
| `tel` | builtin | — | OK |
|
|
397
|
+
| `url` | — | — | **GAP** — trivial builtin (text + `type="url"`) |
|
|
398
|
+
| `search` | — | — | **GAP** as a FORM field; the live-search UX exists as the params-store search (ModelTable) |
|
|
399
|
+
| `hex` | — | — | **GAP** — pattern-validated text; zod `schema` covers it today |
|
|
400
|
+
| `textarea` | builtin | — | OK |
|
|
401
|
+
| `htmlpreview` | — | — | **GAP** — sweep verdict: PATTERN (iframe preview), rebuild small when needed |
|
|
402
|
+
| `json` | — | — | **GAP** — textarea + zod covers today; DataView/JSON viewer (#1303) is the read side |
|
|
403
|
+
| `select` | builtin (SchemaSelect) | — | OK (placeholder + unknown-value warn beat source) |
|
|
404
|
+
| `multiselect` | — | MultiSelectDropdown | OK |
|
|
405
|
+
| `checkbox` | — | — | **GAP** — portal has `switch` only; plain-checkbox look absent (use `switch`) |
|
|
406
|
+
| `toggle` / `switch` | builtin `switch` | — | OK |
|
|
407
|
+
| `radio` | — | — | **GAP** — use `select`; radio-group component unfiled |
|
|
408
|
+
| `date` (native input) | — | — | superseded by `datepicker` (registry) — native input not carried |
|
|
409
|
+
| `datetime` (native) | — | — | superseded by `datetimepicker` |
|
|
410
|
+
| `time` (native) | — | — | superseded by `timepicker` |
|
|
411
|
+
| `file` | — | FileField | OK — positive File id/null; shared 3-stage queue; authoritative owner attach |
|
|
412
|
+
| `image` | — | ImageField | OK — FileField foundation + safe local/stored image preview |
|
|
413
|
+
| `color` | — | — | **GAP** — native `<input type=color>` wrapper unfiled |
|
|
414
|
+
| `range` | — | — | **GAP** — native `<input type=range>` wrapper unfiled |
|
|
415
|
+
| `hidden` | — | — | N/A by design — controlled forms post state, not hidden DOM inputs |
|
|
416
|
+
| `button` | — | — | N/A by design — SchemaForm owns submit/cancel; FormView has no buttons |
|
|
417
|
+
| `divider` | — | — | **GAP** — layout-only field (FormWizard/Tabs #1305 is the structure story) |
|
|
418
|
+
| `html` | — | — | N/A by design — trusted-HTML slots became ReactNode (architecture rule 6) |
|
|
419
|
+
| `heading` / `header` | — | — | **GAP** — layout-only field, same #1305 story |
|
|
420
|
+
| `tag` / `tags` | — | TagInput | OK (CSV wire shape) |
|
|
421
|
+
| `collection` | — | CollectionSelect | OK |
|
|
422
|
+
| `collectionmultiselect` / `collection-multiselect` | — | CollectionMultiSelect | OK |
|
|
423
|
+
| `datepicker` | — | DatePicker | OK (epoch wire; `outputFormat:'date'` opt-out) |
|
|
424
|
+
| `monthpicker` | — | DatePicker | OK (alias → precision `month`; explicit `precision` wins) |
|
|
425
|
+
| `yearpicker` | — | DatePicker | OK (alias → precision `year`) |
|
|
426
|
+
| `daterange` | — | DateRangePicker | OK — deviation: ONE name, `[start, end]` value; no `startName`/`endName` twin keys (use two datepickers for split columns) |
|
|
427
|
+
| `monthrange` | — | DateRangePicker | OK (alias → precision `month`) |
|
|
428
|
+
| `yearrange` | — | DateRangePicker | OK (alias → precision `year`) |
|
|
429
|
+
| `timepicker` | — | TimePicker | OK (real TimezoneSelect in the slot; string wire) |
|
|
430
|
+
| `datetimepicker` | — | seam → DateTimePicker | OK once #1273 merges (alias + epoch boundary live now; interim text input) |
|
|
431
|
+
| `checklistdropdown` | — | — | folded into `multiselect` (sweep: ONE implementation) — alias deliberately NOT registered |
|
|
432
|
+
| `buttongroup` | — | — | **GAP** — segmented control; source was the split-pipeline bug family (do-not-recreate), needs a fresh build |
|
|
433
|
+
| `combo` / `combobox` / `autocomplete` | — | ComboBox | OK (ComboInput feature spec, commit-only) |
|
|
434
|
+
| `address` | — | AddressField | OK (private session token, details → atomic declared-field patch) |
|
|
435
|
+
| `tabset` | FormView `tabs` prop | — | OK — tabsets are FORM STRUCTURE here (registry-of-tabs + permissions), not a field type |
|