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.
Files changed (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. 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
+ }