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/realtime.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Realtime transport
|
|
2
|
+
|
|
3
|
+
Import `RealtimeClient`, `RealtimeProvider`, `useRealtime`,
|
|
4
|
+
`useRealtimeStatus`, `useRealtimeEvent`, `useRealtimeTopic`, and
|
|
5
|
+
`defineRealtimeEvent` from `portal-mojo/client`.
|
|
6
|
+
|
|
7
|
+
Mount one `RealtimeProvider` inside the app's auth root. It owns the connection;
|
|
8
|
+
consumer permissions own topic subscriptions. Disabling the provider or losing
|
|
9
|
+
the auth token closes the socket. A permission loss in one consumer must not
|
|
10
|
+
disable this shared provider.
|
|
11
|
+
|
|
12
|
+
## Wire contract
|
|
13
|
+
|
|
14
|
+
- URL is derived from the configured API origin as exactly `/ws/realtime/`
|
|
15
|
+
with `ws:`/`wss:`. Credentials are never placed in the URL, lifecycle state,
|
|
16
|
+
logs, caches, mock observations, or storage beyond the existing auth store.
|
|
17
|
+
- An open socket is not ready. The client waits for `auth_required`, then sends
|
|
18
|
+
`{type:'authenticate',token,prefix:'bearer'}` and becomes ready only after
|
|
19
|
+
`auth_success`. An auth error closes and suspends the connection until the
|
|
20
|
+
token value changes.
|
|
21
|
+
- Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
|
|
22
|
+
Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
|
|
23
|
+
least 60 seconds, and focus/visibility cannot bypass that deadline.
|
|
24
|
+
- Desired topics are refcounted and replay after authentication. Operations are
|
|
25
|
+
serialized because acknowledgements carry only the topic and denial is a
|
|
26
|
+
generic uncorrelated `error`. Denial yields topic status `denied`; it does not
|
|
27
|
+
reconnect the socket. The server's automatic `user:<id>` topic is reported
|
|
28
|
+
separately and is never explicitly subscribed by the client. That automatic
|
|
29
|
+
topic is a delivery scope, not a presence feed.
|
|
30
|
+
- Framework messages, one wrapped `{type:'message',data,topic?,timestamp?}`
|
|
31
|
+
layer, and direct application events share one dispatch boundary. Malformed
|
|
32
|
+
frames drop. No generic Query invalidation is performed.
|
|
33
|
+
|
|
34
|
+
The transport does not invent application events. Current django-mojo has no
|
|
35
|
+
canonical record-created/updated/deleted stream and no canonical presence
|
|
36
|
+
stream, so neither record cache invalidation nor online status can be derived
|
|
37
|
+
from this client. `defineRealtimeEvent` and `useRealtimeTopic` are useful only
|
|
38
|
+
when a backend producer defines and authorizes that exact event/topic contract;
|
|
39
|
+
Assistant streaming is the package's current concrete application consumer.
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// Hypothetical: this works only if the consuming backend publishes this
|
|
43
|
+
// application-specific event and authorizes the `orders` topic.
|
|
44
|
+
const orderChanged = defineRealtimeEvent('order_changed', (value) => {
|
|
45
|
+
if (!value || typeof value !== 'object') return null;
|
|
46
|
+
const id = (value as {id?: unknown}).id;
|
|
47
|
+
return typeof id === 'number' ? {id} : null;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function OrdersLive() {
|
|
51
|
+
const topic = useRealtimeTopic('orders');
|
|
52
|
+
useRealtimeEvent(orderChanged, ({data}) => console.log(data.id));
|
|
53
|
+
return <span>{topic}</span>;
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`createRealtimeMock()` is an adjacent deterministic in-memory server for demos
|
|
58
|
+
and behavioral verification. Its injected factory replaces no global
|
|
59
|
+
`WebSocket`; controls cover delayed challenge, auth failure left open, 4429,
|
|
60
|
+
topic denial, missing pong, wrapped/direct events, forced disconnect, and
|
|
61
|
+
multiple connections. Observations contain only bounded derived metadata.
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# RecordFeed — record notes, assistant replies, and system history
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import {
|
|
5
|
+
createTicketNoteAdapter,
|
|
6
|
+
createIncidentHistoryAdapter,
|
|
7
|
+
normalizeRecordFeedItem,
|
|
8
|
+
recordFeedQueryKey,
|
|
9
|
+
type RecordFeedAdapter,
|
|
10
|
+
type RecordFeedItem,
|
|
11
|
+
type RecordFeedPage,
|
|
12
|
+
} from 'portal-mojo/client';
|
|
13
|
+
import { RecordFeed } from 'portal-mojo/ui';
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`RecordFeed` is the shared display and cache substrate for ticket comments,
|
|
17
|
+
incident history, assistant-authored notes, status changes, and plain system
|
|
18
|
+
activity. It has two deliberately separate ownership modes:
|
|
19
|
+
|
|
20
|
+
- **Adapter mode** owns a TanStack list query, POST mutation, and exact
|
|
21
|
+
optimistic cache lifecycle.
|
|
22
|
+
- **Controlled mode** accepts `items` + `onSend`; a future streaming assistant
|
|
23
|
+
can own its own state without pretending a stream is a django-mojo list.
|
|
24
|
+
|
|
25
|
+
Adapter mode supports one optional completed File reference. Controlled mode
|
|
26
|
+
keeps its existing `items`/`onSend(text)` contract and has no attachment policy;
|
|
27
|
+
specialized owners may place the shared `AttachmentQueue` in `composerAddon`.
|
|
28
|
+
|
|
29
|
+
## Adapter mode
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
const adapter = useMemo(
|
|
33
|
+
() => createTicketNoteAdapter(ticket.id, { groupId: ticket.group.id }),
|
|
34
|
+
[ticket.id, ticket.group.id],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
<RecordFeed
|
|
38
|
+
adapter={adapter}
|
|
39
|
+
currentUser={{ id: me.id, name: me.display_name ?? me.username }}
|
|
40
|
+
currentUserId={me.id}
|
|
41
|
+
variant="compact"
|
|
42
|
+
attachmentUpload={{
|
|
43
|
+
destination: { groupId: ticket.group.id, use: 'uploads' },
|
|
44
|
+
expectedGroupId: ticket.group.id,
|
|
45
|
+
}}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Use `createIncidentHistoryAdapter(incidentId, {groupId})` for incident
|
|
50
|
+
history. Memoize the adapter for ordinary React hygiene; its query key is
|
|
51
|
+
structural and remains stable either way.
|
|
52
|
+
|
|
53
|
+
### Wire contract
|
|
54
|
+
|
|
55
|
+
Both adapters request only the latest window:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
GET /api/incident/ticket/note
|
|
59
|
+
GET /api/incident/incident/history
|
|
60
|
+
|
|
61
|
+
parent=<record id>
|
|
62
|
+
group=<optional active group id>
|
|
63
|
+
graph=default
|
|
64
|
+
sort=-created
|
|
65
|
+
start=0
|
|
66
|
+
size=100
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
django-mojo returns newest-first. The adapter normalizes those rows and
|
|
70
|
+
reverses the returned window for chronological display. Its result is:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
interface RecordFeedPage {
|
|
74
|
+
items: RecordFeedItem[]; // chronological
|
|
75
|
+
count: number; // full server count
|
|
76
|
+
hasEarlier: boolean; // count > returned rows
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
POST uses the same collection endpoint and `{parent, group, note, media?}` when the
|
|
81
|
+
adapter has a group (`group` is the bare ForeignKey primary key django-mojo's
|
|
82
|
+
generic REST saver consumes). Incident posts also carry `kind: 'comment'`.
|
|
83
|
+
`media` is a single positive completed File id and never replaces required note
|
|
84
|
+
text. Failed saves reject at the shared client boundary.
|
|
85
|
+
|
|
86
|
+
Every incoming `media` value is positively rebuilt to exactly
|
|
87
|
+
`{id, filename, content_type, category}` before domain sanitization,
|
|
88
|
+
normalization, optimistic cache replacement, or rendering. Unknown fields,
|
|
89
|
+
URLs, tokens, manager/provider details, transfer state, and browser `File`
|
|
90
|
+
objects cannot enter RecordFeed state.
|
|
91
|
+
|
|
92
|
+
The attachment queue has capacity/concurrency one. It is record-local,
|
|
93
|
+
deduplicated, and exposes real progress/cancel/retry/recover/remove actions.
|
|
94
|
+
Only a completed reference may be sent. Removal detaches queue state; it never
|
|
95
|
+
deletes the File. Permission loss, logout, or any parent/group/destination
|
|
96
|
+
change cancels and disposes the old queue.
|
|
97
|
+
|
|
98
|
+
Grouped records use the authoritative parent group and immutable
|
|
99
|
+
`{groupId, use:'uploads'}` initiation selectors. Groupless records send no
|
|
100
|
+
selector. Parent payloads currently expose no exact FileManager upload
|
|
101
|
+
capability, so the server resolves its authorized manager; the client then
|
|
102
|
+
requires the completed lifecycle response to carry a positive manager id and
|
|
103
|
+
the exact expected group before enabling association. A future parent contract
|
|
104
|
+
may expose an explicit manager/readiness capability without changing the safe
|
|
105
|
+
File-reference shape.
|
|
106
|
+
|
|
107
|
+
The cache key is always:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
['record-feed', 'ticket-note' | 'incident-history', parentId, groupId ?? null]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
That final group slot is not cosmetic. Two group contexts viewing the same
|
|
114
|
+
numeric record ID must never share a cache entry. Changing any adapter key
|
|
115
|
+
slot also remounts adapter mode so its composer draft, mutation/error state,
|
|
116
|
+
scroll position, and bottom-pin refs cannot leak into the next record.
|
|
117
|
+
|
|
118
|
+
## Author/type normalization
|
|
119
|
+
|
|
120
|
+
`normalizeRecordFeedItem(raw)` applies one strict precedence:
|
|
121
|
+
|
|
122
|
+
1. A **structured status change** with `metadata.type='status_change'` (or a
|
|
123
|
+
`metadata.status_change` object) and a structured old/new value becomes
|
|
124
|
+
`kind:'status'`.
|
|
125
|
+
2. `[LLM Agent]`, `kind:'handler:llm'`, or an explicit LLM handler/origin
|
|
126
|
+
becomes `kind:'assistant'` — even if a modern row also embeds a user.
|
|
127
|
+
3. Legacy null-user action/context records become assistant notes.
|
|
128
|
+
4. Any remaining row with a user becomes `kind:'comment'`.
|
|
129
|
+
5. Everything else becomes `kind:'system'`.
|
|
130
|
+
|
|
131
|
+
Two negative rules matter:
|
|
132
|
+
|
|
133
|
+
- A null user alone **never means assistant**. Most automated history has no
|
|
134
|
+
user and remains System.
|
|
135
|
+
- Prose is never regexed into a status transition. If old/new status is not
|
|
136
|
+
structured, the row follows the remaining precedence.
|
|
137
|
+
|
|
138
|
+
`metadata` is normalized to a plain object. `raw` remains wire-shaped, but it
|
|
139
|
+
may be domain-sanitized. Sensitive domains pass `sanitizeRow` and
|
|
140
|
+
`sanitizeText`; the unsanitized failure-restoration draft remains only in a
|
|
141
|
+
component-local ref and never enters MutationCache.
|
|
142
|
+
|
|
143
|
+
## Exact optimistic lifecycle
|
|
144
|
+
|
|
145
|
+
Adapter mode permits one submit at a time and performs these operations on
|
|
146
|
+
the adapter's exact structural key:
|
|
147
|
+
|
|
148
|
+
1. Trim the draft; ignore empty/duplicate pending submits.
|
|
149
|
+
2. Clear the visible draft and cancel the exact in-flight query.
|
|
150
|
+
3. Snapshot the exact `RecordFeedPage`.
|
|
151
|
+
4. Append a client-only pending comment with a unique temporary ID.
|
|
152
|
+
5. On success, replace that exact temporary row with the normalized server
|
|
153
|
+
row. The composer stays clear and regains focus.
|
|
154
|
+
6. On a deterministic failure, restore the snapshot. If no cache existed, remove the exact
|
|
155
|
+
query instead of manufacturing stale state. Restore the exact submitted
|
|
156
|
+
text, focus the composer, and surface the mutation error in `role=alert`.
|
|
157
|
+
7. On a lost/ambiguous response, retain the draft and completed candidate,
|
|
158
|
+
mark delivery uncertain, and block blind replay while an authoritative
|
|
159
|
+
refetch looks for a newly-created exact text/media row. A snapshot id can
|
|
160
|
+
never satisfy reconciliation. If refetch also fails, changing the draft or
|
|
161
|
+
detaching the candidate is the explicit escape.
|
|
162
|
+
8. In all cases, invalidate only that exact record/group key.
|
|
163
|
+
|
|
164
|
+
The input is disabled during the request, so no second draft can be lost or
|
|
165
|
+
silently overwritten by failure restoration.
|
|
166
|
+
|
|
167
|
+
## Controlled mode
|
|
168
|
+
|
|
169
|
+
```tsx
|
|
170
|
+
<RecordFeed
|
|
171
|
+
items={messages}
|
|
172
|
+
onSend={sendMessage}
|
|
173
|
+
isSending={streamStarting}
|
|
174
|
+
pending={streaming ? <Thinking /> : null}
|
|
175
|
+
error={streamError?.message}
|
|
176
|
+
variant="bubbles"
|
|
177
|
+
/>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The parent owns item append/replacement/streaming. `RecordFeed` still owns the
|
|
181
|
+
composer draft: it clears before awaiting `onSend`, restores the exact text on
|
|
182
|
+
rejection, focuses after either outcome, prevents concurrent submits, and
|
|
183
|
+
surfaces local rejection through the same alert region.
|
|
184
|
+
|
|
185
|
+
## Props
|
|
186
|
+
|
|
187
|
+
| Prop | Default | Meaning |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `adapter` | — | Selects adapter mode. Mutually exclusive with `items/onSend`. |
|
|
190
|
+
| `items`, `onSend` | — | Select controlled mode. |
|
|
191
|
+
| `variant` | `'compact'` | `'compact'` admin activity or `'bubbles'` conversation layout. |
|
|
192
|
+
| `currentUserId` | — | Marks only matching **comments** as the current user's/right-side bubble. Assistant rows never become “mine.” |
|
|
193
|
+
| `currentUser` | `{id:currentUserId,name:'You'}` | Author stamped onto adapter-mode optimistic rows. |
|
|
194
|
+
| `renderAddon(item)` | — | ReactNode slot below a comment/assistant body for domain cards/actions. |
|
|
195
|
+
| `pending` | — | ReactNode after the list for thinking/streaming state. |
|
|
196
|
+
| `showInput` | `true` | Hide the composer for read-only history. |
|
|
197
|
+
| `disabled` | `false` | Disable sends while retaining history. |
|
|
198
|
+
| `attachmentUpload` | — | Adapter-only immutable destination and expected parent group; enables the singular queue. |
|
|
199
|
+
| `composerAddon` | — | Generic controlled-owner slot; RecordFeed itself assigns no attachment semantics. |
|
|
200
|
+
| `submitBlocked` | `false` | Blocks text submission while a specialized composer owner is not ready. |
|
|
201
|
+
| `placeholder` | `'Add a note…'` | Composer placeholder. |
|
|
202
|
+
| `sendLabel` | `'Send'` | Visible/accessibility send label. |
|
|
203
|
+
| `emptyLabel` | `'No activity yet.'` | Empty-state text. |
|
|
204
|
+
| `ariaLabel` | `'Record activity'` | Feed region label. |
|
|
205
|
+
|
|
206
|
+
## Rendering, keyboard, and scrolling
|
|
207
|
+
|
|
208
|
+
- Comment and assistant bodies use existing `<MarkdownView renderer="client">`.
|
|
209
|
+
A feed never fans out one `/api/docit/render` request per row and never
|
|
210
|
+
creates another HTML path.
|
|
211
|
+
- Status and system events are React nodes/plain text, not markdown.
|
|
212
|
+
- The scroll viewport is `role=log` with a semantic ordered list and polite
|
|
213
|
+
addition/text announcements. Loading, pending, and failures have explicit
|
|
214
|
+
status/alert semantics.
|
|
215
|
+
- Enter sends; Shift+Enter inserts a newline. Composition Enter is ignored via
|
|
216
|
+
the native `isComposing` flag, so CJK/IME commits cannot accidentally send.
|
|
217
|
+
- The textarea grows to 144 px, then scrolls internally.
|
|
218
|
+
- The feed pins to bottom only on initial load, the viewer's own send, or an
|
|
219
|
+
update received while already within 48 px of bottom. Reading older rows is
|
|
220
|
+
never interrupted by background/streaming updates.
|
|
221
|
+
|
|
222
|
+
## Styling
|
|
223
|
+
|
|
224
|
+
Import `theme/record-feed.css` after the token declarations (the portal and
|
|
225
|
+
showcase theme aggregators already do). It uses only the mission-control
|
|
226
|
+
tokens and supports both `data-theme` values. Override the default bounded
|
|
227
|
+
height with `--record-feed-height` on a wrapper/component class.
|
|
228
|
+
|
|
229
|
+
## Pitfalls
|
|
230
|
+
|
|
231
|
+
- Do not use an endpoint-only query key; parent and group scope are required.
|
|
232
|
+
- Do not fetch ascending and take the first 100 — that returns the oldest
|
|
233
|
+
window. Fetch descending, take 100, then reverse the returned window.
|
|
234
|
+
- Do not read attachment data from `raw.media`; use the positively rebuilt
|
|
235
|
+
`item.attachment` reference.
|
|
236
|
+
- Do not classify every null-user row as assistant or parse status prose.
|
|
237
|
+
- Do not use server MarkdownView per row. Feeds use the secure React client
|
|
238
|
+
renderer; richer domain UI belongs in `renderAddon` as ReactNode.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# RightPanel — persistent complementary shell content
|
|
2
|
+
|
|
3
|
+
```tsx
|
|
4
|
+
import { RightPanelProvider, RightPanelSlot, useRightPanel } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
RightPanel is an explicit exception for product experiences that need
|
|
8
|
+
persistent, complementary shell context while the main route changes. It is
|
|
9
|
+
not the default Admin record-detail presentation: Admin table records use
|
|
10
|
+
`modal.detail(...)` unless the product specifically asks for a right panel.
|
|
11
|
+
|
|
12
|
+
Mount one provider above the opted-in application shell and one slot beside
|
|
13
|
+
the main route content. Open content with a stable key, visible title, render
|
|
14
|
+
callback, and the exact launcher element:
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
panel.open({
|
|
18
|
+
key: `map-layer:${layer.id}`,
|
|
19
|
+
title: layer.name,
|
|
20
|
+
render: ({ close }) => <MapLayerInspector id={layer.id} close={close} />,
|
|
21
|
+
}, event.currentTarget);
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The provider owns one session-only descriptor. Opening a different key replaces
|
|
25
|
+
it atomically; opening the same key is idempotent. Closing is idempotent and
|
|
26
|
+
restores focus when the captured launcher is still connected.
|
|
27
|
+
|
|
28
|
+
`RightPanelSlot` renders a non-modal `aside[role=complementary]` with a visible
|
|
29
|
+
close control and labelled heading. It moves focus into that control on open.
|
|
30
|
+
Escape closes the panel only when no native `dialog[open]` exists, so
|
|
31
|
+
`ModalHost` keeps ownership of its top-layer keyboard interaction. There is no
|
|
32
|
+
focus trap or backdrop: use `modal.drawer()` when the interaction must be
|
|
33
|
+
modal, awaitable, and transient.
|
|
34
|
+
|
|
35
|
+
Do not add RightPanel to the global Admin shell merely to inspect a selected
|
|
36
|
+
row. That changes the application layout and interaction model for every Admin
|
|
37
|
+
domain; such a choice requires an explicit product decision.
|
|
38
|
+
|
|
39
|
+
Panel selection never enters the hash or query string. Route navigation may
|
|
40
|
+
replace the main `<Outlet>` while provider state stays mounted, and table params
|
|
41
|
+
can never leak a `panel` key into Django filters. The supplied app styles use a
|
|
42
|
+
third grid column on wide layouts and a fixed non-modal aside on narrow ones.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Tabs and FormWizard
|
|
2
|
+
|
|
3
|
+
Import both from `portal-mojo/ui`:
|
|
4
|
+
|
|
5
|
+
```tsx
|
|
6
|
+
import { Tabs, FormWizard, formWizardModal } from 'portal-mojo/ui';
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Tabs
|
|
10
|
+
|
|
11
|
+
`Tabs` accepts `items: { key, label, panel, disabled?, ariaLabel? }[]`, optional controlled `activeKey`/`onActiveKeyChange`, uncontrolled `defaultActiveKey`, `variant`, `ariaLabel`, and `className`. Labels and panels are `ReactNode`.
|
|
12
|
+
|
|
13
|
+
The default variant is `underline-all`. Canonical variants are `minimal`, `traditional`, `underline`, `underline-all`, `pills`, `pills-solid`, `segmented`, and `btn-group`; `buttongroup` and `btngroup` alias `btn-group`. Unknown variants warn once and use the default.
|
|
14
|
+
|
|
15
|
+
In controlled mode, the caller owns the requested key. If it is missing or disabled, Tabs paints the first enabled key and notifies the caller once per requested-key/roster transition. Uncontrolled selection heals internally. Empty and duplicate keys are dropped with a warning; disabled tabs are never selected.
|
|
16
|
+
|
|
17
|
+
The tablist uses automatic activation: Left/Right wraps and skips disabled tabs; Home/End selects the first/last enabled tab. Stable `tab`/`tabpanel` IDs carry `aria-controls` and `aria-labelledby`, with one roving `tabIndex=0`. Every panel shell remains mounted, while only the active panel content mounts. Narrow tab lists scroll horizontally.
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
<Tabs
|
|
21
|
+
variant="segmented"
|
|
22
|
+
defaultActiveKey="profile"
|
|
23
|
+
items={[
|
|
24
|
+
{ key: 'profile', label: 'Profile', panel: <Profile /> },
|
|
25
|
+
{ key: 'audit', label: 'Audit', panel: <Audit />, disabled: !canAudit },
|
|
26
|
+
]}
|
|
27
|
+
/>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## FormWizard
|
|
31
|
+
|
|
32
|
+
Sections have `{ key, label, fields, description?, optional?, content?, eligible?, onNext?, terminal? }`. `content` may be a node or a render function over the shared draft/busy state. `eligible(data)` removes a step without destroying its draft. `onNext(data)` is an async single-flight transition after section validation. `terminal` renders an actionless receipt/status step. Section keys must be non-empty and unique, and field names must be globally unique. Later duplicates are warned and dropped.
|
|
33
|
+
|
|
34
|
+
Core props are `sections`, `initial`, `resetKey`, `mode: 'wizard' | 'tabs'`, `tabVariant`, button labels, `validateAllOnFinish`, `onStepChange`, `onCancel`, and `onFinish(data)`. Wizard mode has ordered Back/Next/Finish flow. Back does not validate; Next validates visible fields on the active step. Tabs mode provides non-linear navigation and one Save action. Finish validates all visible sections by default, opens the first invalid section, and focuses its first error. Set `validateAllOnFinish={false}` only when active-section validation is deliberately sufficient; the emitted payload still includes all visible fields.
|
|
35
|
+
|
|
36
|
+
Required/email checks run before an optional `Field.schema`. Hidden fields neither validate nor submit, but their draft remains available if shown again. `onFinish` is async and single-flight: navigation and actions disable while pending; rejection remains visible and retryable. A new `initial` object does not overwrite drafts. Change `resetKey` to intentionally reseed values/errors/active section. Roster changes preserve surviving values, seed additions, retire removals, and heal selection; pending finish defers reconciliation.
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
const save = User.useSave();
|
|
40
|
+
<FormWizard
|
|
41
|
+
mode="wizard"
|
|
42
|
+
sections={sections}
|
|
43
|
+
initial={row}
|
|
44
|
+
resetKey={row.id}
|
|
45
|
+
onFinish={(data) => save.mutateAsync({ id: row.id, changes: data })}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`formWizardModal(options)` adds `title` and optional `size`, and returns the submitted `FormData` or `null` for idle cancellation. Its required `onFinish` runs before success closes. Next/Back/tabs/Cancel/Escape/backdrop are all locked during async transitions or finish. Reset/unmount generations make late completions stale, so they cannot advance, close, or rewrite a newer roster.
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const result = await formWizardModal({
|
|
53
|
+
title: 'Create member',
|
|
54
|
+
sections,
|
|
55
|
+
mode: 'wizard',
|
|
56
|
+
onFinish: (data) => create.mutateAsync(data),
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`SchemaForm` shares the submit-state/rendering core but deliberately retains its lazy one-time initialization, flat payload, and required/email-only validation. `FormView` remains a separate autosave reducer; it uses Tabs only for presentation so permission filtering and one cross-tab autosave batch stay intact.
|
package/docs/taginput.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# TagInput — chip tags over a CSV string
|
|
2
|
+
|
|
3
|
+
```ts
|
|
4
|
+
import { TagInput } from 'portal-mojo/ui';
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
Free-text tags as removable chips with a full keyboard flow. Ported from
|
|
8
|
+
web-mojo's `TagInputView` (`src/core/forms/inputs/TagInput.js`).
|
|
9
|
+
|
|
10
|
+
**The value is a CSV string, both directions.** django-mojo models store the
|
|
11
|
+
joined string and split it server-side, so `onChange` hands back the string
|
|
12
|
+
FIRST and the array second. A parent that persists the array instead has
|
|
13
|
+
changed the wire shape.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
const [csv, setCsv] = useState(record.tags ?? ''); // "prod,us-east-1,pci"
|
|
17
|
+
|
|
18
|
+
<TagInput
|
|
19
|
+
name="tags"
|
|
20
|
+
value={csv}
|
|
21
|
+
onChange={(next) => setCsv(next)}
|
|
22
|
+
maxTags={10}
|
|
23
|
+
/>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
| Prop | Default | Notes |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `name` | — | also renders a hidden input carrying the CSV, for native form posts |
|
|
31
|
+
| `value` | `''` | CSV string **or** array; both parse to the same tags |
|
|
32
|
+
| `onChange` | — | `(csv: string, tags: string[]) => void` — fires on commit/removal only |
|
|
33
|
+
| `placeholder` | `'Add tags...'` | |
|
|
34
|
+
| `maxTags` | `50` | caps interactive adds; the counter's denominator |
|
|
35
|
+
| `allowDuplicates` | `false` | duplicate check is CASE-SENSITIVE (`Design` ≠ `design`) |
|
|
36
|
+
| `separator` | `','` | exactly one character; joins the CSV, splits input, and commits on keypress |
|
|
37
|
+
| `trimTags` | `true` | |
|
|
38
|
+
| `minLength` / `maxLength` | `1` / `50` | per tag |
|
|
39
|
+
| `disabled` | `false` | field stays, every add/removal refused |
|
|
40
|
+
| `readonly` | `false` | chips only — no input, no remove icons |
|
|
41
|
+
| `className` | — | appended to the root |
|
|
42
|
+
|
|
43
|
+
## Keyboard + commit routes
|
|
44
|
+
|
|
45
|
+
| Key | In the text field | On a focused chip |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `Enter` | commit (preventDefault; an empty field still submits an enclosing form) | — |
|
|
48
|
+
| `Tab` | commit **and move focus on** | — |
|
|
49
|
+
| separator (`,`) | commit (always preventDefault) | — |
|
|
50
|
+
| `Backspace` | empty field → drop the last chip | remove it, focus the previous chip (or the field) |
|
|
51
|
+
| `Delete` | — | same as Backspace |
|
|
52
|
+
| `←` | empty field → focus the last chip | chip 0 → field, else previous chip |
|
|
53
|
+
| `→` | empty field → focus chip 0 | last chip → field, else next chip |
|
|
54
|
+
| `Escape` | clear the draft text | — |
|
|
55
|
+
|
|
56
|
+
Two more commit routes that are not keydowns:
|
|
57
|
+
|
|
58
|
+
- **input-change**: text ending in the separator or a newline commits. This is
|
|
59
|
+
what catches pastes and soft keyboards, which never fire a usable keydown.
|
|
60
|
+
- **blur**: a pending draft commits rather than vanishing (the house commit set
|
|
61
|
+
is select/Enter/blur).
|
|
62
|
+
|
|
63
|
+
Every route funnels through one `commitText`, which splits on the separator and
|
|
64
|
+
on newlines first — so pasting `a,b,c,` yields three tags, never one tag
|
|
65
|
+
containing commas that would re-split on the next load.
|
|
66
|
+
|
|
67
|
+
## Invariants
|
|
68
|
+
|
|
69
|
+
1. **No committed tag contains the separator.** Enforced at commit AND on parse
|
|
70
|
+
(array entries are split too). This is what keeps the CSV round-trip exact.
|
|
71
|
+
2. **`separator` must be one character** — anything else falls back to `,` with
|
|
72
|
+
a `console.warn` (the house rule for unknown option values; an empty
|
|
73
|
+
separator would split every tag into characters).
|
|
74
|
+
3. **Controlled.** Tags derive from `value` on every render — the chips can
|
|
75
|
+
never show something the parent's state doesn't hold. With no `onChange` (and
|
|
76
|
+
not `readonly`/`disabled`) the component warns once at mount instead of
|
|
77
|
+
silently swallowing adds.
|
|
78
|
+
4. **Parsing `value` does NOT apply `minLength`/`maxLength`/`maxTags`.** Those
|
|
79
|
+
cap what a user may add; silently dropping stored values would lose backend
|
|
80
|
+
data on the next save. A record with 7 tags under `maxTags={5}` honestly
|
|
81
|
+
reads `7/5 tags` and refuses further adds.
|
|
82
|
+
5. **Rejected adds are never silent** — a transient inline error (~3s,
|
|
83
|
+
`role="status"`) says why: `Tag "x" already exists`,
|
|
84
|
+
`Maximum N tags allowed`, or the length messages.
|
|
85
|
+
|
|
86
|
+
## Deliberate deviations from web-mojo
|
|
87
|
+
|
|
88
|
+
| web-mojo | here | why |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `Tab` calls `preventDefault` | Tab commits, focus still moves | the source trapped focus in the field for as long as it held text |
|
|
91
|
+
| commits on `,` even when `separator` is something else | commits on the separator key | otherwise a `\|`-separated field could never hold a comma |
|
|
92
|
+
| length violations rejected silently | inline error | "silent failure as policy" is on the do-not-recreate list |
|
|
93
|
+
| `Delete` promised by the chip's aria-label, only Backspace wired | both work | the label was lying |
|
|
94
|
+
| `Escape` also blurs | clears the draft only | Escape inside a modal still reaches the dialog once there is nothing to clear |
|
|
95
|
+
| wrapper `role="combobox" aria-expanded="false"` | `role="group"` | there is no popup; a permanently-false `aria-expanded` misleads AT |
|
|
96
|
+
| `escapeHtml()` on every tag before HTML-string render | plain JSX | React escapes; the "caller must escape" contract class is gone |
|
|
97
|
+
| `console.log` in `focusTag` | dropped | |
|
|
98
|
+
| `tag:added` / `tag:removed` / `tags:cleared` events | `onChange` only | it already carries the full next list |
|
|
99
|
+
|
|
100
|
+
## Styling
|
|
101
|
+
|
|
102
|
+
Classes are styled by the consuming app (`apps/portal/src/theme/taginput.css`,
|
|
103
|
+
tokens only, light + dark): `.tag-input`, `.tag-input-wrap` (wears the `.input`
|
|
104
|
+
field look, accent ring on `:focus-within`), `.tag-chip` / `.tag-chip-text` /
|
|
105
|
+
`.tag-chip-x`, `.tag-input-field`, `.tag-input-foot` / `.tag-input-count` /
|
|
106
|
+
`.tag-input-error`, plus `.tag-input-disabled` / `.tag-input-readonly` on the
|
|
107
|
+
root. Chips show a focus ring on pointer focus too — they are only focusable to
|
|
108
|
+
drive the arrow/Backspace flow, and Backspace must never delete a chip whose
|
|
109
|
+
focus is invisible.
|
|
110
|
+
|
|
111
|
+
## Pitfalls
|
|
112
|
+
|
|
113
|
+
- Don't store the `tags` array and rebuild the string with a different
|
|
114
|
+
separator on save — pass through the CSV the component emits.
|
|
115
|
+
- Clicking the wrapper's padding focuses the field without blurring it
|
|
116
|
+
(mousedown default is swallowed); don't add a competing focus handler.
|
|
117
|
+
- The remove `×` is `aria-hidden` on purpose: it duplicates the chip's own
|
|
118
|
+
keyboard removal, and nesting an interactive element inside `role="button"`
|
|
119
|
+
is invalid ARIA.
|
|
120
|
+
- Live demo: portal → **Develop → Components → TagInput**.
|