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,692 @@
1
+ // DataView — point it at any record and get a sensible detail grid.
2
+ // Port of web-mojo src/core/views/data/DataView.js (1,153 lines).
3
+ //
4
+ // CREATED LAST LOGIN STORAGE USED
5
+ // Apr 21, 2026 3 weeks ago 1.5 GB
6
+ // EMAIL IS ACTIVE PROFILE
7
+ // ian@nativemojo.com [ Yes ] ┌ nested DataView ┐
8
+ //
9
+ // Two modes:
10
+ // · `fields` given → an explicit schema (label, dotted path, type/format)
11
+ // · `fields` absent → EVERY key of the record, typed by inference
12
+ //
13
+ // The ~250 lines of inference heuristics below are the institutional knowledge
14
+ // this port exists to preserve: a field NAME plus its value picks the renderer
15
+ // (`*_at`/`created` → date, `*email*` → mailto, `*_size` → filesize, …).
16
+ //
17
+ // Deviations from source, all deliberate:
18
+ // 1. The string-pipe engine is gone (do-not-recreate: `"date('MMM D')|
19
+ // capitalize"` defeats the type checker and passed unknown formatters
20
+ // through silently). `inferFormatter` returned pipe strings; here the same
21
+ // branches choose TYPED `fmt.*` calls or small ReactNode renderers.
22
+ // Consequences of that mapping, since `fmt` has one shape per concept:
23
+ // · `date("MMM D, YYYY")` and `date("MMMM D, YYYY")` both → `fmt.date`
24
+ // · `time` → `fmt.datetime` (a bare wall-clock time with no date is
25
+ // ambiguous, and `fmt` deliberately ships no `time`)
26
+ // · `capitalize` → the house `.cap` class — CSS capitalization does not
27
+ // mutate the underlying value
28
+ // 2. Trusted HTML is gone (architecture rule 6). The source built every value
29
+ // as an HTML string and ran a 5-regex "syntax highlighter" over escaped
30
+ // JSON; here values are ReactNode and JSON is tokenized into real spans.
31
+ // No `dangerouslySetInnerHTML` anywhere.
32
+ // 3. `showEmptyValues` defaults to TRUE. The source dropped a row whose value
33
+ // was empty, so a record's shape changed with its data; the house rule
34
+ // (KnownFieldsCard) shows `—` and hides only on an explicit opt-out.
35
+ // 4. Bug fixes over the source's inference, each marked BUGFIX below.
36
+ //
37
+ // Relationship to KnownFieldsCard: that one takes a BLOB and promotes a curated
38
+ // list of known keys (everything else stays in the raw `<details>`); this one
39
+ // takes a WHOLE RECORD and infers every field. A detail page often wants both —
40
+ // DataView for the record, KnownFieldsCard for its `metadata` column.
41
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
42
+ import * as fmt from '../format';
43
+ import { Badge } from '../ui';
44
+ import { toast } from '../toast';
45
+
46
+ /** The renderer vocabulary. `type` on a field names one of these directly. */
47
+ export type DataFieldType =
48
+ | 'datetime' | 'date' | 'email' | 'url' | 'phone' | 'currency' | 'filesize'
49
+ | 'percent' | 'number' | 'boolean' | 'text' | 'array' | 'object' | 'dataview' | 'file';
50
+
51
+ const FIELD_TYPES = new Set<string>([
52
+ 'datetime', 'date', 'email', 'url', 'phone', 'currency', 'filesize',
53
+ 'percent', 'number', 'boolean', 'text', 'array', 'object', 'dataview', 'file',
54
+ ]);
55
+
56
+ export interface DataViewField {
57
+ /**
58
+ * Key in the record. Dotted paths traverse nested objects (`os.family`);
59
+ * an own property of that literal name wins (same rule as KnownFieldsCard).
60
+ */
61
+ name: string;
62
+ /** Row label; defaults to the humanized last path segment. */
63
+ label?: string;
64
+ /** Force a renderer instead of inferring one. Unknown values warn + infer. */
65
+ type?: DataFieldType;
66
+ /** Replaces the renderer entirely — return any node. */
67
+ format?: (value: unknown, name: string, data: Record<string, unknown>) => ReactNode;
68
+ /** `full` spans the grid; `auto` (default) is full only for JSON/nested. */
69
+ span?: 'auto' | 'full';
70
+ /** Drop the row when the value is empty (default: render `—`). */
71
+ hideEmpty?: boolean;
72
+ className?: string;
73
+ }
74
+
75
+ export interface DataViewProps {
76
+ /** The record. Plain object — models are definitions here, not instances. */
77
+ data: Record<string, unknown> | null | undefined;
78
+ /** Explicit schema. Omit for full inference over `Object.keys(data)`. */
79
+ fields?: DataViewField[];
80
+ /** Keys to drop. Applies in BOTH modes. */
81
+ exclude?: string[];
82
+ /** Grid columns at full width (default 2); narrow containers collapse to 1. */
83
+ columns?: number;
84
+ /** `false` restores the source behavior: an empty value drops its row. */
85
+ showEmptyValues?: boolean;
86
+ /** Rendered for an empty value (default `—`). */
87
+ emptyValueText?: string;
88
+ /** Shown when the record has no renderable fields at all. */
89
+ emptyText?: string;
90
+ /**
91
+ * How many levels of NESTED DataView to render below this one (default 2).
92
+ * Deeper objects fall back to a collapsed JSON block, so a cyclic-looking
93
+ * or absurdly deep payload can never blow the render stack.
94
+ */
95
+ maxDepth?: number;
96
+ /** Internal — the current nesting level. Callers leave this at 0. */
97
+ depth?: number;
98
+ }
99
+
100
+ // ============================================================ key analysis
101
+
102
+ /** snake / kebab / camel → lowercase word tokens: `lastLoginAt` → last, login, at. */
103
+ function tokenize(key: string): string[] {
104
+ return key
105
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
106
+ .split(/[^A-Za-z0-9]+/)
107
+ .filter(Boolean)
108
+ .map((t) => t.toLowerCase());
109
+ }
110
+
111
+ /**
112
+ * Exact-token match. BUGFIX: the source tested every needle with `includes()`,
113
+ * so short ones matched inside unrelated words — `duration` contains "ratio"
114
+ * (→ percent: 8040 rendered as 804,000%), `generated` contains "rate", `hotel`
115
+ * contains "tel" (→ a tel: link). Long, unambiguous needles (`created`,
116
+ * `email`, `website`) still use the source's substring test.
117
+ */
118
+ function hasWord(tokens: string[], ...words: string[]): boolean {
119
+ return words.some((w) => tokens.includes(w));
120
+ }
121
+
122
+ /** `created_at` → "Created At"; `lastLogin` → "Last Login". Source verbatim. */
123
+ export function formatLabel(name: string): string {
124
+ return name
125
+ .replace(/([A-Z])/g, ' $1')
126
+ .replace(/[_-]/g, ' ')
127
+ .replace(/\s+/g, ' ')
128
+ .replace(/\b\w/g, (l) => l.toUpperCase())
129
+ .trim();
130
+ }
131
+
132
+ const BOOL_PREFIXES = new Set(['is', 'has', 'can', 'should']);
133
+
134
+ // Warnings fire from a render path, so a 40-field record re-rendering on every
135
+ // keystroke would flood the console. One warning per distinct message — the
136
+ // same discipline format.ts uses.
137
+ const warned = new Set<string>();
138
+ function warnOnce(message: string, ...extra: unknown[]): void {
139
+ if (warned.has(message)) return;
140
+ warned.add(message);
141
+ console.warn(message, ...extra);
142
+ }
143
+
144
+ /** Value shapes that read as a boolean on the wire (django-mojo sends 0/1). */
145
+ function isBooleanish(value: unknown): boolean {
146
+ if (typeof value === 'boolean') return true;
147
+ if (value === 0 || value === 1) return true;
148
+ return typeof value === 'string' && /^(true|false|yes|no)$/i.test(value.trim());
149
+ }
150
+
151
+ /**
152
+ * Objects worth a nested DataView instead of a JSON dump. Ported from the
153
+ * source's `shouldUseDataView`, including its size window and the
154
+ * "one deep child disqualifies the whole object" rule.
155
+ */
156
+ export function shouldUseDataView(value: unknown, keyLower: string): boolean {
157
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
158
+ const obj = value as Record<string, unknown>;
159
+
160
+ // Source gated this on `window.utils.isObject` — a global that is simply
161
+ // absent in half of web-mojo's own entry points. The intent survives: an
162
+ // object carrying an `id` is a RELATED RECORD, so it gets a record grid.
163
+ if (obj.id != null) return true;
164
+
165
+ const matches = DATAVIEW_KEY_PATTERNS.some((p) => keyLower.includes(p));
166
+ if (!matches) return false;
167
+
168
+ const keys = Object.keys(obj);
169
+ if (keys.length < 2 || keys.length > 20) return false;
170
+ const hasComplexNesting = keys.some((k) => {
171
+ const child = obj[k];
172
+ return typeof child === 'object' && child !== null && !Array.isArray(child)
173
+ && Object.keys(child as Record<string, unknown>).length > 3;
174
+ });
175
+ return !hasComplexNesting;
176
+ }
177
+
178
+ const DATAVIEW_KEY_PATTERNS = [
179
+ 'permissions', 'perms', 'access', 'rights',
180
+ 'settings', 'config', 'configuration', 'options',
181
+ 'profile', 'info', 'details', 'data',
182
+ 'metadata', 'meta', 'attributes', 'props',
183
+ 'preferences', 'prefs', 'user_data',
184
+ 'contact', 'address', 'location',
185
+ 'stats', 'statistics', 'metrics', 'counts',
186
+ ];
187
+
188
+ /**
189
+ * Field NAME + value → renderer. The source's `inferFieldType`, branch for
190
+ * branch, with the fixes noted inline. Never throws; unknown shapes → 'text'.
191
+ */
192
+ export function inferFieldType(value: unknown, key = ''): DataFieldType {
193
+ if (value === null || value === undefined) return 'text';
194
+
195
+ const k = key.toLowerCase();
196
+ const tokens = tokenize(key);
197
+ const type = typeof value;
198
+
199
+ // BUGFIX (hoisted): the source tested `typeof value === 'boolean'` AFTER the
200
+ // whole name chain, so `is_email_verified: true` matched "email" and
201
+ // rendered `mailto:true`. A boolean is never a date, an email or a URL.
202
+ if (type === 'boolean') return 'boolean';
203
+ // EXTENSION: `is_*` / `has_* `/ `can_*` / `should_*` carrying a wire boolean
204
+ // (0/1, "true"/"false"). The value guard keeps `has_permissions: {…}` an
205
+ // object and `is_expires_at: 1712…` out of the boolean bucket.
206
+ if (tokens.length > 1 && BOOL_PREFIXES.has(tokens[0]!) && isBooleanish(value)) return 'boolean';
207
+
208
+ // Date/time patterns. EXTENSION: a trailing `_at` token — the django-mojo
209
+ // timestamp convention the source predates (`sent_at`, `resolved_at`).
210
+ if (k.includes('date') || k.includes('time') || k.includes('created') || k.includes('updated')
211
+ || k.includes('modified') || k.includes('last_login') || k.includes('expires')
212
+ || k.includes('last_activity') || tokens[tokens.length - 1] === 'at') {
213
+ return 'datetime';
214
+ }
215
+ if (k.includes('email') || k.includes('mail')) return 'email';
216
+ if (k.includes('url') || hasWord(tokens, 'link') || k.includes('website') || k.includes('homepage')) return 'url';
217
+ if (k.includes('phone') || hasWord(tokens, 'tel') || k.includes('mobile') || hasWord(tokens, 'cell')) return 'phone';
218
+ if (k.includes('price') || hasWord(tokens, 'cost') || k.includes('amount') || hasWord(tokens, 'fee')
219
+ || k.includes('salary') || k.includes('revenue')) {
220
+ return 'currency';
221
+ }
222
+ if (hasWord(tokens, 'size', 'bytes', 'filesize')) return 'filesize';
223
+ // BUGFIX: the source wrote `percent || rate || ratio && number`, and `&&`
224
+ // binds tighter — so `rate` matched on ANY type. The number guard now
225
+ // covers the whole branch (a percentage of a string is meaningless).
226
+ if ((k.includes('percent') || hasWord(tokens, 'rate', 'ratio')) && type === 'number') return 'percent';
227
+
228
+ if (type === 'number') return 'number';
229
+
230
+ if (type === 'object') {
231
+ if (Array.isArray(value)) return 'array';
232
+ if ((value as Record<string, unknown>).renditions) return 'file';
233
+ if (shouldUseDataView(value, k)) return 'dataview';
234
+ return 'object';
235
+ }
236
+
237
+ if (type === 'string') {
238
+ const s = value as string;
239
+ if (s.includes('@') && s.includes('.')) return 'email';
240
+ if (/^\d{4}-\d{2}-\d{2}/.test(s)) return 'date';
241
+ if (/^https?:\/\//.test(s)) return 'url';
242
+ // BUGFIX: the source's bare `/^\+?[\d\s\-()]+$/` turned "42" and "2024"
243
+ // into tel: links. A phone number has at least 7 digits.
244
+ if (/^\+?[\d\s\-()]+$/.test(s) && s.replace(/\D/g, '').length >= 7) return 'phone';
245
+ }
246
+
247
+ return 'text';
248
+ }
249
+
250
+ // ============================================================ JSON viewer
251
+
252
+ type JsonTokenClass = 'key' | 'string' | 'number' | 'boolean' | 'null' | 'plain';
253
+ interface JsonToken { text: string; cls: JsonTokenClass }
254
+
255
+ const WS = new Set([' ', '\n', '\t', '\r']);
256
+
257
+ /**
258
+ * Real tokenizer over pretty-printed JSON — keys, string values, numbers,
259
+ * booleans, null; everything else is structural. The source ran five regexes
260
+ * over an HTML-escaped string and injected `<span style>` (so `{"a": "x: 1"}`
261
+ * mis-colored, and the whole thing was `innerHTML`). This emits React spans,
262
+ * which is why architecture rule 6 holds here for free.
263
+ */
264
+ export function tokenizeJson(text: string): JsonToken[] {
265
+ const out: JsonToken[] = [];
266
+ let plain = '';
267
+ const flush = () => {
268
+ if (plain) { out.push({ text: plain, cls: 'plain' }); plain = ''; }
269
+ };
270
+
271
+ let i = 0;
272
+ while (i < text.length) {
273
+ const ch = text[i]!;
274
+
275
+ if (ch === '"') {
276
+ let j = i + 1;
277
+ while (j < text.length) {
278
+ const c = text[j]!;
279
+ if (c === '\\') { j += 2; continue; }
280
+ j++;
281
+ if (c === '"') break;
282
+ }
283
+ // A string is a KEY iff the next non-space character is a colon.
284
+ let k = j;
285
+ while (k < text.length && WS.has(text[k]!)) k++;
286
+ flush();
287
+ out.push({ text: text.slice(i, j), cls: text[k] === ':' ? 'key' : 'string' });
288
+ i = j;
289
+ continue;
290
+ }
291
+
292
+ if (ch === '-' || (ch >= '0' && ch <= '9')) {
293
+ const m = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
294
+ if (m) { flush(); out.push({ text: m[0], cls: 'number' }); i += m[0].length; continue; }
295
+ }
296
+ if (text.startsWith('true', i) || text.startsWith('false', i)) {
297
+ const lit = text[i] === 't' ? 'true' : 'false';
298
+ flush(); out.push({ text: lit, cls: 'boolean' }); i += lit.length; continue;
299
+ }
300
+ if (text.startsWith('null', i)) { flush(); out.push({ text: 'null', cls: 'null' }); i += 4; continue; }
301
+
302
+ plain += ch;
303
+ i++;
304
+ }
305
+ flush();
306
+ return out;
307
+ }
308
+
309
+ function CopyButton({ text, title = 'Copy JSON' }: { text: string; title?: string }) {
310
+ const [copied, setCopied] = useState(false);
311
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
312
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
313
+
314
+ const copy = () => {
315
+ // `navigator.clipboard` is undefined outside a secure context. The
316
+ // source's document.execCommand fallback is deprecated and silently
317
+ // no-ops in modern browsers — say so instead of pretending to copy.
318
+ const clip = navigator.clipboard as Clipboard | undefined;
319
+ if (!clip) { toast.error('Clipboard unavailable in this context'); return; }
320
+ clip.writeText(text).then(
321
+ () => {
322
+ setCopied(true);
323
+ toast.success('JSON copied to clipboard');
324
+ if (timer.current) clearTimeout(timer.current);
325
+ timer.current = setTimeout(() => setCopied(false), 1200);
326
+ },
327
+ (err: unknown) => toast.error(`Copy failed: ${err instanceof Error ? err.message : String(err)}`),
328
+ );
329
+ };
330
+
331
+ return (
332
+ <button
333
+ type="button"
334
+ className={`btn-icon btn-icon-sm json-block-copy${copied ? ' json-block-copied' : ''}`}
335
+ onClick={copy}
336
+ title={title}
337
+ aria-label={title}
338
+ >
339
+ <i className={`bi ${copied ? 'bi-check2' : 'bi-clipboard'}`} />
340
+ </button>
341
+ );
342
+ }
343
+
344
+ export interface JsonBlockProps {
345
+ /** Anything JSON-serializable. Circular payloads degrade, never throw. */
346
+ value: unknown;
347
+ /** Overrides the caption ("Object" / "Array · 12 lines"). */
348
+ label?: string;
349
+ /** Force the collapse affordance. Default: >10 lines or >500 chars (source). */
350
+ collapsible?: boolean;
351
+ /** When collapsible, start expanded. */
352
+ defaultOpen?: boolean;
353
+ }
354
+
355
+ /**
356
+ * Pretty-printed JSON with syntax highlighting, copy-to-clipboard and (for big
357
+ * payloads) a one-line preview behind a Show/Hide toggle. Exported because a
358
+ * raw-JSON panel is useful on its own, not only as a DataView cell.
359
+ */
360
+ export function JsonBlock({ value, label, collapsible, defaultOpen = false }: JsonBlockProps) {
361
+ const [open, setOpen] = useState(defaultOpen);
362
+
363
+ let json: string;
364
+ try {
365
+ json = JSON.stringify(value, null, 2) ?? String(value);
366
+ } catch {
367
+ // Circular structures land here — the source returned a muted note too.
368
+ return <span className="dim-italic">[{Array.isArray(value) ? 'Array' : typeof value}] — cannot display as JSON</span>;
369
+ }
370
+
371
+ const lines = json.split('\n').length;
372
+ const isLarge = collapsible ?? (lines > 10 || json.length > 500);
373
+ const caption = label ?? `${Array.isArray(value) ? 'Array' : 'Object'}${isLarge ? ` · ${lines} lines` : ''}`;
374
+ const flat = JSON.stringify(value) ?? '';
375
+ const preview = flat.length > 100 ? `${flat.slice(0, 100)}…` : flat;
376
+
377
+ return (
378
+ <div className="json-block">
379
+ <div className="json-block-head">
380
+ <span className="json-block-caption">{caption}</span>
381
+ <div className="json-block-actions">
382
+ {isLarge && (
383
+ <button type="button" className="btn btn-compact json-block-toggle" onClick={() => setOpen((v) => !v)} aria-expanded={open}>
384
+ <i className={`bi ${open ? 'bi-eye-slash' : 'bi-eye'}`} /> {open ? 'Hide' : 'Show'}
385
+ </button>
386
+ )}
387
+ <CopyButton text={json} />
388
+ </div>
389
+ </div>
390
+ {isLarge && !open && <div className="json-block-preview"><code>{preview}</code></div>}
391
+ {(!isLarge || open) && (
392
+ <pre className="json-block-body"><code>
393
+ {tokenizeJson(json).map((t, i) => (
394
+ t.cls === 'plain' ? t.text : <span key={i} className={`json-tok-${t.cls}`}>{t.text}</span>
395
+ ))}
396
+ </code></pre>
397
+ )}
398
+ </div>
399
+ );
400
+ }
401
+
402
+ // ============================================================ value rendering
403
+
404
+ interface RenderCtx {
405
+ keyLower: string;
406
+ tokens: string[];
407
+ depth: number;
408
+ maxDepth: number;
409
+ emptyValueText: string;
410
+ showEmptyValues: boolean;
411
+ }
412
+
413
+ /** Anything that reads as "no value": null, '', [], {}. */
414
+ function isEmptyValue(value: unknown): boolean {
415
+ if (value === null || value === undefined || value === '') return true;
416
+ if (Array.isArray(value)) return value.length === 0;
417
+ if (typeof value === 'object') return Object.keys(value as Record<string, unknown>).length === 0;
418
+ return false;
419
+ }
420
+
421
+ /** Fed straight to `fmt.*`, which coerce and never throw. */
422
+ type FmtInput = string | number | null | undefined;
423
+ const asFmt = (value: unknown): FmtInput => (typeof value === 'number' || typeof value === 'string' ? value : null);
424
+
425
+ function ExternalLink({ href, children }: { href: string; children: ReactNode }) {
426
+ return (
427
+ <a className="dataview-link" href={href} target="_blank" rel="noopener noreferrer">
428
+ {children} <i className="bi bi-box-arrow-up-right" />
429
+ </a>
430
+ );
431
+ }
432
+
433
+ function Truncated({ text, length }: { text: string; length: number }) {
434
+ const short = fmt.truncate(text, length);
435
+ return short === text ? <>{text}</> : <span title={text}>{short}</span>;
436
+ }
437
+
438
+ /** `datetime` — the source's four-way split, mapped onto the `fmt` shapes. */
439
+ function renderDatetime(value: unknown, ctx: RenderCtx): ReactNode {
440
+ const { keyLower } = ctx;
441
+ if (keyLower.includes('time') && !keyLower.includes('date')) return fmt.datetime(asFmt(value));
442
+ if (keyLower.includes('relative') || keyLower.includes('ago') || keyLower.includes('last_')) {
443
+ return fmt.relative(asFmt(value), ctx.emptyValueText);
444
+ }
445
+ // The source's remaining two branches — created/updated/modified as
446
+ // `date("MMM D, YYYY")` and everything else as `date("MMMM D, YYYY")` —
447
+ // are one branch here: `fmt` ships ONE date shape by design.
448
+ return fmt.date(asFmt(value), ctx.emptyValueText);
449
+ }
450
+
451
+ function renderText(value: unknown, ctx: RenderCtx): ReactNode {
452
+ if (typeof value !== 'string') return String(value);
453
+ const { keyLower, tokens } = ctx;
454
+ const len = value.length;
455
+
456
+ if (keyLower.includes('description') || keyLower.includes('content') || hasWord(tokens, 'body')) {
457
+ return <Truncated text={value} length={len > 200 ? 200 : 100} />;
458
+ }
459
+ if (keyLower.includes('summary') || keyLower.includes('excerpt')) {
460
+ return <Truncated text={value} length={150} />;
461
+ }
462
+ // BUGFIX (order): the source tested `name` first, and "username" contains
463
+ // "name" — so every username was title-cased and the slug/handle branch
464
+ // below was unreachable for it. Identifiers are checked first and render
465
+ // VERBATIM in monospace: the source's `slug` pipe rewrote `ian.starnes`
466
+ // to `ianstarnes`, corrupting the very value the row exists to show.
467
+ if (hasWord(tokens, 'username', 'slug', 'handle', 'login')) {
468
+ return <code className="dataview-mono">{value}</code>;
469
+ }
470
+ if (keyLower.includes('name') || keyLower.includes('title') || hasWord(tokens, 'label')) {
471
+ // `capitalize` as CSS, not as string surgery — the value is untouched.
472
+ return <span className="cap"><Truncated text={value} length={50} /></span>;
473
+ }
474
+ if (hasWord(tokens, 'code', 'token', 'key', 'secret')) {
475
+ return <code className="dataview-mono">{len > 20 ? fmt.mask(value, '*', 4) : value}</code>;
476
+ }
477
+ return <Truncated text={value} length={100} />;
478
+ }
479
+
480
+ function renderNumber(value: unknown, ctx: RenderCtx): ReactNode {
481
+ const { keyLower, tokens } = ctx;
482
+ if (typeof value !== 'number') return String(value);
483
+
484
+ if (keyLower.includes('count') || keyLower.includes('total') || keyLower.includes('followers') || keyLower.includes('views')) {
485
+ return value >= 1000 ? fmt.compact(value) : fmt.number(value);
486
+ }
487
+ if (keyLower.includes('score') || keyLower.includes('rating')) {
488
+ return fmt.number(value, value % 1 !== 0 ? 1 : 0);
489
+ }
490
+ // IDs and versions are identifiers, not quantities — never separated.
491
+ if (keyLower.includes('version') || hasWord(tokens, 'id')) return String(value);
492
+ return fmt.number(value);
493
+ }
494
+
495
+ function renderCurrency(value: unknown, ctx: RenderCtx): ReactNode {
496
+ const { keyLower } = ctx;
497
+ const code = keyLower.includes('eur') || keyLower.includes('euro') ? 'EUR'
498
+ : keyLower.includes('gbp') || keyLower.includes('pound') ? 'GBP'
499
+ : 'USD';
500
+ // django-mojo stores money as integer MINOR units so it never touches a
501
+ // float; a fractional value is therefore already in major units. Pass an
502
+ // explicit `format` when a record breaks that convention.
503
+ const n = asFmt(value);
504
+ const unit = typeof n === 'number' && !Number.isInteger(n) ? 'major' : 'cents';
505
+ return fmt.currency(n, code, { unit, fallback: ctx.emptyValueText });
506
+ }
507
+
508
+ function renderPercent(value: unknown, ctx: RenderCtx): ReactNode {
509
+ const n = asFmt(value);
510
+ // `fmt.percent` scales a 0–1 ratio by 100. A value above 1 is already a
511
+ // percentage (`error_rate: 12`), so scaling it would read 1,200%.
512
+ const multiply = typeof n === 'number' && Math.abs(n) <= 1;
513
+ return fmt.percent(n, 0, multiply, ctx.emptyValueText);
514
+ }
515
+
516
+ function renderArray(value: unknown[]): ReactNode {
517
+ const scalars = value.every((v) => v === null || typeof v !== 'object');
518
+ if (scalars) return value.map((v) => (v === null ? 'null' : String(v))).join(', ');
519
+ // Deviation from source (which JSON-dumped every array): an array of
520
+ // objects leads with its COUNT, because that is the fact you read first.
521
+ return <JsonBlock value={value} label={`Array · ${value.length} item${value.length === 1 ? '' : 's'}`} collapsible defaultOpen={false} />;
522
+ }
523
+
524
+ function renderFile(value: Record<string, unknown>): ReactNode {
525
+ const url = typeof value.url === 'string' ? value.url : null;
526
+ const name = typeof value.name === 'string' ? value.name
527
+ : typeof value.filename === 'string' ? value.filename : 'File';
528
+ // The source had no `file` renderer at all — its default branch stringified
529
+ // the object to "[object Object]".
530
+ return url ? <ExternalLink href={url}>{name}</ExternalLink> : <JsonBlock value={value} label="File" />;
531
+ }
532
+
533
+ function renderTyped(value: unknown, type: DataFieldType, ctx: RenderCtx): ReactNode {
534
+ switch (type) {
535
+ case 'datetime':
536
+ return renderDatetime(value, ctx);
537
+ case 'date':
538
+ return fmt.date(asFmt(value), ctx.emptyValueText);
539
+ case 'email': {
540
+ const s = String(value);
541
+ return <a className="dataview-link" href={`mailto:${s}`} rel="noopener noreferrer">{s}</a>;
542
+ }
543
+ case 'url': {
544
+ const s = String(value);
545
+ return <ExternalLink href={s}>{s}</ExternalLink>;
546
+ }
547
+ case 'phone': {
548
+ const s = String(value);
549
+ return <a className="dataview-link" href={`tel:${s.replace(/[^\d+]/g, '')}`} rel="noopener noreferrer">{fmt.phone(s)}</a>;
550
+ }
551
+ case 'currency':
552
+ return renderCurrency(value, ctx);
553
+ case 'filesize':
554
+ return fmt.filesize(asFmt(value), false, 1, ctx.emptyValueText);
555
+ case 'percent':
556
+ return renderPercent(value, ctx);
557
+ case 'number':
558
+ return renderNumber(value, ctx);
559
+ case 'boolean': {
560
+ // The house yes/no treatment — Badge + inferTone, exactly what the
561
+ // source's `badge bg-success` / `bg-secondary` meant.
562
+ const yes = fmt.yesNo(value) === 'Yes';
563
+ return <Badge tone={yes ? 'success' : 'muted'}>{yes ? 'Yes' : 'No'}</Badge>;
564
+ }
565
+ case 'array':
566
+ return renderArray(value as unknown[]);
567
+ case 'file':
568
+ return renderFile(value as Record<string, unknown>);
569
+ case 'dataview': {
570
+ if (ctx.depth >= ctx.maxDepth) {
571
+ // Depth cap: past it, the object still renders — as JSON.
572
+ return <JsonBlock value={value} collapsible defaultOpen={false} />;
573
+ }
574
+ return (
575
+ <div className="dataview-nested">
576
+ <DataView
577
+ data={value as Record<string, unknown>}
578
+ depth={ctx.depth + 1}
579
+ maxDepth={ctx.maxDepth}
580
+ emptyValueText={ctx.emptyValueText}
581
+ showEmptyValues={ctx.showEmptyValues}
582
+ columns={2}
583
+ />
584
+ </div>
585
+ );
586
+ }
587
+ case 'object':
588
+ return <JsonBlock value={value} />;
589
+ default:
590
+ return renderText(value, ctx);
591
+ }
592
+ }
593
+
594
+ // ============================================================ the view
595
+
596
+ /**
597
+ * Dotted-path lookup, own property first (KnownFieldsCard's rule, so a blob
598
+ * that literally holds the key `"os.family"` still resolves).
599
+ */
600
+ function lookup(data: Record<string, unknown>, name: string): unknown {
601
+ if (!name) return undefined;
602
+ if (Object.prototype.hasOwnProperty.call(data, name)) return data[name];
603
+ if (!name.includes('.')) return undefined;
604
+ let cursor: unknown = data;
605
+ for (const part of name.split('.')) {
606
+ if (cursor == null || typeof cursor !== 'object') return undefined;
607
+ cursor = (cursor as Record<string, unknown>)[part];
608
+ }
609
+ return cursor;
610
+ }
611
+
612
+ const FULL_WIDTH_TYPES = new Set<DataFieldType>(['array', 'object', 'dataview']);
613
+
614
+ export function DataView({
615
+ data,
616
+ fields,
617
+ exclude,
618
+ columns = 2,
619
+ showEmptyValues = true,
620
+ emptyValueText = '—',
621
+ emptyText = 'No data.',
622
+ maxDepth = 2,
623
+ depth = 0,
624
+ }: DataViewProps) {
625
+ const record = data ?? {};
626
+ const skip = new Set(exclude ?? []);
627
+
628
+ // Ordering is STABLE: the schema's order, or the record's own key order.
629
+ const specs: DataViewField[] = (fields ?? Object.keys(record).map((name) => ({ name })))
630
+ .filter((f) => !skip.has(f.name));
631
+
632
+ const rows = specs.map((spec) => {
633
+ const value = lookup(record, spec.name);
634
+ const empty = isEmptyValue(value);
635
+ if (empty && (spec.hideEmpty || !showEmptyValues)) return null;
636
+
637
+ let type: DataFieldType;
638
+ if (spec.type && FIELD_TYPES.has(spec.type)) {
639
+ type = spec.type;
640
+ } else {
641
+ if (spec.type) {
642
+ // Rule 4: an unknown type falls back to inference WITH a warn,
643
+ // never to rendering nothing.
644
+ warnOnce(`[DataView] unknown field type ${JSON.stringify(spec.type)} for "${spec.name}" — inferring instead. Valid: ${[...FIELD_TYPES].join(', ')}`);
645
+ }
646
+ type = inferFieldType(value, spec.name);
647
+ }
648
+
649
+ const ctx: RenderCtx = {
650
+ keyLower: spec.name.toLowerCase(),
651
+ tokens: tokenize(spec.name),
652
+ depth, maxDepth, emptyValueText, showEmptyValues,
653
+ };
654
+
655
+ let node: ReactNode;
656
+ if (empty && !spec.format) {
657
+ node = <span className="dataview-value-empty">{emptyValueText}</span>;
658
+ } else {
659
+ // A formatter is the last thing allowed to take a record down: any
660
+ // throw degrades to the plain string, loudly (same contract as fmt).
661
+ try {
662
+ node = spec.format ? spec.format(value, spec.name, record) : renderTyped(value, type, ctx);
663
+ } catch (err) {
664
+ warnOnce(`[DataView] renderer for "${spec.name}" threw — falling back to the plain value.`, err);
665
+ node = String(value);
666
+ }
667
+ }
668
+
669
+ const full = spec.span === 'full' || (spec.span !== 'auto' && FULL_WIDTH_TYPES.has(type));
670
+ return (
671
+ <div
672
+ key={spec.name}
673
+ className={`dataview-item${full ? ' dataview-item-full' : ''}${spec.className ? ` ${spec.className}` : ''}`}
674
+ data-field={spec.name}
675
+ >
676
+ <div className="dataview-label">{spec.label ?? formatLabel(spec.name.split('.').pop() ?? spec.name)}</div>
677
+ <div className="dataview-value">{node}</div>
678
+ </div>
679
+ );
680
+ }).filter(Boolean);
681
+
682
+ if (rows.length === 0) return <div className="dim dataview-empty">{emptyText}</div>;
683
+
684
+ return (
685
+ <div
686
+ className="dataview"
687
+ style={{ '--dataview-cols': Math.max(1, Math.trunc(columns)) } as CSSProperties}
688
+ >
689
+ {rows}
690
+ </div>
691
+ );
692
+ }