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