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,848 @@
1
+ // ModelTable — schema-driven server table at web-mojo TableView fidelity
2
+ // (ListView.js / TableView.js / TablePage.js port). No table library: sort,
3
+ // filter, search and paging are all wire params django-mojo processes, so
4
+ // there is no client-side row work for an engine to do. Table state IS the
5
+ // params store; rendering is a map.
6
+ //
7
+ // B2 features, all opt-in (absent props → prior rendering, byte-for-byte):
8
+ // · columnChooser — show/hide columns; `hideable: false` locks a column
9
+ // · persistState — per-table saved view (sort/size/search/filters/hidden)
10
+ // in localStorage; precedence URL > saved > defaults, saved size wins
11
+ // · selectable + batchActions — checkbox selection, select-all with
12
+ // indeterminate, batch bar; runs Promise.allSettled with partial-result
13
+ // toasts (TablePage.batchAction port), then clears + refetches
14
+ // · autoRefresh — interval refetch with the smart-skip predicate (hidden /
15
+ // blurred tab, active selection, any open dialog) + focus resume
16
+ // · rowExpand — chevron + full-width detail row; single-open by default
17
+ // · groupBy — synthetic header rows interleaved on key change; falsy key
18
+ // = ungrouped tail (see ui/grouping.ts helpers)
19
+ // · exportFormats — server-side export via download_format (csv/json)
20
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
21
+ import { useQueryClient } from '@tanstack/react-query';
22
+ import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState } from '../client/params';
23
+ import { useModelList } from '../client/hooks';
24
+ import { mojoDownload } from '../client/client';
25
+ import type { ModelDef } from '../client/model';
26
+ import type { Params } from '../client/types';
27
+ import { FilterBar, FilterPills, type FilterDef } from './FilterBar';
28
+ import { busyWhile } from './loading';
29
+ import { modal } from './modal';
30
+ import { toast } from './toast';
31
+
32
+ export interface Column<T> {
33
+ key: string; // server field; drives the sort param
34
+ label: string;
35
+ sortable?: boolean;
36
+ align?: 'start' | 'center' | 'end';
37
+ /** false → locked in the column chooser (always shown). */
38
+ hideable?: boolean;
39
+ render?: (row: T) => ReactNode; // one cell prop — no formatter/format/template aliases
40
+ }
41
+
42
+ export interface Preset {
43
+ key: string;
44
+ label: string;
45
+ params: Record<string, string>; // empty {} = the "All" chip
46
+ }
47
+
48
+ interface BatchActionBase<T> {
49
+ key: string;
50
+ label: string;
51
+ icon?: string;
52
+ danger?: boolean;
53
+ /** Row-state eligibility. Ineligible selected rows are never submitted. */
54
+ eligible?: (row: T) => boolean;
55
+ /** Confirm copy; false skips the prompt. Default: "<label> N item(s)?" */
56
+ confirm?: string | false;
57
+ /**
58
+ * Optional once-per-batch step (collect a reason, pick a value…) run
59
+ * AFTER the confirm. Resolve null to cancel the batch; the resolved
60
+ * value is passed to every run() call.
61
+ */
62
+ prepare?: (rows: T[]) => Promise<unknown | null>;
63
+ }
64
+ export type BatchAction<T> = BatchActionBase<T> & (
65
+ | { run: (row: T, prepared: unknown) => Promise<unknown>; runBatch?: never }
66
+ | { run?: never; runBatch: (rows: T[], prepared: unknown) => Promise<unknown> }
67
+ );
68
+
69
+ export type RowTone = 'danger' | 'warning';
70
+
71
+ const GROUP_HEADER_STYLES = ['banner', 'mark', 'band', 'rule'] as const;
72
+ export type GroupHeaderStyle = (typeof GROUP_HEADER_STYLES)[number];
73
+
74
+ // Cell widths echo the real columns (web-mojo _buildSkeletonHtml's cycled
75
+ // w-* classes) so the shimmer silhouette lines up with the eventual rows.
76
+ const SKEL_WIDTHS = ['skel-w-90', 'skel-w-75', 'skel-w-60', 'skel-w-40', 'skel-w-25'];
77
+
78
+ /** Page-number window: {1, total, current±1} with … for the gaps. */
79
+ function pageWindow(current: number, total: number): (number | '…')[] {
80
+ const keep = new Set<number>([1, total]);
81
+ for (let i = current - 1; i <= current + 1; i++) if (i >= 1 && i <= total) keep.add(i);
82
+ const sorted = [...keep].sort((a, b) => a - b);
83
+ const out: (number | '…')[] = [];
84
+ let last = 0;
85
+ for (const p of sorted) {
86
+ if (last && p - last > 1) out.push('…');
87
+ out.push(p);
88
+ last = p;
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /**
94
+ * Expanding search (maestro's `.fsearch` port): a 30px icon at rest, a real
95
+ * input on focus, and PINNED open while it carries text — search is the one
96
+ * filter whose state has no pill, so the open input is its indicator. `/`
97
+ * focuses it from anywhere (maestro keyboard.js), hinted on hover.
98
+ *
99
+ * Search is a SERVER query like every other table param — keystrokes buffer
100
+ * locally for 300ms (web-mojo's debounce) and then hit the wire once. Enter
101
+ * commits immediately.
102
+ */
103
+ export function ExpandingSearch({ value, onChange, placeholder }: {
104
+ value: string;
105
+ onChange: (term: string) => void;
106
+ placeholder?: string;
107
+ }) {
108
+ const inputRef = useRef<HTMLInputElement>(null);
109
+ const [draft, setDraft] = useState(value);
110
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
111
+ // External resets (Clear all, preset swap, back/forward) win over a
112
+ // stale draft — but never while the user is mid-typing a pending edit.
113
+ useEffect(() => {
114
+ if (timerRef.current == null) setDraft(value);
115
+ }, [value]);
116
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
117
+
118
+ const commit = (term: string) => {
119
+ if (timerRef.current) clearTimeout(timerRef.current);
120
+ timerRef.current = null;
121
+ onChange(term);
122
+ };
123
+ const edit = (term: string) => {
124
+ setDraft(term);
125
+ if (timerRef.current) clearTimeout(timerRef.current);
126
+ timerRef.current = setTimeout(() => commit(term), 300);
127
+ };
128
+
129
+ useEffect(() => {
130
+ const onKey = (e: KeyboardEvent) => {
131
+ if (e.key !== '/') return;
132
+ const t = e.target as HTMLElement | null;
133
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
134
+ if (document.querySelector('dialog[open]')) return;
135
+ e.preventDefault();
136
+ inputRef.current?.focus();
137
+ };
138
+ document.addEventListener('keydown', onKey);
139
+ return () => document.removeEventListener('keydown', onKey);
140
+ }, []);
141
+ return (
142
+ <div className={`fsearch${draft ? ' holding' : ''}`}>
143
+ <i className="bi bi-search icon" />
144
+ <input
145
+ ref={inputRef}
146
+ className="text"
147
+ type="search"
148
+ value={draft}
149
+ placeholder={placeholder}
150
+ onChange={(e) => edit(e.target.value)}
151
+ onKeyDown={(e) => {
152
+ if (e.key === 'Escape') (e.target as HTMLInputElement).blur();
153
+ if (e.key === 'Enter') commit((e.target as HTMLInputElement).value);
154
+ }}
155
+ aria-label="Search"
156
+ />
157
+ <kbd>/</kbd>
158
+ </div>
159
+ );
160
+ }
161
+
162
+ /** Shared dropdown shell (FilterBar's outside-click + Escape pattern). */
163
+ function MenuDropdown({ button, children, align = 'end' }: {
164
+ button: (open: boolean, toggle: () => void) => ReactNode;
165
+ children: ReactNode;
166
+ align?: 'start' | 'end';
167
+ }) {
168
+ const [open, setOpen] = useState(false);
169
+ const wrapRef = useRef<HTMLDivElement>(null);
170
+ useEffect(() => {
171
+ if (!open) return;
172
+ const onDown = (e: MouseEvent) => {
173
+ if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
174
+ };
175
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
176
+ document.addEventListener('mousedown', onDown);
177
+ document.addEventListener('keydown', onKey);
178
+ return () => {
179
+ document.removeEventListener('mousedown', onDown);
180
+ document.removeEventListener('keydown', onKey);
181
+ };
182
+ }, [open]);
183
+ return (
184
+ <div className="filter-menu-wrap" ref={wrapRef}>
185
+ {button(open, () => setOpen((v) => !v))}
186
+ {open && <div className={`filter-menu${align === 'start' ? ' filter-menu-start' : ''}`} role="menu">{children}</div>}
187
+ </div>
188
+ );
189
+ }
190
+
191
+ /**
192
+ * Row identity as django-mojo serves it. Most models use an integer pk, but
193
+ * some (jobs.Job, jobs.ScheduledTask) declare a 32-char uuid-hex CharField
194
+ * primary key — so selection, expansion and React keys are keyed on the union
195
+ * rather than on `number`.
196
+ */
197
+ export type RowId = number | string;
198
+
199
+ export function ModelTable<T extends { id: RowId }>({
200
+ model, endpoint, columns, filters = [], presets = [], eyebrow, title,
201
+ searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd, defaultSort, defaultParams,
202
+ selectable = false, batchActions = [],
203
+ columnChooser = false, persistState = false, persistKey,
204
+ autoRefresh = 0,
205
+ rowExpand, rowExpandMultiple = false,
206
+ groupBy, groupHeaderLabel, groupHeaderStyle = 'banner',
207
+ exportFormats = [], exporter, rowTone,
208
+ }: {
209
+ /** A defineModel definition — supplies the endpoint (and, later, schema). */
210
+ model?: ModelDef<T>;
211
+ /** Bare endpoint, for tables without a model definition. */
212
+ endpoint?: string;
213
+ columns: Column<T>[];
214
+ filters?: FilterDef[];
215
+ presets?: Preset[];
216
+ eyebrow?: string;
217
+ title: string;
218
+ /** Whether the backend model exposes a real search contract. */
219
+ searchable?: boolean;
220
+ searchPlaceholder?: string;
221
+ onRowClick?: (row: T) => void;
222
+ addLabel?: string;
223
+ onAdd?: () => void;
224
+ /** Initial sort when the URL doesn't carry one (default '-created'). */
225
+ defaultSort?: string;
226
+ /**
227
+ * Initial URL-backed query state. URL keys win; persisted state wins over
228
+ * these defaults. `defaultSort` remains as the backwards-compatible
229
+ * shorthand and wins over `defaultParams.sort` when both are supplied.
230
+ */
231
+ defaultParams?: Params;
232
+ /** Checkbox selection — independent of batchActions by construction. */
233
+ selectable?: boolean;
234
+ batchActions?: BatchAction<T>[];
235
+ columnChooser?: boolean;
236
+ persistState?: boolean;
237
+ /** Storage identity; default derives from route + endpoint. */
238
+ persistKey?: string;
239
+ /** Interval refetch in SECONDS (5s floor). 0/absent = off. */
240
+ autoRefresh?: number;
241
+ /** Inline full-width detail row; chevron column appears when set. */
242
+ rowExpand?: (row: T) => ReactNode;
243
+ rowExpandMultiple?: boolean;
244
+ /** Row → group key; falsy = ungrouped tail. See ui/grouping.ts helpers. */
245
+ groupBy?: (row: T) => string | null;
246
+ groupHeaderLabel?: (key: string) => ReactNode;
247
+ groupHeaderStyle?: GroupHeaderStyle;
248
+ /** Server-side export formats offered in the toolbar. */
249
+ exportFormats?: ('csv' | 'json')[];
250
+ /** Client-side export override. Receives the already-normalized params. */
251
+ exporter?: (format: 'csv' | 'json', normalizedParams: Params) => Promise<void>;
252
+ /** Semantic queue emphasis without coupling callers to table CSS. */
253
+ rowTone?: (row: T) => RowTone | null;
254
+ }) {
255
+ const resolvedEndpoint = model?.endpoint ?? endpoint;
256
+ if (!resolvedEndpoint) throw new Error('ModelTable: pass `model` or `endpoint`');
257
+ if (!GROUP_HEADER_STYLES.includes(groupHeaderStyle)) {
258
+ console.warn(`ModelTable: unknown groupHeaderStyle "${groupHeaderStyle}" — falling back to 'banner'`);
259
+ groupHeaderStyle = 'banner';
260
+ }
261
+
262
+ const qc = useQueryClient();
263
+ const paramDefaults = useMemo(() => ({
264
+ ...(defaultParams?.search != null ? { search: String(defaultParams.search) } : {}),
265
+ ...(defaultParams?.size != null ? { size: Number(defaultParams.size) } : {}),
266
+ ...(defaultParams?.page != null ? { page: Number(defaultParams.page) } : {}),
267
+ ...(defaultParams?.sort != null ? { sort: String(defaultParams.sort) } : {}),
268
+ ...(defaultSort !== undefined ? { sort: defaultSort } : {}),
269
+ }), [defaultParams, defaultSort]);
270
+ const defaultFilters = useMemo<Record<string, string>>(() => {
271
+ const out: Record<string, string> = {};
272
+ for (const [key, value] of Object.entries(defaultParams ?? {})) {
273
+ if (key === 'search' || key === 'sort' || key === 'page' || key === 'size') continue;
274
+ if (value != null && value !== '') out[key] = String(value);
275
+ }
276
+ return out;
277
+ }, [defaultParams]);
278
+ const p = useTableParams(paramDefaults, defaultFilters);
279
+
280
+ // ── View persistence (WM-035): restore once BEFORE the first fetch ──
281
+ const storageKey = persistKey ?? `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}`;
282
+ const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set());
283
+ const [hydrated, setHydrated] = useState(!persistState);
284
+ const restoreRan = useRef(false);
285
+ useEffect(() => {
286
+ if (!persistState || restoreRan.current) return;
287
+ restoreRan.current = true;
288
+ const saved = readTableState(storageKey);
289
+ if (saved) {
290
+ if (Array.isArray(saved.hidden) && saved.hidden.length > 0) {
291
+ const lockedSafe = saved.hidden.filter((key) => columns.find((c) => c.key === key)?.hideable !== false);
292
+ setHiddenCols(new Set(lockedSafe));
293
+ }
294
+ p.applySaved(saved);
295
+ }
296
+ setHydrated(true);
297
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot mount restore
298
+ }, []);
299
+
300
+ // A stale URL/persisted `search` must not become a hidden request when a
301
+ // table declares that its backend has no search contract. Strip it before
302
+ // model normalization so neither the normalizer, query key, nor wire sees
303
+ // the raw value.
304
+ const tableParams = searchable
305
+ ? p.wire
306
+ : Object.fromEntries(Object.entries(p.wire).filter(([key]) => key !== 'search'));
307
+ const listParams = model?.normalizeListParams
308
+ ? model.normalizeListParams(tableParams)
309
+ : tableParams;
310
+ const query = useModelList<T>(resolvedEndpoint, listParams, {
311
+ enabled: hydrated,
312
+ sanitizeRow: model?.sanitizeRow,
313
+ });
314
+ const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
315
+ const count = query.data?.count ?? 0;
316
+ const totalPages = Math.max(1, Math.ceil(count / p.size));
317
+ const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
318
+ const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
319
+ const isFiltered = p.activeFilters.length > 0 || (searchable && p.search !== '');
320
+
321
+ // Save the view on every params/visibility change (post-hydration only,
322
+ // so the restore itself never writes back a half-applied state).
323
+ useEffect(() => {
324
+ if (!persistState || !hydrated) return;
325
+ const state: PersistedTableState = { v: 2 };
326
+ if (p.sort) state.sort = p.sort;
327
+ state.size = p.size;
328
+ if (searchable && p.search) state.search = p.search;
329
+ const rawFilters: Record<string, string> = {};
330
+ for (const [key, value] of Object.entries(p.wire)) {
331
+ if (key === 'start' || key === 'size' || key === 'sort' || key === 'search' || key === 'graph') continue;
332
+ if (value == null || value === '') continue;
333
+ rawFilters[key] = String(value);
334
+ }
335
+ if (Object.keys(rawFilters).length > 0) state.filters = rawFilters;
336
+ const hidden = [...hiddenCols];
337
+ if (hidden.length > 0) state.hidden = hidden;
338
+ writeTableState(storageKey, state);
339
+ }, [persistState, hydrated, p.wire, p.sort, p.size, p.search, hiddenCols, storageKey]);
340
+
341
+ // ── Column chooser ───────────────────────────────────────────────────
342
+ const visibleColumns = useMemo(
343
+ () => (columnChooser && hiddenCols.size > 0)
344
+ ? columns.filter((c) => c.hideable === false || !hiddenCols.has(c.key))
345
+ : columns,
346
+ [columns, columnChooser, hiddenCols],
347
+ );
348
+ const toggleColumn = (key: string) => {
349
+ const col = columns.find((c) => c.key === key);
350
+ if (!col || col.hideable === false) return;
351
+ setHiddenCols((prev) => {
352
+ const next = new Set(prev);
353
+ if (next.has(key)) next.delete(key);
354
+ else next.add(key);
355
+ return next;
356
+ });
357
+ };
358
+ const resetColumns = () => {
359
+ setHiddenCols(new Set());
360
+ clearTableState(storageKey);
361
+ };
362
+
363
+ // ── Selection + batch (decoupled: checkboxes need no batchActions) ──
364
+ const [selected, setSelected] = useState<Set<RowId>>(() => new Set());
365
+ const wireSignature = JSON.stringify(p.wire);
366
+ const prevSignature = useRef(wireSignature);
367
+ useEffect(() => {
368
+ // Page / sort / filter / size changes rebuild the row set — selection
369
+ // clears (web-mojo pages-mode behavior). Auto-refresh ticks don't
370
+ // change the wire, and they skip while a selection is active anyway.
371
+ if (prevSignature.current !== wireSignature) {
372
+ prevSignature.current = wireSignature;
373
+ setSelected((prev) => (prev.size > 0 ? new Set() : prev));
374
+ }
375
+ }, [wireSignature]);
376
+
377
+ const toggleSelected = (id: RowId) => {
378
+ setSelected((prev) => {
379
+ const next = new Set(prev);
380
+ if (next.has(id)) next.delete(id);
381
+ else next.add(id);
382
+ return next;
383
+ });
384
+ };
385
+ const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.id));
386
+ const someSelected = rows.some((r) => selected.has(r.id));
387
+ const toggleSelectAll = () => {
388
+ setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
389
+ };
390
+
391
+ /** TablePage.batchAction port: allSettled + partial-result toasts. */
392
+ const runBatch = async (action: BatchAction<T>) => {
393
+ const targets = rows.filter((r) => selected.has(r.id) && (!action.eligible || action.eligible(r)));
394
+ if (targets.length === 0) return;
395
+ if (action.confirm !== false) {
396
+ const ok = await modal.confirm({
397
+ title: action.label,
398
+ message: action.confirm ?? `${action.label} ${targets.length} item(s)?`,
399
+ confirmText: action.label,
400
+ danger: action.danger,
401
+ });
402
+ if (!ok) return;
403
+ }
404
+ let prepared: unknown = null;
405
+ if (action.prepare) {
406
+ prepared = await action.prepare(targets);
407
+ if (prepared === null) return;
408
+ }
409
+ if (action.runBatch) {
410
+ try {
411
+ await action.runBatch(targets, prepared);
412
+ toast.success(`${action.label}: ${targets.length} item(s) updated`);
413
+ } catch (err) {
414
+ toast.error(err instanceof Error ? err.message : `${action.label} failed`);
415
+ }
416
+ setSelected(new Set());
417
+ void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
418
+ return;
419
+ }
420
+ const results = await Promise.allSettled(targets.map((row) => action.run!(row, prepared)));
421
+ const successes = results.filter((r) => r.status === 'fulfilled').length;
422
+ const failures = results.length - successes;
423
+ if (failures === 0) toast.success(`${action.label}: ${successes} item(s) updated`);
424
+ else if (successes === 0) toast.error(`${action.label} failed for all ${failures} item(s)`);
425
+ else toast.warning(`${action.label}: ${successes} succeeded, ${failures} failed`);
426
+ // Always clear + refetch on a non-cancel path so the user sees the
427
+ // partial state after failures.
428
+ setSelected(new Set());
429
+ void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
430
+ };
431
+
432
+ // ── Row expand ───────────────────────────────────────────────────────
433
+ const expandEnabled = typeof rowExpand === 'function';
434
+ const [expanded, setExpanded] = useState<Set<RowId>>(() => new Set());
435
+ useEffect(() => {
436
+ // Page change / refetch collapses rows that are no longer present.
437
+ if (!expandEnabled) return;
438
+ setExpanded((prev) => {
439
+ if (prev.size === 0) return prev;
440
+ const ids = new Set(rows.map((r) => r.id));
441
+ const next = new Set([...prev].filter((id) => ids.has(id)));
442
+ return next.size === prev.size ? prev : next;
443
+ });
444
+ }, [rows, expandEnabled]);
445
+ const toggleExpand = (id: RowId) => {
446
+ setExpanded((prev) => {
447
+ const next = new Set(rowExpandMultiple ? prev : []);
448
+ if (prev.has(id)) next.delete(id);
449
+ else next.add(id);
450
+ return next;
451
+ });
452
+ };
453
+
454
+ // ── Auto-refresh (WM-034): interval + smart-skip predicate ──────────
455
+ // Interval ONLY — no focus/visibility listeners. Stacking a resume-tick
456
+ // on top of TanStack's own focus handling double-fetched every time the
457
+ // window regained focus (report: "multiple fetches per next page" with
458
+ // devtools open). Ticks simply skip while the tab is hidden/blurred and
459
+ // the next on-interval tick catches up.
460
+ const refetchRef = useRef(query.refetch);
461
+ refetchRef.current = query.refetch;
462
+ const selectedRef = useRef(selected);
463
+ selectedRef.current = selected;
464
+ const autoRefreshMs = autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
465
+ useEffect(() => {
466
+ if (!autoRefreshMs) return;
467
+ const skip = () =>
468
+ document.hidden
469
+ || !document.hasFocus()
470
+ // An open dialog is the portal's "user is mid-interaction" signal
471
+ // (web-mojo paused for cell edits + open menus).
472
+ || document.querySelector('dialog[open]') != null
473
+ || selectedRef.current.size > 0;
474
+ const interval = setInterval(() => {
475
+ if (skip()) return;
476
+ void refetchRef.current();
477
+ }, autoRefreshMs);
478
+ return () => clearInterval(interval);
479
+ }, [autoRefreshMs]);
480
+
481
+ // ── Export ───────────────────────────────────────────────────────────
482
+ // Export must use the SAME model-normalized params as the visible query.
483
+ // Besides keeping filters aligned, this closes a security boundary:
484
+ // models may force a safe graph or discard attacker-controlled graph
485
+ // params before sensitive fields can enter a JSON/CSV export.
486
+ //
487
+ // The one table action with NO feedback: the menu closes and the browser
488
+ // download appears whenever the server finishes building the whole
489
+ // filtered result set (seconds on a six-figure Logs table). busyWhile
490
+ // blocks for exactly that long — and the anti-flash delay means the
491
+ // instant mock export still shows nothing at all. See docs/loading.md.
492
+ const runExport = useCallback(async (format: 'csv' | 'json') => {
493
+ try {
494
+ await busyWhile(
495
+ `Preparing ${format.toUpperCase()} export…`,
496
+ () => exporter ? exporter(format, listParams) : mojoDownload(resolvedEndpoint, listParams, format),
497
+ );
498
+ } catch (err) {
499
+ toast.error(err instanceof Error ? err.message : 'Export failed');
500
+ }
501
+ }, [resolvedEndpoint, listParams, exporter]);
502
+
503
+ const leadCols = (expandEnabled ? 1 : 0) + (selectable ? 1 : 0);
504
+ const totalCols = leadCols + visibleColumns.length;
505
+
506
+ const sortIcon = (col: Column<T>) => {
507
+ if (p.sort === col.key) return <i className="bi bi-arrow-up sort-icon" />;
508
+ if (p.sort === `-${col.key}`) return <i className="bi bi-arrow-down sort-icon" />;
509
+ return <i className="bi bi-arrow-down-up sort-icon sort-idle" />;
510
+ };
511
+
512
+ /** Interleave group headers on key change (falsy = ungrouped tail). */
513
+ const renderBody = () => {
514
+ const out: ReactNode[] = [];
515
+ let prevKey: string | null = null;
516
+ let prevKeySet = false;
517
+ for (const row of rows) {
518
+ if (groupBy) {
519
+ let key: string | null = null;
520
+ try {
521
+ key = groupBy(row);
522
+ } catch (err) {
523
+ console.warn('ModelTable: groupBy threw — treating as ungrouped tail', err);
524
+ }
525
+ if (key && (!prevKeySet || key !== prevKey)) {
526
+ out.push(
527
+ <tr key={`grp:${key}:${row.id}`} className={`group-header-row group-header--${groupHeaderStyle}`}>
528
+ {/* Sticky span: the label stays inside the visible
529
+ scroll viewport however wide the table is. */}
530
+ <th colSpan={totalCols}><span className="group-header-label">{groupHeaderLabel ? groupHeaderLabel(key) : key}</span></th>
531
+ </tr>,
532
+ );
533
+ prevKey = key;
534
+ prevKeySet = true;
535
+ }
536
+ }
537
+ const isExpanded = expandEnabled && expanded.has(row.id);
538
+ const tone = rowTone?.(row) ?? null;
539
+ out.push(
540
+ <tr
541
+ key={row.id}
542
+ className={[
543
+ onRowClick ? 'row-click' : '',
544
+ selected.has(row.id) ? 'row-selected' : '',
545
+ isExpanded ? 'row-expanded' : '',
546
+ tone ? `row-tone-${tone}` : '',
547
+ ].filter(Boolean).join(' ') || undefined}
548
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
549
+ >
550
+ {expandEnabled && (
551
+ <td className="col-expand" onClick={(e) => e.stopPropagation()}>
552
+ <button
553
+ className="expand-toggle"
554
+ aria-expanded={isExpanded}
555
+ title={isExpanded ? 'Collapse' : 'Expand'}
556
+ onClick={() => toggleExpand(row.id)}
557
+ >
558
+ <i className="bi bi-chevron-right" />
559
+ </button>
560
+ </td>
561
+ )}
562
+ {selectable && (
563
+ <td className="col-select" onClick={(e) => e.stopPropagation()}>
564
+ <input
565
+ type="checkbox"
566
+ className="tbl-check"
567
+ checked={selected.has(row.id)}
568
+ onChange={() => toggleSelected(row.id)}
569
+ aria-label={`Select row ${row.id}`}
570
+ />
571
+ </td>
572
+ )}
573
+ {visibleColumns.map((col) => (
574
+ <td key={col.key} className={col.align ? `text-${col.align}` : undefined}>
575
+ {col.render ? col.render(row) : String((row as Record<string, unknown>)[col.key] ?? '—')}
576
+ </td>
577
+ ))}
578
+ </tr>,
579
+ );
580
+ if (isExpanded) {
581
+ out.push(
582
+ <tr key={`detail:${row.id}`} className="detail-row">
583
+ <td colSpan={totalCols}>
584
+ <div className="detail-panel">{rowExpand!(row)}</div>
585
+ </td>
586
+ </tr>,
587
+ );
588
+ }
589
+ }
590
+ return out;
591
+ };
592
+
593
+ return (
594
+ <div className="panel">
595
+ <div className="toolbar">
596
+ <div className="toolbar-heading">
597
+ {eyebrow && <div className="eyebrow">{eyebrow}</div>}
598
+ <h1 className="panel-title">{title}</h1>
599
+ </div>
600
+ <div className="toolbar-controls">
601
+ {searchable && <ExpandingSearch value={p.search} onChange={p.setSearch} placeholder={searchPlaceholder} />}
602
+ {/* Tool cluster: one joined icon group (web-mojo's compact
603
+ toolbar). Icons + tooltips only; text labels appear
604
+ only on very wide screens via .btn-label. */}
605
+ <div className="tool-group">
606
+ {filters.length > 0 && <FilterBar defs={filters} params={p} />}
607
+ {columnChooser && (
608
+ <MenuDropdown button={(open, toggle) => (
609
+ <button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Choose columns">
610
+ <i className="bi bi-layout-three-columns" /> <span className="btn-label">Columns</span>
611
+ </button>
612
+ )}>
613
+ <div className="filter-menu-header">Show columns</div>
614
+ {columns.map((col) => {
615
+ const locked = col.hideable === false;
616
+ return (
617
+ <label key={col.key} className={`chooser-item${locked ? ' chooser-locked' : ''}`} title={locked ? 'Always shown' : undefined}>
618
+ <input
619
+ type="checkbox"
620
+ className="tbl-check"
621
+ checked={locked || !hiddenCols.has(col.key)}
622
+ disabled={locked}
623
+ onChange={() => toggleColumn(col.key)}
624
+ />
625
+ <span>{col.label}</span>
626
+ {locked && <i className="bi bi-lock-fill chooser-lock" />}
627
+ </label>
628
+ );
629
+ })}
630
+ <div className="filter-menu-sep" />
631
+ <button className="filter-menu-item" onClick={resetColumns}>
632
+ <i className="bi bi-arrow-counterclockwise" /> Reset to defaults
633
+ </button>
634
+ {persistState && (
635
+ <div className="chooser-persist"><i className="bi bi-check2-circle" /> Saved for this table</div>
636
+ )}
637
+ </MenuDropdown>
638
+ )}
639
+ {exportFormats.length > 0 && (
640
+ <MenuDropdown button={(open, toggle) => (
641
+ <button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Export">
642
+ <i className="bi bi-download" /> <span className="btn-label">Export</span>
643
+ </button>
644
+ )}>
645
+ {exportFormats.map((format) => (
646
+ <button key={format} className="filter-menu-item" onClick={() => void runExport(format)}>
647
+ <i className={`bi ${format === 'csv' ? 'bi-filetype-csv' : 'bi-filetype-json'}`} />
648
+ Export as {format.toUpperCase()}
649
+ </button>
650
+ ))}
651
+ </MenuDropdown>
652
+ )}
653
+ {/* One refresh affordance: it spins on ANY fetch
654
+ (manual, page change, auto-tick). */}
655
+ <button
656
+ className="btn-icon"
657
+ title={autoRefreshMs > 0 ? `Refresh · auto every ${Math.round(autoRefreshMs / 1000)}s` : 'Refresh'}
658
+ onClick={() => query.refetch()}
659
+ >
660
+ <i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
661
+ </button>
662
+ </div>
663
+ {onAdd && (
664
+ <button className="btn btn-primary btn-compact" onClick={onAdd}>
665
+ <i className="bi bi-plus-lg" /> <span className="btn-label">{addLabel ?? 'Add'}</span>
666
+ </button>
667
+ )}
668
+ </div>
669
+ </div>
670
+
671
+ {presets.length > 0 && (
672
+ <div className="seg-row">
673
+ <div className="seg">
674
+ {presets.map((preset) => (
675
+ <button
676
+ key={preset.key}
677
+ className={`seg-btn${p.presetActive(preset.params) ? ' seg-active' : ''}`}
678
+ onClick={() => p.applyPreset(preset.params)}
679
+ >
680
+ {preset.label}
681
+ </button>
682
+ ))}
683
+ </div>
684
+ {count > 0 && (
685
+ <span className="result-count">
686
+ Showing {firstRow}–{lastRow} of {count}
687
+ {isFiltered && <span className="dim"> · filtered</span>}
688
+ </span>
689
+ )}
690
+ </div>
691
+ )}
692
+
693
+ <FilterPills defs={filters} params={p} />
694
+
695
+ {selectable && batchActions.length > 0 && selected.size > 0 && (
696
+ <div className="batch-bar" role="region" aria-label="Batch actions">
697
+ <strong className="batch-count">{selected.size} selected</strong>
698
+ <div className="batch-actions">
699
+ {batchActions.map((action) => (
700
+ <button
701
+ key={action.key}
702
+ className={`btn btn-compact${action.danger ? ' btn-danger-ghost' : ''}`}
703
+ onClick={() => void runBatch(action)}
704
+ >
705
+ {action.icon && <i className={`bi ${action.icon}`} />} {action.label}
706
+ </button>
707
+ ))}
708
+ <button className="btn btn-compact" onClick={() => setSelected(new Set())}>
709
+ <i className="bi bi-x-circle" /> Clear
710
+ </button>
711
+ </div>
712
+ </div>
713
+ )}
714
+
715
+ <div className="tbl-scroll">
716
+ <table className="tbl">
717
+ <thead>
718
+ <tr>
719
+ {expandEnabled && <th className="col-expand" />}
720
+ {selectable && (
721
+ <th className="col-select">
722
+ <input
723
+ type="checkbox"
724
+ className="tbl-check"
725
+ checked={allSelected}
726
+ ref={(el) => { if (el) el.indeterminate = !allSelected && someSelected; }}
727
+ onChange={toggleSelectAll}
728
+ aria-label="Select all rows on this page"
729
+ />
730
+ </th>
731
+ )}
732
+ {visibleColumns.map((col) => (
733
+ <th key={col.key} className={col.align ? `text-${col.align}` : undefined}>
734
+ {col.sortable === false ? col.label : (
735
+ <button className="th-sort" onClick={() => p.cycleSort(col.key)}>
736
+ {col.label} {sortIcon(col)}
737
+ </button>
738
+ )}
739
+ </th>
740
+ ))}
741
+ </tr>
742
+ </thead>
743
+ <tbody>
744
+ {/* Skeleton whenever THIS query's rows aren't on screen
745
+ yet: cold load, restore gate, or a page/sort/filter
746
+ change still serving the previous key's rows
747
+ (isPlaceholderData). Background refetches of the
748
+ SAME key (autoRefresh) keep the live rows. */}
749
+ {query.isPending || query.isPlaceholderData || !hydrated ? (
750
+ Array.from({ length: Math.min(p.size, 8) }).map((_, i) => (
751
+ <tr key={i} className="skel-row" aria-hidden="true">
752
+ {expandEnabled && <td className="col-expand" />}
753
+ {selectable && <td className="col-select" />}
754
+ {visibleColumns.map((col, ci) => (
755
+ <td key={col.key} className={col.align ? `text-${col.align}` : undefined}>
756
+ {ci === 0 ? (
757
+ <span className="skel-user">
758
+ <span className="skel skel-avatar" />
759
+ <span className="skel-stack">
760
+ <span className="skel skel-w-60" />
761
+ <span className="skel skel-w-90" />
762
+ </span>
763
+ </span>
764
+ ) : (
765
+ <span className={`skel ${col.align === 'center' ? 'skel-pill' : SKEL_WIDTHS[ci % SKEL_WIDTHS.length]}`} />
766
+ )}
767
+ </td>
768
+ ))}
769
+ </tr>
770
+ ))
771
+ ) : query.isError ? (
772
+ <tr><td colSpan={totalCols}>
773
+ <div className="empty-state">
774
+ <i className="bi bi-exclamation-triangle" />
775
+ <h3>Could not load {title.toLowerCase()}</h3>
776
+ <p>{query.error instanceof Error ? query.error.message : 'Request failed'}</p>
777
+ <button className="btn" onClick={() => query.refetch()}>Retry</button>
778
+ </div>
779
+ </td></tr>
780
+ ) : rows.length === 0 ? (
781
+ <tr><td colSpan={totalCols}>
782
+ <div className="empty-state">
783
+ <i className="bi bi-inbox" />
784
+ {isFiltered ? (
785
+ <>
786
+ <h3>No results match your filters</h3>
787
+ <button className="btn" onClick={() => p.clearFilters()}>Clear filters</button>
788
+ </>
789
+ ) : (
790
+ <h3>No {title.toLowerCase()} yet</h3>
791
+ )}
792
+ </div>
793
+ </td></tr>
794
+ ) : renderBody()}
795
+ </tbody>
796
+ </table>
797
+ </div>
798
+
799
+ <div className="pager">
800
+ <span className="result-count">
801
+ {count > 0 ? <>Showing {firstRow}–{lastRow} of {count}</> : 'No records'}
802
+ </span>
803
+ <div className="pager-right">
804
+ {totalPages > 1 && (
805
+ <div className="pager-controls">
806
+ <button
807
+ className="btn-icon" title="Previous page"
808
+ disabled={p.page <= 1}
809
+ onClick={() => p.setPage(p.page - 1)}
810
+ >
811
+ <i className="bi bi-chevron-left" />
812
+ </button>
813
+ {pageWindow(p.page, totalPages).map((entry, i) => entry === '…' ? (
814
+ <span key={`gap${i}`} className="pager-gap">…</span>
815
+ ) : (
816
+ <button
817
+ key={entry}
818
+ className={`pager-num${entry === p.page ? ' pager-current' : ''}`}
819
+ onClick={() => p.setPage(entry)}
820
+ >
821
+ {entry}
822
+ </button>
823
+ ))}
824
+ <button
825
+ className="btn-icon" title="Next page"
826
+ disabled={p.page >= totalPages}
827
+ onClick={() => p.setPage(p.page + 1)}
828
+ >
829
+ <i className="bi bi-chevron-right" />
830
+ </button>
831
+ </div>
832
+ )}
833
+ <label className="page-size">
834
+ <span className="dim">Rows</span>
835
+ <select
836
+ className="input input-compact"
837
+ value={p.size}
838
+ onChange={(e) => p.setSize(Number(e.target.value))}
839
+ aria-label="Rows per page"
840
+ >
841
+ {PAGE_SIZES.map((s) => <option key={s} value={s}>{s}</option>)}
842
+ </select>
843
+ </label>
844
+ </div>
845
+ </div>
846
+ </div>
847
+ );
848
+ }