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,583 @@
|
|
|
1
|
+
// The FormView autosave state machine — web-mojo FormView.js ported:
|
|
2
|
+
// handleFieldChange (:796-822), the 300ms batch pipeline handleFieldSave/
|
|
3
|
+
// executeBatchSave/revertFields (:904-1044), and the showWhen resolution
|
|
4
|
+
// FormBuilder.js:863-888 renders declaratively.
|
|
5
|
+
//
|
|
6
|
+
// Semantics (the contract FormView renders):
|
|
7
|
+
// · Edits COMMIT (select/switch change, text blur/Enter — never per
|
|
8
|
+
// keystroke); each commit validates (zod), then joins the pending batch.
|
|
9
|
+
// · A batch fires 300ms after the LAST commit — rapid commits (autofill,
|
|
10
|
+
// tab-through) coalesce into ONE save carrying only changed fields.
|
|
11
|
+
// · While a save is in flight further commits queue the NEXT batch; batches
|
|
12
|
+
// never interleave (web-mojo's isSaving guard silently DROPPED the queued
|
|
13
|
+
// fields — fixed here: completion re-arms the timer).
|
|
14
|
+
// · Success: the response row is the new server snapshot; saved fields
|
|
15
|
+
// flash 'saved' (~1.5s) then idle.
|
|
16
|
+
// · Failure: the batch's fields revert to the SERVER SNAPSHOT and pin an
|
|
17
|
+
// 'error' with the server's message until their next successful save —
|
|
18
|
+
// a failed value never sticks (Model.save resolve-on-failure heritage
|
|
19
|
+
// ends at the client; this machine assumes `save` REJECTS).
|
|
20
|
+
// · zod failure: the save of that field is blocked client-side; the message
|
|
21
|
+
// occupies the same error slot. Hidden (showWhen) fields never queue and
|
|
22
|
+
// their transient state clears.
|
|
23
|
+
//
|
|
24
|
+
// Shape: ALL machine state lives in ONE reducer (draft values, server
|
|
25
|
+
// snapshot, per-field status, the pending batch, the in-flight flag) so every
|
|
26
|
+
// transition is a pure, inspectable action. The two timers — the batch window
|
|
27
|
+
// and the saved flash — live in refs and only ever dispatch.
|
|
28
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
|
|
29
|
+
import type { Field, FieldValue, FieldValues, ShowWhen } from '../client/types';
|
|
30
|
+
import { fileRelationId, isFileRelationField } from './field-wire';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Public per-field indicator state. The reducer stores five states plus the
|
|
34
|
+
* pending-batch membership; 'pending' is derived (committed + queued, POST
|
|
35
|
+
* not yet started) so the UI can spin from the moment a commit is accepted.
|
|
36
|
+
*/
|
|
37
|
+
export type AutosaveStatus = 'idle' | 'dirty' | 'pending' | 'saving' | 'saved' | 'error';
|
|
38
|
+
|
|
39
|
+
export interface FieldStatus {
|
|
40
|
+
status: AutosaveStatus;
|
|
41
|
+
/** zod or server message — present only while status === 'error'. */
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Pure helpers (shared with SchemaForm where noted) ─────────────────
|
|
46
|
+
|
|
47
|
+
/** Resolve a dotted path ('permissions.manage_users') against a row/dict. */
|
|
48
|
+
export function getPath(source: unknown, path: string): unknown {
|
|
49
|
+
let cur: unknown = source;
|
|
50
|
+
for (const seg of path.split('.')) {
|
|
51
|
+
if (cur == null || typeof cur !== 'object') return undefined;
|
|
52
|
+
cur = (cur as Record<string, unknown>)[seg];
|
|
53
|
+
}
|
|
54
|
+
return cur;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* showWhen resolution — FormBuilder semantics for the declarative rule
|
|
59
|
+
* (String()-coerced membership + negate), or a live-values predicate.
|
|
60
|
+
* Used by FormView AND SchemaForm (one visibility pipeline).
|
|
61
|
+
*/
|
|
62
|
+
export function resolveShowWhen(show: ShowWhen | undefined, values: FieldValues): boolean {
|
|
63
|
+
if (!show) return true;
|
|
64
|
+
if (typeof show === 'function') return show(values);
|
|
65
|
+
const current = String(values[show.field] ?? '');
|
|
66
|
+
const allowed = Array.isArray(show.value) ? show.value : [show.value];
|
|
67
|
+
const matches = allowed.map((v) => String(v ?? '')).includes(current);
|
|
68
|
+
return show.negate ? !matches : matches;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Server raw → controlled input value (switch → loose-truthy boolean, null → '').
|
|
72
|
+
* Arrays survive as string lists (B4 #1278: multiselect ids, daterange pairs) —
|
|
73
|
+
* String() would flatten them to 'a,b' and lose comma-carrying items. */
|
|
74
|
+
export function toDisplay(field: Field, raw: unknown): FieldValue {
|
|
75
|
+
if (isFileRelationField(field)) return fileRelationId(raw);
|
|
76
|
+
if (field.type === 'switch') return raw === true || raw === 1;
|
|
77
|
+
if (Array.isArray(raw)) return raw.map((x) => String(x));
|
|
78
|
+
if (raw == null) return '';
|
|
79
|
+
return String(raw);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The full display-typed server snapshot for a field set against a row. */
|
|
83
|
+
export function serverValuesFor(fields: Field[], row: unknown): FieldValues {
|
|
84
|
+
const out: FieldValues = {};
|
|
85
|
+
for (const f of fields) out[f.name] = toDisplay(f, getPath(row, f.name));
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Change detection between DISPLAY-typed values — FormView.valuesAreDifferent
|
|
91
|
+
* port: strict boolean compare for switches, trimmed string compare for the
|
|
92
|
+
* rest (so 42 vs '42' and null vs '' are NOT changes).
|
|
93
|
+
*/
|
|
94
|
+
export function valueChanged(field: Field, next: FieldValue, server: FieldValue | undefined): boolean {
|
|
95
|
+
if (field.type === 'switch') return next !== (server === true);
|
|
96
|
+
if (isFileRelationField(field)) return fileRelationId(next) !== fileRelationId(server);
|
|
97
|
+
return String(next ?? '').trim() !== String(server ?? '').trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** First zod issue's message, or null when valid / no schema. */
|
|
101
|
+
export function validateFieldValue(field: Field, value: FieldValue): string | null {
|
|
102
|
+
if (!field.schema) return null;
|
|
103
|
+
const result = field.schema.safeParse(value);
|
|
104
|
+
if (result.success) return null;
|
|
105
|
+
return result.error.issues[0]?.message ?? 'Invalid value';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Expand dotted names into partial nested dicts for the wire:
|
|
110
|
+
* {'permissions.a': true, 'permissions.b': false} → {permissions: {a, b}}.
|
|
111
|
+
* Partial is correct — django-mojo MERGES dict bodies into JSONFields
|
|
112
|
+
* (rest.py on_rest_update_jsonfield / objict.merge_dicts), it never replaces.
|
|
113
|
+
*/
|
|
114
|
+
export function expandDotted(flat: Record<string, FieldValue>): Record<string, unknown> {
|
|
115
|
+
const out: Record<string, unknown> = {};
|
|
116
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
117
|
+
if (!key.includes('.')) {
|
|
118
|
+
out[key] = value;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const segs = key.split('.');
|
|
122
|
+
let cur = out;
|
|
123
|
+
for (let i = 0; i < segs.length - 1; i += 1) {
|
|
124
|
+
const seg = segs[i]!;
|
|
125
|
+
if (cur[seg] == null || typeof cur[seg] !== 'object') cur[seg] = {};
|
|
126
|
+
cur = cur[seg] as Record<string, unknown>;
|
|
127
|
+
}
|
|
128
|
+
cur[segs[segs.length - 1]!] = value;
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Keep only keys explicitly declared by this form. Address details are an
|
|
134
|
+
* atomic multi-field gesture, but they must never become an arbitrary row
|
|
135
|
+
* patch just because a provider returned an extra component. */
|
|
136
|
+
export function declaredFieldPatch(fields: Field[], patch: FieldValues): FieldValues {
|
|
137
|
+
const names = new Set(fields.map((field) => field.name));
|
|
138
|
+
return Object.fromEntries(Object.entries(patch).filter(([name]) => names.has(name)));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Reducer ───────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** Reducer-side status — 'pending' is derived from `pending` membership. */
|
|
144
|
+
type StoredStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
|
145
|
+
|
|
146
|
+
export interface AutosaveState {
|
|
147
|
+
/** Controlled display values (what the inputs show). */
|
|
148
|
+
draft: FieldValues;
|
|
149
|
+
/** Display-typed server snapshot — the revert target and dirty baseline. */
|
|
150
|
+
server: FieldValues;
|
|
151
|
+
fieldState: Record<string, StoredStatus>;
|
|
152
|
+
fieldError: Record<string, string | null>;
|
|
153
|
+
/** Committed values awaiting the batch window (name → committed value). */
|
|
154
|
+
pending: Record<string, FieldValue>;
|
|
155
|
+
inflight: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export type AutosaveAction =
|
|
159
|
+
/** Per-keystroke display update (text inputs) — marks dirty, never saves. */
|
|
160
|
+
| { type: 'EDIT'; field: Field; value: FieldValue; fields: Field[] }
|
|
161
|
+
/** The commit pipeline: showWhen gate → zod → change-detect → queue. */
|
|
162
|
+
| { type: 'COMMIT'; field: Field; value: FieldValue; fields: Field[] }
|
|
163
|
+
/** One controlled gesture that updates, validates and queues many fields. */
|
|
164
|
+
| { type: 'COMMIT_PATCH'; patch: FieldValues; fields: Field[] }
|
|
165
|
+
/** pending[names] → in flight ('saving'). */
|
|
166
|
+
| { type: 'BATCH_START'; names: string[] }
|
|
167
|
+
/** Batch settled OK: fresh snapshot; saved flash; mid-flight edits stay dirty. */
|
|
168
|
+
| { type: 'SAVE_OK'; names: string[]; sent: Record<string, FieldValue>; server: FieldValues }
|
|
169
|
+
/** Batch rejected: REVERT names to the snapshot, pin the server message. */
|
|
170
|
+
| { type: 'SAVE_FAIL'; names: string[]; error: string }
|
|
171
|
+
/** The ~1.5s saved flash ended. */
|
|
172
|
+
| { type: 'FLASH_DONE'; name: string }
|
|
173
|
+
/** The row prop changed (refetch/invalidate) or fields arrived late. */
|
|
174
|
+
| { type: 'SYNC'; server: FieldValues };
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* After any draft change, fields hidden by showWhen drop their queued value
|
|
178
|
+
* and transient state ("hidden fields don't submit and their errors clear").
|
|
179
|
+
* An in-flight ('saving') field keeps its status — the settle handles it.
|
|
180
|
+
*/
|
|
181
|
+
function applyHideCascade(state: AutosaveState, fields: Field[]): AutosaveState {
|
|
182
|
+
let next = state;
|
|
183
|
+
for (const f of fields) {
|
|
184
|
+
if (!f.showWhen || resolveShowWhen(f.showWhen, next.draft)) continue;
|
|
185
|
+
const queued = f.name in next.pending;
|
|
186
|
+
const st = next.fieldState[f.name];
|
|
187
|
+
const clearable = st != null && st !== 'idle' && st !== 'saving';
|
|
188
|
+
if (!queued && !clearable) continue;
|
|
189
|
+
const pending = { ...next.pending };
|
|
190
|
+
delete pending[f.name];
|
|
191
|
+
next = {
|
|
192
|
+
...next,
|
|
193
|
+
pending,
|
|
194
|
+
fieldState: clearable ? { ...next.fieldState, [f.name]: 'idle' } : next.fieldState,
|
|
195
|
+
fieldError: clearable ? { ...next.fieldError, [f.name]: null } : next.fieldError,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return next;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The pure transition function. Exported for tests and instrumentation —
|
|
203
|
+
* application code uses the useFormAutosave hook, never this directly.
|
|
204
|
+
*/
|
|
205
|
+
export function autosaveReducer(state: AutosaveState, action: AutosaveAction): AutosaveState {
|
|
206
|
+
switch (action.type) {
|
|
207
|
+
case 'EDIT': {
|
|
208
|
+
const n = action.field.name;
|
|
209
|
+
let s: AutosaveState = { ...state, draft: { ...state.draft, [n]: action.value } };
|
|
210
|
+
const st = s.fieldState[n] ?? 'idle';
|
|
211
|
+
// Typing marks dirty; 'saving' keeps spinning; 'error' pins until
|
|
212
|
+
// the next commit outcome or successful save (workspec).
|
|
213
|
+
if (st === 'idle' || st === 'dirty' || st === 'saved') {
|
|
214
|
+
const dirty = valueChanged(action.field, action.value, s.server[n]);
|
|
215
|
+
s = { ...s, fieldState: { ...s.fieldState, [n]: dirty ? 'dirty' : 'idle' } };
|
|
216
|
+
}
|
|
217
|
+
return applyHideCascade(s, action.fields);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'COMMIT': {
|
|
221
|
+
const n = action.field.name;
|
|
222
|
+
let s: AutosaveState = { ...state, draft: { ...state.draft, [n]: action.value } };
|
|
223
|
+
|
|
224
|
+
// Hidden fields never save.
|
|
225
|
+
if (!resolveShowWhen(action.field.showWhen, s.draft)) {
|
|
226
|
+
return applyHideCascade(s, action.fields);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Client validation blocks the save of THIS field only.
|
|
230
|
+
const invalid = validateFieldValue(action.field, action.value);
|
|
231
|
+
if (invalid) {
|
|
232
|
+
const pending = { ...s.pending };
|
|
233
|
+
delete pending[n];
|
|
234
|
+
s = {
|
|
235
|
+
...s,
|
|
236
|
+
pending,
|
|
237
|
+
fieldState: { ...s.fieldState, [n]: 'error' },
|
|
238
|
+
fieldError: { ...s.fieldError, [n]: invalid },
|
|
239
|
+
};
|
|
240
|
+
return applyHideCascade(s, action.fields);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Back at the server value → nothing to save; clear transient state.
|
|
244
|
+
if (!valueChanged(action.field, action.value, s.server[n])) {
|
|
245
|
+
const pending = { ...s.pending };
|
|
246
|
+
delete pending[n];
|
|
247
|
+
s = { ...s, pending };
|
|
248
|
+
const st = s.fieldState[n];
|
|
249
|
+
if (st != null && st !== 'idle' && st !== 'saving') {
|
|
250
|
+
s = {
|
|
251
|
+
...s,
|
|
252
|
+
fieldState: { ...s.fieldState, [n]: 'idle' },
|
|
253
|
+
fieldError: { ...s.fieldError, [n]: null },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
return applyHideCascade(s, action.fields);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Queue for the batch. The committed value is captured HERE —
|
|
260
|
+
// later uncommitted keystrokes must not ride this batch.
|
|
261
|
+
s = {
|
|
262
|
+
...s,
|
|
263
|
+
pending: { ...s.pending, [n]: action.value },
|
|
264
|
+
fieldState: { ...s.fieldState, [n]: 'dirty' },
|
|
265
|
+
fieldError: { ...s.fieldError, [n]: null },
|
|
266
|
+
};
|
|
267
|
+
return applyHideCascade(s, action.fields);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case 'COMMIT_PATCH': {
|
|
271
|
+
const patch = declaredFieldPatch(action.fields, action.patch);
|
|
272
|
+
if (Object.keys(patch).length === 0) return state;
|
|
273
|
+
let s: AutosaveState = { ...state, draft: { ...state.draft, ...patch } };
|
|
274
|
+
const visible = action.fields.filter((field) =>
|
|
275
|
+
field.name in patch && resolveShowWhen(field.showWhen, s.draft));
|
|
276
|
+
|
|
277
|
+
// A place selection is one transaction. If any mapped value is
|
|
278
|
+
// invalid, none of the values enters the autosave batch.
|
|
279
|
+
const invalid = visible
|
|
280
|
+
.map((field) => [field, validateFieldValue(field, patch[field.name]!)] as const)
|
|
281
|
+
.filter(([, issue]) => issue != null);
|
|
282
|
+
if (invalid.length > 0) {
|
|
283
|
+
const pending = { ...s.pending };
|
|
284
|
+
const fieldState = { ...s.fieldState };
|
|
285
|
+
const fieldError = { ...s.fieldError };
|
|
286
|
+
for (const field of visible) delete pending[field.name];
|
|
287
|
+
for (const [field, issue] of invalid) {
|
|
288
|
+
fieldState[field.name] = 'error';
|
|
289
|
+
fieldError[field.name] = issue;
|
|
290
|
+
}
|
|
291
|
+
return applyHideCascade({ ...s, draft: state.draft, pending, fieldState, fieldError }, action.fields);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const pending = { ...s.pending };
|
|
295
|
+
const fieldState = { ...s.fieldState };
|
|
296
|
+
const fieldError = { ...s.fieldError };
|
|
297
|
+
for (const field of visible) {
|
|
298
|
+
const name = field.name;
|
|
299
|
+
const value = patch[name]!;
|
|
300
|
+
if (valueChanged(field, value, s.server[name])) {
|
|
301
|
+
pending[name] = value;
|
|
302
|
+
fieldState[name] = 'dirty';
|
|
303
|
+
fieldError[name] = null;
|
|
304
|
+
} else {
|
|
305
|
+
delete pending[name];
|
|
306
|
+
if (fieldState[name] !== 'saving') fieldState[name] = 'idle';
|
|
307
|
+
fieldError[name] = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
s = { ...s, pending, fieldState, fieldError };
|
|
311
|
+
return applyHideCascade(s, action.fields);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
case 'BATCH_START': {
|
|
315
|
+
const pending = { ...state.pending };
|
|
316
|
+
const fieldState = { ...state.fieldState };
|
|
317
|
+
for (const n of action.names) {
|
|
318
|
+
delete pending[n];
|
|
319
|
+
fieldState[n] = 'saving';
|
|
320
|
+
}
|
|
321
|
+
return { ...state, pending, fieldState, inflight: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case 'SAVE_OK': {
|
|
325
|
+
const server = { ...state.server, ...action.server };
|
|
326
|
+
const draft = { ...state.draft };
|
|
327
|
+
const fieldState = { ...state.fieldState };
|
|
328
|
+
const fieldError = { ...state.fieldError };
|
|
329
|
+
for (const n of action.names) {
|
|
330
|
+
if (n in state.pending) continue; // re-committed mid-flight — the next batch owns it
|
|
331
|
+
if (draft[n] === action.sent[n]) {
|
|
332
|
+
// Untouched since the POST left: show the server's copy
|
|
333
|
+
// (it may be normalized — e.g. E.164 phone).
|
|
334
|
+
if (n in action.server) draft[n] = action.server[n]!;
|
|
335
|
+
fieldState[n] = 'saved';
|
|
336
|
+
fieldError[n] = null;
|
|
337
|
+
} else {
|
|
338
|
+
// The user kept editing while the save flew — keep their text.
|
|
339
|
+
fieldState[n] = 'dirty';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// Untouched fields follow the fresh snapshot (the response row is
|
|
343
|
+
// server-authoritative for the whole record).
|
|
344
|
+
for (const [n, v] of Object.entries(action.server)) {
|
|
345
|
+
if (action.names.includes(n) || n in state.pending) continue;
|
|
346
|
+
const st = fieldState[n] ?? 'idle';
|
|
347
|
+
if (st === 'idle' || st === 'saved') draft[n] = v;
|
|
348
|
+
}
|
|
349
|
+
return { ...state, server, draft, fieldState, fieldError, inflight: false };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'SAVE_FAIL': {
|
|
353
|
+
const draft = { ...state.draft };
|
|
354
|
+
const pending = { ...state.pending };
|
|
355
|
+
const fieldState = { ...state.fieldState };
|
|
356
|
+
const fieldError = { ...state.fieldError };
|
|
357
|
+
for (const n of action.names) {
|
|
358
|
+
// REVERT to the server snapshot — the failed value never
|
|
359
|
+
// sticks (revertFields port). A mid-flight re-commit is
|
|
360
|
+
// dropped too: it would just fail again.
|
|
361
|
+
if (n in state.server) draft[n] = state.server[n]!;
|
|
362
|
+
delete pending[n];
|
|
363
|
+
fieldState[n] = 'error';
|
|
364
|
+
fieldError[n] = action.error;
|
|
365
|
+
}
|
|
366
|
+
return { ...state, draft, pending, fieldState, fieldError, inflight: false };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case 'FLASH_DONE': {
|
|
370
|
+
if (state.fieldState[action.name] !== 'saved') return state;
|
|
371
|
+
return { ...state, fieldState: { ...state.fieldState, [action.name]: 'idle' } };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case 'SYNC': {
|
|
375
|
+
const server = { ...state.server, ...action.server };
|
|
376
|
+
const draft = { ...state.draft };
|
|
377
|
+
for (const [n, v] of Object.entries(action.server)) {
|
|
378
|
+
if (!(n in draft)) {
|
|
379
|
+
draft[n] = v; // late-arriving field (registry tabs)
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const st = state.fieldState[n] ?? 'idle';
|
|
383
|
+
if ((st === 'idle' || st === 'saved') && !(n in state.pending)) draft[n] = v;
|
|
384
|
+
}
|
|
385
|
+
return { ...state, server, draft };
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ── The hook ──────────────────────────────────────────────────────────
|
|
391
|
+
|
|
392
|
+
export interface AutosaveBatchInfo {
|
|
393
|
+
/** The wire body the batch POSTed (dotted names expanded). */
|
|
394
|
+
changes: Record<string, unknown>;
|
|
395
|
+
/** Flat field names the batch carried. */
|
|
396
|
+
fields: string[];
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export interface UseFormAutosaveOptions<T> {
|
|
400
|
+
/** EVERY saveable field — flat fields plus every tab's fields. */
|
|
401
|
+
fields: Field[];
|
|
402
|
+
/** The current server row: initial values, revert target, reconcile source. */
|
|
403
|
+
row: T;
|
|
404
|
+
/** One batch POST. MUST reject on failure (defineModel useSave contract). */
|
|
405
|
+
save: (changes: Record<string, unknown>) => Promise<T>;
|
|
406
|
+
/** Batch window after the last commit (web-mojo: 300). */
|
|
407
|
+
debounceMs?: number;
|
|
408
|
+
/** How long the 'saved' check shows before returning to idle. */
|
|
409
|
+
savedFlashMs?: number;
|
|
410
|
+
onSaved?: (info: AutosaveBatchInfo & { row: T }) => void;
|
|
411
|
+
onSaveError?: (info: AutosaveBatchInfo & { error: Error }) => void;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export interface FormAutosaveApi {
|
|
415
|
+
/** Controlled display values, keyed by field name. */
|
|
416
|
+
values: FieldValues;
|
|
417
|
+
/** Per-field indicator state. Missing key = idle. */
|
|
418
|
+
status: Record<string, FieldStatus>;
|
|
419
|
+
/** Per-keystroke update (text inputs): display + dirty mark, no save. */
|
|
420
|
+
setValue: (name: string, value: FieldValue) => void;
|
|
421
|
+
/** The commit pipeline. Omitting `value` commits the current draft. */
|
|
422
|
+
commit: (name: string, value?: FieldValue) => void;
|
|
423
|
+
/** Atomic declared-field commit. One patch becomes one autosave batch. */
|
|
424
|
+
commitPatch: (patch: FieldValues) => void;
|
|
425
|
+
/** Visibility under showWhen against the LIVE values. */
|
|
426
|
+
visible: (field: Field) => boolean;
|
|
427
|
+
/** True while a POST is in flight. */
|
|
428
|
+
saving: boolean;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export function useFormAutosave<T>(opts: UseFormAutosaveOptions<T>): FormAutosaveApi {
|
|
432
|
+
const { fields, row } = opts;
|
|
433
|
+
const debounceMs = opts.debounceMs ?? 300;
|
|
434
|
+
const savedFlashMs = opts.savedFlashMs ?? 1500;
|
|
435
|
+
|
|
436
|
+
const [state, dispatch] = useReducer(
|
|
437
|
+
autosaveReducer,
|
|
438
|
+
undefined,
|
|
439
|
+
(): AutosaveState => {
|
|
440
|
+
const server = serverValuesFor(fields, row);
|
|
441
|
+
return {
|
|
442
|
+
draft: { ...server },
|
|
443
|
+
server,
|
|
444
|
+
fieldState: {},
|
|
445
|
+
fieldError: {},
|
|
446
|
+
pending: {},
|
|
447
|
+
inflight: false,
|
|
448
|
+
};
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// Latest state/opts for timer + async callbacks (assigned every render).
|
|
453
|
+
const stateRef = useRef(state);
|
|
454
|
+
stateRef.current = state;
|
|
455
|
+
const optsRef = useRef(opts);
|
|
456
|
+
optsRef.current = opts;
|
|
457
|
+
|
|
458
|
+
const byName = useMemo(() => {
|
|
459
|
+
const map = new Map<string, Field>();
|
|
460
|
+
for (const f of fields) map.set(f.name, f);
|
|
461
|
+
return map;
|
|
462
|
+
}, [fields]);
|
|
463
|
+
|
|
464
|
+
// ── timers (refs; they only ever dispatch) ────────────────────────
|
|
465
|
+
const batchTimerRef = useRef<number | null>(null);
|
|
466
|
+
const flashTimersRef = useRef(new Map<string, number>());
|
|
467
|
+
const fireBatchRef = useRef<() => void>(() => {});
|
|
468
|
+
|
|
469
|
+
const armBatchTimer = useCallback(() => {
|
|
470
|
+
if (batchTimerRef.current != null) window.clearTimeout(batchTimerRef.current);
|
|
471
|
+
batchTimerRef.current = window.setTimeout(() => {
|
|
472
|
+
batchTimerRef.current = null;
|
|
473
|
+
fireBatchRef.current();
|
|
474
|
+
}, debounceMs);
|
|
475
|
+
}, [debounceMs]);
|
|
476
|
+
|
|
477
|
+
const armFlashTimer = useCallback((name: string) => {
|
|
478
|
+
const old = flashTimersRef.current.get(name);
|
|
479
|
+
if (old != null) window.clearTimeout(old);
|
|
480
|
+
flashTimersRef.current.set(name, window.setTimeout(() => {
|
|
481
|
+
flashTimersRef.current.delete(name);
|
|
482
|
+
dispatch({ type: 'FLASH_DONE', name });
|
|
483
|
+
}, savedFlashMs));
|
|
484
|
+
}, [savedFlashMs]);
|
|
485
|
+
|
|
486
|
+
// ── the batch runner ──────────────────────────────────────────────
|
|
487
|
+
const fireBatch = useCallback(() => {
|
|
488
|
+
const s = stateRef.current;
|
|
489
|
+
if (s.inflight) return; // completion re-arms the timer (serialize)
|
|
490
|
+
const names = Object.keys(s.pending);
|
|
491
|
+
if (names.length === 0) return;
|
|
492
|
+
const sent: Record<string, FieldValue> = { ...s.pending };
|
|
493
|
+
const changes = expandDotted(sent);
|
|
494
|
+
dispatch({ type: 'BATCH_START', names });
|
|
495
|
+
|
|
496
|
+
optsRef.current.save(changes).then(
|
|
497
|
+
(savedRow) => {
|
|
498
|
+
const server = serverValuesFor(optsRef.current.fields, savedRow);
|
|
499
|
+
dispatch({ type: 'SAVE_OK', names, sent, server });
|
|
500
|
+
for (const n of names) armFlashTimer(n);
|
|
501
|
+
optsRef.current.onSaved?.({ changes, fields: names, row: savedRow });
|
|
502
|
+
armBatchTimer(); // drain anything committed mid-flight
|
|
503
|
+
},
|
|
504
|
+
(err: unknown) => {
|
|
505
|
+
const error = err instanceof Error ? err : new Error('Save failed');
|
|
506
|
+
dispatch({ type: 'SAVE_FAIL', names, error: error.message });
|
|
507
|
+
optsRef.current.onSaveError?.({ changes, fields: names, error });
|
|
508
|
+
armBatchTimer();
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
}, [armBatchTimer, armFlashTimer]);
|
|
512
|
+
fireBatchRef.current = fireBatch;
|
|
513
|
+
|
|
514
|
+
// ── public input surface ──────────────────────────────────────────
|
|
515
|
+
const setValue = useCallback((name: string, value: FieldValue) => {
|
|
516
|
+
const field = byName.get(name);
|
|
517
|
+
if (!field) return;
|
|
518
|
+
dispatch({ type: 'EDIT', field, value, fields: optsRef.current.fields });
|
|
519
|
+
}, [byName]);
|
|
520
|
+
|
|
521
|
+
const commit = useCallback((name: string, value?: FieldValue) => {
|
|
522
|
+
const field = byName.get(name);
|
|
523
|
+
if (!field) return;
|
|
524
|
+
const v = value !== undefined
|
|
525
|
+
? value
|
|
526
|
+
: stateRef.current.draft[name] ?? toDisplay(field, undefined);
|
|
527
|
+
dispatch({ type: 'COMMIT', field, value: v, fields: optsRef.current.fields });
|
|
528
|
+
// Arm unconditionally: if the commit didn't queue (zod / no-change)
|
|
529
|
+
// the timer fires into an unchanged pending set and no-ops.
|
|
530
|
+
armBatchTimer();
|
|
531
|
+
}, [armBatchTimer, byName]);
|
|
532
|
+
|
|
533
|
+
const commitPatch = useCallback((patch: FieldValues) => {
|
|
534
|
+
dispatch({ type: 'COMMIT_PATCH', patch, fields: optsRef.current.fields });
|
|
535
|
+
armBatchTimer();
|
|
536
|
+
}, [armBatchTimer]);
|
|
537
|
+
|
|
538
|
+
const visible = useCallback(
|
|
539
|
+
(field: Field) => resolveShowWhen(field.showWhen, state.draft),
|
|
540
|
+
[state.draft],
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
// ── reconcile: row prop changes + late-arriving fields ────────────
|
|
544
|
+
useEffect(() => {
|
|
545
|
+
dispatch({ type: 'SYNC', server: serverValuesFor(fields, row) });
|
|
546
|
+
}, [fields, row]);
|
|
547
|
+
|
|
548
|
+
// Unmount: cancel timers (an in-flight POST settles into a dead reducer
|
|
549
|
+
// harmlessly — React drops dispatches to unmounted components).
|
|
550
|
+
useEffect(() => () => {
|
|
551
|
+
if (batchTimerRef.current != null) window.clearTimeout(batchTimerRef.current);
|
|
552
|
+
for (const t of flashTimersRef.current.values()) window.clearTimeout(t);
|
|
553
|
+
flashTimersRef.current.clear();
|
|
554
|
+
}, []);
|
|
555
|
+
|
|
556
|
+
// ── public status (derives 'pending' from batch membership) ───────
|
|
557
|
+
const status = useMemo(() => {
|
|
558
|
+
const out: Record<string, FieldStatus> = {};
|
|
559
|
+
const names = new Set([...Object.keys(state.fieldState), ...Object.keys(state.pending)]);
|
|
560
|
+
for (const n of names) {
|
|
561
|
+
const stored = state.fieldState[n] ?? 'idle';
|
|
562
|
+
const effective: AutosaveStatus =
|
|
563
|
+
stored === 'saving' || stored === 'error' || stored === 'saved'
|
|
564
|
+
? stored
|
|
565
|
+
: n in state.pending ? 'pending' : stored;
|
|
566
|
+
const err = state.fieldError[n];
|
|
567
|
+
out[n] = effective === 'error' && err != null
|
|
568
|
+
? { status: effective, error: err }
|
|
569
|
+
: { status: effective };
|
|
570
|
+
}
|
|
571
|
+
return out;
|
|
572
|
+
}, [state.fieldState, state.fieldError, state.pending]);
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
values: state.draft,
|
|
576
|
+
status,
|
|
577
|
+
setValue,
|
|
578
|
+
commit,
|
|
579
|
+
commitPatch,
|
|
580
|
+
visible,
|
|
581
|
+
saving: state.inflight,
|
|
582
|
+
};
|
|
583
|
+
}
|