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,170 @@
1
+ # DateRangePicker + PresetRail
2
+
3
+ ```ts
4
+ import { DateRangePicker, PresetRail } from 'portal-mojo/ui';
5
+ import type { DateRangeChangeEvent, PresetEntry, PresetsOption } from 'portal-mojo/ui';
6
+ ```
7
+
8
+ The range picker built on the [Calendar engine](calendar.md) and the shared
9
+ [Popover](popover.md), with the "Quick range" preset rail. Ported from
10
+ web-mojo's `DateRangePicker.js` + `calendar/PresetSidebar.js`. One
11
+ component, three precisions: `'day'` (default, two side-by-side panes),
12
+ `'month'`, `'year'` — each with its own default preset list, display
13
+ format, and placeholder.
14
+
15
+ ```tsx
16
+ const [start, setStart] = useState('');
17
+ const [end, setEnd] = useState('');
18
+
19
+ <DateRangePicker
20
+ start={start}
21
+ end={end}
22
+ onChange={(e) => { setStart(e.start); setEnd(e.end); }}
23
+ />
24
+ ```
25
+
26
+ ## Value contract
27
+
28
+ **Controlled, canonical strings.** `start`/`end` are the engine's canonical
29
+ strings at precision — `YYYY-MM-DD` / `YYYY-MM` / `YYYY` — with `''`/null
30
+ as empty. Every commit (completed calendar range, preset pick, clear ✕)
31
+ emits `onChange` and paints only once the owner writes the props back:
32
+
33
+ ```ts
34
+ interface DateRangeChangeEvent {
35
+ start: string; // canonical at precision ('' when cleared)
36
+ end: string;
37
+ formatted: string; // display text of the NEW range (what the trigger shows)
38
+ oldStart: string; // the props at emit time
39
+ oldEnd: string;
40
+ }
41
+ ```
42
+
43
+ `onChange` fires only when the pair actually changes (re-picking the current
44
+ range emits nothing). There is no per-keystroke path — commits only.
45
+
46
+ ## `<DateRangePicker>` API
47
+
48
+ | Prop | Notes |
49
+ |---|---|
50
+ | `start` / `end` | Controlled canonical strings (or `''`/null). |
51
+ | `onChange` | `(DateRangeChangeEvent) => void` — see above. |
52
+ | `precision` | `'day' \| 'month' \| 'year'` (default `day`). Unknown values warn + fall back to `day`. |
53
+ | `months` | Day-view panes `1 \| 2`. Default **2 for day precision, 1 for month/year** (source rule). Clamped like the engine. |
54
+ | `presets` | `'default' \| true` → the precision's built-in list; `PresetEntry[]` → custom; absent/`false`/`[]` → no rail. |
55
+ | `displayFormat` | Display tokens (`YYYY YY MMMM MMM MM M DD D`). Defaults: day `MMM DD, YYYY`, month `MMM YYYY`, year `YYYY`. Tokens the precision can't honor are stripped (the source's `_stripIncompatibleTokens`, ported verbatim — trailing separator debris swept). |
56
+ | `separator` | Between the formatted ends. Default `' – '` (spaced en dash). |
57
+ | `placeholder` | Default per precision: `Select date range...` / `Select month range...` / `Select year range...`. |
58
+ | `min` / `max` | Canonical strings, passed straight to the Calendar (compared at the pane precision being painted). |
59
+ | `disabled` | Trigger disabled; popover can't open. |
60
+ | `readOnly` | Trigger shows the value but won't open; no clear ✕. |
61
+ | `required` | Hides the clear ✕ (the field can't be emptied from the trigger). |
62
+ | `inline` | Render rail + calendar in place — no trigger, no popover. |
63
+ | `autoApply` | Default `true`. See semantics below. |
64
+ | `className` / `id` | Root class / trigger button id. |
65
+
66
+ **`autoApply` means auto-CLOSE, not staged apply** (source semantics carried
67
+ exactly): values apply on every commit either way; `autoApply` only decides
68
+ whether completing a range or picking a preset also closes the popover.
69
+ For an explicit Apply step, mount `inline` inside a dialog and stage the
70
+ values in the dialog's own state — that is precisely what FilterBar does.
71
+
72
+ ## Trigger anatomy
73
+
74
+ `bi-calendar3-range` icon + formatted text (`start – end`, tabular-nums,
75
+ ellipsized) or muted placeholder + clear ✕. The ✕ renders whenever the
76
+ picker is not `required`/`disabled`/`readOnly` and clears both ends in one
77
+ commit. It is an absolutely-positioned **sibling** button of the trigger —
78
+ web-mojo nested `<button>` inside `<button>`, invalid HTML; do not
79
+ reintroduce. A half-set range displays just the one formatted end
80
+ (deep-linked one-sided `dr_*` filters still render).
81
+
82
+ ## Presets
83
+
84
+ ```ts
85
+ type PresetsOption = 'default' | true | PresetEntry[];
86
+ type PresetEntry =
87
+ | { label: string; range: () => { start: ParsedDate | string; end: ParsedDate | string } }
88
+ | { divider: true };
89
+ ```
90
+
91
+ `range()` is resolved at click time (so "Today" is always today) and may
92
+ return parsed shapes (`{y,m,d}` / `{y,m}` / `{y}`) or canonical strings.
93
+ Default lists (verbatim from source; builders exported as
94
+ `dayPresets()` / `monthPresets()` / `yearPresets()` / `defaultPresets(p)`):
95
+
96
+ | Precision | List |
97
+ |---|---|
98
+ | day | Today · Yesterday · Last 7 days · Last 30 days · Last 90 days · ─ · This month · Last month · This year |
99
+ | month | This month · Last month · Last 3 months · Last 6 months · YTD · Last 12 months |
100
+ | year | This year · Last year · Last 3 years · Last 5 years · Last 10 years |
101
+
102
+ Rail behavior: "Quick range" eyebrow (`eyebrow` prop on `PresetRail`; `''`
103
+ hides it); the picked preset highlights and stays highlighted until a
104
+ **manual calendar anchor starts** (`onRangeStart` → highlight cleared —
105
+ source parity). Dividers consume an index, so `activeIndex` lines up with
106
+ the flat entry list. `<PresetRail>` is exported standalone (controlled
107
+ `activeIndex`, `onSelect` emits `{index, label, start, end, range}` with
108
+ canonical strings); the C2 custom-range dialog can reuse it.
109
+
110
+ ## FilterBar integration (the dr_* wire triple)
111
+
112
+ FilterBar's daterange dialog mounts `<DateRangePicker inline months={1}
113
+ presets="default">` and stages `e.start`/`e.end` in dialog state; its
114
+ **Apply** button writes `params.setDateRange(field, start, end)` — the
115
+ `dr_field`/`dr_start`/`dr_end` triple — exactly as the old native date
116
+ inputs did. Same canonical `YYYY-MM-DD` values on the wire, same pills,
117
+ same deep links; only the dialog body changed. The params store remains the
118
+ single source of truth; the picker never touches it directly.
119
+
120
+ ## Form-registry aliases (for the #1278 field registry)
121
+
122
+ Source `inputs/index.js` registers three aliases over this one component —
123
+ carry them when the registry lands:
124
+
125
+ | Field type | Component | `precision` |
126
+ |---|---|---|
127
+ | `daterange` | DateRangePicker | `day` |
128
+ | `monthrange` | DateRangePicker | `month` |
129
+ | `yearrange` | DateRangePicker | `year` |
130
+
131
+ (Explicit `precision` in the field options beats the alias, per source's
132
+ `createInput`.)
133
+
134
+ ## Deviations from source (deliberate)
135
+
136
+ - **Controlled props replace the form-DOM bridge**: no hidden `<input>`s,
137
+ no `name`/`startName`/`endName`/`fieldName`, no `outputFormat`
138
+ (`combined` was `start + separator + end` — derive it if needed), no
139
+ `getFormValue`/`setFormValue`. The #1278 registry adapter owns form
140
+ wiring.
141
+ - **A half-picked anchor does not survive popover close/reopen.** The
142
+ anchor is Calendar-internal state and the popover unmounts its content on
143
+ close; web-mojo kept one live calendar element across opens. The
144
+ load-bearing behavior — the anchor surviving paging/zooming
145
+ mid-selection — is engine-owned and fully preserved; reopening shows the
146
+ committed range. Revisit only if the engine grows a controlled `anchor`
147
+ prop.
148
+ - **Escape closes the popover first** (Popover owns Escape in the capture
149
+ phase — one press, popover only; a second reaches an enclosing modal).
150
+ The engine's Escape-cancels-anchor applies in `inline` mode.
151
+ - `is-invalid` styling was not carried — source's `hasError()` was a stub
152
+ that always returned false.
153
+
154
+ ## Pitfalls
155
+
156
+ - **Do not write `start`/`end` back from anything but `onChange`.** A range
157
+ prop write is `setRange` semantics to the engine — it cancels an
158
+ in-progress anchor (that is also why preset picks repaint correctly:
159
+ owner writes back, engine syncs).
160
+ - Presets are **not min/max-clamped** (source parity): a custom preset can
161
+ commit out-of-bounds values even though the grid cells paint disabled.
162
+ Clamp inside your `range()` if bounds matter.
163
+ - The popover body sizes itself (`180px` rail + fixed calendar width);
164
+ `inline` is fluid to its host. Two panes + rail ≈ 850px — don't mount
165
+ two-pane popovers inside narrow scroll containers, and keep dialogs on
166
+ `months={1}`.
167
+ - Styling lives in `apps/portal/src/theme/daterange.css` (trigger
168
+ `.mojo-daterange-*`, rail `.mojo-calendar-preset*`), tokens only, both
169
+ themes; the calendar grid ships in `calendar.css`, the popover shell in
170
+ `popover.css`.
@@ -0,0 +1,249 @@
1
+ # DateTimePicker
2
+
3
+ The combined date **and** time field, ported from web-mojo
4
+ `forms/inputs/DateTimePicker.js` (523 lines) — the source's locked **variant
5
+ A**: one popover with the day [`Calendar`](calendar.md) on the left, the
6
+ embedded [`TimePicker`](timepicker.md) steppers on the right under a "Time"
7
+ heading, a full-width [`TimezoneSelect`](timezone-select.md) row below the
8
+ pair, and a Now/Done footer. **One field, one value** — the timezone belongs
9
+ to this picker, it is never a second field.
10
+
11
+ ```ts
12
+ import { DateTimePicker } from 'portal-mojo/ui';
13
+ import type {
14
+ DateTimeValue, DateTimeInput, DateTimeChangeEvent,
15
+ DateTimeObjectValue, DateTimeOutputFormat,
16
+ } from 'portal-mojo/ui';
17
+ ```
18
+
19
+ ## Value shapes (the contract)
20
+
21
+ Storage is **always canonical**: `YYYY-MM-DD` + 24h `HH:MM` + an optional IANA
22
+ zone. `displayFormat`/`timeFormat` only decide what the trigger renders;
23
+ `outputFormat` decides what leaves:
24
+
25
+ | `outputFormat` | with a zone | without a zone | empty |
26
+ |---|---|---|---|
27
+ | `'iso'` (default) | `'2026-07-04T14:30:00-07:00'` | `'2026-07-04T14:30:00'` | `''` |
28
+ | `'iana'` (legacy) | `'2026-07-04 14:30 America/Los_Angeles'` | `'2026-07-04 14:30'` | `''` |
29
+ | `'object'` | `{date:'2026-07-04', time:'14:30', timezone:'America/Los_Angeles'}` | `{date, time}` (no `timezone` key) | `null` |
30
+
31
+ `'object'` always yields an object when a date is set — unlike TimePicker, it
32
+ never degrades to a string. The `timezone` key exists (possibly `null`) exactly
33
+ when the zone row is enabled.
34
+
35
+ **The ISO offset is DST-correct at the value's own date.** It is computed with
36
+ `dateFns.ianaOffset(zone, <the selected instant>)`, so `America/Los_Angeles`
37
+ serializes `-07:00` on July 4 and `-08:00` on January 4. (`TimePicker` cannot
38
+ do this — a wall-clock time has no date, so it must resolve against
39
+ `new Date()`.)
40
+
41
+ **Everything above parses back in**, plus the looser shapes the source
42
+ accepted:
43
+
44
+ - date only — `'2026-05-04'` (time reads as `00:00`)
45
+ - 12h text — `'2026-05-04 2:30 PM'`, with or without an IANA tail
46
+ - ISO offsets in every spelling — `Z` → `+00:00`, `+0530` → `+05:30`
47
+ - `T` or a space between date and time
48
+ - loose numbers — `'2026-5-4 14:30'`
49
+
50
+ Two coverage **extensions over `dateFns.parseDateTime`**, both implemented in
51
+ `DateTimePicker.tsx` because the shared parser would otherwise drop data the
52
+ caller supplied:
53
+
54
+ 1. an object with **no date keeps its `timezone`** (`parseDateTime` returns
55
+ `null` for the whole thing);
56
+ 2. an offset separated from the time by a **space** —
57
+ `'2026-05-04 14:30 -07:00'` (`parseDateTime` reads an offset only when it is
58
+ glued to the time, and a zone *name* only after a space).
59
+
60
+ Anything else parses to empty **with one `console.warn` per distinct value**
61
+ (house rule 4) — never a silent blank.
62
+
63
+ ## API
64
+
65
+ | Prop | Notes |
66
+ |---|---|
67
+ | `value` | `string \| {date?, time?, timezone?} \| null` — see above. Omit entirely for an uncontrolled picker |
68
+ | `onChange` | `({value, formatted, oldValue})` — fires on **commit only**, and only when the serialized string actually changed |
69
+ | `displayFormat` | date half of the trigger text (`formatForDisplay` tokens). Default `'MMM DD, YYYY'` |
70
+ | `timeFormat` | `'24h'` (default) \| `'12h'`. Unknown values warn + fall back |
71
+ | `timeStep` | minute stepping increment for the time column, ≥1 (default 1) |
72
+ | `min` / `max` | a date (`'2026-05-04'`) **or** a datetime (`'2026-05-04 09:00'`) — see bounds below |
73
+ | `placeholder` | default `'Pick date & time...'` |
74
+ | `disabled` / `readOnly` / `required` / `invalid` | trigger states; `required`, `disabled` and `readOnly` each suppress the clear ✕ |
75
+ | `inline` | render the panel in place — no trigger, no popover, no Done |
76
+ | `disabledDates` | individually blocked days, `"YYYY-MM-DD"` |
77
+ | `firstDay` / `locale` | passed to the Calendar. Default `1` (Mon) / `'en-US'` |
78
+ | `timezone` | `true` enables the zone row; a `string[]` enables it **and is the zone list** |
79
+ | `timezones` | zone list when `timezone` is `true` (default: the engine's own list) |
80
+ | `outputFormat` | `'iso'` \| `'iana'` \| `'object'`. Unknown values warn + fall back |
81
+ | `renderTimezoneSelect` | the zone-picker slot — see below |
82
+ | `name` | renders a hidden input carrying the serialized string (native-form use) |
83
+ | `id` | trigger id, for a label's `htmlFor` |
84
+ | `className` | appended to the root |
85
+
86
+ `DateTimeChangeEvent.oldValue` is always the **previous canonical serialized
87
+ string** (`''` when empty), in every `outputFormat` — it is the diffing key,
88
+ not a mirror of `value`.
89
+
90
+ Types split in/out on purpose: `value` takes `DateTimeInput` (every part
91
+ optional), `onChange` hands back `DateTimeValue` (parts guaranteed). Writing
92
+ `event.value` straight back into `value` type-checks.
93
+
94
+ ## Layout (the locked variant)
95
+
96
+ ```
97
+ ┌──────────────────────────────┬──────────────────┐
98
+ │ Calendar precision="day" │ TIME │
99
+ │ mode="single" months={1} │ ▲ ▲ [AM] │
100
+ │ │ 09 : 30 [PM] │
101
+ │ │ ▼ ▼ │
102
+ ├──────────────────────────────┴──────────────────┤
103
+ │ TIMEZONE │
104
+ │ [ America/Los_Angeles (UTC−07:00) ▾ ] │
105
+ ├─────────────────────────────────────────────────┤
106
+ │ Now [Done] │
107
+ └─────────────────────────────────────────────────┘
108
+ ```
109
+
110
+ The time column embeds `TimePicker inline showFooter={false} timezone={false}`
111
+ — the footerless variant that exists for exactly this. The zone row sits below
112
+ the pair, full width, so the IANA combobox has room for its labels. **Done only
113
+ closes the popover**; every edit committed the moment it was made.
114
+
115
+ ## Behavior invariants (from source, keep them true)
116
+
117
+ - **Commit-only pipeline.** `onChange` fires on: a calendar pick, any
118
+ TimePicker commit (stepper, typed + Enter/blur, AM/PM), a zone pick, **Now**,
119
+ and the clear ✕. Typing in a time column changes nothing until it commits.
120
+ Re-committing the same value is silent.
121
+ - **Defaults that make one field out of two.** Picking a date with no time yet
122
+ → time `00:00`. Committing a time with no date → date **today**, and the
123
+ calendar follows (it reads the same state). **Now** sets both.
124
+ - **min/max**: the DATE part bounds the calendar (out-of-range days are
125
+ disabled). Where the bound also spelled a TIME, that time clamps **on the
126
+ boundary day only** — `min="2026-05-04 09:00"` forbids 08:00 on May 4 and
127
+ nothing at all on May 5. `Now` is clamped through the same rule, so it can
128
+ never escape the window.
129
+ - **The clamp lives in two places on purpose.** The picker clamps every commit,
130
+ *and* hands the embedded TimePicker a day-scoped `min`/`max` so it clamps
131
+ itself. Without the second, the steppers would keep showing the pre-clamp
132
+ value the parent just rejected.
133
+ - **A cleared field keeps its zone** — the zone is a picker setting, not data
134
+ (source `clear()`).
135
+ - The trigger is `bi-calendar3` + `MMM DD, YYYY HH:MM [Zone]` + a clear ✕ that
136
+ appears on hover/focus (suppressed by `required`/`disabled`/`readOnly`).
137
+
138
+ ## The timezone seam
139
+
140
+ The picker owns every bit of the zone's value and serialization logic but
141
+ renders the picker itself through a slot. Its **default tenant is the real
142
+ `<TimezoneSelect>`**, so `timezone` alone gives you the full searchable,
143
+ offset-labelled zone list:
144
+
145
+ ```tsx
146
+ <DateTimePicker timezone value={value} onChange={(e) => setValue(e.value)} />
147
+ ```
148
+
149
+ Override only to change the rendering (a shorter list, a different control):
150
+
151
+ ```tsx
152
+ <DateTimePicker
153
+ timezone
154
+ renderTimezoneSelect={({ value, onChange, timezones, disabled }) => (
155
+ <TimezoneSelect value={value} timezones={timezones ?? null} disabled={disabled}
156
+ onChange={(e) => onChange(e.value)} />
157
+ )}
158
+ />
159
+ ```
160
+
161
+ The slot contract is `TimezoneSelectSlotProps` — the same one `TimePicker`
162
+ uses, so a slot written for one drops into the other. Enabling `timezone`
163
+ seeds the browser's local zone **without emitting** (source parity); the owner
164
+ learns it on the first real commit.
165
+
166
+ ## State ownership (read this before wiring a form)
167
+
168
+ The picker holds its **own committed state**, seeded from `value` and re-seeded
169
+ whenever `value` changes to something that is not an echo of its own last emit
170
+ — the same shape [`Calendar`](calendar.md) and [`TimePicker`](timepicker.md)
171
+ use. Write `event.value` straight back into `value` and the two stay in
172
+ lockstep; omit `value` entirely and it behaves as an uncontrolled picker.
173
+
174
+ Why not derive purely from props: `'iso'` output carries only the **offset**,
175
+ so re-parsing our own echo would silently degrade `'America/Los_Angeles'` →
176
+ `'-07:00'`. The zone therefore lives in component state, and the echo guard is
177
+ what protects it. Corollary: **writing back the same value is a no-op, and
178
+ writing a different one re-seeds the panel** — to refuse a value, write the
179
+ value you *do* want.
180
+
181
+ ## The wire reality (epoch seconds)
182
+
183
+ django-mojo model datetimes are **epoch SECONDS** on the wire — this component
184
+ never speaks them. Its contract is the string/object shapes above; the
185
+ **field-type registry (#1278) owns the save-boundary conversion** in both
186
+ directions:
187
+
188
+ ```
189
+ wire (int seconds) ──▶ registry: seconds → 'YYYY-MM-DDTHH:MM:00±HH:MM' ──▶ value
190
+ value ──▶ registry: parse + Date.getTime()/1000 ──▶ wire (int seconds)
191
+ ```
192
+
193
+ Passing an epoch number (or its digits as a string) as `value` parses to empty
194
+ with the usual warn — that is the registry's bug to catch, not a shape this
195
+ picker guesses at. When the zone matters to the *server*, prefer `'iana'` or
196
+ `'object'` output: an offset is a fact about one instant, an IANA name is a
197
+ fact about a place. And remember `fmt.*` accepts epoch seconds directly — do
198
+ not `new Date(epochSeconds)` without `× 1000`.
199
+
200
+ ## Deviations from source (deliberate)
201
+
202
+ 1. **No-op commits are silent, and `oldValue` is real.** web-mojo called
203
+ `_syncOutputs()` with no argument on nearly every path, which emitted
204
+ `change` *every time* with `oldValue: null` — even when nothing changed.
205
+ Here `onChange` fires only on a real change and always carries the previous
206
+ serialized string.
207
+ 2. **Time clamping on the boundary day** (the source only date-bounded the
208
+ calendar, so a `09:00`–`17:30` window was unenforceable on the first and
209
+ last day). A date-only bound still clamps nothing but the date.
210
+ 3. **`Now` is clamped** like every other commit path.
211
+ 4. **An already-offset zone passes through** `'iso'` serialization
212
+ (`'…-07:00'` re-saves as `'…-07:00'`). `ianaOffset('-07:00')` returns
213
+ `null`, so the source dropped it on re-save.
214
+ 5. **The clear ✕ is a sibling of the trigger**, not nested inside it (the
215
+ source produced a `<button>` inside a `<button>`).
216
+ 6. **`inline` omits Done** — it exists to dismiss the popover and there is
217
+ none. The source rendered a dead button.
218
+ 7. `disabled`/`readOnly` are enforced at the commit boundary, so a locked
219
+ `inline` panel is inert (the source only guarded the popover).
220
+ 8. The dead `format` and `autoApply` options (stored, never read in source)
221
+ are not carried, and there is no `hasError()` stub — pass `invalid`.
222
+
223
+ ## Styling
224
+
225
+ `apps/portal/src/theme/datetimepicker.css` — layout glue only
226
+ (`mojo-datetime-*`: `trigger` / `trigger-text` / `trigger-clear` /
227
+ `popover-inner` / `row` / `cal-col` / `time-col` / `time-head` / `tz-row` /
228
+ `tz-label` / `tz-host` / `foot`), tokens only, both themes. The grid, the
229
+ steppers, the combobox and the floating shell are already styled by
230
+ `calendar.css`, `timepicker.css`, `combobox.css` and `popover.css`; this file
231
+ adds the two-column popover (520px floor, stacked under 620px), strips the
232
+ embedded TimePicker's card chrome, and gives the trigger the house `.input`
233
+ look so it lines up with DatePicker/TimePicker in a form grid.
234
+
235
+ ## Pitfalls
236
+
237
+ - Don't read the time columns to get the value — they may hold an uncommitted
238
+ draft. `onChange`/`value` is the only value channel.
239
+ - `'object'` output is `null` when empty, `''` for every other `outputFormat`.
240
+ Check both if you switch formats at runtime.
241
+ - `min`/`max` are **not** a general datetime window: the time half only applies
242
+ on the boundary day (that is what the calendar can express). A window like
243
+ "May 4 09:00 → May 8 17:30" is honored at both ends and unrestricted between
244
+ them.
245
+ - With the zone row open, **Escape closes the whole picker**, not just the
246
+ combobox dropdown — the shared Popover claims Escape in the capture phase.
247
+ - The zone row's dropdown is locally positioned and can extend past the
248
+ popover's edge; that is intentional (`.mojo-popover` is `overflow: visible`)
249
+ and matches web-mojo.
@@ -0,0 +1,72 @@
1
+ # defineModel — typed model definitions + hooks
2
+
3
+ ```ts
4
+ import { defineModel, type ModelDef, type ActionOutcome } from 'portal-mojo/client';
5
+ ```
6
+
7
+ A model is a **stateless definition** — endpoint + permissions + forms +
8
+ actions — plus hooks bound to it. TanStack Query owns all data; there are
9
+ no model instances (web-mojo's `Model`/`Collection` classes end here).
10
+
11
+ ```ts
12
+ export const UserModel = defineModel<User>({
13
+ name: 'user',
14
+ endpoint: '/api/user',
15
+ permissions: { view: ['users', 'view_users'], manage: ['users', 'manage_users'] },
16
+ forms: { create: { title: 'Add user', submitText: 'Create', fields: [/* Field[] */] } },
17
+ actions: {
18
+ disable: { permissions: ['users', 'manage_users'] }, // response: 'row' (default)
19
+ revoke_sessions: { response: 'payload' }, // action-specific body
20
+ },
21
+ });
22
+ ```
23
+
24
+ `T` must extend `{ id: number | string }`.
25
+
26
+ ## Hooks & helpers on the returned def
27
+
28
+ | Member | Behavior |
29
+ |---|---|
30
+ | `useList(params?, {enabled?})` | `useQuery` list under key `[endpoint, params]` with `keepPreviousData`; `enabled:false` suppresses permission-hidden background reads |
31
+ | `useOne(id \| null)` | one record, key `[endpoint, 'one', id]`; disabled while `id` is null |
32
+ | `useSave()` | mutation `{id, changes}`; `id: null` creates. Handles one fresh-auth 440/retry, then writes the returned row into the one-record cache and invalidates `[endpoint]`. REJECTS on failure |
33
+ | `useDelete()` | mutation `{id}`; removes the one-record cache entry + invalidates |
34
+ | `useAction(name)` | mutation for ONE declared POST_SAVE_ACTION (below), with one fresh-auth 440/retry. THROWS at render time for an undeclared name |
35
+ | `fetchOne(queryClient, id)` | imperative fetch through the same cache key — dedupes with mounted `useOne`s (prefetch pattern) |
36
+ | `invalidate(queryClient)` | invalidate everything under `[endpoint]` |
37
+ | `keys.root / keys.list(params) / keys.one(id)` | key builders for targeted cache surgery |
38
+
39
+ Cache keys are shared with the generic hooks (`useModelList`, `useModel`),
40
+ so `ModelTable` and model hooks read/invalidate one cache.
41
+
42
+ Save/action mutation functions use `withFreshAuth` globally. Their original
43
+ variables and declared response mode survive the retry, and cache updates run
44
+ only on final success. Delete remains unwrapped, as do direct `mojoCall`
45
+ mutations; callers opt those in only when the endpoint is both sensitive and
46
+ safe to retry once.
47
+
48
+ ## POST_SAVE_ACTIONS
49
+
50
+ Backend contract (`mojo/models/rest.py on_rest_save`): an action is a key
51
+ in the save body — `POST endpoint/<id>` `{disable: {reason, note}}`. Plain
52
+ fields in the same body save first, then `on_action_<key>(value)` runs.
53
+ The response is either the refreshed row (handler returned nothing) or an
54
+ action-specific payload. **Which one is DECLARED, never sniffed:**
55
+
56
+ - `response: 'row'` (default) → `ActionOutcome.row` is the refreshed record
57
+ (written through to the cache).
58
+ - `response: 'payload'` → `row` is null; read `outcome.body` (e.g.
59
+ `body.message` for `revoke_sessions` — toast the server's text).
60
+
61
+ Invoke: `await disableAction.mutateAsync({ id, payload: { reason: 'abuse' } })`.
62
+ `payload` defaults to `{}`; handlers treat the value as a dict. Errors
63
+ (e.g. `"reason must be one of: abuse, admin"`) REJECT with the server's
64
+ message.
65
+
66
+ ## Pitfalls
67
+
68
+ - Don't build a second store around these hooks — pages read query state.
69
+ - The action-name union isn't typed; an undeclared name throws in dev the
70
+ first time the hook renders (fail loud, not a 404 later).
71
+ - UI `permissions` on the def are affordance gates for `useCan`; the server
72
+ re-checks every request.
@@ -0,0 +1,174 @@
1
+ # Detail primitives — the record-page building blocks
2
+
3
+ ```ts
4
+ import {
5
+ StatusPanel, FlowStrip, Timeline, KnownFieldsCard, MetadataSection, StackTraceView,
6
+ } from 'portal-mojo/ui';
7
+ ```
8
+
9
+ Six small pieces admin record pages are composed from, ported from
10
+ web-mojo's `src/core/views/data/*` primitives plus its admin metadata
11
+ section. They are presentational and stateless (except `MetadataSection`,
12
+ which writes) — drop them inside a `DetailView` section, a `panel`, or a
13
+ `modal.detail`. Styles: `apps/portal/src/theme/detail-primitives.css`.
14
+
15
+ **All three trusted-HTML slots are gone.** web-mojo rendered
16
+ `StatusPanel.meta`, `FlowStrip.value/hint` and `Timeline.detail` as
17
+ trusted HTML with a "the caller must escape interpolations" contract.
18
+ Here they are `ReactNode` props — compose `<code>`/`<strong>`/`<a>`
19
+ directly and the escaping question never arises (architecture rule 6).
20
+
21
+ ## StatusPanel — "what state is this in, what do I do next"
22
+
23
+ ```tsx
24
+ <StatusPanel
25
+ tone="danger" state="Firing" icon="bi-fire" // icon replaces the tone dot
26
+ headline="Auth brute-force from 203.0.113.0/24"
27
+ meta={<>Triggered <code>4m ago</code> · 218 events</>}
28
+ actions={<><button className="btn btn-primary btn-compact">Acknowledge</button></>}
29
+ />
30
+ ```
31
+
32
+ The hero banner that opens an Overview section. Tone tints the surface and
33
+ colors the state eyebrow + dot. `actions` is a slot — the caller owns the
34
+ handlers (web-mojo dispatched `data-action` through a global pipeline).
35
+ Container queries stack the actions under the headline below 480px of the
36
+ panel's OWN width, so it behaves inside an `lg` modal.
37
+
38
+ ## FlowStrip — STEP 1 → N
39
+
40
+ ```tsx
41
+ <FlowStrip
42
+ steps={[
43
+ { title: 'Match', tone: 'primary', value: <code>category=auth</code>, hint: 'Every condition must match.' },
44
+ { title: 'Re-trigger', empty: true, value: 'Not configured' },
45
+ ]}
46
+ onEditStep={(step, i) => openTab(step, i)}
47
+ />
48
+ ```
49
+
50
+ | Step field | Effect |
51
+ |---|---|
52
+ | `num` | eyebrow; defaults to `STEP <n>` |
53
+ | `tone` | 2px cap over the column + colored eyebrow (which stage failed) |
54
+ | `empty` | renders `value` muted-italic ("not configured") |
55
+ | `editable: false` | opts one step out of the pencil |
56
+ | `editTitle` | pencil tooltip; defaults to `Edit <title>` |
57
+
58
+ Column count rides the inline `--flow-strip-cols` custom property; the CSS
59
+ container queries reflow 4 → 2 → 1 off the strip's own width. Empty
60
+ `steps` renders nothing.
61
+
62
+ ## Timeline — the vertical event feed
63
+
64
+ ```tsx
65
+ <Timeline items={[{ tone: 'danger', title: 'Incident opened', meta: fmt.relative(ev.at), body: <>…</> }]} limit={5} />
66
+ ```
67
+
68
+ `{tone, title, meta, body}` — source names were `headline/when/detail`.
69
+ `meta` is the right-aligned monospace timestamp column: format it yourself
70
+ (`fmt.relative` / `fmt.datetime`, both epoch-SECONDS aware). `limit` slices
71
+ to the first N ("recent activity" mode). The empty state renders inside the
72
+ `<ol>` so the rail stays visually intact.
73
+
74
+ ## KnownFieldsCard — promoted keys + raw blob
75
+
76
+ ```tsx
77
+ <KnownFieldsCard
78
+ data={record.metadata ?? {}}
79
+ known={[
80
+ { key: 'created_by', label: 'Created by' },
81
+ { key: 'last_resolved', label: 'Resolved', format: 'datetime' }, // an fmt.* name
82
+ { key: 'os.family', label: 'OS', format: (v, key, data) => <code>{String(v)}</code> },
83
+ { key: 'agent_prompt', hideEmpty: true },
84
+ ]}
85
+ rawLabel="Raw metadata"
86
+ />
87
+ ```
88
+
89
+ - `key` is a **dotted path** (`os.family`); an own property of that literal
90
+ name wins over traversal.
91
+ - `format` is an `fmt.*` name (`date` | `datetime` | `relative`) or a
92
+ function returning any node. An unknown name **warns and renders the raw
93
+ value** — never nothing (rule 4).
94
+ - Missing values render a muted `—`; `hideEmpty` drops the row instead.
95
+ - Objects with no formatter render as compact JSON in a `<code>`.
96
+ - The raw blob goes in a collapsed `<details>` below (`showRaw={false}` to
97
+ drop it, `rawCollapsed={false}` to start open). Skipped for an empty blob.
98
+
99
+ **Not to be confused with `DataView`** ([dataview.md](dataview.md)):
100
+ KnownFieldsCard takes one **blob** column and promotes a **curated** list of
101
+ known keys, leaving the rest in the raw `<details>`; DataView takes a
102
+ **whole record** and **infers** a renderer for every key from its name and
103
+ value. They coexist happily — `<DataView data={record} />` for the record,
104
+ `<KnownFieldsCard data={record.metadata} known={…} />` for its blob.
105
+
106
+ ## MetadataSection — editable key/value CRUD (writes)
107
+
108
+ ```tsx
109
+ <MetadataSection
110
+ endpoint="/api/user" id={user.id} metadata={user.metadata}
111
+ onSaved={(next) => qc.setQueryData<User>(['/api/user', 'one', user.id], (p) => p && { ...p, metadata: next })}
112
+ />
113
+ ```
114
+
115
+ **Wire contract:** every mutation is one `POST <endpoint>/<id>` with body
116
+ `{metadata: {…the whole blob…}}` through `mojoCall` — the same single
117
+ envelope-unwrap boundary as everything else. Add, edit and delete all take
118
+ this one path; there is no per-key endpoint.
119
+
120
+ Invariants:
121
+
122
+ 1. **Controlled on `metadata`; `onSaved` is REQUIRED.** The component never
123
+ mirrors server state (rule 5) — wire `onSaved` into the TanStack cache or
124
+ parent state or the UI will not move after a save.
125
+ 2. **A rejected save is unmissable** (rule 3). `mojoCall` REJECTS; the
126
+ server's own message lands in a persistent inline banner AND the editor
127
+ stays open holding what you typed. web-mojo read `resp.status === 200`
128
+ off a never-rejecting `model.save()` and fired a generic toast — that is
129
+ exactly the trap this port retires. Editing is therefore inline, not a
130
+ modal: a closed modal has no editor state to keep.
131
+ 3. **Values are JSON-parsed when they can be** (source semantics): `42`,
132
+ `true` and `{"a":1}` land as real types, everything else stays a string.
133
+ Object values then display **read-only** — a string editor round-trips
134
+ them lossily; remove and re-add to change one.
135
+
136
+ Deletion confirms through `modal.confirm`; success toasts.
137
+
138
+ ## StackTraceView — JS **and** Python tracebacks
139
+
140
+ ```tsx
141
+ <StackTraceView trace={job.stack_trace} collapseAfter={24} />
142
+ ```
143
+
144
+ One parser, both dialects, in this match order (ported verbatim):
145
+ `at fn (file:line:col)` → `at file:line:col` → `File "…", line N, in fn` →
146
+ any `at …` line → context. Non-string traces are JSON-pretty-printed first.
147
+
148
+ - **Re-themed.** The source shipped a hardcoded light-only `<style>` block
149
+ (`#f8f9fa` surface, `#dc3545`/`#0d6efd`/`#6610f2` text) that was
150
+ unreadable in dark mode. This renders semantic classes over tokens.
151
+ - **The exception line is found anywhere.** The source only colored line 0,
152
+ so every Python traceback — which opens with `Traceback (most recent call
153
+ last):` and puts the exception LAST — rendered its most important line as
154
+ plain context. An anchored `^[A-Za-z_][\w.]*(Error|Exception|…)` pattern
155
+ now catches it, including dotted ones (`django.db.utils.IntegrityError`).
156
+ - Frame indentation is preserved so Python frames stay aligned with their
157
+ source lines, terminal-style.
158
+ - Traces longer than `collapseAfter` (default 24) collapse behind a "Show
159
+ all N lines" toggle; `0` disables collapsing. The body scroll-caps at
160
+ 600px either way.
161
+
162
+ ## Pitfalls
163
+
164
+ - `tone` is portal-mojo's 6-value `Tone` (`success | warning | danger |
165
+ info | muted | primary`), not web-mojo's 7 — `default`/`secondary` both
166
+ map to `muted`. Tones arriving as wire data go through a runtime guard:
167
+ unknown values fall back to `muted` **with a `console.warn`**.
168
+ - Timestamps are never formatted for you. django-mojo sends epoch SECONDS;
169
+ pass them through `fmt.*` before handing them to `Timeline.meta`.
170
+ - `KnownFieldsCard` and `MetadataSection` both render a `metadata` blob and
171
+ are easy to confuse: the card is read-only with promoted known keys; the
172
+ section is the editor. A detail page often wants both. `DataView` is the
173
+ third member of that family — whole-record inference rather than a
174
+ curated key list over one blob.