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,227 @@
|
|
|
1
|
+
// defineModel — the definition layer that replaces web-mojo's Model/Collection
|
|
2
|
+
// classes (src/core/Model.js, Collection.js, models/*.js).
|
|
3
|
+
//
|
|
4
|
+
// A model is a typed, STATELESS definition: endpoint + forms + permissions +
|
|
5
|
+
// actions, plus hooks bound to that endpoint. TanStack Query owns every byte
|
|
6
|
+
// of fetched data and all reactivity — the attribute stores, change events,
|
|
7
|
+
// dirty tracking, request dedup/abort/rate-limiting that Model/Collection
|
|
8
|
+
// implemented by hand are exactly the machinery Query already provides.
|
|
9
|
+
// Nothing here holds row data; there is ONE ownership system.
|
|
10
|
+
//
|
|
11
|
+
// Cache keys deliberately match the generic hooks (hooks.ts):
|
|
12
|
+
// list → [endpoint, params] (useModelList)
|
|
13
|
+
// one → [endpoint, 'one', id] (useModel)
|
|
14
|
+
// so ModelTable, GroupSwitcher and model-defined hooks share one cache and
|
|
15
|
+
// one invalidation root ([endpoint]).
|
|
16
|
+
//
|
|
17
|
+
// POST_SAVE_ACTIONS (django-mojo mojo/models/rest.py on_rest_save): an action
|
|
18
|
+
// is a key in the save body — `POST <endpoint>/<id>` `{disable: {...}}` — the
|
|
19
|
+
// backend saves any plain fields first, then runs `on_action_<key>(value)`.
|
|
20
|
+
// The response is either the refreshed record (handler returned nothing) or
|
|
21
|
+
// an action-specific payload (e.g. User.revoke_sessions → {status, message}).
|
|
22
|
+
// Which one is DECLARED per action (`response: 'row' | 'payload'`), never
|
|
23
|
+
// sniffed from the body — the resp.data heuristics class ends at this layer.
|
|
24
|
+
import { useMutation, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
|
25
|
+
import { mojoCall, mojoDelete, mojoGet, mojoSave, type Envelope } from './client';
|
|
26
|
+
import { withFreshAuth } from './auth';
|
|
27
|
+
import { useModel, useModelList } from './hooks';
|
|
28
|
+
import type { ModelForm, Params } from './types';
|
|
29
|
+
|
|
30
|
+
/** Permission spec as useCan/Guarded/hasPermission accept: one name, or any-of list. */
|
|
31
|
+
export type PermSpec = string | string[];
|
|
32
|
+
|
|
33
|
+
export interface ModelActionDef {
|
|
34
|
+
/** Save-body key for the POST; defaults to the action's name in the map. */
|
|
35
|
+
key?: string;
|
|
36
|
+
/** UI gate for offering the action (useCan). The server stays authoritative. */
|
|
37
|
+
permissions?: PermSpec;
|
|
38
|
+
/**
|
|
39
|
+
* 'row' (default): the backend answers with the refreshed record → written
|
|
40
|
+
* through to the one-record cache. 'payload': the backend answers with an
|
|
41
|
+
* action-specific body → returned verbatim, caches invalidated only.
|
|
42
|
+
*/
|
|
43
|
+
response?: 'row' | 'payload';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ModelConfig<T> {
|
|
47
|
+
/** Singular slug ('user') — labels, export filenames, devtools. */
|
|
48
|
+
name: string;
|
|
49
|
+
/** REST collection endpoint ('/api/account/user'). */
|
|
50
|
+
endpoint: string;
|
|
51
|
+
/**
|
|
52
|
+
* UI permission gates by convention: view / manage / create / delete.
|
|
53
|
+
* Carried metadata — consumers fold them through useCan; the backend
|
|
54
|
+
* remains the authority on every request.
|
|
55
|
+
*/
|
|
56
|
+
permissions?: Record<string, PermSpec>;
|
|
57
|
+
/** Named form configs (ADD_FORM/EDIT_FORM heritage) — plain data for ui. */
|
|
58
|
+
forms?: Record<string, ModelForm>;
|
|
59
|
+
/** The POST_SAVE_ACTIONS the backend RestMeta declares for this model. */
|
|
60
|
+
actions?: Record<string, ModelActionDef>;
|
|
61
|
+
/** Normalize/allowlist list params before they become a query key or request. */
|
|
62
|
+
normalizeListParams?: (params: Params) => Params;
|
|
63
|
+
/** Strip fields that must never enter Query or Mutation cache. */
|
|
64
|
+
sanitizeRow?(row: T): T;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SaveVars {
|
|
68
|
+
/** null → create (POST collection); id → update (POST detail). */
|
|
69
|
+
id: number | string | null;
|
|
70
|
+
changes: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ActionVars {
|
|
74
|
+
id: number | string;
|
|
75
|
+
/**
|
|
76
|
+
* The action's argument body — `{reason, note}` for disable, omitted for
|
|
77
|
+
* argument-less actions ({} is sent; handlers treat value as a dict).
|
|
78
|
+
*/
|
|
79
|
+
payload?: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ActionOutcome<T> {
|
|
83
|
+
/** The refreshed record, for `response: 'row'` actions; null otherwise. */
|
|
84
|
+
row: T | null;
|
|
85
|
+
/** The unwrapped envelope — message + any action-specific fields. */
|
|
86
|
+
body: Envelope;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ModelDef<T extends { id: number | string }> {
|
|
90
|
+
name: string;
|
|
91
|
+
endpoint: string;
|
|
92
|
+
permissions: Record<string, PermSpec>;
|
|
93
|
+
forms: Record<string, ModelForm>;
|
|
94
|
+
actions: Record<string, ModelActionDef>;
|
|
95
|
+
normalizeListParams?: (params: Params) => Params;
|
|
96
|
+
sanitizeRow?(row: T): T;
|
|
97
|
+
/** Query-key builders — for targeted setQueryData/invalidate in app code. */
|
|
98
|
+
keys: {
|
|
99
|
+
root: readonly [string];
|
|
100
|
+
list: (params: Params) => readonly [string, Params];
|
|
101
|
+
one: (id: number | string) => readonly [string, 'one', number | string];
|
|
102
|
+
};
|
|
103
|
+
/** Server-driven list under the shared cache key (keepPreviousData). */
|
|
104
|
+
useList: (params?: Params, opts?: { enabled?: boolean }) => ReturnType<typeof useModelList<T>>;
|
|
105
|
+
/** One record; disabled while id is null. */
|
|
106
|
+
useOne: (id: number | string | null) => ReturnType<typeof useModel<T>>;
|
|
107
|
+
/** Create/update mutation. Write-through + invalidate. Rejects on failure. */
|
|
108
|
+
useSave: () => ReturnType<typeof useMutation<T, Error, SaveVars>>;
|
|
109
|
+
/** DELETE mutation; drops the one-record cache and invalidates lists. */
|
|
110
|
+
useDelete: () => ReturnType<typeof useMutation<void, Error, { id: number | string }>>;
|
|
111
|
+
/** Mutation for one declared POST_SAVE_ACTION. Throws on an undeclared name. */
|
|
112
|
+
useAction: (action: string) => ReturnType<typeof useMutation<ActionOutcome<T>, Error, ActionVars>>;
|
|
113
|
+
/**
|
|
114
|
+
* Imperative fetch through the query cache (Collection.fetchOne heritage):
|
|
115
|
+
* same key + fn as useOne, so it dedupes with mounted hooks and warms the
|
|
116
|
+
* cache for them. Rejects on failure (fetchOne's null-swallow is gone).
|
|
117
|
+
*/
|
|
118
|
+
fetchOne: (qc: QueryClient, id: number | string) => Promise<T>;
|
|
119
|
+
/** Invalidate everything cached under this model's endpoint. */
|
|
120
|
+
invalidate: (qc: QueryClient) => Promise<void>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function defineModel<T extends { id: number | string }>(config: ModelConfig<T>): ModelDef<T> {
|
|
124
|
+
const { endpoint } = config;
|
|
125
|
+
const actions = config.actions ?? {};
|
|
126
|
+
const normalizeListParams = config.normalizeListParams;
|
|
127
|
+
const sanitizeRow = config.sanitizeRow;
|
|
128
|
+
|
|
129
|
+
const keys = {
|
|
130
|
+
root: [endpoint] as const,
|
|
131
|
+
list: (params: Params) => [
|
|
132
|
+
endpoint,
|
|
133
|
+
normalizeListParams ? normalizeListParams(params) : params,
|
|
134
|
+
] as const,
|
|
135
|
+
one: (id: number | string) => [endpoint, 'one', id] as const,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
name: config.name,
|
|
140
|
+
endpoint,
|
|
141
|
+
permissions: config.permissions ?? {},
|
|
142
|
+
forms: config.forms ?? {},
|
|
143
|
+
actions,
|
|
144
|
+
normalizeListParams,
|
|
145
|
+
sanitizeRow,
|
|
146
|
+
keys,
|
|
147
|
+
|
|
148
|
+
useList: (params: Params = {}, opts: { enabled?: boolean } = {}) => {
|
|
149
|
+
const safeParams = normalizeListParams ? normalizeListParams(params) : params;
|
|
150
|
+
return useModelList<T>(endpoint, safeParams, { ...opts, sanitizeRow });
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
useOne: (id: number | string | null) => useModel<T>(endpoint, id, sanitizeRow),
|
|
154
|
+
|
|
155
|
+
useSave: () => {
|
|
156
|
+
const qc = useQueryClient();
|
|
157
|
+
return useMutation({
|
|
158
|
+
mutationFn: async ({ id, changes }: SaveVars) => {
|
|
159
|
+
const row = await withFreshAuth(() => mojoSave<T>(endpoint, id, changes));
|
|
160
|
+
return sanitizeRow ? sanitizeRow(row) : row;
|
|
161
|
+
},
|
|
162
|
+
onSuccess: (row) => {
|
|
163
|
+
// The response body is the server-authoritative record —
|
|
164
|
+
// paint it immediately, then let invalidation re-sort and
|
|
165
|
+
// re-filter the lists.
|
|
166
|
+
qc.setQueryData(keys.one(row.id), row);
|
|
167
|
+
void qc.invalidateQueries({ queryKey: keys.root });
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
useDelete: () => {
|
|
173
|
+
const qc = useQueryClient();
|
|
174
|
+
return useMutation({
|
|
175
|
+
mutationFn: ({ id }: { id: number | string }) => mojoDelete(endpoint, id),
|
|
176
|
+
onSuccess: (_res, { id }) => {
|
|
177
|
+
qc.removeQueries({ queryKey: keys.one(id) });
|
|
178
|
+
void qc.invalidateQueries({ queryKey: keys.root });
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
useAction: (action: string) => {
|
|
184
|
+
const def = actions[action];
|
|
185
|
+
if (!def) {
|
|
186
|
+
// Programmer error — fail loud at the call site, not with a
|
|
187
|
+
// request the backend will ignore (dev-throw policy).
|
|
188
|
+
throw new Error(`defineModel(${config.name}): unknown action "${action}" — declare it in the model's actions map`);
|
|
189
|
+
}
|
|
190
|
+
const bodyKey = def.key ?? action;
|
|
191
|
+
const returnsRow = (def.response ?? 'row') === 'row';
|
|
192
|
+
const qc = useQueryClient();
|
|
193
|
+
return useMutation({
|
|
194
|
+
mutationFn: async ({ id, payload = {} }: ActionVars): Promise<ActionOutcome<T>> => {
|
|
195
|
+
const body = await withFreshAuth(() => mojoCall(`${endpoint}/${id}`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
body: { [bodyKey]: payload },
|
|
198
|
+
}));
|
|
199
|
+
const row = returnsRow ? (body.data as T) : null;
|
|
200
|
+
const safeRow = row && sanitizeRow ? sanitizeRow(row) : row;
|
|
201
|
+
// MutationCache retains the entire outcome. Keep its two
|
|
202
|
+
// row representations consistent so body.data cannot hold
|
|
203
|
+
// the original unsanitized record beside `row`.
|
|
204
|
+
const safeBody = safeRow && sanitizeRow
|
|
205
|
+
? { ...body, data: safeRow }
|
|
206
|
+
: body;
|
|
207
|
+
return { row: safeRow, body: safeBody };
|
|
208
|
+
},
|
|
209
|
+
onSuccess: (outcome, { id }) => {
|
|
210
|
+
if (outcome.row) qc.setQueryData(keys.one(id), outcome.row);
|
|
211
|
+
void qc.invalidateQueries({ queryKey: keys.root });
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
fetchOne: (qc: QueryClient, id: number | string) =>
|
|
217
|
+
qc.fetchQuery({
|
|
218
|
+
queryKey: keys.one(id),
|
|
219
|
+
queryFn: async () => {
|
|
220
|
+
const row = await mojoGet<T>(endpoint, id);
|
|
221
|
+
return sanitizeRow ? sanitizeRow(row) : row;
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
|
|
225
|
+
invalidate: (qc: QueryClient) => qc.invalidateQueries({ queryKey: keys.root }),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// The table params store — THE architecture rule from the port extraction:
|
|
2
|
+
// one flat params object is the single source of truth for search, sort,
|
|
3
|
+
// filters, and paging. It drives the server query, the URL, the pills, and
|
|
4
|
+
// preset matching. Components read it; nothing else owns table state.
|
|
5
|
+
//
|
|
6
|
+
// Two wire shapes carried over from web-mojo:
|
|
7
|
+
// · multi-value filters collapse to `field__in=a,b` (single value → `field`)
|
|
8
|
+
// · a daterange is the TRIPLE `dr_field` / `dr_start` / `dr_end`, so only
|
|
9
|
+
// one range can be active at a time — by construction, not by convention.
|
|
10
|
+
import { useCallback, useMemo, useRef } from 'react';
|
|
11
|
+
import { useSearchParams } from 'react-router-dom';
|
|
12
|
+
import type { Params } from './types';
|
|
13
|
+
|
|
14
|
+
export interface ActiveFilter {
|
|
15
|
+
/** Pill identity: the param key for plain filters, the field for ranges. */
|
|
16
|
+
id: string;
|
|
17
|
+
kind: 'plain' | 'daterange';
|
|
18
|
+
value?: string;
|
|
19
|
+
start?: string;
|
|
20
|
+
end?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TableParams {
|
|
24
|
+
search: string;
|
|
25
|
+
sort: string; // 'field' | '-field' | ''
|
|
26
|
+
page: number; // 1-based
|
|
27
|
+
size: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TableParamsApi extends TableParams {
|
|
31
|
+
wire: Params;
|
|
32
|
+
activeFilters: ActiveFilter[];
|
|
33
|
+
filterValue(key: string): string | undefined;
|
|
34
|
+
dateRange(): { field: string; start: string; end: string } | null;
|
|
35
|
+
setSearch(term: string): void;
|
|
36
|
+
cycleSort(field: string): void;
|
|
37
|
+
setFilter(key: string, value: string | string[] | null): void;
|
|
38
|
+
setDateRange(field: string, start: string, end: string): void;
|
|
39
|
+
removeFilter(f: ActiveFilter): void;
|
|
40
|
+
applyPreset(params: Record<string, string>): void;
|
|
41
|
+
presetActive(params: Record<string, string>): boolean;
|
|
42
|
+
clearFilters(): void;
|
|
43
|
+
setPage(page: number): void;
|
|
44
|
+
setSize(size: number): void;
|
|
45
|
+
/**
|
|
46
|
+
* One-shot rehydrate from a persisted view blob. Precedence is URL >
|
|
47
|
+
* saved (a shared link always wins) — except `size`, a per-user viewing
|
|
48
|
+
* preference the URL merely echoes, where saved wins. `sort: ''` (sort
|
|
49
|
+
* deliberately off) is not persisted, so it can't be restored — the
|
|
50
|
+
* default reappears; documented trade for clean URLs.
|
|
51
|
+
*/
|
|
52
|
+
applySaved(saved: { sort?: string; size?: number; search?: string; filters?: Record<string, string> }): void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const RESERVED = new Set(['search', 'sort', 'page', 'size']);
|
|
56
|
+
const RANGE_KEYS = new Set(['dr_field', 'dr_start', 'dr_end']);
|
|
57
|
+
export const PAGE_SIZES = [5, 10, 25, 50, 100];
|
|
58
|
+
const DEFAULT_SIZE = 10;
|
|
59
|
+
|
|
60
|
+
// ── Host-page params (not filters) ────────────────────────────────────
|
|
61
|
+
// Every non-reserved URL key is treated as a Django lookup — that is what
|
|
62
|
+
// lets any `field__lookup=` filter deep-link without a client-side schema.
|
|
63
|
+
// But the PAGE around a table can own query params too (the component
|
|
64
|
+
// playground's `?demo=`, a tab id, a wizard step). Those are not filters:
|
|
65
|
+
// they must never reach the wire (`?demo=filters` on /api/group is a lookup
|
|
66
|
+
// on a field that does not exist — django-mojo raises, the mock returns zero
|
|
67
|
+
// rows), never render as a pill, and never break preset matching — yet they
|
|
68
|
+
// MUST survive every params write, since each write rebuilds the whole query
|
|
69
|
+
// string. Apps declare them once, at module load, before any table renders.
|
|
70
|
+
const NON_FILTER = new Set<string>();
|
|
71
|
+
|
|
72
|
+
/** Declare URL params the host page owns, so tables carry them without filtering on them. */
|
|
73
|
+
export function registerNonFilterParams(...keys: string[]): void {
|
|
74
|
+
for (const key of keys) NON_FILTER.add(key);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── View persistence (WM-035 port) ────────────────────────────────────
|
|
78
|
+
// One localStorage blob per table remembers how each user likes it — sort,
|
|
79
|
+
// page size, search + filters (field__in / dr_* triples verbatim), hidden
|
|
80
|
+
// columns. Versioned; anything unparseable or from another schema version is
|
|
81
|
+
// discarded AND cleared. Storage access always degrades to a no-op (privacy
|
|
82
|
+
// mode / SSR). Precedence on restore is URL > saved > defaults — except
|
|
83
|
+
// `size`, a per-user viewing preference the URL merely echoes, where saved
|
|
84
|
+
// wins (web-mojo's documented rule).
|
|
85
|
+
|
|
86
|
+
export interface PersistedTableState {
|
|
87
|
+
v: 2;
|
|
88
|
+
sort?: string;
|
|
89
|
+
size?: number;
|
|
90
|
+
search?: string;
|
|
91
|
+
filters?: Record<string, string>;
|
|
92
|
+
hidden?: string[];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const PERSIST_PREFIX = 'mojo:tableview:';
|
|
96
|
+
|
|
97
|
+
function persistStorage(): Storage | null {
|
|
98
|
+
try {
|
|
99
|
+
const ls = globalThis.localStorage;
|
|
100
|
+
if (ls && typeof ls.getItem === 'function') return ls;
|
|
101
|
+
} catch { /* access denied */ }
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function readTableState(key: string): PersistedTableState | null {
|
|
106
|
+
const store = persistStorage();
|
|
107
|
+
if (!store) return null;
|
|
108
|
+
let raw: string | null;
|
|
109
|
+
try { raw = store.getItem(PERSIST_PREFIX + key); } catch { return null; }
|
|
110
|
+
if (!raw) return null;
|
|
111
|
+
let parsed: unknown;
|
|
112
|
+
try { parsed = JSON.parse(raw); } catch { clearTableState(key); return null; }
|
|
113
|
+
if (!parsed || typeof parsed !== 'object' || (parsed as PersistedTableState).v !== 2) {
|
|
114
|
+
clearTableState(key);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
return parsed as PersistedTableState;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function writeTableState(key: string, state: PersistedTableState): void {
|
|
121
|
+
const store = persistStorage();
|
|
122
|
+
if (!store) return;
|
|
123
|
+
try { store.setItem(PERSIST_PREFIX + key, JSON.stringify(state)); } catch { /* quota / denied */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clearTableState(key: string): void {
|
|
127
|
+
const store = persistStorage();
|
|
128
|
+
if (!store) return;
|
|
129
|
+
try { store.removeItem(PERSIST_PREFIX + key); } catch { /* denied */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function useTableParams(
|
|
133
|
+
defaults: Partial<TableParams> = {},
|
|
134
|
+
defaultFilters: Readonly<Record<string, string>> = {},
|
|
135
|
+
): TableParamsApi {
|
|
136
|
+
const [sp, setSp] = useSearchParams();
|
|
137
|
+
// Defaults seed only the untouched route. Once this table writes params,
|
|
138
|
+
// an intentionally empty filter set must stay empty ("Clear all" must
|
|
139
|
+
// not immediately resurrect a route default).
|
|
140
|
+
const wroteParams = useRef(false);
|
|
141
|
+
|
|
142
|
+
const filters = useMemo(() => {
|
|
143
|
+
// Defaults are real params-store state: shared URLs override them,
|
|
144
|
+
// and applySaved() overwrites them when no URL key is present.
|
|
145
|
+
// This is the same URL > persisted > defaults precedence as sort.
|
|
146
|
+
const out: Record<string, string> = wroteParams.current ? {} : { ...defaultFilters };
|
|
147
|
+
for (const [key, value] of sp.entries()) {
|
|
148
|
+
if (!RESERVED.has(key) && !NON_FILTER.has(key)) out[key] = value;
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}, [sp, defaultFilters]);
|
|
152
|
+
|
|
153
|
+
const state = useMemo<TableParams>(() => ({
|
|
154
|
+
search: sp.get('search') ?? defaults.search ?? '',
|
|
155
|
+
sort: sp.get('sort') ?? defaults.sort ?? '-created',
|
|
156
|
+
page: Math.max(1, Number(sp.get('page') ?? 1)),
|
|
157
|
+
size: Number(sp.get('size') ?? defaults.size ?? DEFAULT_SIZE),
|
|
158
|
+
}), [sp, defaults.search, defaults.sort, defaults.size]);
|
|
159
|
+
|
|
160
|
+
const write = useCallback((next: TableParams, nextFilters: Record<string, string>) => {
|
|
161
|
+
wroteParams.current = true;
|
|
162
|
+
const out = new URLSearchParams();
|
|
163
|
+
if (next.search) out.set('search', next.search);
|
|
164
|
+
if (next.sort) out.set('sort', next.sort);
|
|
165
|
+
if (next.page > 1) out.set('page', String(next.page));
|
|
166
|
+
if (next.size !== DEFAULT_SIZE) out.set('size', String(next.size));
|
|
167
|
+
for (const [key, value] of Object.entries(nextFilters)) {
|
|
168
|
+
if (value !== '') out.set(key, value);
|
|
169
|
+
}
|
|
170
|
+
// Host-page params ride through untouched — not ours to filter on,
|
|
171
|
+
// not ours to drop either.
|
|
172
|
+
for (const key of NON_FILTER) {
|
|
173
|
+
const carried = sp.get(key);
|
|
174
|
+
if (carried != null) out.set(key, carried);
|
|
175
|
+
}
|
|
176
|
+
setSp(out, { replace: true });
|
|
177
|
+
}, [setSp, sp]);
|
|
178
|
+
|
|
179
|
+
const wire = useMemo<Params>(() => ({
|
|
180
|
+
start: (state.page - 1) * state.size,
|
|
181
|
+
size: state.size,
|
|
182
|
+
sort: state.sort || undefined,
|
|
183
|
+
search: state.search || undefined,
|
|
184
|
+
...filters,
|
|
185
|
+
}), [state, filters]);
|
|
186
|
+
|
|
187
|
+
/** Collapse the daterange triple back into one pill entry. */
|
|
188
|
+
const activeFilters = useMemo<ActiveFilter[]>(() => {
|
|
189
|
+
const out: ActiveFilter[] = [];
|
|
190
|
+
if (filters.dr_field) {
|
|
191
|
+
out.push({
|
|
192
|
+
id: filters.dr_field,
|
|
193
|
+
kind: 'daterange',
|
|
194
|
+
start: filters.dr_start ?? '',
|
|
195
|
+
end: filters.dr_end ?? '',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
199
|
+
if (RANGE_KEYS.has(key)) continue;
|
|
200
|
+
out.push({ id: key, kind: 'plain', value });
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}, [filters]);
|
|
204
|
+
|
|
205
|
+
const setFilter = useCallback((key: string, value: string | string[] | null) => {
|
|
206
|
+
const next = { ...filters };
|
|
207
|
+
// A field never carries both its plain and its __in form.
|
|
208
|
+
const field = key.replace(/__in$/, '');
|
|
209
|
+
delete next[field];
|
|
210
|
+
delete next[`${field}__in`];
|
|
211
|
+
delete next[key];
|
|
212
|
+
if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
|
213
|
+
write({ ...state, page: 1 }, next);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (Array.isArray(value)) {
|
|
217
|
+
if (value.length === 1) next[field] = value[0]!;
|
|
218
|
+
else next[`${field}__in`] = value.join(',');
|
|
219
|
+
} else {
|
|
220
|
+
next[key] = value;
|
|
221
|
+
}
|
|
222
|
+
write({ ...state, page: 1 }, next);
|
|
223
|
+
}, [filters, state, write]);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
...state,
|
|
227
|
+
wire,
|
|
228
|
+
activeFilters,
|
|
229
|
+
filterValue: (key) => filters[key],
|
|
230
|
+
dateRange: () => filters.dr_field
|
|
231
|
+
? { field: filters.dr_field, start: filters.dr_start ?? '', end: filters.dr_end ?? '' }
|
|
232
|
+
: null,
|
|
233
|
+
setSearch: (term) => write({ ...state, search: term, page: 1 }, filters),
|
|
234
|
+
// 3-way header sort: asc → desc → off (web-mojo's explicit cycle).
|
|
235
|
+
cycleSort: (field) => {
|
|
236
|
+
const next = state.sort === field ? `-${field}` : state.sort === `-${field}` ? '' : field;
|
|
237
|
+
write({ ...state, sort: next, page: 1 }, filters);
|
|
238
|
+
},
|
|
239
|
+
setFilter,
|
|
240
|
+
setDateRange: (field, start, end) => {
|
|
241
|
+
const next = { ...filters };
|
|
242
|
+
delete next.dr_field; delete next.dr_start; delete next.dr_end;
|
|
243
|
+
if (field && (start || end)) {
|
|
244
|
+
next.dr_field = field;
|
|
245
|
+
if (start) next.dr_start = start;
|
|
246
|
+
if (end) next.dr_end = end;
|
|
247
|
+
}
|
|
248
|
+
write({ ...state, page: 1 }, next);
|
|
249
|
+
},
|
|
250
|
+
removeFilter: (f) => {
|
|
251
|
+
const next = { ...filters };
|
|
252
|
+
if (f.kind === 'daterange') {
|
|
253
|
+
delete next.dr_field; delete next.dr_start; delete next.dr_end;
|
|
254
|
+
} else {
|
|
255
|
+
delete next[f.id];
|
|
256
|
+
}
|
|
257
|
+
write({ ...state, page: 1 }, next);
|
|
258
|
+
},
|
|
259
|
+
// Presets are mutually-exclusive bundles; active state is DERIVED by
|
|
260
|
+
// matching, never stored.
|
|
261
|
+
applyPreset: (params) => {
|
|
262
|
+
const active = Object.entries(params).every(([k, v]) => filters[k] === v);
|
|
263
|
+
write({ ...state, page: 1 }, active ? {} : { ...params });
|
|
264
|
+
},
|
|
265
|
+
presetActive: (params) => {
|
|
266
|
+
const keys = Object.keys(params);
|
|
267
|
+
if (keys.length === 0) return Object.keys(filters).length === 0;
|
|
268
|
+
return keys.every((k) => filters[k] === params[k]);
|
|
269
|
+
},
|
|
270
|
+
clearFilters: () => write({ ...state, search: '', page: 1 }, {}),
|
|
271
|
+
setPage: (page) => write({ ...state, page }, filters),
|
|
272
|
+
setSize: (size) => write({ ...state, size, page: 1 }, filters),
|
|
273
|
+
applySaved: (saved) => {
|
|
274
|
+
const urlKeys = new Set(Array.from(sp.keys()));
|
|
275
|
+
const next = { ...state };
|
|
276
|
+
// A persisted filter set replaces defaults wholesale; only keys
|
|
277
|
+
// explicitly present in the URL survive above it. This makes an
|
|
278
|
+
// intentionally saved "all tickets" view beat a route default.
|
|
279
|
+
const nextFilters: Record<string, string> = {};
|
|
280
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
281
|
+
if (urlKeys.has(key)) nextFilters[key] = value;
|
|
282
|
+
}
|
|
283
|
+
if (saved.sort !== undefined && saved.sort !== '' && !urlKeys.has('sort')) next.sort = saved.sort;
|
|
284
|
+
if (saved.search !== undefined && !urlKeys.has('search')) next.search = saved.search;
|
|
285
|
+
if (saved.size !== undefined && PAGE_SIZES.includes(saved.size)) next.size = saved.size;
|
|
286
|
+
for (const [key, value] of Object.entries(saved.filters ?? {})) {
|
|
287
|
+
if (!urlKeys.has(key)) nextFilters[key] = value;
|
|
288
|
+
}
|
|
289
|
+
write(next, nextFilters);
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|