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,848 @@
|
|
|
1
|
+
// ModelTable — schema-driven server table at web-mojo TableView fidelity
|
|
2
|
+
// (ListView.js / TableView.js / TablePage.js port). No table library: sort,
|
|
3
|
+
// filter, search and paging are all wire params django-mojo processes, so
|
|
4
|
+
// there is no client-side row work for an engine to do. Table state IS the
|
|
5
|
+
// params store; rendering is a map.
|
|
6
|
+
//
|
|
7
|
+
// B2 features, all opt-in (absent props → prior rendering, byte-for-byte):
|
|
8
|
+
// · columnChooser — show/hide columns; `hideable: false` locks a column
|
|
9
|
+
// · persistState — per-table saved view (sort/size/search/filters/hidden)
|
|
10
|
+
// in localStorage; precedence URL > saved > defaults, saved size wins
|
|
11
|
+
// · selectable + batchActions — checkbox selection, select-all with
|
|
12
|
+
// indeterminate, batch bar; runs Promise.allSettled with partial-result
|
|
13
|
+
// toasts (TablePage.batchAction port), then clears + refetches
|
|
14
|
+
// · autoRefresh — interval refetch with the smart-skip predicate (hidden /
|
|
15
|
+
// blurred tab, active selection, any open dialog) + focus resume
|
|
16
|
+
// · rowExpand — chevron + full-width detail row; single-open by default
|
|
17
|
+
// · groupBy — synthetic header rows interleaved on key change; falsy key
|
|
18
|
+
// = ungrouped tail (see ui/grouping.ts helpers)
|
|
19
|
+
// · exportFormats — server-side export via download_format (csv/json)
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
21
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
22
|
+
import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState } from '../client/params';
|
|
23
|
+
import { useModelList } from '../client/hooks';
|
|
24
|
+
import { mojoDownload } from '../client/client';
|
|
25
|
+
import type { ModelDef } from '../client/model';
|
|
26
|
+
import type { Params } from '../client/types';
|
|
27
|
+
import { FilterBar, FilterPills, type FilterDef } from './FilterBar';
|
|
28
|
+
import { busyWhile } from './loading';
|
|
29
|
+
import { modal } from './modal';
|
|
30
|
+
import { toast } from './toast';
|
|
31
|
+
|
|
32
|
+
export interface Column<T> {
|
|
33
|
+
key: string; // server field; drives the sort param
|
|
34
|
+
label: string;
|
|
35
|
+
sortable?: boolean;
|
|
36
|
+
align?: 'start' | 'center' | 'end';
|
|
37
|
+
/** false → locked in the column chooser (always shown). */
|
|
38
|
+
hideable?: boolean;
|
|
39
|
+
render?: (row: T) => ReactNode; // one cell prop — no formatter/format/template aliases
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Preset {
|
|
43
|
+
key: string;
|
|
44
|
+
label: string;
|
|
45
|
+
params: Record<string, string>; // empty {} = the "All" chip
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BatchActionBase<T> {
|
|
49
|
+
key: string;
|
|
50
|
+
label: string;
|
|
51
|
+
icon?: string;
|
|
52
|
+
danger?: boolean;
|
|
53
|
+
/** Row-state eligibility. Ineligible selected rows are never submitted. */
|
|
54
|
+
eligible?: (row: T) => boolean;
|
|
55
|
+
/** Confirm copy; false skips the prompt. Default: "<label> N item(s)?" */
|
|
56
|
+
confirm?: string | false;
|
|
57
|
+
/**
|
|
58
|
+
* Optional once-per-batch step (collect a reason, pick a value…) run
|
|
59
|
+
* AFTER the confirm. Resolve null to cancel the batch; the resolved
|
|
60
|
+
* value is passed to every run() call.
|
|
61
|
+
*/
|
|
62
|
+
prepare?: (rows: T[]) => Promise<unknown | null>;
|
|
63
|
+
}
|
|
64
|
+
export type BatchAction<T> = BatchActionBase<T> & (
|
|
65
|
+
| { run: (row: T, prepared: unknown) => Promise<unknown>; runBatch?: never }
|
|
66
|
+
| { run?: never; runBatch: (rows: T[], prepared: unknown) => Promise<unknown> }
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
export type RowTone = 'danger' | 'warning';
|
|
70
|
+
|
|
71
|
+
const GROUP_HEADER_STYLES = ['banner', 'mark', 'band', 'rule'] as const;
|
|
72
|
+
export type GroupHeaderStyle = (typeof GROUP_HEADER_STYLES)[number];
|
|
73
|
+
|
|
74
|
+
// Cell widths echo the real columns (web-mojo _buildSkeletonHtml's cycled
|
|
75
|
+
// w-* classes) so the shimmer silhouette lines up with the eventual rows.
|
|
76
|
+
const SKEL_WIDTHS = ['skel-w-90', 'skel-w-75', 'skel-w-60', 'skel-w-40', 'skel-w-25'];
|
|
77
|
+
|
|
78
|
+
/** Page-number window: {1, total, current±1} with … for the gaps. */
|
|
79
|
+
function pageWindow(current: number, total: number): (number | '…')[] {
|
|
80
|
+
const keep = new Set<number>([1, total]);
|
|
81
|
+
for (let i = current - 1; i <= current + 1; i++) if (i >= 1 && i <= total) keep.add(i);
|
|
82
|
+
const sorted = [...keep].sort((a, b) => a - b);
|
|
83
|
+
const out: (number | '…')[] = [];
|
|
84
|
+
let last = 0;
|
|
85
|
+
for (const p of sorted) {
|
|
86
|
+
if (last && p - last > 1) out.push('…');
|
|
87
|
+
out.push(p);
|
|
88
|
+
last = p;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Expanding search (maestro's `.fsearch` port): a 30px icon at rest, a real
|
|
95
|
+
* input on focus, and PINNED open while it carries text — search is the one
|
|
96
|
+
* filter whose state has no pill, so the open input is its indicator. `/`
|
|
97
|
+
* focuses it from anywhere (maestro keyboard.js), hinted on hover.
|
|
98
|
+
*
|
|
99
|
+
* Search is a SERVER query like every other table param — keystrokes buffer
|
|
100
|
+
* locally for 300ms (web-mojo's debounce) and then hit the wire once. Enter
|
|
101
|
+
* commits immediately.
|
|
102
|
+
*/
|
|
103
|
+
export function ExpandingSearch({ value, onChange, placeholder }: {
|
|
104
|
+
value: string;
|
|
105
|
+
onChange: (term: string) => void;
|
|
106
|
+
placeholder?: string;
|
|
107
|
+
}) {
|
|
108
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
109
|
+
const [draft, setDraft] = useState(value);
|
|
110
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
|
+
// External resets (Clear all, preset swap, back/forward) win over a
|
|
112
|
+
// stale draft — but never while the user is mid-typing a pending edit.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (timerRef.current == null) setDraft(value);
|
|
115
|
+
}, [value]);
|
|
116
|
+
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
|
117
|
+
|
|
118
|
+
const commit = (term: string) => {
|
|
119
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
120
|
+
timerRef.current = null;
|
|
121
|
+
onChange(term);
|
|
122
|
+
};
|
|
123
|
+
const edit = (term: string) => {
|
|
124
|
+
setDraft(term);
|
|
125
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
126
|
+
timerRef.current = setTimeout(() => commit(term), 300);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const onKey = (e: KeyboardEvent) => {
|
|
131
|
+
if (e.key !== '/') return;
|
|
132
|
+
const t = e.target as HTMLElement | null;
|
|
133
|
+
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
|
134
|
+
if (document.querySelector('dialog[open]')) return;
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
inputRef.current?.focus();
|
|
137
|
+
};
|
|
138
|
+
document.addEventListener('keydown', onKey);
|
|
139
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
140
|
+
}, []);
|
|
141
|
+
return (
|
|
142
|
+
<div className={`fsearch${draft ? ' holding' : ''}`}>
|
|
143
|
+
<i className="bi bi-search icon" />
|
|
144
|
+
<input
|
|
145
|
+
ref={inputRef}
|
|
146
|
+
className="text"
|
|
147
|
+
type="search"
|
|
148
|
+
value={draft}
|
|
149
|
+
placeholder={placeholder}
|
|
150
|
+
onChange={(e) => edit(e.target.value)}
|
|
151
|
+
onKeyDown={(e) => {
|
|
152
|
+
if (e.key === 'Escape') (e.target as HTMLInputElement).blur();
|
|
153
|
+
if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
|
|
154
|
+
}}
|
|
155
|
+
aria-label="Search"
|
|
156
|
+
/>
|
|
157
|
+
<kbd>/</kbd>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Shared dropdown shell (FilterBar's outside-click + Escape pattern). */
|
|
163
|
+
function MenuDropdown({ button, children, align = 'end' }: {
|
|
164
|
+
button: (open: boolean, toggle: () => void) => ReactNode;
|
|
165
|
+
children: ReactNode;
|
|
166
|
+
align?: 'start' | 'end';
|
|
167
|
+
}) {
|
|
168
|
+
const [open, setOpen] = useState(false);
|
|
169
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!open) return;
|
|
172
|
+
const onDown = (e: MouseEvent) => {
|
|
173
|
+
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
|
|
174
|
+
};
|
|
175
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
|
176
|
+
document.addEventListener('mousedown', onDown);
|
|
177
|
+
document.addEventListener('keydown', onKey);
|
|
178
|
+
return () => {
|
|
179
|
+
document.removeEventListener('mousedown', onDown);
|
|
180
|
+
document.removeEventListener('keydown', onKey);
|
|
181
|
+
};
|
|
182
|
+
}, [open]);
|
|
183
|
+
return (
|
|
184
|
+
<div className="filter-menu-wrap" ref={wrapRef}>
|
|
185
|
+
{button(open, () => setOpen((v) => !v))}
|
|
186
|
+
{open && <div className={`filter-menu${align === 'start' ? ' filter-menu-start' : ''}`} role="menu">{children}</div>}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Row identity as django-mojo serves it. Most models use an integer pk, but
|
|
193
|
+
* some (jobs.Job, jobs.ScheduledTask) declare a 32-char uuid-hex CharField
|
|
194
|
+
* primary key — so selection, expansion and React keys are keyed on the union
|
|
195
|
+
* rather than on `number`.
|
|
196
|
+
*/
|
|
197
|
+
export type RowId = number | string;
|
|
198
|
+
|
|
199
|
+
export function ModelTable<T extends { id: RowId }>({
|
|
200
|
+
model, endpoint, columns, filters = [], presets = [], eyebrow, title,
|
|
201
|
+
searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd, defaultSort, defaultParams,
|
|
202
|
+
selectable = false, batchActions = [],
|
|
203
|
+
columnChooser = false, persistState = false, persistKey,
|
|
204
|
+
autoRefresh = 0,
|
|
205
|
+
rowExpand, rowExpandMultiple = false,
|
|
206
|
+
groupBy, groupHeaderLabel, groupHeaderStyle = 'banner',
|
|
207
|
+
exportFormats = [], exporter, rowTone,
|
|
208
|
+
}: {
|
|
209
|
+
/** A defineModel definition — supplies the endpoint (and, later, schema). */
|
|
210
|
+
model?: ModelDef<T>;
|
|
211
|
+
/** Bare endpoint, for tables without a model definition. */
|
|
212
|
+
endpoint?: string;
|
|
213
|
+
columns: Column<T>[];
|
|
214
|
+
filters?: FilterDef[];
|
|
215
|
+
presets?: Preset[];
|
|
216
|
+
eyebrow?: string;
|
|
217
|
+
title: string;
|
|
218
|
+
/** Whether the backend model exposes a real search contract. */
|
|
219
|
+
searchable?: boolean;
|
|
220
|
+
searchPlaceholder?: string;
|
|
221
|
+
onRowClick?: (row: T) => void;
|
|
222
|
+
addLabel?: string;
|
|
223
|
+
onAdd?: () => void;
|
|
224
|
+
/** Initial sort when the URL doesn't carry one (default '-created'). */
|
|
225
|
+
defaultSort?: string;
|
|
226
|
+
/**
|
|
227
|
+
* Initial URL-backed query state. URL keys win; persisted state wins over
|
|
228
|
+
* these defaults. `defaultSort` remains as the backwards-compatible
|
|
229
|
+
* shorthand and wins over `defaultParams.sort` when both are supplied.
|
|
230
|
+
*/
|
|
231
|
+
defaultParams?: Params;
|
|
232
|
+
/** Checkbox selection — independent of batchActions by construction. */
|
|
233
|
+
selectable?: boolean;
|
|
234
|
+
batchActions?: BatchAction<T>[];
|
|
235
|
+
columnChooser?: boolean;
|
|
236
|
+
persistState?: boolean;
|
|
237
|
+
/** Storage identity; default derives from route + endpoint. */
|
|
238
|
+
persistKey?: string;
|
|
239
|
+
/** Interval refetch in SECONDS (5s floor). 0/absent = off. */
|
|
240
|
+
autoRefresh?: number;
|
|
241
|
+
/** Inline full-width detail row; chevron column appears when set. */
|
|
242
|
+
rowExpand?: (row: T) => ReactNode;
|
|
243
|
+
rowExpandMultiple?: boolean;
|
|
244
|
+
/** Row → group key; falsy = ungrouped tail. See ui/grouping.ts helpers. */
|
|
245
|
+
groupBy?: (row: T) => string | null;
|
|
246
|
+
groupHeaderLabel?: (key: string) => ReactNode;
|
|
247
|
+
groupHeaderStyle?: GroupHeaderStyle;
|
|
248
|
+
/** Server-side export formats offered in the toolbar. */
|
|
249
|
+
exportFormats?: ('csv' | 'json')[];
|
|
250
|
+
/** Client-side export override. Receives the already-normalized params. */
|
|
251
|
+
exporter?: (format: 'csv' | 'json', normalizedParams: Params) => Promise<void>;
|
|
252
|
+
/** Semantic queue emphasis without coupling callers to table CSS. */
|
|
253
|
+
rowTone?: (row: T) => RowTone | null;
|
|
254
|
+
}) {
|
|
255
|
+
const resolvedEndpoint = model?.endpoint ?? endpoint;
|
|
256
|
+
if (!resolvedEndpoint) throw new Error('ModelTable: pass `model` or `endpoint`');
|
|
257
|
+
if (!GROUP_HEADER_STYLES.includes(groupHeaderStyle)) {
|
|
258
|
+
console.warn(`ModelTable: unknown groupHeaderStyle "${groupHeaderStyle}" — falling back to 'banner'`);
|
|
259
|
+
groupHeaderStyle = 'banner';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const qc = useQueryClient();
|
|
263
|
+
const paramDefaults = useMemo(() => ({
|
|
264
|
+
...(defaultParams?.search != null ? { search: String(defaultParams.search) } : {}),
|
|
265
|
+
...(defaultParams?.size != null ? { size: Number(defaultParams.size) } : {}),
|
|
266
|
+
...(defaultParams?.page != null ? { page: Number(defaultParams.page) } : {}),
|
|
267
|
+
...(defaultParams?.sort != null ? { sort: String(defaultParams.sort) } : {}),
|
|
268
|
+
...(defaultSort !== undefined ? { sort: defaultSort } : {}),
|
|
269
|
+
}), [defaultParams, defaultSort]);
|
|
270
|
+
const defaultFilters = useMemo<Record<string, string>>(() => {
|
|
271
|
+
const out: Record<string, string> = {};
|
|
272
|
+
for (const [key, value] of Object.entries(defaultParams ?? {})) {
|
|
273
|
+
if (key === 'search' || key === 'sort' || key === 'page' || key === 'size') continue;
|
|
274
|
+
if (value != null && value !== '') out[key] = String(value);
|
|
275
|
+
}
|
|
276
|
+
return out;
|
|
277
|
+
}, [defaultParams]);
|
|
278
|
+
const p = useTableParams(paramDefaults, defaultFilters);
|
|
279
|
+
|
|
280
|
+
// ── View persistence (WM-035): restore once BEFORE the first fetch ──
|
|
281
|
+
const storageKey = persistKey ?? `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}`;
|
|
282
|
+
const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set());
|
|
283
|
+
const [hydrated, setHydrated] = useState(!persistState);
|
|
284
|
+
const restoreRan = useRef(false);
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!persistState || restoreRan.current) return;
|
|
287
|
+
restoreRan.current = true;
|
|
288
|
+
const saved = readTableState(storageKey);
|
|
289
|
+
if (saved) {
|
|
290
|
+
if (Array.isArray(saved.hidden) && saved.hidden.length > 0) {
|
|
291
|
+
const lockedSafe = saved.hidden.filter((key) => columns.find((c) => c.key === key)?.hideable !== false);
|
|
292
|
+
setHiddenCols(new Set(lockedSafe));
|
|
293
|
+
}
|
|
294
|
+
p.applySaved(saved);
|
|
295
|
+
}
|
|
296
|
+
setHydrated(true);
|
|
297
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount restore
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
// A stale URL/persisted `search` must not become a hidden request when a
|
|
301
|
+
// table declares that its backend has no search contract. Strip it before
|
|
302
|
+
// model normalization so neither the normalizer, query key, nor wire sees
|
|
303
|
+
// the raw value.
|
|
304
|
+
const tableParams = searchable
|
|
305
|
+
? p.wire
|
|
306
|
+
: Object.fromEntries(Object.entries(p.wire).filter(([key]) => key !== 'search'));
|
|
307
|
+
const listParams = model?.normalizeListParams
|
|
308
|
+
? model.normalizeListParams(tableParams)
|
|
309
|
+
: tableParams;
|
|
310
|
+
const query = useModelList<T>(resolvedEndpoint, listParams, {
|
|
311
|
+
enabled: hydrated,
|
|
312
|
+
sanitizeRow: model?.sanitizeRow,
|
|
313
|
+
});
|
|
314
|
+
const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
|
|
315
|
+
const count = query.data?.count ?? 0;
|
|
316
|
+
const totalPages = Math.max(1, Math.ceil(count / p.size));
|
|
317
|
+
const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
|
|
318
|
+
const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
|
|
319
|
+
const isFiltered = p.activeFilters.length > 0 || (searchable && p.search !== '');
|
|
320
|
+
|
|
321
|
+
// Save the view on every params/visibility change (post-hydration only,
|
|
322
|
+
// so the restore itself never writes back a half-applied state).
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (!persistState || !hydrated) return;
|
|
325
|
+
const state: PersistedTableState = { v: 2 };
|
|
326
|
+
if (p.sort) state.sort = p.sort;
|
|
327
|
+
state.size = p.size;
|
|
328
|
+
if (searchable && p.search) state.search = p.search;
|
|
329
|
+
const rawFilters: Record<string, string> = {};
|
|
330
|
+
for (const [key, value] of Object.entries(p.wire)) {
|
|
331
|
+
if (key === 'start' || key === 'size' || key === 'sort' || key === 'search' || key === 'graph') continue;
|
|
332
|
+
if (value == null || value === '') continue;
|
|
333
|
+
rawFilters[key] = String(value);
|
|
334
|
+
}
|
|
335
|
+
if (Object.keys(rawFilters).length > 0) state.filters = rawFilters;
|
|
336
|
+
const hidden = [...hiddenCols];
|
|
337
|
+
if (hidden.length > 0) state.hidden = hidden;
|
|
338
|
+
writeTableState(storageKey, state);
|
|
339
|
+
}, [persistState, hydrated, p.wire, p.sort, p.size, p.search, hiddenCols, storageKey]);
|
|
340
|
+
|
|
341
|
+
// ── Column chooser ───────────────────────────────────────────────────
|
|
342
|
+
const visibleColumns = useMemo(
|
|
343
|
+
() => (columnChooser && hiddenCols.size > 0)
|
|
344
|
+
? columns.filter((c) => c.hideable === false || !hiddenCols.has(c.key))
|
|
345
|
+
: columns,
|
|
346
|
+
[columns, columnChooser, hiddenCols],
|
|
347
|
+
);
|
|
348
|
+
const toggleColumn = (key: string) => {
|
|
349
|
+
const col = columns.find((c) => c.key === key);
|
|
350
|
+
if (!col || col.hideable === false) return;
|
|
351
|
+
setHiddenCols((prev) => {
|
|
352
|
+
const next = new Set(prev);
|
|
353
|
+
if (next.has(key)) next.delete(key);
|
|
354
|
+
else next.add(key);
|
|
355
|
+
return next;
|
|
356
|
+
});
|
|
357
|
+
};
|
|
358
|
+
const resetColumns = () => {
|
|
359
|
+
setHiddenCols(new Set());
|
|
360
|
+
clearTableState(storageKey);
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
// ── Selection + batch (decoupled: checkboxes need no batchActions) ──
|
|
364
|
+
const [selected, setSelected] = useState<Set<RowId>>(() => new Set());
|
|
365
|
+
const wireSignature = JSON.stringify(p.wire);
|
|
366
|
+
const prevSignature = useRef(wireSignature);
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
// Page / sort / filter / size changes rebuild the row set — selection
|
|
369
|
+
// clears (web-mojo pages-mode behavior). Auto-refresh ticks don't
|
|
370
|
+
// change the wire, and they skip while a selection is active anyway.
|
|
371
|
+
if (prevSignature.current !== wireSignature) {
|
|
372
|
+
prevSignature.current = wireSignature;
|
|
373
|
+
setSelected((prev) => (prev.size > 0 ? new Set() : prev));
|
|
374
|
+
}
|
|
375
|
+
}, [wireSignature]);
|
|
376
|
+
|
|
377
|
+
const toggleSelected = (id: RowId) => {
|
|
378
|
+
setSelected((prev) => {
|
|
379
|
+
const next = new Set(prev);
|
|
380
|
+
if (next.has(id)) next.delete(id);
|
|
381
|
+
else next.add(id);
|
|
382
|
+
return next;
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
|
|
386
|
+
const someSelected = rows.some((r) => selected.has(r.id));
|
|
387
|
+
const toggleSelectAll = () => {
|
|
388
|
+
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
/** TablePage.batchAction port: allSettled + partial-result toasts. */
|
|
392
|
+
const runBatch = async (action: BatchAction<T>) => {
|
|
393
|
+
const targets = rows.filter((r) => selected.has(r.id) && (!action.eligible || action.eligible(r)));
|
|
394
|
+
if (targets.length === 0) return;
|
|
395
|
+
if (action.confirm !== false) {
|
|
396
|
+
const ok = await modal.confirm({
|
|
397
|
+
title: action.label,
|
|
398
|
+
message: action.confirm ?? `${action.label} ${targets.length} item(s)?`,
|
|
399
|
+
confirmText: action.label,
|
|
400
|
+
danger: action.danger,
|
|
401
|
+
});
|
|
402
|
+
if (!ok) return;
|
|
403
|
+
}
|
|
404
|
+
let prepared: unknown = null;
|
|
405
|
+
if (action.prepare) {
|
|
406
|
+
prepared = await action.prepare(targets);
|
|
407
|
+
if (prepared === null) return;
|
|
408
|
+
}
|
|
409
|
+
if (action.runBatch) {
|
|
410
|
+
try {
|
|
411
|
+
await action.runBatch(targets, prepared);
|
|
412
|
+
toast.success(`${action.label}: ${targets.length} item(s) updated`);
|
|
413
|
+
} catch (err) {
|
|
414
|
+
toast.error(err instanceof Error ? err.message : `${action.label} failed`);
|
|
415
|
+
}
|
|
416
|
+
setSelected(new Set());
|
|
417
|
+
void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const results = await Promise.allSettled(targets.map((row) => action.run!(row, prepared)));
|
|
421
|
+
const successes = results.filter((r) => r.status === 'fulfilled').length;
|
|
422
|
+
const failures = results.length - successes;
|
|
423
|
+
if (failures === 0) toast.success(`${action.label}: ${successes} item(s) updated`);
|
|
424
|
+
else if (successes === 0) toast.error(`${action.label} failed for all ${failures} item(s)`);
|
|
425
|
+
else toast.warning(`${action.label}: ${successes} succeeded, ${failures} failed`);
|
|
426
|
+
// Always clear + refetch on a non-cancel path so the user sees the
|
|
427
|
+
// partial state after failures.
|
|
428
|
+
setSelected(new Set());
|
|
429
|
+
void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// ── Row expand ───────────────────────────────────────────────────────
|
|
433
|
+
const expandEnabled = typeof rowExpand === 'function';
|
|
434
|
+
const [expanded, setExpanded] = useState<Set<RowId>>(() => new Set());
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
// Page change / refetch collapses rows that are no longer present.
|
|
437
|
+
if (!expandEnabled) return;
|
|
438
|
+
setExpanded((prev) => {
|
|
439
|
+
if (prev.size === 0) return prev;
|
|
440
|
+
const ids = new Set(rows.map((r) => r.id));
|
|
441
|
+
const next = new Set([...prev].filter((id) => ids.has(id)));
|
|
442
|
+
return next.size === prev.size ? prev : next;
|
|
443
|
+
});
|
|
444
|
+
}, [rows, expandEnabled]);
|
|
445
|
+
const toggleExpand = (id: RowId) => {
|
|
446
|
+
setExpanded((prev) => {
|
|
447
|
+
const next = new Set(rowExpandMultiple ? prev : []);
|
|
448
|
+
if (prev.has(id)) next.delete(id);
|
|
449
|
+
else next.add(id);
|
|
450
|
+
return next;
|
|
451
|
+
});
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// ── Auto-refresh (WM-034): interval + smart-skip predicate ──────────
|
|
455
|
+
// Interval ONLY — no focus/visibility listeners. Stacking a resume-tick
|
|
456
|
+
// on top of TanStack's own focus handling double-fetched every time the
|
|
457
|
+
// window regained focus (report: "multiple fetches per next page" with
|
|
458
|
+
// devtools open). Ticks simply skip while the tab is hidden/blurred and
|
|
459
|
+
// the next on-interval tick catches up.
|
|
460
|
+
const refetchRef = useRef(query.refetch);
|
|
461
|
+
refetchRef.current = query.refetch;
|
|
462
|
+
const selectedRef = useRef(selected);
|
|
463
|
+
selectedRef.current = selected;
|
|
464
|
+
const autoRefreshMs = autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (!autoRefreshMs) return;
|
|
467
|
+
const skip = () =>
|
|
468
|
+
document.hidden
|
|
469
|
+
|| !document.hasFocus()
|
|
470
|
+
// An open dialog is the portal's "user is mid-interaction" signal
|
|
471
|
+
// (web-mojo paused for cell edits + open menus).
|
|
472
|
+
|| document.querySelector('dialog[open]') != null
|
|
473
|
+
|| selectedRef.current.size > 0;
|
|
474
|
+
const interval = setInterval(() => {
|
|
475
|
+
if (skip()) return;
|
|
476
|
+
void refetchRef.current();
|
|
477
|
+
}, autoRefreshMs);
|
|
478
|
+
return () => clearInterval(interval);
|
|
479
|
+
}, [autoRefreshMs]);
|
|
480
|
+
|
|
481
|
+
// ── Export ───────────────────────────────────────────────────────────
|
|
482
|
+
// Export must use the SAME model-normalized params as the visible query.
|
|
483
|
+
// Besides keeping filters aligned, this closes a security boundary:
|
|
484
|
+
// models may force a safe graph or discard attacker-controlled graph
|
|
485
|
+
// params before sensitive fields can enter a JSON/CSV export.
|
|
486
|
+
//
|
|
487
|
+
// The one table action with NO feedback: the menu closes and the browser
|
|
488
|
+
// download appears whenever the server finishes building the whole
|
|
489
|
+
// filtered result set (seconds on a six-figure Logs table). busyWhile
|
|
490
|
+
// blocks for exactly that long — and the anti-flash delay means the
|
|
491
|
+
// instant mock export still shows nothing at all. See docs/loading.md.
|
|
492
|
+
const runExport = useCallback(async (format: 'csv' | 'json') => {
|
|
493
|
+
try {
|
|
494
|
+
await busyWhile(
|
|
495
|
+
`Preparing ${format.toUpperCase()} export…`,
|
|
496
|
+
() => exporter ? exporter(format, listParams) : mojoDownload(resolvedEndpoint, listParams, format),
|
|
497
|
+
);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
toast.error(err instanceof Error ? err.message : 'Export failed');
|
|
500
|
+
}
|
|
501
|
+
}, [resolvedEndpoint, listParams, exporter]);
|
|
502
|
+
|
|
503
|
+
const leadCols = (expandEnabled ? 1 : 0) + (selectable ? 1 : 0);
|
|
504
|
+
const totalCols = leadCols + visibleColumns.length;
|
|
505
|
+
|
|
506
|
+
const sortIcon = (col: Column<T>) => {
|
|
507
|
+
if (p.sort === col.key) return <i className="bi bi-arrow-up sort-icon" />;
|
|
508
|
+
if (p.sort === `-${col.key}`) return <i className="bi bi-arrow-down sort-icon" />;
|
|
509
|
+
return <i className="bi bi-arrow-down-up sort-icon sort-idle" />;
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
/** Interleave group headers on key change (falsy = ungrouped tail). */
|
|
513
|
+
const renderBody = () => {
|
|
514
|
+
const out: ReactNode[] = [];
|
|
515
|
+
let prevKey: string | null = null;
|
|
516
|
+
let prevKeySet = false;
|
|
517
|
+
for (const row of rows) {
|
|
518
|
+
if (groupBy) {
|
|
519
|
+
let key: string | null = null;
|
|
520
|
+
try {
|
|
521
|
+
key = groupBy(row);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
console.warn('ModelTable: groupBy threw — treating as ungrouped tail', err);
|
|
524
|
+
}
|
|
525
|
+
if (key && (!prevKeySet || key !== prevKey)) {
|
|
526
|
+
out.push(
|
|
527
|
+
<tr key={`grp:${key}:${row.id}`} className={`group-header-row group-header--${groupHeaderStyle}`}>
|
|
528
|
+
{/* Sticky span: the label stays inside the visible
|
|
529
|
+
scroll viewport however wide the table is. */}
|
|
530
|
+
<th colSpan={totalCols}><span className="group-header-label">{groupHeaderLabel ? groupHeaderLabel(key) : key}</span></th>
|
|
531
|
+
</tr>,
|
|
532
|
+
);
|
|
533
|
+
prevKey = key;
|
|
534
|
+
prevKeySet = true;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
const isExpanded = expandEnabled && expanded.has(row.id);
|
|
538
|
+
const tone = rowTone?.(row) ?? null;
|
|
539
|
+
out.push(
|
|
540
|
+
<tr
|
|
541
|
+
key={row.id}
|
|
542
|
+
className={[
|
|
543
|
+
onRowClick ? 'row-click' : '',
|
|
544
|
+
selected.has(row.id) ? 'row-selected' : '',
|
|
545
|
+
isExpanded ? 'row-expanded' : '',
|
|
546
|
+
tone ? `row-tone-${tone}` : '',
|
|
547
|
+
].filter(Boolean).join(' ') || undefined}
|
|
548
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
549
|
+
>
|
|
550
|
+
{expandEnabled && (
|
|
551
|
+
<td className="col-expand" onClick={(e) => e.stopPropagation()}>
|
|
552
|
+
<button
|
|
553
|
+
className="expand-toggle"
|
|
554
|
+
aria-expanded={isExpanded}
|
|
555
|
+
title={isExpanded ? 'Collapse' : 'Expand'}
|
|
556
|
+
onClick={() => toggleExpand(row.id)}
|
|
557
|
+
>
|
|
558
|
+
<i className="bi bi-chevron-right" />
|
|
559
|
+
</button>
|
|
560
|
+
</td>
|
|
561
|
+
)}
|
|
562
|
+
{selectable && (
|
|
563
|
+
<td className="col-select" onClick={(e) => e.stopPropagation()}>
|
|
564
|
+
<input
|
|
565
|
+
type="checkbox"
|
|
566
|
+
className="tbl-check"
|
|
567
|
+
checked={selected.has(row.id)}
|
|
568
|
+
onChange={() => toggleSelected(row.id)}
|
|
569
|
+
aria-label={`Select row ${row.id}`}
|
|
570
|
+
/>
|
|
571
|
+
</td>
|
|
572
|
+
)}
|
|
573
|
+
{visibleColumns.map((col) => (
|
|
574
|
+
<td key={col.key} className={col.align ? `text-${col.align}` : undefined}>
|
|
575
|
+
{col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
|
|
576
|
+
</td>
|
|
577
|
+
))}
|
|
578
|
+
</tr>,
|
|
579
|
+
);
|
|
580
|
+
if (isExpanded) {
|
|
581
|
+
out.push(
|
|
582
|
+
<tr key={`detail:${row.id}`} className="detail-row">
|
|
583
|
+
<td colSpan={totalCols}>
|
|
584
|
+
<div className="detail-panel">{rowExpand!(row)}</div>
|
|
585
|
+
</td>
|
|
586
|
+
</tr>,
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return out;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
return (
|
|
594
|
+
<div className="panel">
|
|
595
|
+
<div className="toolbar">
|
|
596
|
+
<div className="toolbar-heading">
|
|
597
|
+
{eyebrow && <div className="eyebrow">{eyebrow}</div>}
|
|
598
|
+
<h1 className="panel-title">{title}</h1>
|
|
599
|
+
</div>
|
|
600
|
+
<div className="toolbar-controls">
|
|
601
|
+
{searchable && <ExpandingSearch value={p.search} onChange={p.setSearch} placeholder={searchPlaceholder} />}
|
|
602
|
+
{/* Tool cluster: one joined icon group (web-mojo's compact
|
|
603
|
+
toolbar). Icons + tooltips only; text labels appear
|
|
604
|
+
only on very wide screens via .btn-label. */}
|
|
605
|
+
<div className="tool-group">
|
|
606
|
+
{filters.length > 0 && <FilterBar defs={filters} params={p} />}
|
|
607
|
+
{columnChooser && (
|
|
608
|
+
<MenuDropdown button={(open, toggle) => (
|
|
609
|
+
<button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Choose columns">
|
|
610
|
+
<i className="bi bi-layout-three-columns" /> <span className="btn-label">Columns</span>
|
|
611
|
+
</button>
|
|
612
|
+
)}>
|
|
613
|
+
<div className="filter-menu-header">Show columns</div>
|
|
614
|
+
{columns.map((col) => {
|
|
615
|
+
const locked = col.hideable === false;
|
|
616
|
+
return (
|
|
617
|
+
<label key={col.key} className={`chooser-item${locked ? ' chooser-locked' : ''}`} title={locked ? 'Always shown' : undefined}>
|
|
618
|
+
<input
|
|
619
|
+
type="checkbox"
|
|
620
|
+
className="tbl-check"
|
|
621
|
+
checked={locked || !hiddenCols.has(col.key)}
|
|
622
|
+
disabled={locked}
|
|
623
|
+
onChange={() => toggleColumn(col.key)}
|
|
624
|
+
/>
|
|
625
|
+
<span>{col.label}</span>
|
|
626
|
+
{locked && <i className="bi bi-lock-fill chooser-lock" />}
|
|
627
|
+
</label>
|
|
628
|
+
);
|
|
629
|
+
})}
|
|
630
|
+
<div className="filter-menu-sep" />
|
|
631
|
+
<button className="filter-menu-item" onClick={resetColumns}>
|
|
632
|
+
<i className="bi bi-arrow-counterclockwise" /> Reset to defaults
|
|
633
|
+
</button>
|
|
634
|
+
{persistState && (
|
|
635
|
+
<div className="chooser-persist"><i className="bi bi-check2-circle" /> Saved for this table</div>
|
|
636
|
+
)}
|
|
637
|
+
</MenuDropdown>
|
|
638
|
+
)}
|
|
639
|
+
{exportFormats.length > 0 && (
|
|
640
|
+
<MenuDropdown button={(open, toggle) => (
|
|
641
|
+
<button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Export">
|
|
642
|
+
<i className="bi bi-download" /> <span className="btn-label">Export</span>
|
|
643
|
+
</button>
|
|
644
|
+
)}>
|
|
645
|
+
{exportFormats.map((format) => (
|
|
646
|
+
<button key={format} className="filter-menu-item" onClick={() => void runExport(format)}>
|
|
647
|
+
<i className={`bi ${format === 'csv' ? 'bi-filetype-csv' : 'bi-filetype-json'}`} />
|
|
648
|
+
Export as {format.toUpperCase()}
|
|
649
|
+
</button>
|
|
650
|
+
))}
|
|
651
|
+
</MenuDropdown>
|
|
652
|
+
)}
|
|
653
|
+
{/* One refresh affordance: it spins on ANY fetch
|
|
654
|
+
(manual, page change, auto-tick). */}
|
|
655
|
+
<button
|
|
656
|
+
className="btn-icon"
|
|
657
|
+
title={autoRefreshMs > 0 ? `Refresh · auto every ${Math.round(autoRefreshMs / 1000)}s` : 'Refresh'}
|
|
658
|
+
onClick={() => query.refetch()}
|
|
659
|
+
>
|
|
660
|
+
<i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
|
|
661
|
+
</button>
|
|
662
|
+
</div>
|
|
663
|
+
{onAdd && (
|
|
664
|
+
<button className="btn btn-primary btn-compact" onClick={onAdd}>
|
|
665
|
+
<i className="bi bi-plus-lg" /> <span className="btn-label">{addLabel ?? 'Add'}</span>
|
|
666
|
+
</button>
|
|
667
|
+
)}
|
|
668
|
+
</div>
|
|
669
|
+
</div>
|
|
670
|
+
|
|
671
|
+
{presets.length > 0 && (
|
|
672
|
+
<div className="seg-row">
|
|
673
|
+
<div className="seg">
|
|
674
|
+
{presets.map((preset) => (
|
|
675
|
+
<button
|
|
676
|
+
key={preset.key}
|
|
677
|
+
className={`seg-btn${p.presetActive(preset.params) ? ' seg-active' : ''}`}
|
|
678
|
+
onClick={() => p.applyPreset(preset.params)}
|
|
679
|
+
>
|
|
680
|
+
{preset.label}
|
|
681
|
+
</button>
|
|
682
|
+
))}
|
|
683
|
+
</div>
|
|
684
|
+
{count > 0 && (
|
|
685
|
+
<span className="result-count">
|
|
686
|
+
Showing {firstRow}–{lastRow} of {count}
|
|
687
|
+
{isFiltered && <span className="dim"> · filtered</span>}
|
|
688
|
+
</span>
|
|
689
|
+
)}
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
<FilterPills defs={filters} params={p} />
|
|
694
|
+
|
|
695
|
+
{selectable && batchActions.length > 0 && selected.size > 0 && (
|
|
696
|
+
<div className="batch-bar" role="region" aria-label="Batch actions">
|
|
697
|
+
<strong className="batch-count">{selected.size} selected</strong>
|
|
698
|
+
<div className="batch-actions">
|
|
699
|
+
{batchActions.map((action) => (
|
|
700
|
+
<button
|
|
701
|
+
key={action.key}
|
|
702
|
+
className={`btn btn-compact${action.danger ? ' btn-danger-ghost' : ''}`}
|
|
703
|
+
onClick={() => void runBatch(action)}
|
|
704
|
+
>
|
|
705
|
+
{action.icon && <i className={`bi ${action.icon}`} />} {action.label}
|
|
706
|
+
</button>
|
|
707
|
+
))}
|
|
708
|
+
<button className="btn btn-compact" onClick={() => setSelected(new Set())}>
|
|
709
|
+
<i className="bi bi-x-circle" /> Clear
|
|
710
|
+
</button>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
|
|
715
|
+
<div className="tbl-scroll">
|
|
716
|
+
<table className="tbl">
|
|
717
|
+
<thead>
|
|
718
|
+
<tr>
|
|
719
|
+
{expandEnabled && <th className="col-expand" />}
|
|
720
|
+
{selectable && (
|
|
721
|
+
<th className="col-select">
|
|
722
|
+
<input
|
|
723
|
+
type="checkbox"
|
|
724
|
+
className="tbl-check"
|
|
725
|
+
checked={allSelected}
|
|
726
|
+
ref={(el) => { if (el) el.indeterminate = !allSelected && someSelected; }}
|
|
727
|
+
onChange={toggleSelectAll}
|
|
728
|
+
aria-label="Select all rows on this page"
|
|
729
|
+
/>
|
|
730
|
+
</th>
|
|
731
|
+
)}
|
|
732
|
+
{visibleColumns.map((col) => (
|
|
733
|
+
<th key={col.key} className={col.align ? `text-${col.align}` : undefined}>
|
|
734
|
+
{col.sortable === false ? col.label : (
|
|
735
|
+
<button className="th-sort" onClick={() => p.cycleSort(col.key)}>
|
|
736
|
+
{col.label} {sortIcon(col)}
|
|
737
|
+
</button>
|
|
738
|
+
)}
|
|
739
|
+
</th>
|
|
740
|
+
))}
|
|
741
|
+
</tr>
|
|
742
|
+
</thead>
|
|
743
|
+
<tbody>
|
|
744
|
+
{/* Skeleton whenever THIS query's rows aren't on screen
|
|
745
|
+
yet: cold load, restore gate, or a page/sort/filter
|
|
746
|
+
change still serving the previous key's rows
|
|
747
|
+
(isPlaceholderData). Background refetches of the
|
|
748
|
+
SAME key (autoRefresh) keep the live rows. */}
|
|
749
|
+
{query.isPending || query.isPlaceholderData || !hydrated ? (
|
|
750
|
+
Array.from({ length: Math.min(p.size, 8) }).map((_, i) => (
|
|
751
|
+
<tr key={i} className="skel-row" aria-hidden="true">
|
|
752
|
+
{expandEnabled && <td className="col-expand" />}
|
|
753
|
+
{selectable && <td className="col-select" />}
|
|
754
|
+
{visibleColumns.map((col, ci) => (
|
|
755
|
+
<td key={col.key} className={col.align ? `text-${col.align}` : undefined}>
|
|
756
|
+
{ci === 0 ? (
|
|
757
|
+
<span className="skel-user">
|
|
758
|
+
<span className="skel skel-avatar" />
|
|
759
|
+
<span className="skel-stack">
|
|
760
|
+
<span className="skel skel-w-60" />
|
|
761
|
+
<span className="skel skel-w-90" />
|
|
762
|
+
</span>
|
|
763
|
+
</span>
|
|
764
|
+
) : (
|
|
765
|
+
<span className={`skel ${col.align === 'center' ? 'skel-pill' : SKEL_WIDTHS[ci % SKEL_WIDTHS.length]}`} />
|
|
766
|
+
)}
|
|
767
|
+
</td>
|
|
768
|
+
))}
|
|
769
|
+
</tr>
|
|
770
|
+
))
|
|
771
|
+
) : query.isError ? (
|
|
772
|
+
<tr><td colSpan={totalCols}>
|
|
773
|
+
<div className="empty-state">
|
|
774
|
+
<i className="bi bi-exclamation-triangle" />
|
|
775
|
+
<h3>Could not load {title.toLowerCase()}</h3>
|
|
776
|
+
<p>{query.error instanceof Error ? query.error.message : 'Request failed'}</p>
|
|
777
|
+
<button className="btn" onClick={() => query.refetch()}>Retry</button>
|
|
778
|
+
</div>
|
|
779
|
+
</td></tr>
|
|
780
|
+
) : rows.length === 0 ? (
|
|
781
|
+
<tr><td colSpan={totalCols}>
|
|
782
|
+
<div className="empty-state">
|
|
783
|
+
<i className="bi bi-inbox" />
|
|
784
|
+
{isFiltered ? (
|
|
785
|
+
<>
|
|
786
|
+
<h3>No results match your filters</h3>
|
|
787
|
+
<button className="btn" onClick={() => p.clearFilters()}>Clear filters</button>
|
|
788
|
+
</>
|
|
789
|
+
) : (
|
|
790
|
+
<h3>No {title.toLowerCase()} yet</h3>
|
|
791
|
+
)}
|
|
792
|
+
</div>
|
|
793
|
+
</td></tr>
|
|
794
|
+
) : renderBody()}
|
|
795
|
+
</tbody>
|
|
796
|
+
</table>
|
|
797
|
+
</div>
|
|
798
|
+
|
|
799
|
+
<div className="pager">
|
|
800
|
+
<span className="result-count">
|
|
801
|
+
{count > 0 ? <>Showing {firstRow}–{lastRow} of {count}</> : 'No records'}
|
|
802
|
+
</span>
|
|
803
|
+
<div className="pager-right">
|
|
804
|
+
{totalPages > 1 && (
|
|
805
|
+
<div className="pager-controls">
|
|
806
|
+
<button
|
|
807
|
+
className="btn-icon" title="Previous page"
|
|
808
|
+
disabled={p.page <= 1}
|
|
809
|
+
onClick={() => p.setPage(p.page - 1)}
|
|
810
|
+
>
|
|
811
|
+
<i className="bi bi-chevron-left" />
|
|
812
|
+
</button>
|
|
813
|
+
{pageWindow(p.page, totalPages).map((entry, i) => entry === '…' ? (
|
|
814
|
+
<span key={`gap${i}`} className="pager-gap">…</span>
|
|
815
|
+
) : (
|
|
816
|
+
<button
|
|
817
|
+
key={entry}
|
|
818
|
+
className={`pager-num${entry === p.page ? ' pager-current' : ''}`}
|
|
819
|
+
onClick={() => p.setPage(entry)}
|
|
820
|
+
>
|
|
821
|
+
{entry}
|
|
822
|
+
</button>
|
|
823
|
+
))}
|
|
824
|
+
<button
|
|
825
|
+
className="btn-icon" title="Next page"
|
|
826
|
+
disabled={p.page >= totalPages}
|
|
827
|
+
onClick={() => p.setPage(p.page + 1)}
|
|
828
|
+
>
|
|
829
|
+
<i className="bi bi-chevron-right" />
|
|
830
|
+
</button>
|
|
831
|
+
</div>
|
|
832
|
+
)}
|
|
833
|
+
<label className="page-size">
|
|
834
|
+
<span className="dim">Rows</span>
|
|
835
|
+
<select
|
|
836
|
+
className="input input-compact"
|
|
837
|
+
value={p.size}
|
|
838
|
+
onChange={(e) => p.setSize(Number(e.target.value))}
|
|
839
|
+
aria-label="Rows per page"
|
|
840
|
+
>
|
|
841
|
+
{PAGE_SIZES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
842
|
+
</select>
|
|
843
|
+
</label>
|
|
844
|
+
</div>
|
|
845
|
+
</div>
|
|
846
|
+
</div>
|
|
847
|
+
);
|
|
848
|
+
}
|