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,77 @@
|
|
|
1
|
+
// Mini FormBuilder — the field-definition language ported as data → JSX.
|
|
2
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
3
|
+
import type { Field, FormData } from '../client/types';
|
|
4
|
+
import { modal } from './modal';
|
|
5
|
+
import { SchemaFieldGrid, useSchemaFormState } from './schema-form-core';
|
|
6
|
+
|
|
7
|
+
export type { Field, FormData } from '../client/types';
|
|
8
|
+
export { SchemaSelect } from './schema-form-core';
|
|
9
|
+
|
|
10
|
+
export function SchemaForm({ fields, initial = {}, submitText = 'Save', onSubmit, onCancel, onBusyChange }: {
|
|
11
|
+
fields: Field[];
|
|
12
|
+
initial?: FormData;
|
|
13
|
+
submitText?: string;
|
|
14
|
+
/** Return the authoritative saved owner row when the form contains File relations. */
|
|
15
|
+
onSubmit: (data: FormData) => unknown | Promise<unknown>;
|
|
16
|
+
onCancel?: () => void;
|
|
17
|
+
onBusyChange?: (busy: boolean) => void;
|
|
18
|
+
}) {
|
|
19
|
+
const form = useSchemaFormState({ fields, initial, profile: 'schema-form' });
|
|
20
|
+
const [busy, setBusy] = useState(false);
|
|
21
|
+
useEffect(() => onBusyChange?.(busy || form.uploadPending), [busy, form.uploadPending, onBusyChange]);
|
|
22
|
+
|
|
23
|
+
const submit = async (event: React.FormEvent) => {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
const errors = form.validate();
|
|
26
|
+
if (Object.keys(errors).length) {
|
|
27
|
+
const first = form.visibleFields.find((field) => errors[field.name]);
|
|
28
|
+
if (first) form.focusField(first.name);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (form.uploadPending) return;
|
|
32
|
+
setBusy(true);
|
|
33
|
+
form.setFormError('');
|
|
34
|
+
const requested = form.payload();
|
|
35
|
+
try {
|
|
36
|
+
const row = await onSubmit(form.payload());
|
|
37
|
+
form.settleOwnerSave(row, false, requested);
|
|
38
|
+
} catch (error) {
|
|
39
|
+
form.settleOwnerSave(undefined, true, requested);
|
|
40
|
+
form.setFormError(error instanceof Error ? error.message : 'Save failed');
|
|
41
|
+
} finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<form onSubmit={submit} noValidate>
|
|
48
|
+
{form.formError && <div className="form-alert">{form.formError}</div>}
|
|
49
|
+
<SchemaFieldGrid fields={fields} state={form} disabled={busy} />
|
|
50
|
+
<div className="modal-actions">
|
|
51
|
+
{onCancel && <button type="button" className="btn" disabled={busy || form.uploadPending} onClick={onCancel}>Cancel</button>}
|
|
52
|
+
<button type="submit" className="btn btn-primary" disabled={busy || form.uploadPending}>
|
|
53
|
+
{busy ? 'Saving…' : submitText}
|
|
54
|
+
</button>
|
|
55
|
+
</div>
|
|
56
|
+
</form>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Awaitable form dialog: resolves the submitted data, or null on cancel. */
|
|
61
|
+
export function formModal(opts: { title: string; fields: Field[]; initial?: FormData; submitText?: string; intro?: ReactNode }): Promise<FormData | null> {
|
|
62
|
+
let pending = false;
|
|
63
|
+
return modal.open<FormData>((close) => (
|
|
64
|
+
<div className="modal-pad">
|
|
65
|
+
<h2 className="modal-title">{opts.title}</h2>
|
|
66
|
+
{opts.intro && <div className="modal-message">{opts.intro}</div>}
|
|
67
|
+
<SchemaForm
|
|
68
|
+
fields={opts.fields}
|
|
69
|
+
initial={opts.initial}
|
|
70
|
+
submitText={opts.submitText}
|
|
71
|
+
onBusyChange={(busy) => { pending = busy; }}
|
|
72
|
+
onCancel={() => close(null as unknown as FormData)}
|
|
73
|
+
onSubmit={(data) => close(data)}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
), { canDismiss: () => !pending });
|
|
77
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// FormView — the inline-autosave form surface ("no save buttons"), ported
|
|
2
|
+
// from web-mojo src/core/forms/FormView.js at behavior level:
|
|
3
|
+
// · the SchemaForm field language bound to a server row via defineModel
|
|
4
|
+
// · edits commit → 300ms batch → ONE save carrying only changed fields
|
|
5
|
+
// · per-field indicators: pending/saving spinner → saved flash → idle;
|
|
6
|
+
// errors pin with the server (or zod) message; failed batches revert
|
|
7
|
+
// · showWhen conditional fields (declarative rule or predicate)
|
|
8
|
+
// · permission tabsets via a REGISTRY (registerFormTabs) resolved
|
|
9
|
+
// fail-closed against me + the active-group member — replacing web-mojo's
|
|
10
|
+
// live-mutated permission arrays (User.js:160-347 / Member.js:80-170
|
|
11
|
+
// rebuildPermissions), per the do-not-recreate list.
|
|
12
|
+
//
|
|
13
|
+
// The machine itself lives in form-autosave.ts; this file renders it.
|
|
14
|
+
import { useCallback, useMemo, useRef, useState, useSyncExternalStore, useContext } from 'react';
|
|
15
|
+
import type { ReactNode } from 'react';
|
|
16
|
+
import { hasPermission, useMe } from '../client/me';
|
|
17
|
+
import { GroupContext } from '../client/group-context';
|
|
18
|
+
import type { PermSpec, ModelDef, SaveVars } from '../client/model';
|
|
19
|
+
import type { Field } from '../client/types';
|
|
20
|
+
import { SchemaSelect } from './FormFields';
|
|
21
|
+
import { Tabs } from './Tabs';
|
|
22
|
+
import { emptyFieldValue, resolveFieldRenderer } from './field-registry';
|
|
23
|
+
import { getPath } from './form-autosave';
|
|
24
|
+
import type { FileFieldOwnerResult } from './FileField';
|
|
25
|
+
import { toast } from './toast';
|
|
26
|
+
import {
|
|
27
|
+
useFormAutosave,
|
|
28
|
+
type AutosaveBatchInfo,
|
|
29
|
+
type FieldStatus,
|
|
30
|
+
type FormAutosaveApi,
|
|
31
|
+
} from './form-autosave';
|
|
32
|
+
|
|
33
|
+
// ── Tabset registry ───────────────────────────────────────────────────
|
|
34
|
+
// web-mojo extended permission tabsets by MUTATING cached arrays in place
|
|
35
|
+
// (`arr.length = 0; arr.push(…)` + rebuildPermissions). Here tabs live in a
|
|
36
|
+
// registry: register under a name once (or again — same-key tabs replace,
|
|
37
|
+
// new keys append, mirroring registerPermissions' additive semantics), and
|
|
38
|
+
// every FormView resolving that name re-renders via the subscription.
|
|
39
|
+
|
|
40
|
+
export interface FormTab {
|
|
41
|
+
key: string;
|
|
42
|
+
label: ReactNode;
|
|
43
|
+
/** useCan-style gate (any-of array). Fail-closed like <Guarded>. */
|
|
44
|
+
permissions?: PermSpec;
|
|
45
|
+
fields: Field[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const tabRegistry = new Map<string, FormTab[]>();
|
|
49
|
+
let tabsVersion = 0;
|
|
50
|
+
const tabListeners = new Set<() => void>();
|
|
51
|
+
|
|
52
|
+
function bumpTabs(): void {
|
|
53
|
+
tabsVersion += 1;
|
|
54
|
+
for (const cb of tabListeners) cb();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Register (or extend) a named tabset. Tabs with a key already present
|
|
59
|
+
* REPLACE that tab (idempotent under HMR / re-registration); new keys append
|
|
60
|
+
* in call order — an app adds its own tab beside the framework's.
|
|
61
|
+
*/
|
|
62
|
+
export function registerFormTabs(name: string, tabs: FormTab[]): void {
|
|
63
|
+
const current = [...(tabRegistry.get(name) ?? [])];
|
|
64
|
+
for (const tab of tabs) {
|
|
65
|
+
const at = current.findIndex((t) => t.key === tab.key);
|
|
66
|
+
if (at >= 0) current[at] = tab;
|
|
67
|
+
else current.push(tab);
|
|
68
|
+
}
|
|
69
|
+
tabRegistry.set(name, current);
|
|
70
|
+
bumpTabs();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Snapshot copy — callers can never mutate the registry through it. */
|
|
74
|
+
export function getFormTabs(name: string): FormTab[] {
|
|
75
|
+
return [...(tabRegistry.get(name) ?? [])];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function subscribeFormTabs(cb: () => void): () => void {
|
|
79
|
+
tabListeners.add(cb);
|
|
80
|
+
return () => tabListeners.delete(cb);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formTabsVersion(): number {
|
|
84
|
+
return tabsVersion;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One warn per unknown (name:type) — a config bug, not a render event.
|
|
88
|
+
const warnedFieldTypes = new Set<string>();
|
|
89
|
+
|
|
90
|
+
// ── FormView ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export interface FormViewProps<T extends { id: number | string }> {
|
|
93
|
+
/** The defineModel definition — its useSave carries the batch POST. */
|
|
94
|
+
model: Pick<ModelDef<T>, 'useSave'>;
|
|
95
|
+
/** The current server row this form edits. */
|
|
96
|
+
row: T;
|
|
97
|
+
/** Flat fields rendered above the tabset. */
|
|
98
|
+
fields?: Field[];
|
|
99
|
+
/** Permission tabset: a registry name (live) or inline tab defs. */
|
|
100
|
+
tabs?: string | FormTab[];
|
|
101
|
+
/** Batch window after the last commit. Default 300 (web-mojo parity). */
|
|
102
|
+
debounceMs?: number;
|
|
103
|
+
/** 'saved' flash duration. Default 1500. */
|
|
104
|
+
savedFlashMs?: number;
|
|
105
|
+
onSaved?: (info: AutosaveBatchInfo & { row: T }) => void;
|
|
106
|
+
onSaveError?: (info: AutosaveBatchInfo & { error: Error }) => void;
|
|
107
|
+
/** True while any FileField transfer is active. Owners can block dismissal. */
|
|
108
|
+
onPendingChange?: (pending: boolean) => void;
|
|
109
|
+
className?: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function FormView<T extends { id: number | string }>(props: FormViewProps<T>) {
|
|
113
|
+
const { model, row, fields, tabs, debounceMs, savedFlashMs, onSaved, onSaveError, className } = props;
|
|
114
|
+
const save = model.useSave();
|
|
115
|
+
|
|
116
|
+
// Registry-named tabsets re-render on registration; inline defs pass through.
|
|
117
|
+
const version = useSyncExternalStore(subscribeFormTabs, formTabsVersion, formTabsVersion);
|
|
118
|
+
const allTabs = useMemo<FormTab[]>(
|
|
119
|
+
() => (typeof tabs === 'string' ? getFormTabs(tabs) : tabs ?? []),
|
|
120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- version invalidates registry reads
|
|
121
|
+
[tabs, version],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Tab permissions resolve fail-closed (Guarded semantics): hidden while
|
|
125
|
+
// `me` loads, while anonymous, and when the check fails. The member fold
|
|
126
|
+
// matches useCan — group `admin` never grants sys.*.
|
|
127
|
+
const { data: me } = useMe();
|
|
128
|
+
const member = useContext(GroupContext)?.member ?? null;
|
|
129
|
+
const visibleTabs = useMemo(
|
|
130
|
+
() => allTabs.filter((t) => !t.permissions || hasPermission(me ?? null, t.permissions, member)),
|
|
131
|
+
[allTabs, me, member],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// The machine spans EVERY field — flat + all tabs (visible or not), so a
|
|
135
|
+
// batch can carry fields from anywhere and tab switches lose nothing.
|
|
136
|
+
const allFields = useMemo(
|
|
137
|
+
() => [...(fields ?? []), ...allTabs.flatMap((t) => t.fields)],
|
|
138
|
+
[fields, allTabs],
|
|
139
|
+
);
|
|
140
|
+
const pendingFields = useRef(new Set<string>());
|
|
141
|
+
const [ownerResults, setOwnerResults] = useState<Record<string, FileFieldOwnerResult>>({});
|
|
142
|
+
const ownerGeneration = useRef(0);
|
|
143
|
+
const setUploadPending = useCallback((name: string, pending: boolean) => {
|
|
144
|
+
if (pending) pendingFields.current.add(name);
|
|
145
|
+
else pendingFields.current.delete(name);
|
|
146
|
+
props.onPendingChange?.(pendingFields.current.size > 0);
|
|
147
|
+
}, [props.onPendingChange]);
|
|
148
|
+
const publishOwnerResult = useCallback((names: string[], status: FileFieldOwnerResult['status'], changes: Record<string, unknown>, savedRow?: T) => {
|
|
149
|
+
const generation = ++ownerGeneration.current;
|
|
150
|
+
setOwnerResults((current) => ({
|
|
151
|
+
...current,
|
|
152
|
+
...Object.fromEntries(names.map((name) => [name, {
|
|
153
|
+
generation,
|
|
154
|
+
status,
|
|
155
|
+
requestedValue: getPath(changes, name),
|
|
156
|
+
...(status === 'success' ? { authoritativeValue: getPath(savedRow, name) } : {}),
|
|
157
|
+
}])),
|
|
158
|
+
}));
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
const form = useFormAutosave<T>({
|
|
162
|
+
fields: allFields,
|
|
163
|
+
row,
|
|
164
|
+
save: (changes) => save.mutateAsync({ id: row.id, changes } satisfies SaveVars),
|
|
165
|
+
debounceMs,
|
|
166
|
+
savedFlashMs,
|
|
167
|
+
onSaved: (info) => {
|
|
168
|
+
publishOwnerResult(info.fields, 'success', info.changes, info.row);
|
|
169
|
+
onSaved?.(info);
|
|
170
|
+
},
|
|
171
|
+
onSaveError: (info) => {
|
|
172
|
+
publishOwnerResult(info.fields, 'failed', info.changes);
|
|
173
|
+
// Unmissable failure (workspec): pinned field error + toast.
|
|
174
|
+
toast.error(info.error.message);
|
|
175
|
+
onSaveError?.(info);
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={className ? `form-view ${className}` : 'form-view'}>
|
|
181
|
+
{fields && fields.length > 0 && (
|
|
182
|
+
<div className="form-grid">
|
|
183
|
+
{fields.filter(form.visible).map((f) => <FieldRow key={f.name} field={f} form={form} ownerResult={ownerResults[f.name]} onPendingChange={setUploadPending} />)}
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{visibleTabs.length > 0 && (
|
|
188
|
+
<Tabs
|
|
189
|
+
className="fv-tabs-shell"
|
|
190
|
+
variant="underline-all"
|
|
191
|
+
ariaLabel="Form sections"
|
|
192
|
+
items={visibleTabs.map((tab) => ({
|
|
193
|
+
key: tab.key,
|
|
194
|
+
label: (
|
|
195
|
+
<>
|
|
196
|
+
{tab.label}
|
|
197
|
+
{tab.fields.some((field) => form.status[field.name]?.status === 'error') && (
|
|
198
|
+
<span className="fv-tab-err" aria-label="Tab has an error" />
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
),
|
|
202
|
+
panel: (
|
|
203
|
+
<div className="form-grid">
|
|
204
|
+
{tab.fields.filter(form.visible).map((field) => <FieldRow key={field.name} field={field} form={form} ownerResult={ownerResults[field.name]} onPendingChange={setUploadPending} />)}
|
|
205
|
+
</div>
|
|
206
|
+
),
|
|
207
|
+
}))}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Field row ─────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** The label-inline status slot (FieldStatusManager's label-inline strategy). */
|
|
217
|
+
function StatusIcon({ st }: { st: FieldStatus | undefined }) {
|
|
218
|
+
switch (st?.status) {
|
|
219
|
+
case 'dirty':
|
|
220
|
+
return <span className="fv-status"><span className="fv-dot" title="Unsaved edit" /></span>;
|
|
221
|
+
case 'pending':
|
|
222
|
+
case 'saving':
|
|
223
|
+
return <span className="fv-status"><span className="fv-spinner" role="status" aria-label="Saving" /></span>;
|
|
224
|
+
case 'saved':
|
|
225
|
+
return <span className="fv-status"><i className="bi bi-check-circle-fill fv-saved" aria-label="Saved" /></span>;
|
|
226
|
+
case 'error':
|
|
227
|
+
return <span className="fv-status"><i className="bi bi-exclamation-circle-fill fv-error-ico" title={st.error} /></span>;
|
|
228
|
+
default:
|
|
229
|
+
return <span className="fv-status" />;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function FieldRow({ field, form, ownerResult, onPendingChange }: { field: Field; form: FormAutosaveApi; ownerResult?: FileFieldOwnerResult; onPendingChange: (name: string, pending: boolean) => void }) {
|
|
234
|
+
const st = form.status[field.name];
|
|
235
|
+
const error = st?.status === 'error' ? st.error : undefined;
|
|
236
|
+
const value = form.values[field.name];
|
|
237
|
+
|
|
238
|
+
const labelSlot: ReactNode = (
|
|
239
|
+
<span className="field-label fv-label">
|
|
240
|
+
<span>{field.label}{field.required && <em> *</em>}</span>
|
|
241
|
+
<StatusIcon st={st} />
|
|
242
|
+
</span>
|
|
243
|
+
);
|
|
244
|
+
const footSlot: ReactNode = (
|
|
245
|
+
<>
|
|
246
|
+
{error && <span className="field-error">{error}</span>}
|
|
247
|
+
{field.help && !error && <span className="field-help">{field.help}</span>}
|
|
248
|
+
</>
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
if (field.type === 'switch') {
|
|
252
|
+
return (
|
|
253
|
+
<div className={field.columns === 6 ? 'col-6' : 'col-12'}>
|
|
254
|
+
<label className="switch-row fv-switch-row">
|
|
255
|
+
{labelSlot}
|
|
256
|
+
<input
|
|
257
|
+
type="checkbox"
|
|
258
|
+
role="switch"
|
|
259
|
+
className="switch"
|
|
260
|
+
checked={value === true}
|
|
261
|
+
onChange={(e) => form.commit(field.name, e.target.checked)}
|
|
262
|
+
/>
|
|
263
|
+
</label>
|
|
264
|
+
{footSlot}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Non-builtin types resolve through the field-type registry (B4 #1278).
|
|
270
|
+
// Every registry control fires on COMMIT, so its one change gesture maps
|
|
271
|
+
// straight onto form.commit — same batch/indicator lifecycle as builtins.
|
|
272
|
+
// A <div>, not a <label>: these are composite widgets (triggers,
|
|
273
|
+
// popovers, chip lists) and label-click forwarding would fight them.
|
|
274
|
+
if (!['text', 'email', 'tel', 'select', 'textarea'].includes(field.type)) {
|
|
275
|
+
const Registered = resolveFieldRenderer(field.type);
|
|
276
|
+
if (Registered) {
|
|
277
|
+
return (
|
|
278
|
+
<div className={field.columns === 6 ? 'col-6' : 'col-12'}>
|
|
279
|
+
<div className="field">
|
|
280
|
+
{labelSlot}
|
|
281
|
+
<Registered
|
|
282
|
+
field={field}
|
|
283
|
+
value={value ?? emptyFieldValue(field)}
|
|
284
|
+
invalid={!!error}
|
|
285
|
+
commit={(v) => form.commit(field.name, v)}
|
|
286
|
+
commitPatch={form.commitPatch}
|
|
287
|
+
onPendingChange={(pending) => onPendingChange(field.name, pending)}
|
|
288
|
+
ownerResult={ownerResult}
|
|
289
|
+
/>
|
|
290
|
+
{footSlot}
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let control: ReactNode;
|
|
298
|
+
const text = String(value ?? '');
|
|
299
|
+
if (field.type === 'select') {
|
|
300
|
+
// Selects commit on change (a change IS the commit gesture).
|
|
301
|
+
control = (
|
|
302
|
+
<SchemaSelect
|
|
303
|
+
field={field}
|
|
304
|
+
value={text}
|
|
305
|
+
invalid={!!error}
|
|
306
|
+
onChange={(v) => form.commit(field.name, v)}
|
|
307
|
+
/>
|
|
308
|
+
);
|
|
309
|
+
} else if (field.type === 'textarea') {
|
|
310
|
+
// Textareas commit on blur only — Enter inserts a newline.
|
|
311
|
+
control = (
|
|
312
|
+
<textarea
|
|
313
|
+
className={`input${error ? ' input-invalid' : ''}`}
|
|
314
|
+
rows={3}
|
|
315
|
+
placeholder={field.placeholder}
|
|
316
|
+
value={text}
|
|
317
|
+
onChange={(e) => form.setValue(field.name, e.target.value)}
|
|
318
|
+
onBlur={() => form.commit(field.name)}
|
|
319
|
+
/>
|
|
320
|
+
);
|
|
321
|
+
} else {
|
|
322
|
+
// text | email | tel — and the unknown-type fallback (warn + text,
|
|
323
|
+
// never "render nothing").
|
|
324
|
+
if (!['text', 'email', 'tel'].includes(field.type)) {
|
|
325
|
+
const key = `${field.name}:${field.type}`;
|
|
326
|
+
if (!warnedFieldTypes.has(key)) {
|
|
327
|
+
warnedFieldTypes.add(key);
|
|
328
|
+
console.warn(`FormView: unknown field type "${field.type}" on "${field.name}" — rendering a text input`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
control = (
|
|
332
|
+
<input
|
|
333
|
+
className={`input${error ? ' input-invalid' : ''}`}
|
|
334
|
+
type={['text', 'email', 'tel'].includes(field.type) ? field.type : 'text'}
|
|
335
|
+
placeholder={field.placeholder}
|
|
336
|
+
value={text}
|
|
337
|
+
onChange={(e) => form.setValue(field.name, e.target.value)}
|
|
338
|
+
onBlur={() => form.commit(field.name)}
|
|
339
|
+
onKeyDown={(e) => {
|
|
340
|
+
// Enter commits (change-on-commit contract) without leaving
|
|
341
|
+
// the field.
|
|
342
|
+
if (e.key === 'Enter') {
|
|
343
|
+
e.preventDefault();
|
|
344
|
+
form.commit(field.name);
|
|
345
|
+
}
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<div className={field.columns === 6 ? 'col-6' : 'col-12'}>
|
|
353
|
+
<label className="field">
|
|
354
|
+
{labelSlot}
|
|
355
|
+
{control}
|
|
356
|
+
{footSlot}
|
|
357
|
+
</label>
|
|
358
|
+
</div>
|
|
359
|
+
);
|
|
360
|
+
}
|