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,132 @@
|
|
|
1
|
+
# CollectionSelect — the single-record server picker
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import { CollectionSelect, type CollectionSelectValue } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Search-as-you-type against any model or endpoint, pick ONE row, and hydrate
|
|
8
|
+
a bare stored id into its display label. The value is **controlled** in both
|
|
9
|
+
directions. Ported from web-mojo `CollectionSelect.js`; sibling of
|
|
10
|
+
`CollectionMultiSelect` in the server-data selects family (epic #1275) —
|
|
11
|
+
the two share the model-binding prop contract.
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
const [ownerId, setOwnerId] = useState<string | number | null>(42);
|
|
15
|
+
|
|
16
|
+
<CollectionSelect<User>
|
|
17
|
+
model={UserModel} // or endpoint="/api/user"
|
|
18
|
+
labelField="display_name"
|
|
19
|
+
value={ownerId} // bare id → renders "#42", then the label
|
|
20
|
+
onChange={(id, row) => setOwnerId(id)}
|
|
21
|
+
label="Owner"
|
|
22
|
+
required
|
|
23
|
+
/>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Props
|
|
27
|
+
|
|
28
|
+
| Prop | Behavior |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `model` / `endpoint` | One is required. `model` is a `defineModel` def (supplies endpoint + row type); `endpoint` is the bare path. |
|
|
31
|
+
| `value` | Controlled: `string \| number \| null`, **or a row-like object** (a record with the relation expanded) — id and label extract via the `valueField`/`labelField` dot paths, no fetch. `0`/`'0'` (web-mojo's no-selection sentinel) and `''` normalize to "no selection" on the way IN and never leak OUT. |
|
|
32
|
+
| `onChange` | `(id \| null, row?)` — fires on **commit only**: picking a row → `(id, row)`; clearing (✕, or typing away from the committed label) → `(null)`. Re-picking the already-committed row does not re-fire. Never fires per keystroke; never emits `'0'` — `null` is the empty value. |
|
|
33
|
+
| `labelField` / `valueField` | Row fields for label and id, **dot notation** reaches nested fields (`'user.email'`). Defaults `'name'` / `'id'`. A missing field warns once and falls back (label → the id; missing id → row dropped / object value → no selection) — never renders nothing silently. |
|
|
34
|
+
| `maxItems` | Rows fetched per search — the `size` wire param. Default 10. |
|
|
35
|
+
| `placeholder` | Input placeholder. Default `'Search…'`. |
|
|
36
|
+
| `debounceMs` | Keystroke → `search` wire param delay. Default 400 (source parity — deliberately not ModelTable's 300). |
|
|
37
|
+
| `emptyFetch` | Default true: opening the dropdown (focus/click) fetches the initial unsearched page so options exist before typing. `false`: nothing is fetched until a term exists — the dropdown shows "Start typing to search…". |
|
|
38
|
+
| `defaultParams` | Extra wire params — a dict, or a **callback re-evaluated per render** (each fetch uses the freshest result; a changed result is a new query key → refetch). Merge order: `{size: maxItems} → defaultParams → group → search` (later wins). |
|
|
39
|
+
| `requiresActiveGroup` | Folds the active group id in as `group`. With NO active group the fetch is **held** (the dropdown says "Select an active group first") — never an unscoped list where a scoped one was demanded. Needs a `<GroupProvider>` above; missing one warns. |
|
|
40
|
+
| `label` / `required` / `help` / `error` | Field wrapper (house `.field-label` / `.field-help` / `.field-error`; `error` replaces `help` and paints the input `.input-invalid`). |
|
|
41
|
+
| `disabled` / `readOnly` | Both freeze the pipeline (no open, no typing, no ✕). `readOnly` still shows the committed label at input contrast. |
|
|
42
|
+
|
|
43
|
+
## Selection semantics (the contract)
|
|
44
|
+
|
|
45
|
+
- **Picking commits**: the input fills with the row's label, the dropdown
|
|
46
|
+
closes, `onChange(id, row)` fires (only when the id actually changed).
|
|
47
|
+
- **Typing that diverges from the committed label clears the selection** —
|
|
48
|
+
`onChange(null)` fires ONCE and the typed text becomes the live search
|
|
49
|
+
(web-mojo re-emitted the clear on every keystroke; fixed here).
|
|
50
|
+
- **Clear ✕** shows while a selection exists: resets to empty, fires
|
|
51
|
+
`onChange(null)`, refocuses the input — which reopens the dropdown on
|
|
52
|
+
the base page (the source's emergent behavior, kept on purpose).
|
|
53
|
+
- **Escape** closes the dropdown and restores the committed label (the
|
|
54
|
+
in-progress search text is dropped). Inside a native-`<dialog>` modal the
|
|
55
|
+
first Escape closes only the dropdown, the second the modal (Popover's
|
|
56
|
+
capture-phase handling). **Outside click and Tab-away do the same
|
|
57
|
+
revert** — deliberate deviation from the source, which left typed text
|
|
58
|
+
visible in the input while its hidden value said "no selection" (the
|
|
59
|
+
display-diverges-from-state bug class ends here).
|
|
60
|
+
- **Keyboard**: ArrowDown/ArrowUp walk the options (clamped at the ends, no
|
|
61
|
+
wrap — source parity; ArrowDown also reopens a closed dropdown), Enter
|
|
62
|
+
commits the focused option (and never submits an enclosing form while the
|
|
63
|
+
dropdown is open). Focus never leaves the input — options are
|
|
64
|
+
`tabIndex={-1}` and mousedown-prevented, and the input is never
|
|
65
|
+
remounted, so it stays focus-stable across result updates.
|
|
66
|
+
|
|
67
|
+
## id → label hydration
|
|
68
|
+
|
|
69
|
+
A `value` whose label is unknown renders `#<id>` immediately, then resolves
|
|
70
|
+
in this order:
|
|
71
|
+
|
|
72
|
+
1. **Object value** — label extracted via the dot path, no fetch.
|
|
73
|
+
2. **Label learned at pick time** (kept per instance — survives list-cache
|
|
74
|
+
garbage collection).
|
|
75
|
+
3. **Any cached LIST row** under `[endpoint, …]` — ModelTable pages, other
|
|
76
|
+
pickers and this picker's own fetches all share those keys.
|
|
77
|
+
4. **Fetch the one record** through the generic `[endpoint, 'one', id]` key
|
|
78
|
+
— the same cache `useOne`/DetailView read, so the fetch dedupes with
|
|
79
|
+
them. Numeric-string ids fetch under the **numeric** key (django-mojo
|
|
80
|
+
pks are ints; uuid/slug ids pass through).
|
|
81
|
+
|
|
82
|
+
A failed hydration keeps `#<id>` on screen and warns once per id (mojo 4xx
|
|
83
|
+
errors never retry, per `mojoQueryDefaults`).
|
|
84
|
+
|
|
85
|
+
## Wire contract
|
|
86
|
+
|
|
87
|
+
GETs to the list endpoint through `useModelList` — cache key
|
|
88
|
+
`[endpoint, params]`, `placeholderData: keepPreviousData`. Params sent:
|
|
89
|
+
`size: maxItems`, merged with `defaultParams`, plus `group` (when
|
|
90
|
+
`requiresActiveGroup`) and `search` (trimmed; **dropped entirely when
|
|
91
|
+
empty** — an empty search is the plain first page, not `search=`).
|
|
92
|
+
Fetches run only while the dropdown is open; with `emptyFetch: false`,
|
|
93
|
+
only once a term exists. `model.invalidate(qc)` refreshes an open picker
|
|
94
|
+
too — there is no parallel fetch path.
|
|
95
|
+
|
|
96
|
+
## Invariants
|
|
97
|
+
|
|
98
|
+
1. Controlled value, one pipeline: the input displays either the committed
|
|
99
|
+
label (or `#<id>` while resolving) or the live draft — never a mix, and
|
|
100
|
+
never text the state doesn't hold. Commit fires on selection/clear only.
|
|
101
|
+
2. Every id comparison goes through one `String()` normalization; `'0'`/`0`
|
|
102
|
+
normalize to null at the boundary and `null` is the only empty value on
|
|
103
|
+
the way out.
|
|
104
|
+
3. Rows the server returns are shown as-is — every narrowing (kind, group,
|
|
105
|
+
search…) is a wire param, never a client-side filter.
|
|
106
|
+
4. Dropdown states: spinner row while the current key's rows aren't on
|
|
107
|
+
screen; "No results found" (searched) vs "Start typing to search…"
|
|
108
|
+
(no term); fetch errors show the server's message + Retry; a held
|
|
109
|
+
group-scoped fetch says "Select an active group first".
|
|
110
|
+
5. The dropdown lives in the shared `<Popover>` (top layer): it stacks
|
|
111
|
+
above native-`<dialog>` modals, escapes overflow containers, and
|
|
112
|
+
matches the field's width.
|
|
113
|
+
|
|
114
|
+
## Pitfalls
|
|
115
|
+
|
|
116
|
+
- `maxItems` is the fetch size — there is no paging in the dropdown. If the
|
|
117
|
+
target row isn't in the first page, the user searches; search is
|
|
118
|
+
server-side, so it can find anything.
|
|
119
|
+
- The picker never invents rows: a `value` id that doesn't exist
|
|
120
|
+
server-side stays `#<id>` (with one console warning). Feed it real ids
|
|
121
|
+
or a row object.
|
|
122
|
+
- `labelField` must exist on the **list** graph rows for the dropdown to
|
|
123
|
+
read well; hydration can additionally use one-record-graph fields (the
|
|
124
|
+
detail graph is often wider), but options come from list rows.
|
|
125
|
+
- An inline `defaultParams` object/callback re-evaluates per render; that
|
|
126
|
+
is fine (TanStack hashes keys structurally). Only a changed RESULT
|
|
127
|
+
refetches.
|
|
128
|
+
- `requiresActiveGroup` holds the fetch until a group is active; pair the
|
|
129
|
+
control with `RequiresGroup` or group-scoped screens so users aren't
|
|
130
|
+
handed a picker that says to pick a group they can't pick there.
|
|
131
|
+
- Don't wire `onChange` to a per-keystroke autosave expectation — it fires
|
|
132
|
+
on commit (that is the point; see the B3 autosave contract).
|
package/docs/combobox.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# ComboBox — the house autocomplete
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { ComboBox, type ComboOption, type ComboValue } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
One editable select: a text input over a filtered suggestion list. Ported
|
|
8
|
+
from web-mojo's `ComboInput.js` (the feature spec — descriptions, meta,
|
|
9
|
+
highlight, ARIA, relevance ranking) plus `ComboBox.js`'s open-on-focus.
|
|
10
|
+
This is the base for TimezoneSelect (#1274) and rule-builder field pickers;
|
|
11
|
+
there is exactly ONE combobox in this toolkit.
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type ComboValue = string | number;
|
|
17
|
+
|
|
18
|
+
interface ComboOption {
|
|
19
|
+
value: ComboValue;
|
|
20
|
+
label: string; // what the input displays
|
|
21
|
+
description?: string; // muted second line in the list
|
|
22
|
+
meta?: Record<string, unknown>; // opaque; returned via onSelect
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
<ComboBox
|
|
26
|
+
value={v} // committed value (string | number | null)
|
|
27
|
+
options={opts} // ComboOption[] — bare strings normalize to {value:s,label:s}
|
|
28
|
+
onChange={(value, option?) => …} // COMMIT only, and only when the value changed
|
|
29
|
+
onSelect={(option) => …} // any commit that resolves to an option — carries meta
|
|
30
|
+
allowCustom // default true: unmatched text commits as-is
|
|
31
|
+
showDescription // default true
|
|
32
|
+
minChars={0} // typing auto-opens the list only at ≥ N chars
|
|
33
|
+
maxSuggestions={10} // caps the rendered list AND keyboard wraparound
|
|
34
|
+
placeholder disabled readOnly required id className
|
|
35
|
+
/>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The commit pipeline (the invariant)
|
|
39
|
+
|
|
40
|
+
The input's text is a private **draft**; the committed value lives in the
|
|
41
|
+
`value` prop. `onChange` fires ONLY on:
|
|
42
|
+
|
|
43
|
+
| Gesture | Result |
|
|
44
|
+
|---|---|
|
|
45
|
+
| Click option / Enter on highlighted | commit that option |
|
|
46
|
+
| Enter on typed text (`allowCustom`) | resolve → option, else commit text as custom |
|
|
47
|
+
| Blur / outside click / Tab / chevron-close | **settle**: exact label or `String(value)` match → that option; else custom when allowed (`''` commits a clear); else **revert** — the display falls back to the committed label |
|
|
48
|
+
| Escape while list is open | Popover dismisses → revert, keep focus — never commits |
|
|
49
|
+
|
|
50
|
+
Never per keystroke — web-mojo's ComboBox emitted per keystroke and
|
|
51
|
+
FormView's 300ms autosave saved partial text. Typed text equal to an
|
|
52
|
+
option's **label** commits the option's **value** (display shows labels;
|
|
53
|
+
labels must never leak into state). `onChange` is skipped when the resolved
|
|
54
|
+
value equals the current `value`; `onSelect` still fires on an explicit
|
|
55
|
+
option commit (rule builders may want the meta again).
|
|
56
|
+
|
|
57
|
+
## Filtering
|
|
58
|
+
|
|
59
|
+
Case-insensitive substring across label + value + description; ranked exact
|
|
60
|
+
label match, then label starts-with, then input order (stable). `minChars`
|
|
61
|
+
gates only the auto-open while typing (below it the list closes); explicit
|
|
62
|
+
opens — focus, ArrowDown, chevron — always show the full (capped) list.
|
|
63
|
+
The chevron reopens with the filter cleared and the input text selected, so
|
|
64
|
+
the next keystroke starts a fresh query. Empty states (verbatim):
|
|
65
|
+
with `allowCustom` — "No matches found. Press Enter to use custom value." /
|
|
66
|
+
"Start typing to see suggestions..."; without — "No matching options found."
|
|
67
|
+
|
|
68
|
+
## Keyboard + ARIA
|
|
69
|
+
|
|
70
|
+
ArrowDown opens, then moves next; ArrowUp previous; both wrap over the
|
|
71
|
+
*visible* (maxSuggestions-capped) list. Enter selects the highlighted
|
|
72
|
+
option, else commits custom text. Escape reverts through the shared Popover
|
|
73
|
+
(and reaches a parent `<dialog>` once the list is closed). Tab settles
|
|
74
|
+
and passes through. The highlighted option scrolls into view
|
|
75
|
+
(`block:'nearest'`). Full combobox pattern: `role=combobox`,
|
|
76
|
+
`aria-expanded`, `aria-autocomplete=list`, `aria-controls`,
|
|
77
|
+
`aria-activedescendant`, `role=listbox/option`, `aria-selected` + check
|
|
78
|
+
icon on the selected option. Match highlighting renders as split React
|
|
79
|
+
nodes around `<mark class="combo-mark">` — regex-escaped query, no HTML
|
|
80
|
+
strings.
|
|
81
|
+
|
|
82
|
+
## Invariants / fixed web-mojo bugs (do not reintroduce)
|
|
83
|
+
|
|
84
|
+
- **Commit-only `onChange`** — the draft/value split is the fix; there is no
|
|
85
|
+
code path that emits while typing.
|
|
86
|
+
- **`allowCustom:false` reverts for real** — web-mojo overwrote its
|
|
87
|
+
committed value per keystroke, so its revert restored the typed text.
|
|
88
|
+
- **Numeric values survive** — selection passes option objects in closures;
|
|
89
|
+
web-mojo strict-equalled stringified `data-value` DOM attributes.
|
|
90
|
+
- **One dismissal owner** — shared Popover alone listens for outside
|
|
91
|
+
mousedown/Escape; ComboBox only chooses settle-vs-revert.
|
|
92
|
+
- Unknown `value` under `allowCustom:false` warns once and displays
|
|
93
|
+
verbatim — never renders nothing (rule 4).
|
|
94
|
+
|
|
95
|
+
## Pitfalls
|
|
96
|
+
|
|
97
|
+
- Value matching is strict `===`: `5` and `'5'` are different values. Keep
|
|
98
|
+
option values and the `value` prop the same type (URL params are strings —
|
|
99
|
+
convert before binding).
|
|
100
|
+
- `description` renders only when provided (web-mojo silently defaulted it
|
|
101
|
+
to the label, duplicating every row — not carried).
|
|
102
|
+
- The dropdown is mounted in the shared top-layer Popover, so overflow
|
|
103
|
+
containers and native dialogs do not clip it.
|
|
104
|
+
- Styles live in `apps/portal/src/theme/combobox.css` (tokens only, both
|
|
105
|
+
themes); consuming apps must include it.
|
package/docs/dataview.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# DataView — the inferred record grid
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import { DataView, JsonBlock, inferFieldType, type DataViewField } from 'portal-mojo/ui';
|
|
5
|
+
|
|
6
|
+
<DataView data={user} /> // schemaless: infer everything
|
|
7
|
+
<DataView data={user} fields={SCHEMA} /> // explicit schema
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Point it at any record and get a sensible detail grid. Field **names** and
|
|
11
|
+
**values** together pick the renderer, nested objects become nested grids,
|
|
12
|
+
and anything that isn't a scalar lands in a JSON block with copy and
|
|
13
|
+
collapse. Port of web-mojo `src/core/views/data/DataView.js` (1,153 lines).
|
|
14
|
+
Styles: `apps/portal/src/theme/dataview.css`. Demo: **Develop → Components →
|
|
15
|
+
DataView**.
|
|
16
|
+
|
|
17
|
+
## DataView vs KnownFieldsCard
|
|
18
|
+
|
|
19
|
+
They look similar and solve opposite problems. Both often appear on the
|
|
20
|
+
same page.
|
|
21
|
+
|
|
22
|
+
| | Input | Which keys | Unknown keys |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| **`DataView`** | a whole **record** (`user`, `job`, `incident`) | **all of them**, inferred | inferred too — that is the point |
|
|
25
|
+
| **`KnownFieldsCard`** | one **blob** column (`metadata`, `ip_info`, `payload`) | only the **curated** `known` list you pass | left in the raw `<details>` below |
|
|
26
|
+
|
|
27
|
+
Rule of thumb: `DataView` when you don't know (or don't want to enumerate)
|
|
28
|
+
the shape; `KnownFieldsCard` when you know exactly which few keys matter
|
|
29
|
+
and the rest is an open bag. `<DataView data={record} />` +
|
|
30
|
+
`<KnownFieldsCard data={record.metadata} known={…} />` is a normal pairing.
|
|
31
|
+
|
|
32
|
+
## Props
|
|
33
|
+
|
|
34
|
+
| Prop | Default | Effect |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `data` | — | the record. `null`/`undefined` is safe (renders `emptyText`) |
|
|
37
|
+
| `fields` | inferred | explicit schema; omit for full inference over `Object.keys(data)` |
|
|
38
|
+
| `exclude` | `[]` | keys to drop. Applies in **both** modes |
|
|
39
|
+
| `columns` | `2` | grid columns at full width |
|
|
40
|
+
| `showEmptyValues` | `true` | `false` restores the source behavior: an empty value drops its row |
|
|
41
|
+
| `emptyValueText` | `'—'` | rendered for an empty value |
|
|
42
|
+
| `emptyText` | `'No data.'` | shown when nothing is renderable |
|
|
43
|
+
| `maxDepth` | `2` | levels of **nested** DataView below this one; deeper → JSON block |
|
|
44
|
+
| `depth` | `0` | internal recursion counter — callers leave it alone |
|
|
45
|
+
|
|
46
|
+
**Ordering is stable**: the schema's order, or the record's own key order
|
|
47
|
+
(`Object.keys`, i.e. insertion order).
|
|
48
|
+
|
|
49
|
+
### `DataViewField`
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const SCHEMA: DataViewField[] = [
|
|
53
|
+
{ name: 'display_name', label: 'Name' },
|
|
54
|
+
{ name: 'settings.theme', label: 'Theme' }, // dotted path
|
|
55
|
+
{ name: 'plan_amount', label: 'Plan', type: 'currency' }, // declared renderer
|
|
56
|
+
{ name: 'trust_score', format: (v) => <b>{Number(v).toFixed(1)} / 10</b> },
|
|
57
|
+
{ name: 'bio', span: 'full' },
|
|
58
|
+
{ name: 'last_error', hideEmpty: true },
|
|
59
|
+
];
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- `name` — a **dotted path** (`settings.theme`); an own property of that
|
|
63
|
+
literal name wins over traversal (KnownFieldsCard's rule).
|
|
64
|
+
- `label` — defaults to the humanized last path segment (`created_at` →
|
|
65
|
+
`Created At`, `lastLogin` → `Last Login`).
|
|
66
|
+
- `type` — names a built-in renderer, skipping inference. An unknown value
|
|
67
|
+
**warns and infers** — never renders nothing (rule 4).
|
|
68
|
+
- `format(value, name, data)` — replaces the renderer entirely; returns any
|
|
69
|
+
`ReactNode`. Same signature as `KnownFieldsCard`'s.
|
|
70
|
+
- `span` — `'full'` spans the grid; `'auto'` (default) is full only for
|
|
71
|
+
arrays / objects / nested grids.
|
|
72
|
+
- `hideEmpty` — drop the row instead of showing `—`.
|
|
73
|
+
|
|
74
|
+
## Inference — the heuristics
|
|
75
|
+
|
|
76
|
+
Two exported pure functions carry it, usable on their own:
|
|
77
|
+
`inferFieldType(value, key)` and `shouldUseDataView(value, keyLower)`.
|
|
78
|
+
|
|
79
|
+
**Order matters** — the first match wins:
|
|
80
|
+
|
|
81
|
+
| # | Test | Type |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| 1 | `value == null` | `text` |
|
|
84
|
+
| 2 | `typeof value === 'boolean'` | `boolean` |
|
|
85
|
+
| 3 | key starts `is_`/`has_`/`can_`/`should_` **and** value is boolean-ish (`0`/`1`/`"true"`/`"yes"`…) | `boolean` |
|
|
86
|
+
| 4 | key contains `date`, `time`, `created`, `updated`, `modified`, `last_login`, `expires`, `last_activity`, or **ends in `_at`** | `datetime` |
|
|
87
|
+
| 5 | key contains `email` / `mail` | `email` |
|
|
88
|
+
| 6 | key contains `url` / `website` / `homepage`, or has the word `link` | `url` |
|
|
89
|
+
| 7 | key contains `phone` / `mobile`, or has the word `tel` / `cell` | `phone` |
|
|
90
|
+
| 8 | key contains `price` / `amount` / `salary` / `revenue`, or has the word `cost` / `fee` | `currency` |
|
|
91
|
+
| 9 | key has the word `size` / `bytes` / `filesize` | `filesize` |
|
|
92
|
+
| 10 | (key contains `percent` or has the word `rate` / `ratio`) **and** value is a number | `percent` |
|
|
93
|
+
| 11 | `typeof value === 'number'` | `number` |
|
|
94
|
+
| 12 | array | `array` |
|
|
95
|
+
| 13 | object with `renditions` | `file` |
|
|
96
|
+
| 14 | object passing `shouldUseDataView` | `dataview` |
|
|
97
|
+
| 15 | any other object | `object` |
|
|
98
|
+
| 16 | string `"…@….…"` / `^\d{4}-\d{2}-\d{2}` / `^https?://` / phone-shaped **with ≥7 digits** | `email` / `date` / `url` / `phone` |
|
|
99
|
+
| 17 | anything else | `text` |
|
|
100
|
+
|
|
101
|
+
"contains" is a substring test (the source's); "has the word X" is an exact
|
|
102
|
+
**token** match after splitting the key on `_`, `-` and camelCase humps —
|
|
103
|
+
see *Deviations* for why.
|
|
104
|
+
|
|
105
|
+
### `shouldUseDataView` — nested grid or JSON?
|
|
106
|
+
|
|
107
|
+
An object gets a nested `DataView` when **either**:
|
|
108
|
+
|
|
109
|
+
1. it carries an `id` — it's a related record; or
|
|
110
|
+
2. its key contains one of `permissions perms access rights settings config
|
|
111
|
+
configuration options profile info details data metadata meta attributes
|
|
112
|
+
props preferences prefs user_data contact address location stats
|
|
113
|
+
statistics metrics counts`, **and** it has 2–20 keys, **and** no child of
|
|
114
|
+
it is an object with more than 3 keys.
|
|
115
|
+
|
|
116
|
+
Everything else is a JSON block. That last clause is the source's: one
|
|
117
|
+
deep child disqualifies the whole object, on the theory that a grid of
|
|
118
|
+
grids stops being readable.
|
|
119
|
+
|
|
120
|
+
### What each type renders
|
|
121
|
+
|
|
122
|
+
| Type | Rendering |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `datetime` | `fmt.relative` for `last_*`/`*ago*`/`*relative*`; `fmt.datetime` for a `time`-not-`date` key; else `fmt.date` |
|
|
125
|
+
| `date` | `fmt.date` |
|
|
126
|
+
| `email` | `<a href="mailto:…">` |
|
|
127
|
+
| `url` / `file` | `<a target="_blank" rel="noopener noreferrer">` + external-link icon |
|
|
128
|
+
| `phone` | `<a href="tel:…">` around `fmt.phone` (digits + `+` only in the href) |
|
|
129
|
+
| `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer → cents, fractional → major units** |
|
|
130
|
+
| `filesize` | `fmt.filesize` (decimal KB/MB/GB) |
|
|
131
|
+
| `percent` | `fmt.percent`; **`\|v\| ≤ 1` is scaled ×100**, anything larger is already a percentage |
|
|
132
|
+
| `number` | `count`/`total`/`followers`/`views` → `fmt.compact` at ≥1000 else `fmt.number`; `score`/`rating` → `fmt.number(v, 1)` when fractional; `version` / the word `id` → verbatim; else `fmt.number` |
|
|
133
|
+
| `boolean` | `<Badge tone="success\|muted">Yes/No</Badge>` — the house treatment, via `fmt.yesNo` |
|
|
134
|
+
| `text` | `description`/`content`/`body` → truncate 200/100; `summary`/`excerpt` → 150; `username`/`slug`/`handle`/`login` → verbatim monospace; `name`/`title`/`label` → `.cap` + truncate 50; `code`/`token`/`key`/`secret` → monospace, `fmt.mask` over 20 chars; else truncate 100 |
|
|
135
|
+
| `array` | scalars join with `, `; objects → `Array · N items` in a collapsed `JsonBlock` |
|
|
136
|
+
| `object` | `JsonBlock` |
|
|
137
|
+
| `dataview` | nested `<DataView>` (tinted inset) until `maxDepth`, then `JsonBlock` |
|
|
138
|
+
|
|
139
|
+
Truncated text keeps the full value on a `title` attribute.
|
|
140
|
+
|
|
141
|
+
## JsonBlock
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<JsonBlock value={payload} /> // auto
|
|
145
|
+
<JsonBlock value={payload} label="Request trace" collapsible defaultOpen />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Prop | Default | Effect |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `value` | — | anything JSON-serializable |
|
|
151
|
+
| `label` | `Object` / `Array · N lines` | caption override |
|
|
152
|
+
| `collapsible` | `>10 lines \|\| >500 chars` (source rule) | force the Show/Hide affordance |
|
|
153
|
+
| `defaultOpen` | `false` | start expanded when collapsible |
|
|
154
|
+
|
|
155
|
+
- **Syntax highlighting is real tokenization**, not regex-over-HTML:
|
|
156
|
+
`tokenizeJson()` walks the string emitting `key` / `string` / `number` /
|
|
157
|
+
`boolean` / `null` / `plain` tokens, rendered as React `<span>`s. **No
|
|
158
|
+
`dangerouslySetInnerHTML`.** The token stream reassembles to the exact
|
|
159
|
+
input, so a value like `"has a colon: and \"q\""` colors correctly (the
|
|
160
|
+
source's five regexes did not).
|
|
161
|
+
- Copy writes with the async Clipboard API, toasts on success, flips the
|
|
162
|
+
icon to a check for 1.2s, and toasts an error if the write rejects.
|
|
163
|
+
Outside a secure context `navigator.clipboard` is absent — it says so
|
|
164
|
+
rather than silently no-op'ing through `document.execCommand`.
|
|
165
|
+
- A circular payload renders a muted `[Object] — cannot display as JSON`.
|
|
166
|
+
Nothing here throws.
|
|
167
|
+
|
|
168
|
+
## Invariants
|
|
169
|
+
|
|
170
|
+
1. **A renderer is the last thing allowed to take a record down.** Every
|
|
171
|
+
per-field render is wrapped: a throw becomes a `console.warn` plus the
|
|
172
|
+
plain `String(value)`. `fmt.*` never throws either.
|
|
173
|
+
2. **Empty is `—`, not absent** (`emptyValueText`). `null`, `''`, `[]` and
|
|
174
|
+
`{}` all read empty. `showEmptyValues={false}` or `hideEmpty` opt out.
|
|
175
|
+
3. **Unknown `type` warns and infers** (rule 4) — never renders nothing.
|
|
176
|
+
4. **`ReactNode` in, `ReactNode` out.** No HTML strings anywhere (rule 6).
|
|
177
|
+
5. **Epoch SECONDS** are handled by `fmt.*`; don't pre-convert.
|
|
178
|
+
6. **Depth is capped.** `maxDepth` (default 2) bounds nesting, so a
|
|
179
|
+
pathological payload degrades to JSON instead of recursing.
|
|
180
|
+
|
|
181
|
+
## Pitfalls
|
|
182
|
+
|
|
183
|
+
- **Money is CENTS when it's an integer.** django-mojo stores minor units,
|
|
184
|
+
so `plan_amount: 129900` → `$1,299.00` while `overage_price: 4.75` →
|
|
185
|
+
`$4.75`. A record that breaks that convention needs an explicit
|
|
186
|
+
`format`.
|
|
187
|
+
- **Percent auto-scales below 1.** `error_rate: 0.0182` → `2%`;
|
|
188
|
+
`error_rate: 12` → `12%`. A ratio that legitimately exceeds 1 needs an
|
|
189
|
+
explicit `format`.
|
|
190
|
+
- **The name wins over the value.** `contact_email: 1234` still renders a
|
|
191
|
+
`mailto:` link — that is the source's contract, and it is what makes
|
|
192
|
+
schemaless inference useful on sparse rows. Declare a `type` (or a
|
|
193
|
+
`format`) on any field whose name lies.
|
|
194
|
+
- **No pipe strings.** `format: "date('MMM D')|capitalize"` does not exist
|
|
195
|
+
here (do-not-recreate); nor does the source's inline `name: 'is_tor|
|
|
196
|
+
status_text'` syntax. Use `type` or a function.
|
|
197
|
+
- **`fmt.capitalize` doesn't exist**, so `name`-ish fields get the `.cap`
|
|
198
|
+
class — CSS capitalization, which leaves the stored value intact.
|
|
199
|
+
- Container queries do the reflow off the grid's **own** width, so a
|
|
200
|
+
DataView inside a modal or a detail rail stacks correctly without
|
|
201
|
+
knowing the viewport.
|
|
202
|
+
|
|
203
|
+
## Deviations from web-mojo
|
|
204
|
+
|
|
205
|
+
Beyond the pipe engine and trusted HTML (both architecture rules), these
|
|
206
|
+
are behavior changes, each a source bug:
|
|
207
|
+
|
|
208
|
+
| Source | Here |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `percent \|\| rate \|\| ratio && number` — `&&` binds tighter, so `rate` matched any type, and `includes()` matched **inside words**: `duration` contains "ratio" (8040 → `804,000%`), `generated` contains "rate" | short needles (`rate` `ratio` `size` `bytes` `tel` `cell` `cost` `fee` `id` `key` `code` `link` `label` `body`) match on **word boundaries**, and the number guard covers the whole percent branch |
|
|
211
|
+
| `typeof value === 'boolean'` tested **after** the name chain, so `is_email_verified: true` → `mailto:true` | booleans are typed first, before any name pattern |
|
|
212
|
+
| `/^\+?[\d\s\-()]+$/` made `"42"` and `"2024"` tel: links | a phone string needs ≥7 digits |
|
|
213
|
+
| `username` contains `name`, so it hit the capitalize branch and the `slug` branch below was unreachable for it — and `slug` rewrote `ian.starnes` to `ianstarnes` | identifiers are checked first and render **verbatim** in monospace |
|
|
214
|
+
| an empty value **dropped its row**, so a record's shape changed with its data | `showEmptyValues` defaults `true`; `—` is the house empty |
|
|
215
|
+
| `shouldUseDataView`'s `id` rule was gated on `window.utils`, a global absent in half of web-mojo's entry points | the rule always applies |
|
|
216
|
+
| no `file` renderer — `{renditions}` objects stringified to `[object Object]` | anchor to `url` with `name`/`filename`, else a JSON block |
|
|
217
|
+
| unbounded nesting | `maxDepth`, then JSON |
|
|
218
|
+
| JSON "highlighting" = 5 regexes over escaped HTML with hardcoded light-mode hexes (`#0969da`, `#0a3069`, …) injected as `innerHTML` | a real tokenizer → React spans over theme tokens, correct in both themes |
|
|
219
|
+
|
|
220
|
+
Not ported (deliberately): `model` binding and the `change` re-render
|
|
221
|
+
listener (TanStack Query owns reactivity — pass `data` from a hook),
|
|
222
|
+
`updateData`/`updateFields`/`updateConfig`/`refresh` (props, not
|
|
223
|
+
imperative setters), `setFieldFormat`/`addFormatPipe`/`clearFieldFormat`/
|
|
224
|
+
`getFieldFormats` (the fluent pipe-string API), `{{key}}` template
|
|
225
|
+
strings, and the `field:click` event (`data-field` is still on each item
|
|
226
|
+
for tests and CSS).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# DatePicker — the single-value picker shell
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { DatePicker, type DatePickerChangeEvent } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
The trigger-and-popover shell over the [Calendar engine](calendar.md), ported
|
|
8
|
+
from web-mojo's `forms/inputs/DatePicker.js`. One component covers all three
|
|
9
|
+
precisions — day, month-only, year-only — and renders either as a form
|
|
10
|
+
control that opens a [Popover](popover.md), or `inline` as a bare calendar.
|
|
11
|
+
|
|
12
|
+
It owns exactly three things the engine does not: the display text, the
|
|
13
|
+
open/closed popover, and the clear affordance. Everything else is
|
|
14
|
+
pass-through.
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
const [due, setDue] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
<DatePicker
|
|
20
|
+
value={due}
|
|
21
|
+
min="2026-01-01"
|
|
22
|
+
disabledDates={['2026-03-12']}
|
|
23
|
+
onChange={(e) => setDue(e.value)}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Value shape (the contract)
|
|
28
|
+
|
|
29
|
+
- `value` is the **canonical string at precision** — `YYYY-MM-DD` /
|
|
30
|
+
`YYYY-MM` / `YYYY` — or `null` for empty. Same strings the engine speaks;
|
|
31
|
+
no `Date` objects, no epochs. (django-mojo datetime *fields* are epoch
|
|
32
|
+
seconds — convert at the model/form boundary, not here.)
|
|
33
|
+
- **Controlled.** A commit fires `onChange`; the trigger repaints only once
|
|
34
|
+
the owner writes the value back. The picker holds no shadow copy.
|
|
35
|
+
- A loose-but-valid prop is canonicalized before anything reads it
|
|
36
|
+
(`"2026-3-4"` → `"2026-03-04"`), so `onChange().oldValue` and the calendar
|
|
37
|
+
always see the same normal form.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
interface DatePickerChangeEvent {
|
|
41
|
+
value: string | null; // canonical, or null when cleared
|
|
42
|
+
formatted: string; // the trigger's display text ('' when null)
|
|
43
|
+
oldValue: string | null; // canonical value being replaced
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`onChange` fires on **commit only** — a calendar pick or the clear ✕ — and
|
|
48
|
+
only when the canonical value actually changes. Re-picking the selected day
|
|
49
|
+
closes the popover without an event.
|
|
50
|
+
|
|
51
|
+
## API
|
|
52
|
+
|
|
53
|
+
| Prop | Notes |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `value` | controlled canonical string, or `null` |
|
|
56
|
+
| `precision` | `'day' \| 'month' \| 'year'` (default `day`). Unknown values warn + fall back to `day` |
|
|
57
|
+
| `onChange` | `(e: DatePickerChangeEvent) => void` — commit only |
|
|
58
|
+
| `displayFormat` | trigger text format (`formatForDisplay` tokens). Default per precision: `MMM DD, YYYY` / `MMM YYYY` / `YYYY` |
|
|
59
|
+
| `placeholder` | empty-state text. Default `Select date/month/year...` |
|
|
60
|
+
| `min` / `max` | pass-through bounds (compared at the pane precision being painted) |
|
|
61
|
+
| `disabledDates` | `YYYY-MM-DD[]`, day grids only |
|
|
62
|
+
| `firstDay` | 0=Sun … default 1=Mon |
|
|
63
|
+
| `locale` | BCP-47 for month/weekday names, default `en-US` |
|
|
64
|
+
| `autoApply` | close the popover on commit. Default `true`; `false` commits and stays open |
|
|
65
|
+
| `inline` | render the calendar in place — no trigger, no popover, no ✕ |
|
|
66
|
+
| `disabled` / `readOnly` | both make the picker inert (no open, no commits); both hide the ✕ |
|
|
67
|
+
| `required` | hides the ✕ — a required field cannot return to empty |
|
|
68
|
+
| `invalid` | paints the error border (`is-invalid`) |
|
|
69
|
+
| `name` | renders a hidden input with the canonical value (native form posts only) |
|
|
70
|
+
| `id` / `className` | trigger id (for a label's `htmlFor`) / extra class on the root |
|
|
71
|
+
|
|
72
|
+
The popover is the shared `Popover` at `bottom-start`: outside-mousedown and
|
|
73
|
+
Escape close it, it repositions on scroll/resize, and it joins the top layer
|
|
74
|
+
so a picker inside a `<dialog>` modal paints above it.
|
|
75
|
+
|
|
76
|
+
## Display formatting
|
|
77
|
+
|
|
78
|
+
`displayFormat` is filtered to the tokens the precision can actually fill,
|
|
79
|
+
then trailing separators are trimmed (ported verbatim as
|
|
80
|
+
`_stripIncompatibleTokens`):
|
|
81
|
+
|
|
82
|
+
| precision | behavior |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `day` | passes through untouched |
|
|
85
|
+
| `month` | `\bDD\b` / `\bD\b` removed |
|
|
86
|
+
| `year` | all month + day tokens removed; empties fall back to `'YYYY'` |
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
month "D MMMM YYYY" → "MMMM YYYY" → "August 2026"
|
|
90
|
+
month "YYYY-MM-DD" → "YYYY-MM" → "2026-08"
|
|
91
|
+
year "MMM DD" → "YYYY" → "2026"
|
|
92
|
+
month "MMM DD, YYYY" → "MMM , YYYY" → "Aug , 2026" ← the wart
|
|
93
|
+
year "DD/MM/YYYY" → "//YYYY" → "//2026" ← the wart
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The strip removes the **token**, not the punctuation around it, and only
|
|
97
|
+
trailing separators are cleaned. Pass a format that suits the precision, or
|
|
98
|
+
lean on the per-precision default.
|
|
99
|
+
|
|
100
|
+
## Invariants (keep them true)
|
|
101
|
+
|
|
102
|
+
- **One value pipeline.** The prop is parsed + re-formatted once into
|
|
103
|
+
`canonical`; the trigger text, the calendar's `value`, the hidden input and
|
|
104
|
+
every emitted event read that single derivation. The control can never
|
|
105
|
+
display something the owner's state does not hold.
|
|
106
|
+
- **A value that does not parse renders as empty and logs one
|
|
107
|
+
`console.warn`** naming the expected shape — once per distinct bad value,
|
|
108
|
+
from an effect (never during render), never silently blank.
|
|
109
|
+
- **The clear ✕ is hidden when `required || disabled || readOnly`.** It is a
|
|
110
|
+
SIBLING of the trigger button, absolutely positioned over it — clicking it
|
|
111
|
+
can never open the popover.
|
|
112
|
+
- **`disabled`/`readOnly` are inert in both modes** — the trigger opens
|
|
113
|
+
nothing and, inline, the calendar takes no commits.
|
|
114
|
+
- The ✕ shows whenever clearing is allowed, even with nothing selected
|
|
115
|
+
(source parity); clicking it then is a no-op — no `onChange`.
|
|
116
|
+
- The trigger is a real `<button>`: Enter/Space open it, Escape closes.
|
|
117
|
+
|
|
118
|
+
## Deviations from web-mojo (deliberate)
|
|
119
|
+
|
|
120
|
+
| Source | Here | Why |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| clear `<button>` nested inside the trigger `<button>` | sibling, absolutely positioned | button-in-button is invalid DOM; React logs a `validateDOMNesting` error |
|
|
123
|
+
| `_setValue` patched `textContent` + a hidden input in place | value is a controlled prop | one pipeline; no shadow state to drift |
|
|
124
|
+
| `change` emitted `value: ''` when cleared | `value: null` | matches the `string \| null` prop type end to end |
|
|
125
|
+
| calendar instance persisted across opens (page position too) | popover content unmounts on close | React idiom; reopening always re-pages to the value, else today |
|
|
126
|
+
| inline mode ignored `disabled` | inline honors `disabled`/`readOnly` | the props have to mean something |
|
|
127
|
+
| imperative `setValue/getValue/clear/setMin/setMax/show/hide/focus` | props | nothing to call — the owner holds the value |
|
|
128
|
+
|
|
129
|
+
## Pitfalls
|
|
130
|
+
|
|
131
|
+
- The ✕ is `tabIndex={-1}` (source parity — a second tab stop per date field
|
|
132
|
+
was not wanted). Keyboard users clear the field through the owning form,
|
|
133
|
+
not the trigger.
|
|
134
|
+
- `min`/`max` gate at the **pane's** precision: with `min="2026-03-15"` the
|
|
135
|
+
March tile and the 2026 tile stay enabled, only out-of-bounds days
|
|
136
|
+
disable. Day-level rules belong in `disabledDates`.
|
|
137
|
+
- `inline` fills its container — give it a sized wrapper (~280px), same as a
|
|
138
|
+
bare `<Calendar>`.
|
|
139
|
+
- Don't feed it `new Date().toISOString().slice(0,10)` if you care about the
|
|
140
|
+
local day — that's UTC. Build canonical strings with `dateFns.formatYmd`.
|
|
141
|
+
- `autoApply={false}` keeps the popover open after a commit; there is no
|
|
142
|
+
Apply button (there wasn't one in the source either) — the user closes it
|
|
143
|
+
by clicking out, pressing Escape, or clicking the trigger.
|
|
144
|
+
|
|
145
|
+
## Styling
|
|
146
|
+
|
|
147
|
+
`apps/portal/src/theme/datepicker.css` — trigger, clear ✕, popover content
|
|
148
|
+
box, inline host. The grid itself is `theme/calendar.css` and the floating
|
|
149
|
+
surface is `theme/popover.css`; this file adds nothing they already cover.
|
|
150
|
+
Trigger metrics follow the house control (`.input`): `--surface2` fill,
|
|
151
|
+
`--line` border, 8px radius, accent focus ring. Tokens only, both themes.
|