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,517 @@
|
|
|
1
|
+
// CollectionSelect — the single-record server picker: search-as-you-type
|
|
2
|
+
// against any model/endpoint, pick one row, and hydrate a bare stored id
|
|
3
|
+
// into its display label. Ported from web-mojo
|
|
4
|
+
// src/core/forms/inputs/CollectionSelect.js (669 lines: CollectionSelectView
|
|
5
|
+
// + CollectionDropdownView) into the one-cache world: list data flows through
|
|
6
|
+
// useModelList's query keys ([endpoint, params]) and id→label hydration
|
|
7
|
+
// through the generic one-record key ([endpoint, 'one', id]), so this picker,
|
|
8
|
+
// ModelTable, DetailView and every model hook share one cache and one
|
|
9
|
+
// invalidation root — never a parallel fetch path (the #1275 epic contract).
|
|
10
|
+
// Sibling of CollectionMultiSelect (shared prop contract: model/endpoint,
|
|
11
|
+
// labelField/valueField, defaultParams, requiresActiveGroup).
|
|
12
|
+
//
|
|
13
|
+
// Deliberate departures from the source, all bug-class kills:
|
|
14
|
+
// · The value is CONTROLLED (`value` + `onChange`) — no hidden input, no
|
|
15
|
+
// setValue/setFormValue imperative surface. The input can never display
|
|
16
|
+
// text the committed value + draft state don't hold (rule 4): closing
|
|
17
|
+
// by outside click or Escape DROPS the in-progress search text and the
|
|
18
|
+
// display reverts to the committed label (the source left typed text in
|
|
19
|
+
// the DOM input while its hidden value said "no selection").
|
|
20
|
+
// · web-mojo's '0' no-selection sentinel stops at this boundary: 0/'0'
|
|
21
|
+
// coming IN normalize to "no selection", and the empty value going OUT
|
|
22
|
+
// is null — '0' never reaches the API or the parent.
|
|
23
|
+
// · Typing that diverges from the committed label clears the selection
|
|
24
|
+
// exactly once (onChange(null)) — the source re-emitted the clear on
|
|
25
|
+
// every subsequent keystroke (searchValue !== '' ≠ selectedLabel='').
|
|
26
|
+
// · Ids compare NORMALIZED (String()) everywhere; numeric-string ids
|
|
27
|
+
// hydrate under the NUMERIC one-record key so DetailView/useOne share
|
|
28
|
+
// the cached record (django-mojo pks are ints; uuid/slug ids pass
|
|
29
|
+
// through untouched).
|
|
30
|
+
// · Re-selecting the already-committed row does not re-fire onChange
|
|
31
|
+
// (commit fires only when the value actually changes — ComboBox idiom).
|
|
32
|
+
// · The dropdown mounts in the shared <Popover> (top layer) instead of an
|
|
33
|
+
// absolutely-positioned div, so the picker works inside native-<dialog>
|
|
34
|
+
// modals and never clips in overflow containers.
|
|
35
|
+
// · "Start typing to search…" is actually reachable — in the source it
|
|
36
|
+
// was dead code (its showNoResults guard required hasSearched).
|
|
37
|
+
import { useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
38
|
+
import { useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
39
|
+
import { useModel, useModelList } from '../client/hooks';
|
|
40
|
+
import type { ModelDef } from '../client/model';
|
|
41
|
+
import type { Params } from '../client/types';
|
|
42
|
+
import { GroupContext } from '../client/group-context';
|
|
43
|
+
import { Popover } from './Popover';
|
|
44
|
+
|
|
45
|
+
/** Every id comparison in this component goes through here — string and
|
|
46
|
+
* number ids (and mixes of the two) work by construction. */
|
|
47
|
+
const keyOf = (v: string | number): string => String(v);
|
|
48
|
+
|
|
49
|
+
/** MOJOUtils.getNestedValue heritage: dot-notation nested access. */
|
|
50
|
+
function getPath(obj: unknown, path: string): unknown {
|
|
51
|
+
let cur: unknown = obj;
|
|
52
|
+
for (const part of path.split('.')) {
|
|
53
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
54
|
+
cur = (cur as Record<string, unknown>)[part];
|
|
55
|
+
}
|
|
56
|
+
return cur;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// One warn per (endpoint, field) — a bad field name is a config bug, not a
|
|
60
|
+
// render event (unknown-value rule: fall back WITH a warn, never to nothing).
|
|
61
|
+
const warnedFields = new Set<string>();
|
|
62
|
+
function warnFieldOnce(endpoint: string, kind: 'labelField' | 'valueField', field: string, note: string): void {
|
|
63
|
+
const key = `${endpoint}:${kind}:${field}`;
|
|
64
|
+
if (warnedFields.has(key)) return;
|
|
65
|
+
warnedFields.add(key);
|
|
66
|
+
console.warn(`CollectionSelect: ${kind} "${field}" ${note} (endpoint ${endpoint})`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** django-mojo pks are ints — a numeric-string id fetches under the NUMBER
|
|
70
|
+
* one-record key ([endpoint,'one',42]) so DetailView/useOne share the cache.
|
|
71
|
+
* Non-numeric ids (uuid, slug) pass through unchanged. */
|
|
72
|
+
const coerceFetchId = (id: string | number): string | number =>
|
|
73
|
+
typeof id === 'string' && /^[1-9]\d*$/.test(id) ? Number(id) : id;
|
|
74
|
+
|
|
75
|
+
/** "Label known already?" — scan every cached LIST under this endpoint's
|
|
76
|
+
* root for the row (ModelTable pages, other pickers, this picker's own
|
|
77
|
+
* fetches all share the [endpoint, params] keys). Non-reactive by design:
|
|
78
|
+
* a miss falls through to the reactive one-record fetch. */
|
|
79
|
+
function findRowInListCaches(qc: QueryClient, endpoint: string, key: string, valueField: string): unknown {
|
|
80
|
+
for (const [, data] of qc.getQueriesData<unknown>({ queryKey: [endpoint] })) {
|
|
81
|
+
if (data == null || typeof data !== 'object') continue;
|
|
82
|
+
const rows = (data as { rows?: unknown }).rows;
|
|
83
|
+
if (!Array.isArray(rows)) continue; // skips [endpoint,'one',id] entries
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
const raw = getPath(row, valueField);
|
|
86
|
+
if (raw != null && String(raw) === key) return row;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface Item<T> {
|
|
93
|
+
/** Normalized identity — the one string every comparison uses. */
|
|
94
|
+
key: string;
|
|
95
|
+
/** The row's raw id, emitted through onChange when the row is picked. */
|
|
96
|
+
id: string | number;
|
|
97
|
+
label: string;
|
|
98
|
+
row: T;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The controlled value: an id, null for "no selection", or a full row-like
|
|
103
|
+
* OBJECT (web-mojo setFormValue heritage — a saved record with the relation
|
|
104
|
+
* expanded) from which id + label extract via the valueField/labelField dot
|
|
105
|
+
* paths. 0 and '0' (web-mojo's no-selection sentinel) normalize to null.
|
|
106
|
+
*/
|
|
107
|
+
export type CollectionSelectValue = string | number | Record<string, unknown> | null;
|
|
108
|
+
|
|
109
|
+
export interface CollectionSelectProps<T extends { id: number | string }> {
|
|
110
|
+
/** A defineModel definition — supplies the endpoint (and the row type). */
|
|
111
|
+
model?: ModelDef<T>;
|
|
112
|
+
/** Bare endpoint, for lists without a model definition. */
|
|
113
|
+
endpoint?: string;
|
|
114
|
+
/** Selected id — CONTROLLED. Accepts id | null | row-object (see CollectionSelectValue). */
|
|
115
|
+
value: CollectionSelectValue;
|
|
116
|
+
/**
|
|
117
|
+
* Fires on COMMIT only: picking a row → (id, row); clearing (✕, or typing
|
|
118
|
+
* away from the committed label) → (null). Never per keystroke; never '0'.
|
|
119
|
+
*/
|
|
120
|
+
onChange: (id: string | number | null, row?: T) => void;
|
|
121
|
+
/** Row field shown as the label; dot notation reaches nested fields. Default 'name'. */
|
|
122
|
+
labelField?: string;
|
|
123
|
+
/** Row field used as the id; dot notation reaches nested fields. Default 'id'. */
|
|
124
|
+
valueField?: string;
|
|
125
|
+
/** Rows fetched per search — the `size` wire param. Default 10. */
|
|
126
|
+
maxItems?: number;
|
|
127
|
+
placeholder?: string;
|
|
128
|
+
/** Debounce from keystroke to the `search` wire param. Default 400ms. */
|
|
129
|
+
debounceMs?: number;
|
|
130
|
+
/**
|
|
131
|
+
* Fetch the initial (unsearched) page when the dropdown opens, so options
|
|
132
|
+
* exist before typing. Default true; false = "Start typing to search…".
|
|
133
|
+
*/
|
|
134
|
+
emptyFetch?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* Extra wire params merged into every fetch — a dict, or a callback
|
|
137
|
+
* re-evaluated per render (each fetch uses its freshest result; a changed
|
|
138
|
+
* result is a new query key and refetches). Overrides the built-in
|
|
139
|
+
* `size`; `group` (requiresActiveGroup) and `search` win over it.
|
|
140
|
+
*/
|
|
141
|
+
defaultParams?: Params | (() => Params | null | undefined);
|
|
142
|
+
/**
|
|
143
|
+
* Fold the active group in as the `group` wire param. Without an active
|
|
144
|
+
* group the fetch is HELD (never an unscoped list where a scoped one was
|
|
145
|
+
* demanded — the sibling's decision) and the dropdown says so.
|
|
146
|
+
*/
|
|
147
|
+
requiresActiveGroup?: boolean;
|
|
148
|
+
label?: string;
|
|
149
|
+
required?: boolean;
|
|
150
|
+
help?: string;
|
|
151
|
+
/** Error text below the field (replaces help); the input gets invalid styling. */
|
|
152
|
+
error?: string;
|
|
153
|
+
disabled?: boolean;
|
|
154
|
+
/** Display-only: the committed label shows, but no opening/typing/clearing. */
|
|
155
|
+
readOnly?: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function CollectionSelect<T extends { id: number | string }>({
|
|
159
|
+
model, endpoint, value, onChange,
|
|
160
|
+
labelField = 'name', valueField = 'id',
|
|
161
|
+
maxItems = 10, placeholder = 'Search…', debounceMs = 400,
|
|
162
|
+
emptyFetch = true, defaultParams, requiresActiveGroup = false,
|
|
163
|
+
label, required = false, help, error,
|
|
164
|
+
disabled = false, readOnly = false,
|
|
165
|
+
}: CollectionSelectProps<T>) {
|
|
166
|
+
const resolvedEndpoint = model?.endpoint ?? endpoint;
|
|
167
|
+
if (!resolvedEndpoint) throw new Error('CollectionSelect: pass `model` or `endpoint`');
|
|
168
|
+
|
|
169
|
+
const qc = useQueryClient();
|
|
170
|
+
const uid = useId();
|
|
171
|
+
const inputDomId = `colsel-input-${uid}`;
|
|
172
|
+
const listboxId = `colsel-list-${uid}`;
|
|
173
|
+
const optionId = (i: number) => `colsel-opt-${uid}-${i}`;
|
|
174
|
+
|
|
175
|
+
const wrapRef = useRef<HTMLDivElement>(null); // the Popover anchor: input + clear
|
|
176
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
177
|
+
const popRef = useRef<HTMLDivElement>(null); // dropdown content (blur containment)
|
|
178
|
+
/** Label learned at selection time — keeps the display stable even after
|
|
179
|
+
* the list cache that held the row is garbage-collected. */
|
|
180
|
+
const lastPickedRef = useRef<{ key: string; label: string } | null>(null);
|
|
181
|
+
const warnedIdsRef = useRef<Set<string>>(new Set());
|
|
182
|
+
|
|
183
|
+
// ── The controlled value, normalized ────────────────────────────────
|
|
184
|
+
// 0/'0'/'' → no selection (the web-mojo sentinel ends here); an object
|
|
185
|
+
// value carries its own id + label via the dot paths.
|
|
186
|
+
let selId: string | number | null = null;
|
|
187
|
+
let seedLabel: string | null = null;
|
|
188
|
+
if (value != null && value !== 0 && value !== '0' && value !== '') {
|
|
189
|
+
if (typeof value === 'object') {
|
|
190
|
+
const rawId = getPath(value, valueField);
|
|
191
|
+
if (rawId == null || rawId === 0 || rawId === '0') {
|
|
192
|
+
warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on the object value — treating it as no selection');
|
|
193
|
+
} else {
|
|
194
|
+
selId = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
|
|
195
|
+
const rawLabel = getPath(value, labelField);
|
|
196
|
+
seedLabel = rawLabel == null ? null : String(rawLabel);
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
selId = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const selKey = selId == null ? null : keyOf(selId);
|
|
203
|
+
|
|
204
|
+
// ── Draft: the ONE value pipeline ───────────────────────────────────
|
|
205
|
+
// null = not searching → the input displays the committed label; a string
|
|
206
|
+
// = the in-progress search text. Committing/reverting nulls the draft.
|
|
207
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
208
|
+
const [term, setTerm] = useState('');
|
|
209
|
+
const [open, setOpen] = useState(false);
|
|
210
|
+
const [focusIdx, setFocusIdx] = useState(-1);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const next = (draft ?? '').trim();
|
|
213
|
+
const t = setTimeout(() => setTerm(next), debounceMs);
|
|
214
|
+
return () => clearTimeout(t);
|
|
215
|
+
}, [draft, debounceMs]);
|
|
216
|
+
|
|
217
|
+
// ── Active group (requiresActiveGroup) ──────────────────────────────
|
|
218
|
+
// Read the context nullable-safely: consumers without a GroupProvider
|
|
219
|
+
// only misconfigure when they ASK for group scoping.
|
|
220
|
+
const groupCtx = useContext(GroupContext);
|
|
221
|
+
const activeGroupId = groupCtx?.group?.id ?? null;
|
|
222
|
+
const waitingForGroup = requiresActiveGroup && activeGroupId == null;
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (requiresActiveGroup && groupCtx === null) {
|
|
225
|
+
console.warn('CollectionSelect: requiresActiveGroup is set but there is no <GroupProvider> above — the fetch stays held');
|
|
226
|
+
}
|
|
227
|
+
}, [requiresActiveGroup, groupCtx]);
|
|
228
|
+
|
|
229
|
+
// ── The list fetch — SHARED cache keys ([endpoint, params]) ─────────
|
|
230
|
+
// defaultParams may be a callback: evaluated every render, so each fetch
|
|
231
|
+
// carries its freshest result (the source evaluated it once at setup).
|
|
232
|
+
const extra = typeof defaultParams === 'function' ? (defaultParams() ?? {}) : (defaultParams ?? {});
|
|
233
|
+
const wire: Params = {
|
|
234
|
+
size: maxItems,
|
|
235
|
+
...extra,
|
|
236
|
+
...(requiresActiveGroup && activeGroupId != null ? { group: activeGroupId } : {}),
|
|
237
|
+
...(term ? { search: term } : {}), // trimmed; dropped when empty
|
|
238
|
+
};
|
|
239
|
+
// Fetch only while the dropdown is open (the source fetched on focus);
|
|
240
|
+
// with emptyFetch off, additionally only once there is a search term.
|
|
241
|
+
const listEnabled = open && !waitingForGroup && (emptyFetch || term !== '');
|
|
242
|
+
const query = useModelList<T>(resolvedEndpoint, wire, { enabled: listEnabled });
|
|
243
|
+
// A held query (emptyFetch=false with no term, or no active group) must
|
|
244
|
+
// show its empty state — keepPreviousData would otherwise keep serving
|
|
245
|
+
// the previous key's rows through the placeholder.
|
|
246
|
+
const rows = listEnabled ? query.data?.rows : undefined;
|
|
247
|
+
|
|
248
|
+
const items = useMemo<Item<T>[]>(() => {
|
|
249
|
+
const out: Item<T>[] = [];
|
|
250
|
+
for (const row of rows ?? []) {
|
|
251
|
+
const rawId = getPath(row, valueField);
|
|
252
|
+
if (rawId == null) {
|
|
253
|
+
warnFieldOnce(resolvedEndpoint, 'valueField', valueField, 'is missing on a row — row dropped');
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const id: string | number = typeof rawId === 'number' || typeof rawId === 'string' ? rawId : String(rawId);
|
|
257
|
+
const rawLabel = getPath(row, labelField);
|
|
258
|
+
if (rawLabel == null) {
|
|
259
|
+
warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on a row — showing the id');
|
|
260
|
+
}
|
|
261
|
+
out.push({ key: keyOf(id), id, label: rawLabel == null ? String(id) : String(rawLabel), row });
|
|
262
|
+
}
|
|
263
|
+
return out;
|
|
264
|
+
}, [rows, valueField, labelField, resolvedEndpoint]);
|
|
265
|
+
|
|
266
|
+
// A new row set invalidates the walk position (source parity: reset on
|
|
267
|
+
// input; stronger here — stale indices into replaced data are meaningless).
|
|
268
|
+
useEffect(() => { setFocusIdx(-1); }, [rows]);
|
|
269
|
+
|
|
270
|
+
// ── id → label hydration ────────────────────────────────────────────
|
|
271
|
+
// Resolution order: object-value label → label learned at pick time →
|
|
272
|
+
// any cached LIST row → fetch the one record through the generic
|
|
273
|
+
// [endpoint,'one',id] key (shared with DetailView/useOne — one cache).
|
|
274
|
+
const picked = lastPickedRef.current;
|
|
275
|
+
const pickedLabel = selKey != null && picked?.key === selKey ? picked.label : null;
|
|
276
|
+
let scanLabel: string | null = null;
|
|
277
|
+
if (selKey != null && seedLabel == null && pickedLabel == null) {
|
|
278
|
+
const row = findRowInListCaches(qc, resolvedEndpoint, selKey, valueField);
|
|
279
|
+
if (row != null) {
|
|
280
|
+
const raw = getPath(row, labelField);
|
|
281
|
+
// A row without the label field stays unresolved — the one-record
|
|
282
|
+
// graph can carry fields the list graph omits.
|
|
283
|
+
if (raw != null) scanLabel = String(raw);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const needFetch = selKey != null && seedLabel == null && pickedLabel == null && scanLabel == null;
|
|
287
|
+
const oneQuery = useModel<T>(resolvedEndpoint, needFetch && selId != null ? coerceFetchId(selId) : null);
|
|
288
|
+
let oneLabel: string | null = null;
|
|
289
|
+
if (needFetch && oneQuery.data != null) {
|
|
290
|
+
const raw = getPath(oneQuery.data, labelField);
|
|
291
|
+
if (raw == null) {
|
|
292
|
+
warnFieldOnce(resolvedEndpoint, 'labelField', labelField, 'is missing on the fetched record — showing the id');
|
|
293
|
+
} else {
|
|
294
|
+
oneLabel = String(raw);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
useEffect(() => {
|
|
298
|
+
if (!oneQuery.isError || selKey == null) return;
|
|
299
|
+
if (warnedIdsRef.current.has(selKey)) return;
|
|
300
|
+
warnedIdsRef.current.add(selKey);
|
|
301
|
+
console.warn(`CollectionSelect: could not hydrate ${resolvedEndpoint} id ${selKey} — displaying "#${selKey}"`, oneQuery.error);
|
|
302
|
+
}, [oneQuery.isError, oneQuery.error, selKey, resolvedEndpoint]);
|
|
303
|
+
|
|
304
|
+
const selLabel = seedLabel ?? pickedLabel ?? scanLabel ?? oneLabel;
|
|
305
|
+
// Interim "#<id>" while the label resolves (or when it never can).
|
|
306
|
+
const committedText = selKey != null ? (selLabel ?? `#${selKey}`) : '';
|
|
307
|
+
const displayText = draft ?? committedText;
|
|
308
|
+
|
|
309
|
+
// ── Commit / revert (the only paths that touch the pipeline) ────────
|
|
310
|
+
const canOpen = !disabled && !readOnly;
|
|
311
|
+
const openList = () => { if (canOpen) setOpen(true); };
|
|
312
|
+
/** Close + drop the draft — the display reverts to the committed label. */
|
|
313
|
+
const closeAndRevert = () => {
|
|
314
|
+
setOpen(false);
|
|
315
|
+
setDraft(null);
|
|
316
|
+
setTerm('');
|
|
317
|
+
setFocusIdx(-1);
|
|
318
|
+
};
|
|
319
|
+
const selectItem = (item: Item<T>) => {
|
|
320
|
+
lastPickedRef.current = { key: item.key, label: item.label };
|
|
321
|
+
closeAndRevert();
|
|
322
|
+
if (item.key !== selKey) onChange(item.id, item.row);
|
|
323
|
+
};
|
|
324
|
+
const clearSelection = () => {
|
|
325
|
+
lastPickedRef.current = null;
|
|
326
|
+
setDraft(null);
|
|
327
|
+
setTerm('');
|
|
328
|
+
setFocusIdx(-1);
|
|
329
|
+
// Clear refocuses AND reopens on the base page — the source's
|
|
330
|
+
// clear-then-refocus behavior, made deterministic (the ✕ renders
|
|
331
|
+
// only while canOpen, so opening here is always legal).
|
|
332
|
+
setOpen(true);
|
|
333
|
+
if (selKey != null) onChange(null);
|
|
334
|
+
inputRef.current?.focus();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
338
|
+
const text = e.target.value;
|
|
339
|
+
setDraft(text);
|
|
340
|
+
setFocusIdx(-1);
|
|
341
|
+
if (canOpen) setOpen(true);
|
|
342
|
+
// Typing that diverges from the committed label IS the clear commit
|
|
343
|
+
// (source semantics) — fired once; the parent's null comes back as
|
|
344
|
+
// selKey null, so further keystrokes are draft-only.
|
|
345
|
+
if (selKey != null && text !== committedText) onChange(null);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const onBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
|
349
|
+
const next = e.relatedTarget as Node | null;
|
|
350
|
+
if (next && (wrapRef.current?.contains(next) || popRef.current?.contains(next))) return;
|
|
351
|
+
closeAndRevert(); // Tab-away settles like outside click
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// While open, Escape is owned by the Popover (capture phase; first Escape
|
|
355
|
+
// closes only the dropdown even inside a modal) → onClose('escape') →
|
|
356
|
+
// closeAndRevert. Keyboard here covers the walk + commit.
|
|
357
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
358
|
+
switch (e.key) {
|
|
359
|
+
case 'ArrowDown':
|
|
360
|
+
if (!canOpen) break;
|
|
361
|
+
e.preventDefault();
|
|
362
|
+
if (!open) setOpen(true);
|
|
363
|
+
else setFocusIdx((i) => Math.min(i + 1, items.length - 1)); // clamp, no wrap (source)
|
|
364
|
+
break;
|
|
365
|
+
case 'ArrowUp':
|
|
366
|
+
if (!open) break;
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
setFocusIdx((i) => Math.max(i - 1, 0));
|
|
369
|
+
break;
|
|
370
|
+
case 'Enter': {
|
|
371
|
+
if (!open) break;
|
|
372
|
+
e.preventDefault(); // never submit an enclosing form while picking
|
|
373
|
+
const it = focusIdx >= 0 ? items[focusIdx] : undefined;
|
|
374
|
+
if (it) selectItem(it);
|
|
375
|
+
break;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
// Keep the keyboard-focused option in view.
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
if (focusIdx < 0) return;
|
|
383
|
+
document.getElementById(`colsel-opt-${uid}-${focusIdx}`)?.scrollIntoView({ block: 'nearest' });
|
|
384
|
+
}, [focusIdx, uid]);
|
|
385
|
+
|
|
386
|
+
// The dropdown matches the field's width (source: w-100) — measured, since
|
|
387
|
+
// the Popover shell is width:max-content in the top layer.
|
|
388
|
+
const [menuWidth, setMenuWidth] = useState<number | undefined>(undefined);
|
|
389
|
+
useLayoutEffect(() => {
|
|
390
|
+
if (!open) return;
|
|
391
|
+
const el = wrapRef.current;
|
|
392
|
+
if (!el) return;
|
|
393
|
+
const sync = () => setMenuWidth(el.offsetWidth);
|
|
394
|
+
sync();
|
|
395
|
+
const ro = new ResizeObserver(sync);
|
|
396
|
+
ro.observe(el);
|
|
397
|
+
return () => ro.disconnect();
|
|
398
|
+
}, [open]);
|
|
399
|
+
|
|
400
|
+
// Spinner whenever the CURRENT key's rows aren't on screen yet (cold load
|
|
401
|
+
// or a search/params change still holding the previous key's data).
|
|
402
|
+
const pendingView = listEnabled && (query.isPending || query.isPlaceholderData);
|
|
403
|
+
const showClear = selKey != null && !disabled && !readOnly;
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<div className={`collection-select${disabled ? ' collection-select-disabled' : ''}`}>
|
|
407
|
+
{label && (
|
|
408
|
+
<label className="field-label" htmlFor={inputDomId}>
|
|
409
|
+
{label}
|
|
410
|
+
{required && <em> *</em>}
|
|
411
|
+
</label>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
<div ref={wrapRef} className={`collection-select-field${showClear ? ' has-clear' : ''}`}>
|
|
415
|
+
<input
|
|
416
|
+
ref={inputRef}
|
|
417
|
+
id={inputDomId}
|
|
418
|
+
type="text"
|
|
419
|
+
className={`input collection-select-input${error ? ' input-invalid' : ''}`}
|
|
420
|
+
role="combobox"
|
|
421
|
+
aria-expanded={open}
|
|
422
|
+
aria-autocomplete="list"
|
|
423
|
+
aria-controls={listboxId}
|
|
424
|
+
aria-activedescendant={open && focusIdx >= 0 ? optionId(focusIdx) : undefined}
|
|
425
|
+
aria-invalid={error ? true : undefined}
|
|
426
|
+
autoComplete="off"
|
|
427
|
+
placeholder={placeholder}
|
|
428
|
+
value={displayText}
|
|
429
|
+
disabled={disabled}
|
|
430
|
+
readOnly={readOnly}
|
|
431
|
+
required={required}
|
|
432
|
+
onFocus={openList}
|
|
433
|
+
onClick={openList}
|
|
434
|
+
onBlur={onBlur}
|
|
435
|
+
onChange={onInput}
|
|
436
|
+
onKeyDown={onKeyDown}
|
|
437
|
+
/>
|
|
438
|
+
{showClear && (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
className="collection-select-clear"
|
|
442
|
+
aria-label="Clear selection"
|
|
443
|
+
title="Clear selection"
|
|
444
|
+
onMouseDown={(e) => e.preventDefault() /* keep input focus */}
|
|
445
|
+
onClick={clearSelection}
|
|
446
|
+
>
|
|
447
|
+
<i className="bi bi-x-circle" aria-hidden="true" />
|
|
448
|
+
</button>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<Popover anchorRef={wrapRef} open={open} onClose={closeAndRevert} role="presentation">
|
|
453
|
+
<div
|
|
454
|
+
ref={popRef}
|
|
455
|
+
id={listboxId}
|
|
456
|
+
role="listbox"
|
|
457
|
+
aria-label={label ?? 'Options'}
|
|
458
|
+
className="collection-select-pop"
|
|
459
|
+
style={{ width: menuWidth }}
|
|
460
|
+
>
|
|
461
|
+
{waitingForGroup ? (
|
|
462
|
+
<div className="collection-select-state">
|
|
463
|
+
<i className="bi bi-people" aria-hidden="true" />
|
|
464
|
+
Select an active group first
|
|
465
|
+
</div>
|
|
466
|
+
) : query.isError ? (
|
|
467
|
+
// Error beats spinner: a failed fetch must be unmissable,
|
|
468
|
+
// never masked by placeholder-data edge states.
|
|
469
|
+
<div className="collection-select-state collection-select-state-error" role="alert">
|
|
470
|
+
<i className="bi bi-exclamation-triangle" aria-hidden="true" />
|
|
471
|
+
<span>{query.error instanceof Error ? query.error.message : 'Request failed'}</span>
|
|
472
|
+
<button type="button" className="btn btn-compact" onMouseDown={(e) => e.preventDefault()} onClick={() => void query.refetch()}>
|
|
473
|
+
Retry
|
|
474
|
+
</button>
|
|
475
|
+
</div>
|
|
476
|
+
) : pendingView ? (
|
|
477
|
+
<div className="collection-select-state" aria-live="polite">
|
|
478
|
+
<i className="bi bi-arrow-repeat spin" aria-hidden="true" />
|
|
479
|
+
Loading…
|
|
480
|
+
</div>
|
|
481
|
+
) : items.length === 0 ? (
|
|
482
|
+
<div className="collection-select-state">
|
|
483
|
+
<i className={`bi ${term ? 'bi-inbox' : 'bi-search'}`} aria-hidden="true" />
|
|
484
|
+
{term ? 'No results found' : 'Start typing to search…'}
|
|
485
|
+
</div>
|
|
486
|
+
) : (
|
|
487
|
+
items.map((item, i) => {
|
|
488
|
+
const isSelected = item.key === selKey;
|
|
489
|
+
return (
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
key={item.key}
|
|
493
|
+
id={optionId(i)}
|
|
494
|
+
role="option"
|
|
495
|
+
aria-selected={isSelected}
|
|
496
|
+
tabIndex={-1}
|
|
497
|
+
className={`collection-select-option${i === focusIdx ? ' is-focused' : ''}${isSelected ? ' is-selected' : ''}`}
|
|
498
|
+
onMouseDown={(e) => e.preventDefault() /* option click must not blur the input */}
|
|
499
|
+
onClick={() => selectItem(item)}
|
|
500
|
+
>
|
|
501
|
+
<span className="collection-select-option-label">{item.label}</span>
|
|
502
|
+
{isSelected && <i className="bi bi-check collection-select-check" aria-hidden="true" />}
|
|
503
|
+
</button>
|
|
504
|
+
);
|
|
505
|
+
})
|
|
506
|
+
)}
|
|
507
|
+
</div>
|
|
508
|
+
</Popover>
|
|
509
|
+
|
|
510
|
+
{error ? (
|
|
511
|
+
<div className="field-error">{error}</div>
|
|
512
|
+
) : help ? (
|
|
513
|
+
<div className="field-help">{help}</div>
|
|
514
|
+
) : null}
|
|
515
|
+
</div>
|
|
516
|
+
);
|
|
517
|
+
}
|