@modern-admin/react 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 (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. package/src/validation.ts +361 -0
@@ -0,0 +1,2645 @@
1
+ // List page powered by @tanstack/react-table. Server-side sorting / filtering
2
+ // / pagination — TanStack just handles state + UI. Each visible PropertyJSON
3
+ // becomes a column; reference cells link to the related record's show page,
4
+ // id cells link to their own show page, and clicking anywhere else in a row
5
+ // opens edit. A toolbar offers global search, per-column filters and column
6
+ // visibility, plus a paginator with page-size selector.
7
+
8
+ import * as React from 'react'
9
+ import {
10
+ type ColumnDef,
11
+ type ColumnFiltersState,
12
+ type ColumnSizingState,
13
+ flexRender,
14
+ getCoreRowModel,
15
+ type RowSelectionState,
16
+ type SortingState,
17
+ useReactTable,
18
+ type VisibilityState,
19
+ } from '@tanstack/react-table'
20
+ import {
21
+ Badge,
22
+ Button,
23
+ Card,
24
+ CardContent,
25
+ CardHeader,
26
+ CardTitle,
27
+ Checkbox,
28
+ cn,
29
+ DatePicker,
30
+ DropdownMenu,
31
+ DropdownMenuCheckboxItem,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ DropdownMenuLabel,
35
+ DropdownMenuSeparator,
36
+ DropdownMenuTrigger,
37
+ Empty,
38
+ EmptyContent,
39
+ EmptyDescription,
40
+ EmptyHeader,
41
+ EmptyMedia,
42
+ EmptyTitle,
43
+ Input,
44
+ Kbd,
45
+ Label,
46
+ Popover,
47
+ PopoverContent,
48
+ PopoverTrigger,
49
+ ScrollArea,
50
+ Select,
51
+ SelectContent,
52
+ SelectItem,
53
+ SelectTrigger,
54
+ SelectValue,
55
+ Sheet,
56
+ SheetContent,
57
+ SheetHeader,
58
+ SheetTitle,
59
+ Skeleton,
60
+ TableBody,
61
+ TableCell,
62
+ TableHead,
63
+ TableHeader,
64
+ TableRow,
65
+ Tooltip,
66
+ TooltipContent,
67
+ TooltipTrigger,
68
+ } from '@modern-admin/ui'
69
+ import {
70
+ ArrowDown,
71
+ ArrowUp,
72
+ ArrowUpDown,
73
+ ChevronLeft,
74
+ ChevronRight,
75
+ ChevronsLeft,
76
+ ChevronsRight,
77
+ Download,
78
+ Eye,
79
+ Inbox,
80
+ ListFilter,
81
+ MoreHorizontal,
82
+ Pencil,
83
+ Plus,
84
+ RefreshCw,
85
+ Search,
86
+ SlidersHorizontal,
87
+ Trash2,
88
+ X,
89
+ Zap,
90
+ } from 'lucide-react'
91
+ import {
92
+ useBulkDeleteRecords,
93
+ useDeleteRecord,
94
+ useDistinctValues,
95
+ useInvokeBulkAction,
96
+ useInvokeRecordAction,
97
+ useInvokeResourceAction,
98
+ useRecords,
99
+ useResource,
100
+ } from '../hooks.js'
101
+ import { PropertyDisplay } from '../property-renderer.js'
102
+ import { ReferenceCombobox, ReferenceLink, ReferenceLinkList } from '../reference.js'
103
+ import {
104
+ Link,
105
+ type ListQueryState,
106
+ type Route,
107
+ useNavigate,
108
+ useOpenInNewTab,
109
+ useRoute,
110
+ } from '../router.js'
111
+ import { useI18n } from '../i18n.js'
112
+ import { useNotify } from '../notify.js'
113
+ import { useDialogs } from '../dialogs.js'
114
+ import { useHotkey } from '../use-hotkey.js'
115
+ import { homeCrumb, PageBreadcrumbs } from '../breadcrumbs.js'
116
+ import { ExportDialog } from './export-dialog.js'
117
+ import { ActionMenu, ActionMenuItems } from '../action-menu.js'
118
+ import { visibleRecordProperties } from '../relations.js'
119
+ import type { ActionDescriptor, ListQuery, PropertyJSON, RecordJSON } from '../types.js'
120
+ import { confirmGuard } from '../action-guard.js'
121
+
122
+ const PAGE_SIZES = [10, 20, 50, 100] as const
123
+
124
+ // Cycling widths for skeleton cells — varied so rows don't look identical.
125
+ const SKEL_WIDTHS = ['w-16', 'w-24', 'w-20', 'w-32', 'w-14', 'w-28', 'w-18', 'w-22'] as const
126
+
127
+ /** Attach click-and-drag horizontal scrolling to a scroll container.
128
+ *
129
+ * Used on the table wrapper and the pagination buttons row so users on a
130
+ * mouse can drag horizontally without first scrolling to the native scroll-
131
+ * bar. Mouse-only — touch devices already get smooth native momentum
132
+ * scrolling. Engages only after a small movement threshold so plain clicks
133
+ * on rows/buttons still fire, and swallows the synthetic post-drag click to
134
+ * avoid accidental navigation. Returns a cleanup function. */
135
+ function attachDragScroll(el: HTMLElement): () => void {
136
+ const DRAG_THRESHOLD = 5
137
+ let startX = 0
138
+ let startScrollLeft = 0
139
+ let pointerId: number | null = null
140
+ let dragging = false
141
+ let armed = false
142
+
143
+ const onPointerDown = (e: PointerEvent) => {
144
+ if (e.pointerType !== 'mouse') return
145
+ const target = e.target as HTMLElement | null
146
+ if (!target) return
147
+ if (e.button !== 0) return
148
+ if (
149
+ target.closest(
150
+ 'button, a, input, label, [role="checkbox"], [role="menuitem"], [data-resize-handle], [contenteditable="true"]',
151
+ )
152
+ )
153
+ return
154
+ if (el.scrollWidth <= el.clientWidth) return
155
+ armed = true
156
+ dragging = false
157
+ pointerId = e.pointerId
158
+ startX = e.clientX
159
+ startScrollLeft = el.scrollLeft
160
+ }
161
+
162
+ const onPointerMove = (e: PointerEvent) => {
163
+ if (!armed || pointerId !== e.pointerId) return
164
+ const dx = e.clientX - startX
165
+ if (!dragging) {
166
+ if (Math.abs(dx) < DRAG_THRESHOLD) return
167
+ dragging = true
168
+ el.setPointerCapture(pointerId)
169
+ el.style.cursor = 'grabbing'
170
+ el.style.userSelect = 'none'
171
+ }
172
+ el.scrollLeft = startScrollLeft - dx
173
+ e.preventDefault()
174
+ }
175
+
176
+ const endDrag = (e: PointerEvent) => {
177
+ if (pointerId !== e.pointerId) return
178
+ armed = false
179
+ if (dragging) {
180
+ dragging = false
181
+ if (el.hasPointerCapture(pointerId)) el.releasePointerCapture(pointerId)
182
+ el.style.cursor = ''
183
+ el.style.userSelect = ''
184
+ const swallow = (ev: MouseEvent) => {
185
+ ev.stopPropagation()
186
+ ev.preventDefault()
187
+ }
188
+ el.addEventListener('click', swallow, { capture: true, once: true })
189
+ }
190
+ pointerId = null
191
+ }
192
+
193
+ el.addEventListener('pointerdown', onPointerDown)
194
+ el.addEventListener('pointermove', onPointerMove)
195
+ el.addEventListener('pointerup', endDrag)
196
+ el.addEventListener('pointercancel', endDrag)
197
+ return () => {
198
+ el.removeEventListener('pointerdown', onPointerDown)
199
+ el.removeEventListener('pointermove', onPointerMove)
200
+ el.removeEventListener('pointerup', endDrag)
201
+ el.removeEventListener('pointercancel', endDrag)
202
+ }
203
+ }
204
+
205
+ /** Reasonable starting width per property type. Users can resize from there
206
+ * and the chosen widths are persisted per-resource in localStorage. */
207
+ function defaultColumnSize(property: PropertyJSON): number {
208
+ if (property.isId) return 100
209
+ switch (property.type) {
210
+ case 'boolean':
211
+ return 110
212
+ case 'date':
213
+ return 140
214
+ case 'datetime':
215
+ return 180
216
+ case 'number':
217
+ case 'float':
218
+ case 'money':
219
+ case 'currency':
220
+ return 120
221
+ case 'color':
222
+ return 140
223
+ case 'reference':
224
+ return 200
225
+ case 'richtext':
226
+ case 'textarea':
227
+ return 320
228
+ default:
229
+ return 200
230
+ }
231
+ }
232
+
233
+ const COLUMN_SIZE_STORAGE_PREFIX = 'modern-admin:colSizes:'
234
+
235
+ // Internal system columns (_select, _actions) must never have their sizes
236
+ // persisted — their widths are determined by the layout logic, not the user.
237
+ const isSystemCol = (id: string) => id.startsWith('_')
238
+
239
+ function loadColumnSizing(resourceId: string): ColumnSizingState {
240
+ if (typeof window === 'undefined') return {}
241
+ try {
242
+ const raw = window.localStorage.getItem(COLUMN_SIZE_STORAGE_PREFIX + resourceId)
243
+ if (!raw) return {}
244
+ const parsed = JSON.parse(raw) as ColumnSizingState
245
+ return Object.fromEntries(Object.entries(parsed).filter(([k]) => !isSystemCol(k)))
246
+ } catch {
247
+ return {}
248
+ }
249
+ }
250
+
251
+ function saveColumnSizing(resourceId: string, sizing: ColumnSizingState): void {
252
+ if (typeof window === 'undefined') return
253
+ try {
254
+ const toSave = Object.fromEntries(Object.entries(sizing).filter(([k]) => !isSystemCol(k)))
255
+ window.localStorage.setItem(COLUMN_SIZE_STORAGE_PREFIX + resourceId, JSON.stringify(toSave))
256
+ } catch { /* quota / private mode — ignore */
257
+ }
258
+ }
259
+
260
+ /** Toggles for individual chrome / toolbar pieces. All default to `true`. */
261
+ export interface ResourceListFeatures {
262
+ breadcrumbs?: boolean
263
+ title?: boolean
264
+ refresh?: boolean
265
+ filters?: boolean
266
+ columns?: boolean
267
+ export?: boolean
268
+ create?: boolean
269
+ bulk?: boolean
270
+ /** Toolbar-level "Actions" dropdown for resource-scoped custom actions. */
271
+ actions?: boolean
272
+ /** Per-column filter popovers in the table header. */
273
+ headerFilters?: boolean
274
+ /** Wrap table + toolbar in a Card. Set false when embedding inside another card. */
275
+ card?: boolean
276
+ }
277
+
278
+ export interface ResourceListPageProps {
279
+ resourceId: string
280
+ /** When provided, the table runs in "controlled" mode: query state comes
281
+ * from props instead of the URL hash. Both `query` and `onQueryChange`
282
+ * must be supplied together. */
283
+ query?: ListQueryState
284
+ onQueryChange?: (next: ListQueryState) => void
285
+ /** Filters always applied to the data query but hidden from the filter UI
286
+ * and never written to the URL. Used to embed the list as a related-records
287
+ * view filtered by a parent record's id. */
288
+ lockedFilters?: Record<string, string>
289
+ features?: ResourceListFeatures
290
+ /** When provided, row selection is controlled from outside. The
291
+ * internal bulk action bar should be hidden (`features.bulk = false`)
292
+ * in this mode — the parent is consuming the selection. */
293
+ selectedIds?: ReadonlyArray<string>
294
+ onSelectionChange?: (next: string[]) => void
295
+ /** Disable row click → edit and link cells. Used by the picker dialog
296
+ * so clicking a row just toggles selection. */
297
+ disableRowNavigation?: boolean
298
+ }
299
+
300
+ export function ResourceListPage({
301
+ resourceId,
302
+ query: controlledQuery,
303
+ onQueryChange,
304
+ lockedFilters,
305
+ features,
306
+ selectedIds: controlledSelectedIds,
307
+ onSelectionChange,
308
+ disableRowNavigation,
309
+ }: ResourceListPageProps): React.ReactElement {
310
+ const resource = useResource(resourceId)
311
+ const navigate = useNavigate()
312
+ const openInNewTab = useOpenInNewTab()
313
+ const route = useRoute()
314
+ const remove = useDeleteRecord(resourceId)
315
+ const bulkRemove = useBulkDeleteRecords(resourceId)
316
+ const invokeRecord = useInvokeRecordAction(resourceId)
317
+ const invokeBulk = useInvokeBulkAction(resourceId)
318
+ const invokeResource = useInvokeResourceAction(resourceId)
319
+ const { t } = useI18n()
320
+ const notify = useNotify()
321
+ const dialogs = useDialogs()
322
+
323
+ const isSelectionControlled = controlledSelectedIds !== undefined && onSelectionChange !== undefined
324
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
325
+ const controlledRowSelection = React.useMemo<RowSelectionState>(() => {
326
+ if (!isSelectionControlled) return {}
327
+ const next: RowSelectionState = {}
328
+ for (const id of controlledSelectedIds!) next[id] = true
329
+ return next
330
+ }, [isSelectionControlled, controlledSelectedIds])
331
+ const rowSelection = isSelectionControlled ? controlledRowSelection : internalRowSelection
332
+ const setRowSelection = React.useCallback(
333
+ (
334
+ updater:
335
+ | RowSelectionState
336
+ | ((prev: RowSelectionState) => RowSelectionState),
337
+ ) => {
338
+ if (isSelectionControlled) {
339
+ const prev = controlledRowSelection
340
+ const next = typeof updater === 'function'
341
+ ? (updater as (p: RowSelectionState) => RowSelectionState)(prev)
342
+ : updater
343
+ onSelectionChange!(Object.keys(next).filter((id) => next[id]))
344
+ return
345
+ }
346
+ setInternalRowSelection(updater)
347
+ },
348
+ [isSelectionControlled, controlledRowSelection, onSelectionChange],
349
+ )
350
+
351
+ const isControlled = controlledQuery !== undefined && onQueryChange !== undefined
352
+ const f = React.useMemo(
353
+ () => ({
354
+ breadcrumbs: features?.breadcrumbs ?? true,
355
+ title: features?.title ?? true,
356
+ refresh: features?.refresh ?? true,
357
+ filters: features?.filters ?? true,
358
+ columns: features?.columns ?? true,
359
+ export: features?.export ?? true,
360
+ create: features?.create ?? true,
361
+ bulk: features?.bulk ?? true,
362
+ actions: features?.actions ?? true,
363
+ headerFilters: features?.headerFilters ?? true,
364
+ card: features?.card ?? true,
365
+ }),
366
+ [features],
367
+ )
368
+
369
+ // ── URL-driven (or prop-driven) query state ──
370
+ // In standalone mode, filters/page/perPage/sortBy/direction live in the URL
371
+ // hash (`?page=2&perPage=50&sortBy=name&direction=asc&filters[email]=ada`)
372
+ // so they survive refresh, back, and link sharing. In embedded mode, the
373
+ // parent component owns the same state shape and passes it via `query`.
374
+ const urlQuery = React.useMemo<ListQueryState>(
375
+ () =>
376
+ isControlled
377
+ ? (controlledQuery ?? {})
378
+ : ((route.name === 'list' && route.query) || {}),
379
+ [isControlled, controlledQuery, route],
380
+ )
381
+
382
+ const sorting = React.useMemo<SortingState>(
383
+ () =>
384
+ urlQuery.sortBy
385
+ ? [{ id: urlQuery.sortBy, desc: urlQuery.direction === 'desc' }]
386
+ : [],
387
+ [urlQuery.sortBy, urlQuery.direction],
388
+ )
389
+ const columnFilters = React.useMemo<ColumnFiltersState>(
390
+ () =>
391
+ urlQuery.filters
392
+ ? Object.entries(urlQuery.filters).map(([id, value]) => ({ id, value }))
393
+ : [],
394
+ [urlQuery.filters],
395
+ )
396
+ const pagination = React.useMemo(
397
+ () => ({
398
+ pageIndex: (urlQuery.page ?? 1) - 1,
399
+ pageSize: urlQuery.perPage ?? 20,
400
+ }),
401
+ [urlQuery.page, urlQuery.perPage],
402
+ )
403
+
404
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
405
+ const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>(() =>
406
+ loadColumnSizing(resourceId),
407
+ )
408
+ // Reload + persist coherently. When resourceId changes we reload first
409
+ // (and skip saving the old state under the new key); on subsequent updates
410
+ // we persist the user's resize choices.
411
+ const lastResourceIdRef = React.useRef(resourceId)
412
+ React.useEffect(() => {
413
+ if (lastResourceIdRef.current !== resourceId) {
414
+ lastResourceIdRef.current = resourceId
415
+ setColumnSizing(loadColumnSizing(resourceId))
416
+ return
417
+ }
418
+ saveColumnSizing(resourceId, columnSizing)
419
+ }, [resourceId, columnSizing])
420
+
421
+ // Track the wrapper width so the last visible column can flex to fill any
422
+ // leftover space (mirrors unitify's "distribute remaining space" pattern
423
+ // without re-layouting all columns on every observe).
424
+ const [wrapperWidth, setWrapperWidth] = React.useState(0)
425
+ const roRef = React.useRef<ResizeObserver | null>(null)
426
+ const dragCleanupRef = React.useRef<(() => void) | null>(null)
427
+ // Callback ref: re-attaches ResizeObserver and click-and-drag handlers
428
+ // whenever the wrapper mounts. (Plain useRef + useEffect runs only once,
429
+ // so if the wrapper is initially unmounted due to conditional rendering,
430
+ // the observer never attaches.)
431
+ const tableWrapperRef = React.useCallback((el: HTMLDivElement | null) => {
432
+ if (roRef.current) {
433
+ roRef.current.disconnect()
434
+ roRef.current = null
435
+ }
436
+ if (dragCleanupRef.current) {
437
+ dragCleanupRef.current()
438
+ dragCleanupRef.current = null
439
+ }
440
+ if (!el) return
441
+ const ro = new ResizeObserver((entries) => {
442
+ const w = entries[0]?.contentRect.width ?? 0
443
+ setWrapperWidth(w)
444
+ })
445
+ ro.observe(el)
446
+ roRef.current = ro
447
+ setWrapperWidth(el.clientWidth)
448
+ dragCleanupRef.current = attachDragScroll(el)
449
+ }, [])
450
+
451
+ const [filterOpen, setFilterOpen] = React.useState(false)
452
+
453
+ const updateUrlQuery = React.useCallback(
454
+ (changes: Partial<ListQueryState>) => {
455
+ const merged: ListQueryState = { ...urlQuery, ...changes }
456
+ const next: ListQueryState = {}
457
+ if (merged.page && merged.page > 1) next.page = merged.page
458
+ if (merged.perPage && merged.perPage !== 20) next.perPage = merged.perPage
459
+ if (merged.sortBy) next.sortBy = merged.sortBy
460
+ if (merged.direction) next.direction = merged.direction
461
+ if (merged.filters && Object.keys(merged.filters).length > 0) next.filters = merged.filters
462
+ if (isControlled) {
463
+ onQueryChange!(next)
464
+ return
465
+ }
466
+ navigate({
467
+ name: 'list',
468
+ resourceId,
469
+ ...(Object.keys(next).length > 0 ? { query: next } : {}),
470
+ })
471
+ },
472
+ [isControlled, onQueryChange, navigate, resourceId, urlQuery],
473
+ )
474
+
475
+ const handleSortingChange = React.useCallback(
476
+ (updater: SortingState | ((prev: SortingState) => SortingState)) => {
477
+ const next = typeof updater === 'function' ? updater(sorting) : updater
478
+ const first = next[0]
479
+ updateUrlQuery({
480
+ sortBy: first?.id,
481
+ direction: first ? (first.desc ? 'desc' : 'asc') : undefined,
482
+ page: 1,
483
+ })
484
+ },
485
+ [sorting, updateUrlQuery],
486
+ )
487
+
488
+ const handleFilterChange = React.useCallback(
489
+ (
490
+ updater:
491
+ | ColumnFiltersState
492
+ | ((prev: ColumnFiltersState) => ColumnFiltersState),
493
+ ) => {
494
+ const next = typeof updater === 'function' ? updater(columnFilters) : updater
495
+ const filters: Record<string, string> = {}
496
+ for (const f of next) {
497
+ if (f.value != null && f.value !== '') filters[f.id] = String(f.value)
498
+ }
499
+ updateUrlQuery({
500
+ filters: Object.keys(filters).length > 0 ? filters : undefined,
501
+ page: 1,
502
+ })
503
+ },
504
+ [columnFilters, updateUrlQuery],
505
+ )
506
+
507
+ // Ref so column header popovers can read latest filter state without being
508
+ // in the `columns` useMemo dependency array (which would recreate all columns
509
+ // on every filter change).
510
+ const columnFiltersRef = React.useRef(columnFilters)
511
+ React.useEffect(() => {
512
+ columnFiltersRef.current = columnFilters
513
+ }, [columnFilters])
514
+
515
+ const handleColumnFilterApply = React.useCallback(
516
+ (updates: Record<string, string>) => {
517
+ const next = columnFiltersRef.current.filter((f) => !(f.id in updates))
518
+ for (const [id, value] of Object.entries(updates)) {
519
+ if (value) next.push({ id, value })
520
+ }
521
+ handleFilterChange(next)
522
+ },
523
+ [handleFilterChange],
524
+ )
525
+
526
+ const handlePaginationChange = React.useCallback(
527
+ (
528
+ updater:
529
+ | { pageIndex: number; pageSize: number }
530
+ | ((prev: { pageIndex: number; pageSize: number }) => {
531
+ pageIndex: number
532
+ pageSize: number
533
+ }),
534
+ ) => {
535
+ const next = typeof updater === 'function' ? updater(pagination) : updater
536
+ updateUrlQuery({
537
+ page: next.pageIndex + 1,
538
+ perPage: next.pageSize,
539
+ })
540
+ },
541
+ [pagination, updateUrlQuery],
542
+ )
543
+
544
+ const query = React.useMemo<ListQuery>(() => {
545
+ // Locked filters are merged in but never written to URL or column state.
546
+ const mergedFilters = { ...(lockedFilters ?? {}), ...(urlQuery.filters ?? {}) }
547
+ return {
548
+ page: urlQuery.page ?? 1,
549
+ perPage: urlQuery.perPage ?? 20,
550
+ ...(urlQuery.sortBy
551
+ ? {
552
+ sortBy: urlQuery.sortBy,
553
+ ...(urlQuery.direction ? { direction: urlQuery.direction } : {}),
554
+ }
555
+ : {}),
556
+ ...(Object.keys(mergedFilters).length > 0 ? { filters: mergedFilters } : {}),
557
+ }
558
+ }, [
559
+ urlQuery.page,
560
+ urlQuery.perPage,
561
+ urlQuery.sortBy,
562
+ urlQuery.direction,
563
+ urlQuery.filters,
564
+ lockedFilters,
565
+ ])
566
+
567
+ const records = useRecords(resourceId, query)
568
+
569
+ const visible = React.useMemo<PropertyJSON[]>(() => {
570
+ const all = resource ? visibleRecordProperties(resource.properties, 'list') : []
571
+ // Drop columns pinned by lockedFilters — they're identical for every row.
572
+ if (lockedFilters && Object.keys(lockedFilters).length > 0) {
573
+ return all.filter((p) => !(p.path in lockedFilters))
574
+ }
575
+ return all
576
+ }, [resource, lockedFilters])
577
+
578
+ const builtInActionNames = new Set([
579
+ 'list',
580
+ 'show',
581
+ 'new',
582
+ 'edit',
583
+ 'delete',
584
+ 'bulkDelete',
585
+ 'search',
586
+ 'values',
587
+ ])
588
+ const customResourceActions = (resource?.actions ?? []).filter(
589
+ (a) => a.actionType === 'resource' && !builtInActionNames.has(a.name),
590
+ )
591
+ const customRecordActions = (resource?.actions ?? []).filter(
592
+ (a) => a.actionType === 'record' && !builtInActionNames.has(a.name),
593
+ )
594
+ const customBulkActions = (resource?.actions ?? []).filter(
595
+ (a) => a.actionType === 'bulk' && !builtInActionNames.has(a.name),
596
+ )
597
+
598
+ const showSelectColumn = f.bulk || isSelectionControlled
599
+ const columns = React.useMemo<ColumnDef<RecordJSON>[]>(() => {
600
+ const cols: ColumnDef<RecordJSON>[] = []
601
+ if (showSelectColumn) {
602
+ cols.push({
603
+ id: '_select',
604
+ enableSorting: false,
605
+ enableHiding: false,
606
+ enableResizing: false,
607
+ size: 40,
608
+ minSize: 0,
609
+ header: ({ table }) => {
610
+ const all = table.getIsAllPageRowsSelected()
611
+ const some = table.getIsSomePageRowsSelected()
612
+ return (
613
+ <Checkbox
614
+ checked={all ? true : some ? 'indeterminate' : false}
615
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
616
+ aria-label={t('common:selectAll')}
617
+ />
618
+ )
619
+ },
620
+ cell: ({ row }) => (
621
+ <Checkbox
622
+ checked={row.getIsSelected()}
623
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
624
+ onClick={(e) => e.stopPropagation()}
625
+ aria-label={t('common:selectRow')}
626
+ />
627
+ ),
628
+ })
629
+ }
630
+ cols.push(...visible.map<ColumnDef<RecordJSON>>((property) => ({
631
+ id: property.path,
632
+ accessorFn: (row) => row.params[property.path],
633
+ size: defaultColumnSize(property),
634
+ minSize: 80,
635
+ header: ({ column }) => (
636
+ <div className="flex items-center gap-0.5">
637
+ <SortHeader
638
+ property={property}
639
+ state={
640
+ column.getIsSorted() === 'asc'
641
+ ? 'asc'
642
+ : column.getIsSorted() === 'desc'
643
+ ? 'desc'
644
+ : 'none'
645
+ }
646
+ onSort={() => {
647
+ if (!property.isSortable) return
648
+ const cur = column.getIsSorted()
649
+ if (cur === false) column.toggleSorting(false)
650
+ else if (cur === 'asc') column.toggleSorting(true)
651
+ else column.clearSorting()
652
+ }}
653
+ />
654
+ {f.headerFilters && (
655
+ <ColumnFilterPopover
656
+ property={property}
657
+ getFilters={() => columnFiltersRef.current}
658
+ onApply={handleColumnFilterApply}
659
+ resourceId={resourceId}
660
+ t={t}
661
+ />
662
+ )}
663
+ </div>
664
+ ),
665
+ enableSorting: property.isSortable,
666
+ cell: ({ row }) => (
667
+ <CellContent
668
+ resourceId={resourceId}
669
+ recordId={row.original.id}
670
+ property={property}
671
+ value={row.original.params[property.path]}
672
+ populated={row.original.populated}
673
+ />
674
+ ),
675
+ })))
676
+ if (!disableRowNavigation) cols.push({
677
+ id: '_actions',
678
+ header: () => null,
679
+ enableSorting: false,
680
+ enableHiding: false,
681
+ enableResizing: false,
682
+ size: 44,
683
+ minSize: 0,
684
+ cell: ({ row }) => (
685
+ <RowActions
686
+ t={t}
687
+ customActions={customRecordActions}
688
+ onView={() =>
689
+ navigate({ name: 'show', resourceId, recordId: row.original.id })
690
+ }
691
+ onEdit={() =>
692
+ navigate({ name: 'edit', resourceId, recordId: row.original.id })
693
+ }
694
+ onDelete={async () => {
695
+ const ok = await dialogs.confirm({
696
+ title: t('common:confirmDelete'),
697
+ description: row.original.title || row.original.id,
698
+ confirmLabel: t('common:delete'),
699
+ destructive: true,
700
+ })
701
+ if (!ok) return
702
+ remove.mutate(row.original.id, {
703
+ onSuccess: () => notify.success({ key: 'toast:deleted' }),
704
+ onError: (err) =>
705
+ notify.error(
706
+ { key: 'toast:deleteFailed' },
707
+ { description: err instanceof Error ? err.message : String(err) },
708
+ ),
709
+ })
710
+ }}
711
+ onInvokeAction={async (action) => {
712
+ if (!await confirmGuard(action, dialogs)) return
713
+ invokeRecord.mutate(
714
+ { recordId: row.original.id, actionName: action.name },
715
+ {
716
+ onSuccess: (res) => {
717
+ if (res.notice) {
718
+ const type = res.notice.type === 'error' ? 'error'
719
+ : res.notice.type === 'warning' ? 'warning'
720
+ : res.notice.type === 'info' ? 'info'
721
+ : 'success'
722
+ notify[type]({ message: res.notice.message })
723
+ }
724
+ },
725
+ onError: (err) => notify.error({ message: err.message }),
726
+ },
727
+ )
728
+ }}
729
+ />
730
+ ),
731
+ })
732
+ return cols
733
+ }, [
734
+ visible,
735
+ resourceId,
736
+ navigate,
737
+ remove,
738
+ t,
739
+ notify,
740
+ dialogs,
741
+ handleColumnFilterApply,
742
+ f.headerFilters,
743
+ showSelectColumn,
744
+ disableRowNavigation,
745
+ customRecordActions,
746
+ invokeRecord,
747
+ ])
748
+
749
+ const total = records.data?.meta.total ?? 0
750
+ const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize))
751
+
752
+ const table = useReactTable({
753
+ data: records.data?.records ?? [],
754
+ columns,
755
+ pageCount,
756
+ state: { sorting, columnFilters, columnVisibility, pagination, rowSelection, columnSizing },
757
+ onSortingChange: handleSortingChange,
758
+ onColumnFiltersChange: handleFilterChange,
759
+ onColumnVisibilityChange: setColumnVisibility,
760
+ onColumnSizingChange: setColumnSizing,
761
+ onPaginationChange: handlePaginationChange,
762
+ onRowSelectionChange: setRowSelection,
763
+ enableRowSelection: true,
764
+ manualSorting: true,
765
+ manualFiltering: true,
766
+ manualPagination: true,
767
+ enableColumnResizing: true,
768
+ // 'onEnd' commits the new size only when the user releases the handle,
769
+ // avoiding a re-render storm that 'onChange' triggers on every mousemove.
770
+ columnResizeMode: 'onEnd',
771
+ defaultColumn: { minSize: 80, size: 200, maxSize: 800 },
772
+ getCoreRowModel: getCoreRowModel(),
773
+ getRowId: (row) => row.id,
774
+ })
775
+
776
+ // Selection lives at the page level (we always know the IDs from rowSelection
777
+ // keys because getRowId returns row.id). The bulk-delete button shows
778
+ // whenever the user has at least one row selected.
779
+ const selectedIds = React.useMemo(() => Object.keys(rowSelection), [rowSelection])
780
+ const selectedCount = selectedIds.length
781
+ const showStandaloneEmptyState = !records.isFetching && !records.isError && total === 0
782
+
783
+ // ── Keyboard shortcuts ──
784
+ // Plain `n` creates, `r` refreshes, `f` opens the filters drawer.
785
+ // Bare-key bindings are skipped while typing in inputs. Ctrl+N would
786
+ // be the conventional choice for "new" but every major browser
787
+ // reserves it for "new window" and won't surrender the keydown, so we
788
+ // settle for a single-letter binding consistent with `r` / `f`.
789
+ useHotkey(
790
+ 'n',
791
+ () => {
792
+ navigate({ name: 'new', resourceId })
793
+ },
794
+ { enabled: f.create, description: t('common:new') },
795
+ )
796
+ useHotkey(
797
+ 'r',
798
+ () => {
799
+ if (!records.isFetching) records.refetch()
800
+ },
801
+ { enabled: f.refresh, description: t('common:refresh') },
802
+ )
803
+ useHotkey(
804
+ 'f',
805
+ () => {
806
+ setFilterOpen((v) => !v)
807
+ },
808
+ { enabled: f.filters, description: t('common:filters') },
809
+ )
810
+
811
+ const handleBulkDelete = React.useCallback(async () => {
812
+ const ok = await dialogs.confirm({
813
+ title: t('common:bulkDeleteConfirm', { count: selectedCount }),
814
+ confirmLabel: t('common:delete'),
815
+ destructive: true,
816
+ })
817
+ if (!ok) return
818
+ bulkRemove.mutate(selectedIds, {
819
+ onSuccess: () => {
820
+ setRowSelection({})
821
+ notify.success({ key: 'toast:bulkDeleted', params: { count: selectedCount } })
822
+ },
823
+ onError: (err) =>
824
+ notify.error(
825
+ { key: 'toast:bulkDeleteFailed' },
826
+ { description: err instanceof Error ? err.message : String(err) },
827
+ ),
828
+ })
829
+ }, [bulkRemove, dialogs, notify, selectedCount, selectedIds, setRowSelection, t])
830
+
831
+ if (!resource) {
832
+ return (
833
+ <Card>
834
+ <CardContent className="p-6">
835
+ <Skeleton className="h-6 w-1/3"/>
836
+ </CardContent>
837
+ </Card>
838
+ )
839
+ }
840
+
841
+ const showCustomResourceActions = f.actions && customResourceActions.length > 0
842
+ const hasToolbarActions = !showStandaloneEmptyState && (
843
+ f.refresh || f.filters || f.columns || f.export || f.create || showCustomResourceActions
844
+ )
845
+ const hasHeader = f.title || hasToolbarActions || (!showStandaloneEmptyState && visible.some((p) => p.isSortable))
846
+
847
+ // CardHeader/Content add their own padding. When `card: false` we're embedded
848
+ // inside another container that already provides spacing, so use a bare div
849
+ // wrapper to avoid compounding paddings.
850
+ const HeaderEl = f.card ? CardHeader : 'div'
851
+ const ContentEl = f.card ? CardContent : 'div'
852
+ const headerCls = cn(
853
+ 'flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4',
854
+ !f.card && 'mb-3',
855
+ )
856
+
857
+ const inner = (
858
+ <>
859
+ {f.filters && (
860
+ <FilterPanel
861
+ open={filterOpen}
862
+ onOpenChange={setFilterOpen}
863
+ properties={visible}
864
+ filters={columnFilters}
865
+ onChange={handleFilterChange}
866
+ resourceId={resourceId}
867
+ t={t}
868
+ />
869
+ )}
870
+ {hasHeader && (
871
+ <HeaderEl className={headerCls}>
872
+ {f.title ? <CardTitle>{resource.name}</CardTitle> : <span/>}
873
+ {hasToolbarActions && (
874
+ <div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
875
+ {f.refresh && (
876
+ <Tooltip>
877
+ <TooltipTrigger asChild>
878
+ <Button
879
+ variant="outline"
880
+ size="sm"
881
+ onClick={() => records.refetch()}
882
+ disabled={records.isFetching}
883
+ aria-label={t('common:refresh')}
884
+ >
885
+ <RefreshCw className={records.isFetching ? 'size-4 animate-spin' : 'size-4'}/>
886
+ </Button>
887
+ </TooltipTrigger>
888
+ <TooltipContent className="flex items-center gap-1.5">
889
+ <span>{t('common:refresh')}</span>
890
+ <Kbd>R</Kbd>
891
+ </TooltipContent>
892
+ </Tooltip>
893
+ )}
894
+ {f.filters && (
895
+ <Tooltip>
896
+ <TooltipTrigger asChild>
897
+ <Button variant="outline" size="sm" onClick={() => setFilterOpen(true)}>
898
+ <ListFilter className="size-4"/>
899
+ <span className="hidden sm:inline">{t('common:filters')}</span>
900
+ {columnFilters.length > 0 && (
901
+ <Badge className="ml-1 h-5 rounded-full px-1.5 text-xs">
902
+ {columnFilters.length}
903
+ </Badge>
904
+ )}
905
+ </Button>
906
+ </TooltipTrigger>
907
+ <TooltipContent className="flex items-center gap-1.5">
908
+ <span>{t('common:filters')}</span>
909
+ <Kbd>F</Kbd>
910
+ </TooltipContent>
911
+ </Tooltip>
912
+ )}
913
+ {f.columns && (
914
+ <ColumnVisibilityMenu table={table} properties={visible} t={t}/>
915
+ )}
916
+ {f.export && (
917
+ <Button
918
+ variant="outline"
919
+ size="sm"
920
+ onClick={() =>
921
+ dialogs.open({
922
+ render: ({ close }) => (
923
+ <ExportDialog
924
+ resourceId={resourceId}
925
+ resourceLabel={resource.name}
926
+ properties={visible}
927
+ query={query}
928
+ onClose={() => close()}
929
+ />
930
+ ),
931
+ })
932
+ }
933
+ >
934
+ <Download className="size-4"/>
935
+ <span className="hidden sm:inline">{t('common:export')}</span>
936
+ </Button>
937
+ )}
938
+ {showCustomResourceActions && (
939
+ <ActionMenu
940
+ actions={customResourceActions}
941
+ onAction={async (action) => {
942
+ if (!await confirmGuard(action, dialogs)) return
943
+ invokeResource.mutate(
944
+ { actionName: action.name },
945
+ {
946
+ onSuccess: (res) => {
947
+ if (res.notice) {
948
+ const type = res.notice.type === 'error' ? 'error'
949
+ : res.notice.type === 'warning' ? 'warning'
950
+ : res.notice.type === 'info' ? 'info'
951
+ : 'success'
952
+ notify[type]({ message: res.notice.message })
953
+ }
954
+ },
955
+ onError: (err) => notify.error({ message: err.message }),
956
+ },
957
+ )
958
+ }}
959
+ t={t}
960
+ trigger={(
961
+ <Button variant="outline" size="sm" disabled={invokeResource.isPending}>
962
+ <Zap className="size-4"/>
963
+ <span className="hidden sm:inline">{t('common:actions')}</span>
964
+ </Button>
965
+ )}
966
+ />
967
+ )}
968
+ {f.create && (
969
+ <Tooltip>
970
+ <TooltipTrigger asChild>
971
+ <Button size="sm" onClick={() => navigate({ name: 'new', resourceId })}>
972
+ <Plus className="size-4"/>
973
+ <span className="hidden sm:inline">{t('common:new')}</span>
974
+ </Button>
975
+ </TooltipTrigger>
976
+ <TooltipContent className="flex items-center gap-1.5">
977
+ <span>{t('common:new')}</span>
978
+ <Kbd>N</Kbd>
979
+ </TooltipContent>
980
+ </Tooltip>
981
+ )}
982
+ </div>
983
+ )}
984
+ {/* Mobile-only sort selector — desktop uses column header clicks */}
985
+ {visible.some((p) => p.isSortable) && (
986
+ <div className="flex w-full items-center gap-2 sm:hidden">
987
+ <ArrowUpDown className="size-4 shrink-0 text-muted-foreground"/>
988
+ <Select
989
+ value={
990
+ sorting[0]
991
+ ? `${sorting[0].id}:${sorting[0].desc ? 'desc' : 'asc'}`
992
+ : '_none_'
993
+ }
994
+ onValueChange={(v) => {
995
+ if (v === '_none_') {
996
+ handleSortingChange([])
997
+ return
998
+ }
999
+ const sep = v.lastIndexOf(':')
1000
+ handleSortingChange([{ id: v.slice(0, sep), desc: v.slice(sep + 1) === 'desc' }])
1001
+ }}
1002
+ >
1003
+ <SelectTrigger className="h-8 flex-1">
1004
+ <SelectValue placeholder={t('common:sortBy')}/>
1005
+ </SelectTrigger>
1006
+ <SelectContent>
1007
+ <SelectItem value="_none_">{t('common:sortBy')}: —</SelectItem>
1008
+ {visible
1009
+ .filter((p) => p.isSortable)
1010
+ .flatMap((p) => [
1011
+ <SelectItem key={`${p.path}:asc`} value={`${p.path}:asc`}>
1012
+ {p.label} ↑
1013
+ </SelectItem>,
1014
+ <SelectItem key={`${p.path}:desc`} value={`${p.path}:desc`}>
1015
+ {p.label} ↓
1016
+ </SelectItem>,
1017
+ ])}
1018
+ </SelectContent>
1019
+ </Select>
1020
+ </div>
1021
+ )}
1022
+ </HeaderEl>
1023
+ )}
1024
+ <ContentEl className="flex flex-1 flex-col gap-2 sm:gap-3">
1025
+ {showStandaloneEmptyState ? (
1026
+ <Empty>
1027
+ <EmptyHeader>
1028
+ <EmptyMedia>
1029
+ <Inbox/>
1030
+ </EmptyMedia>
1031
+ <EmptyTitle>{t('common:noRecords')}</EmptyTitle>
1032
+ {f.create && (
1033
+ <EmptyDescription>
1034
+ {t('common:noRecordsHint', { resource: resource.name })}
1035
+ </EmptyDescription>
1036
+ )}
1037
+ </EmptyHeader>
1038
+ {f.create && (
1039
+ <EmptyContent>
1040
+ <Button size="sm" onClick={() => navigate({ name: 'new', resourceId })}>
1041
+ <Plus className="size-4"/>
1042
+ {t('common:new')}
1043
+ </Button>
1044
+ </EmptyContent>
1045
+ )}
1046
+ </Empty>
1047
+ ) : (
1048
+ <>
1049
+ {/* Bulk action bar — only visible when at least one row is selected.
1050
+ Sits above the list so the user can act on the selection without
1051
+ having to scroll. Mirrors a typical email-client multi-select. */}
1052
+ {f.bulk && selectedCount > 0 && (
1053
+ <div
1054
+ className="flex flex-row items-center justify-between gap-2 rounded-md border border-primary/30 bg-primary/5 px-3 py-2">
1055
+ <div className="min-w-0 truncate text-sm font-medium">
1056
+ {t('common:selectedCount', { count: selectedCount })}
1057
+ </div>
1058
+ <div className="flex shrink-0 items-center gap-2">
1059
+ <Button
1060
+ variant="ghost"
1061
+ size="sm"
1062
+ onClick={() => setRowSelection({})}
1063
+ disabled={bulkRemove.isPending}
1064
+ >
1065
+ <X className="size-4"/>
1066
+ <span className="hidden sm:inline">{t('common:clearSelection')}</span>
1067
+ </Button>
1068
+ {customBulkActions.length > 0 && (
1069
+ <ActionMenu
1070
+ actions={customBulkActions}
1071
+ onAction={async (action) => {
1072
+ if (!await confirmGuard(action, dialogs)) return
1073
+ invokeBulk.mutate(
1074
+ { actionName: action.name, ids: selectedIds },
1075
+ {
1076
+ onSuccess: (res) => {
1077
+ setRowSelection({})
1078
+ if (res.notice) {
1079
+ const type = res.notice.type === 'error' ? 'error'
1080
+ : res.notice.type === 'warning' ? 'warning'
1081
+ : res.notice.type === 'info' ? 'info'
1082
+ : 'success'
1083
+ notify[type]({ message: res.notice.message })
1084
+ }
1085
+ },
1086
+ onError: (err) => notify.error({ message: err.message }),
1087
+ },
1088
+ )
1089
+ }}
1090
+ t={t}
1091
+ trigger={(
1092
+ <Button variant="outline" size="sm" disabled={invokeBulk.isPending}>
1093
+ <Zap className="size-4"/>
1094
+ <span className="hidden sm:inline">{t('common:actions')}</span>
1095
+ </Button>
1096
+ )}
1097
+ />
1098
+ )}
1099
+ <Button
1100
+ variant="destructive"
1101
+ size="sm"
1102
+ onClick={handleBulkDelete}
1103
+ disabled={bulkRemove.isPending}
1104
+ >
1105
+ <Trash2 className="size-4"/>
1106
+ <span className="hidden sm:inline">{t('common:deleteSelected')}</span>
1107
+ </Button>
1108
+ </div>
1109
+ </div>
1110
+ )}
1111
+ {/* Mobile: card-per-record stack. Hidden ≥ sm. */}
1112
+ <div className="space-y-2 sm:hidden">
1113
+ {records.isFetching && Array.from({ length: pagination.pageSize }, (_, i) => (
1114
+ <div key={`skel-card-${i}`} className="rounded-lg border border-border bg-card p-3">
1115
+ <div className="flex items-start gap-3">
1116
+ <Skeleton className="mt-1 h-4 w-4 flex-none rounded"/>
1117
+ <div className="flex-1 space-y-2">
1118
+ <div className="flex items-start justify-between gap-2">
1119
+ <div className="flex-1 space-y-1.5">
1120
+ <Skeleton className="h-4 w-32"/>
1121
+ <Skeleton className="h-3 w-16"/>
1122
+ </div>
1123
+ <Skeleton className="h-7 w-7 shrink-0 rounded"/>
1124
+ </div>
1125
+ <div className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2">
1126
+ {Array.from({ length: 4 }, (_, j) => (
1127
+ <div key={j} className="space-y-1">
1128
+ <Skeleton className="h-2.5 w-14"/>
1129
+ <Skeleton className={`h-4 ${SKEL_WIDTHS[(i * 3 + j) % SKEL_WIDTHS.length]}`}/>
1130
+ </div>
1131
+ ))}
1132
+ </div>
1133
+ </div>
1134
+ </div>
1135
+ </div>
1136
+ ))}
1137
+ {!records.isFetching && records.isError && (
1138
+ <div className="rounded-md border border-border px-4 py-8 text-left text-destructive">
1139
+ {t('common:loadFailed', { error: String(records.error) })}
1140
+ </div>
1141
+ )}
1142
+ {!records.isFetching && table.getRowModel().rows.map((row) => (
1143
+ <RecordCard
1144
+ key={row.id}
1145
+ record={row.original}
1146
+ properties={visible.filter((p) =>
1147
+ table.getColumn(p.path)?.getIsVisible() ?? true,
1148
+ )}
1149
+ resourceId={resourceId}
1150
+ showSelect={showSelectColumn}
1151
+ selected={row.getIsSelected()}
1152
+ onToggleSelect={(v) => row.toggleSelected(v)}
1153
+ onView={() => navigate({ name: 'show', resourceId, recordId: row.original.id })}
1154
+ onEdit={() => navigate({ name: 'edit', resourceId, recordId: row.original.id })}
1155
+ onDelete={async () => {
1156
+ const ok = await dialogs.confirm({
1157
+ title: t('common:confirmDelete'),
1158
+ description: row.original.title || row.original.id,
1159
+ confirmLabel: t('common:delete'),
1160
+ destructive: true,
1161
+ })
1162
+ if (!ok) return
1163
+ remove.mutate(row.original.id, {
1164
+ onSuccess: () => notify.success({ key: 'toast:deleted' }),
1165
+ onError: (err) =>
1166
+ notify.error(
1167
+ { key: 'toast:deleteFailed' },
1168
+ { description: err instanceof Error ? err.message : String(err) },
1169
+ ),
1170
+ })
1171
+ }}
1172
+ customActions={customRecordActions}
1173
+ onInvokeAction={async (action) => {
1174
+ if (!await confirmGuard(action, dialogs)) return
1175
+ invokeRecord.mutate(
1176
+ { recordId: row.original.id, actionName: action.name },
1177
+ {
1178
+ onSuccess: (res) => {
1179
+ if (res.notice) {
1180
+ const type = res.notice.type === 'error' ? 'error'
1181
+ : res.notice.type === 'warning' ? 'warning'
1182
+ : res.notice.type === 'info' ? 'info'
1183
+ : 'success'
1184
+ notify[type]({ message: res.notice.message })
1185
+ }
1186
+ },
1187
+ onError: (err) => notify.error({ message: err.message }),
1188
+ },
1189
+ )
1190
+ }}
1191
+ t={t}
1192
+ />
1193
+ ))}
1194
+ </div>
1195
+
1196
+ {/* Desktop: tabular layout. Hidden < sm.
1197
+ `cursor-grab` is a hint that the table can be dragged
1198
+ horizontally; the actual drag handlers live in
1199
+ `tableWrapperRef`. The grip on column resize handles takes
1200
+ precedence (cursor-col-resize is set on a child) so users
1201
+ still get the right cursor while resizing. */}
1202
+ <div
1203
+ ref={tableWrapperRef}
1204
+ className="relative hidden cursor-grab overflow-x-auto rounded-md border border-border sm:block"
1205
+ >
1206
+ {(() => {
1207
+ // Distribute leftover wrapper space proportionally across data columns
1208
+ // so the table always fills the full wrapper width.
1209
+ // _select and _actions keep their fixed sizes; only data columns stretch.
1210
+ const leafCols = table.getVisibleLeafColumns()
1211
+ const totalSize = table.getCenterTotalSize()
1212
+ const wrapperW = wrapperWidth > 0 ? wrapperWidth : totalSize
1213
+ const extra = Math.max(0, wrapperW - totalSize)
1214
+
1215
+ // _select (checkbox) and _actions stay fixed-width; only data
1216
+ // columns participate in proportional stretch.
1217
+ const fixedIds = new Set(['_select', '_actions'])
1218
+ const stretchCols = leafCols.filter((c) => !fixedIds.has(c.id))
1219
+ const stretchBaseTotal = stretchCols.reduce((s, c) => s + c.getSize(), 0)
1220
+
1221
+ // Pre-compute each column's rendered pixel width (base + proportional boost).
1222
+ const renderedWidth = new Map<string, number>(
1223
+ leafCols.map((c) => [c.id, c.getSize()]),
1224
+ )
1225
+ if (extra > 0 && stretchBaseTotal > 0) {
1226
+ let assigned = 0
1227
+ stretchCols.forEach((c, i) => {
1228
+ const share =
1229
+ i === stretchCols.length - 1
1230
+ ? extra - assigned
1231
+ : Math.floor((extra * c.getSize()) / stretchBaseTotal)
1232
+ renderedWidth.set(c.id, c.getSize() + share)
1233
+ assigned += share
1234
+ })
1235
+ }
1236
+
1237
+ const renderedTotal = totalSize + extra
1238
+ const sizeOf = (colId: string, base: number): number =>
1239
+ renderedWidth.get(colId) ?? base
1240
+
1241
+ // Resize guide: compute left offset using rendered (boosted) widths.
1242
+ const resizingHeader = table.getHeaderGroups()
1243
+ .flatMap((hg) => hg.headers)
1244
+ .find((h) => h.column.getIsResizing())
1245
+ const deltaOffset = table.getState().columnSizingInfo.deltaOffset ?? 0
1246
+ const resizeLeft = resizingHeader
1247
+ ? leafCols
1248
+ .slice(
1249
+ 0,
1250
+ leafCols.findIndex((c) => c.id === resizingHeader.column.id) + 1,
1251
+ )
1252
+ .reduce((s, c) => s + (renderedWidth.get(c.id) ?? c.getSize()), 0) +
1253
+ deltaOffset
1254
+ : 0
1255
+
1256
+ return (
1257
+ <div className="relative" style={{ width: renderedTotal }}>
1258
+ {/* Use a raw <table> instead of the shadcn <Table> wrapper:
1259
+ <Table> renders an internal <div className="overflow-auto">
1260
+ which becomes the containing block for sticky cells, pinning
1261
+ them to the off-screen right edge of the full table width
1262
+ rather than the visible scroll area. */}
1263
+ <table
1264
+ className="w-full caption-bottom text-sm"
1265
+ style={{ tableLayout: 'fixed', width: renderedTotal }}
1266
+ >
1267
+ <TableHeader>
1268
+ {table.getHeaderGroups().map((hg) => (
1269
+ <TableRow key={hg.id}>
1270
+ {hg.headers.map((header) => (
1271
+ <TableHead
1272
+ key={header.id}
1273
+ style={{ width: sizeOf(header.column.id, header.getSize()) }}
1274
+ className={cn(
1275
+ 'relative select-none',
1276
+ header.column.id === '_actions' &&
1277
+ 'sticky right-0 z-20 bg-muted px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)]',
1278
+ )}
1279
+ >
1280
+ {header.isPlaceholder
1281
+ ? null
1282
+ : flexRender(header.column.columnDef.header, header.getContext())}
1283
+ {header.column.getCanResize() && (
1284
+ <div
1285
+ data-resize-handle=""
1286
+ onMouseDown={header.getResizeHandler()}
1287
+ onTouchStart={header.getResizeHandler()}
1288
+ onClick={(e) => e.stopPropagation()}
1289
+ onDoubleClick={() => header.column.resetSize()}
1290
+ className={cn(
1291
+ 'absolute right-0 top-0 z-10 h-full w-1.5 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/40',
1292
+ header.column.getIsResizing() && 'bg-primary',
1293
+ )}
1294
+ aria-hidden="true"
1295
+ />
1296
+ )}
1297
+ </TableHead>
1298
+ ))}
1299
+ </TableRow>
1300
+ ))}
1301
+ </TableHeader>
1302
+ <TableBody>
1303
+ {records.isFetching ? (
1304
+ Array.from({ length: pagination.pageSize }, (_, i) => (
1305
+ <TableRow key={`skel-${i}`} className="pointer-events-none">
1306
+ {table.getVisibleLeafColumns().map((col, j) => (
1307
+ <TableCell
1308
+ key={col.id}
1309
+ style={{ width: sizeOf(col.id, col.getSize()) }}
1310
+ className={cn(
1311
+ col.id === '_actions' &&
1312
+ 'sticky right-0 z-10 bg-card px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)]',
1313
+ )}
1314
+ >
1315
+ {col.id === '_select' ? (
1316
+ <Skeleton className="h-4 w-4 rounded"/>
1317
+ ) : col.id === '_actions' ? (
1318
+ <Skeleton className="h-8 w-8 rounded"/>
1319
+ ) : (
1320
+ <Skeleton className={`h-4 ${SKEL_WIDTHS[(i * 3 + j) % SKEL_WIDTHS.length]}`}/>
1321
+ )}
1322
+ </TableCell>
1323
+ ))}
1324
+ </TableRow>
1325
+ ))
1326
+ ) : records.isError ? (
1327
+ <TableRow>
1328
+ <TableCell colSpan={columns.length} className="py-8">
1329
+ <div className="sticky left-4 w-fit text-destructive">
1330
+ {t('common:loadFailed', { error: String(records.error) })}
1331
+ </div>
1332
+ </TableCell>
1333
+ </TableRow>
1334
+ ) : (
1335
+ table.getRowModel().rows.map((row) => (
1336
+ <TableRow
1337
+ key={row.id}
1338
+ data-state={row.getIsSelected() && 'selected'}
1339
+ className="group cursor-pointer"
1340
+ onClick={(e) => {
1341
+ const target = e.target as HTMLElement
1342
+ if (target.closest('a, button, [role="menuitem"], [role="checkbox"]')) return
1343
+ if (disableRowNavigation) {
1344
+ row.toggleSelected(!row.getIsSelected())
1345
+ return
1346
+ }
1347
+ navigate({ name: 'edit', resourceId, recordId: row.original.id })
1348
+ }}
1349
+ onAuxClick={(e) => {
1350
+ if (e.button !== 1) return
1351
+ const target = e.target as HTMLElement
1352
+ if (target.closest('a, button, [role="menuitem"]')) return
1353
+ if (disableRowNavigation) return
1354
+ e.preventDefault()
1355
+ openInNewTab({ name: 'edit', resourceId, recordId: row.original.id })
1356
+ }}
1357
+ onMouseDown={(e) => {
1358
+ if (e.button === 1) {
1359
+ const target = e.target as HTMLElement
1360
+ if (target.closest('a, button, [role="menuitem"]')) return
1361
+ e.preventDefault()
1362
+ }
1363
+ }}
1364
+ >
1365
+ {row.getVisibleCells().map((cell) => (
1366
+ <TableCell
1367
+ key={cell.id}
1368
+ style={{ width: sizeOf(cell.column.id, cell.column.getSize()) }}
1369
+ className={cn(
1370
+ 'overflow-hidden',
1371
+ cell.column.id === '_actions' &&
1372
+ 'sticky right-0 z-10 bg-card px-1 shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.15)] group-hover:bg-muted group-data-[state=selected]:bg-muted',
1373
+ )}
1374
+ >
1375
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
1376
+ </TableCell>
1377
+ ))}
1378
+ </TableRow>
1379
+ ))
1380
+ )}
1381
+ </TableBody>
1382
+ </table>
1383
+ {/* Vertical guide line follows the cursor while a column is being
1384
+ resized. Position is computed from rendered (boosted) widths. */}
1385
+ {resizingHeader && (
1386
+ <div
1387
+ aria-hidden="true"
1388
+ className="pointer-events-none absolute top-0 z-20 h-full w-px bg-primary"
1389
+ style={{ left: resizeLeft }}
1390
+ />
1391
+ )}
1392
+ </div>
1393
+ )
1394
+ })()}
1395
+ </div>
1396
+ </>
1397
+ )}
1398
+ </ContentEl>
1399
+ </>
1400
+ )
1401
+
1402
+ return (
1403
+ <div className={cn('flex flex-col', f.card ? 'min-h-full' : 'h-full')}>
1404
+ {f.breadcrumbs && (
1405
+ <PageBreadcrumbs
1406
+ className="mb-2 sm:mb-4"
1407
+ items={[homeCrumb(t('common:home')), { label: resource.name }]}
1408
+ />
1409
+ )}
1410
+ {f.card ? (
1411
+ <Card className="flex flex-1 flex-col">{inner}</Card>
1412
+ ) : (
1413
+ // Embedded mode (e.g. picker dialog): the table area scrolls
1414
+ // internally so the paginator below can sit flush at the host's
1415
+ // bottom edge, full-width, without a scrollbar gutter eating its
1416
+ // right side. `min-h-0` is required for `flex-1 overflow-y-auto`
1417
+ // inside a flex column to actually constrain its height. Horizontal
1418
+ // + top padding lives on the scroll container — the paginator is
1419
+ // a sibling so it stays edge-to-edge.
1420
+ <div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 pt-4">{inner}</div>
1421
+ )}
1422
+ {!showStandaloneEmptyState && (
1423
+ // Standalone page mode: sticky at the page-wrapper level so the
1424
+ // paginator pins to the viewport bottom while the user scrolls
1425
+ // through the list. The bar extends edge-to-edge via negative
1426
+ // margins that exactly cancel the main scroll-container padding
1427
+ // (`px-4 sm:px-6`) so it sits flush against the screen edges with
1428
+ // no visible gutter. Right padding (`pr-14 sm:pr-16`) reserves
1429
+ // space for the floating AI assistant widget (`fixed bottom-4
1430
+ // right-4`, ~40px wide) so pagination buttons never slide under
1431
+ // it. A top shadow lifts the bar visually off the table when the
1432
+ // user is mid-scroll.
1433
+ //
1434
+ // Embedded mode (`card: false`, e.g. picker dialog): plain flex
1435
+ // child below the scrollable table area. No sticky, no scrollbar
1436
+ // gutter interference — the bar spans the host's full width and
1437
+ // sits directly above whatever the host renders next (e.g.
1438
+ // DialogFooter).
1439
+ <div
1440
+ className={cn(
1441
+ 'border-t border-border bg-card py-3',
1442
+ f.card
1443
+ ? 'sticky bottom-0 -mb-px z-20 -mx-2 mt-0 px-2 pr-14 shadow-[0_-4px_8px_-6px_rgba(0,0,0,0.08)] sm:-mx-6 sm:px-6 sm:pr-16'
1444
+ : 'shrink-0 px-6',
1445
+ )}
1446
+ >
1447
+ <Paginator table={table} total={total} t={t}/>
1448
+ </div>
1449
+ )}
1450
+ </div>
1451
+ )
1452
+ }
1453
+
1454
+ function SortHeader({
1455
+ property,
1456
+ state,
1457
+ onSort,
1458
+ }: {
1459
+ property: PropertyJSON
1460
+ state: 'none' | 'asc' | 'desc'
1461
+ onSort(): void
1462
+ }): React.ReactElement {
1463
+ if (!property.isSortable) {
1464
+ return <span className="font-semibold">{property.label}</span>
1465
+ }
1466
+ const Icon = state === 'asc' ? ArrowUp : state === 'desc' ? ArrowDown : ArrowUpDown
1467
+ return (
1468
+ <button
1469
+ type="button"
1470
+ onClick={onSort}
1471
+ className="-ml-2 inline-flex h-8 items-center gap-1 rounded-md px-2 font-semibold hover:bg-accent hover:text-accent-foreground"
1472
+ >
1473
+ {property.label}
1474
+ <Icon className="size-3.5 opacity-60"/>
1475
+ </button>
1476
+ )
1477
+ }
1478
+
1479
+ function CellContent({
1480
+ resourceId,
1481
+ recordId,
1482
+ property,
1483
+ value,
1484
+ populated,
1485
+ }: {
1486
+ resourceId: string
1487
+ recordId: string
1488
+ property: PropertyJSON
1489
+ value: unknown
1490
+ populated?: Record<string, unknown>
1491
+ }): React.ReactElement {
1492
+ if (property.isId) {
1493
+ return (
1494
+ <Link
1495
+ to={{ name: 'show', resourceId, recordId }}
1496
+ className="font-mono text-sm font-medium text-foreground hover:underline"
1497
+ onClick={(e) => e.stopPropagation()}
1498
+ >
1499
+ {String(value ?? '')}
1500
+ </Link>
1501
+ )
1502
+ }
1503
+ // m2m properties also set `reference` + `isArray`, but their value is an
1504
+ // array of `{ id, ...extras }` objects — not scalar FKs. Hand them off to
1505
+ // PropertyDisplay so its dedicated `case 'm2m'` branch can extract ids.
1506
+ if (property.reference && property.type !== 'm2m' && value != null && value !== '') {
1507
+ if (property.isArray) {
1508
+ const ids = Array.isArray(value) ? (value as Array<string | number>) : []
1509
+ return (
1510
+ <ReferenceLinkList
1511
+ resourceId={property.reference}
1512
+ recordIds={ids}
1513
+ populated={populated}
1514
+ populatedKeyPrefix={property.path}
1515
+ />
1516
+ )
1517
+ }
1518
+ // The list endpoint pre-populates scalar references in batch
1519
+ // (`record.populated[property.path]`), so we hand the inline record to
1520
+ // <ReferenceLink> and avoid the per-row `show` request.
1521
+ const populatedRecord = populated?.[property.path] as
1522
+ | { id?: string; title?: string }
1523
+ | undefined
1524
+ return (
1525
+ <ReferenceLink
1526
+ resourceId={property.reference}
1527
+ recordId={value as string | number}
1528
+ populated={populatedRecord}
1529
+ />
1530
+ )
1531
+ }
1532
+ return <PropertyDisplay property={property} value={value} view="list" populated={populated}/>
1533
+ }
1534
+
1535
+ function RowActions({
1536
+ onView,
1537
+ onEdit,
1538
+ onDelete,
1539
+ onInvokeAction,
1540
+ customActions = [],
1541
+ t,
1542
+ }: {
1543
+ onView(): void
1544
+ onEdit(): void
1545
+ onDelete(): void
1546
+ onInvokeAction?(action: ActionDescriptor): void
1547
+ customActions?: ActionDescriptor[]
1548
+ t: (key: string, params?: Record<string, string | number>) => string
1549
+ }): React.ReactElement {
1550
+ return (
1551
+ <div className="flex justify-end" onClick={(e) => e.stopPropagation()}>
1552
+ <DropdownMenu>
1553
+ <DropdownMenuTrigger asChild>
1554
+ <Button variant="ghost" size="icon" className="size-8">
1555
+ <MoreHorizontal className="size-4"/>
1556
+ <span className="sr-only">{t('common:openMenu')}</span>
1557
+ </Button>
1558
+ </DropdownMenuTrigger>
1559
+ <DropdownMenuContent align="end">
1560
+ <DropdownMenuLabel>{t('common:actions')}</DropdownMenuLabel>
1561
+ <DropdownMenuItem onSelect={onView}>
1562
+ <Eye className="size-4"/> {t('common:show')}
1563
+ </DropdownMenuItem>
1564
+ <DropdownMenuItem onSelect={onEdit}>
1565
+ <Pencil className="size-4"/> {t('common:edit')}
1566
+ </DropdownMenuItem>
1567
+ {customActions.length > 0 && (
1568
+ <>
1569
+ <DropdownMenuSeparator/>
1570
+ <ActionMenuItems
1571
+ actions={customActions}
1572
+ onAction={(action) => onInvokeAction?.(action)}
1573
+ />
1574
+ </>
1575
+ )}
1576
+ <DropdownMenuSeparator/>
1577
+ <DropdownMenuItem onSelect={onDelete} className="text-destructive focus:text-destructive">
1578
+ <Trash2 className="size-4"/> {t('common:delete')}
1579
+ </DropdownMenuItem>
1580
+ </DropdownMenuContent>
1581
+ </DropdownMenu>
1582
+ </div>
1583
+ )
1584
+ }
1585
+
1586
+ function ColumnVisibilityMenu<TData>({
1587
+ table,
1588
+ properties,
1589
+ t,
1590
+ }: {
1591
+ table: ReturnType<typeof useReactTable<TData>>
1592
+ properties: PropertyJSON[]
1593
+ t: (key: string, params?: Record<string, string | number>) => string
1594
+ }): React.ReactElement {
1595
+ const labelMap = new Map(properties.map((p) => [p.path, p.label]))
1596
+ return (
1597
+ <DropdownMenu>
1598
+ <DropdownMenuTrigger asChild>
1599
+ <Button variant="outline" size="sm">
1600
+ <SlidersHorizontal className="size-4"/>
1601
+ <span className="hidden sm:inline">{t('common:columns')}</span>
1602
+ </Button>
1603
+ </DropdownMenuTrigger>
1604
+ <DropdownMenuContent align="end" className="w-48">
1605
+ <DropdownMenuLabel>{t('common:toggleColumns')}</DropdownMenuLabel>
1606
+ <DropdownMenuSeparator/>
1607
+ {table
1608
+ .getAllColumns()
1609
+ .filter((c) => c.getCanHide())
1610
+ .map((column) => (
1611
+ <DropdownMenuCheckboxItem
1612
+ key={column.id}
1613
+ checked={column.getIsVisible()}
1614
+ onCheckedChange={(v) => column.toggleVisibility(!!v)}
1615
+ onSelect={(e) => e.preventDefault()}
1616
+ >
1617
+ {labelMap.get(column.id) ?? column.id}
1618
+ </DropdownMenuCheckboxItem>
1619
+ ))}
1620
+ </DropdownMenuContent>
1621
+ </DropdownMenu>
1622
+ )
1623
+ }
1624
+
1625
+ /** Sliding window of up to `windowSize` page indices centred on `pageIndex`. */
1626
+ function getPageRange(pageIndex: number, pageCount: number, windowSize = 10): number[] {
1627
+ if (pageCount <= 0) return []
1628
+ const half = Math.floor(windowSize / 2)
1629
+ let start = pageIndex - half
1630
+ let end = start + windowSize
1631
+ if (start < 0) {
1632
+ start = 0
1633
+ end = windowSize
1634
+ }
1635
+ if (end > pageCount) {
1636
+ end = pageCount
1637
+ start = Math.max(0, end - windowSize)
1638
+ }
1639
+ return Array.from({ length: end - start }, (_, i) => start + i)
1640
+ }
1641
+
1642
+ function Paginator<TData>({
1643
+ table,
1644
+ total,
1645
+ t,
1646
+ }: {
1647
+ table: ReturnType<typeof useReactTable<TData>>
1648
+ total: number
1649
+ t: (key: string, params?: Record<string, string | number>) => string
1650
+ }): React.ReactElement {
1651
+ const { pageIndex, pageSize } = table.getState().pagination
1652
+ const pageCount = table.getPageCount()
1653
+ const pages = getPageRange(pageIndex, pageCount)
1654
+ // Click-and-drag horizontal scroll on the page-buttons row, mirroring the
1655
+ // table wrapper. Callback ref returns a cleanup so React 19 detaches the
1656
+ // pointer listeners automatically when the row unmounts.
1657
+ const paginationScrollRef = React.useCallback((el: HTMLDivElement | null) => {
1658
+ if (!el) return
1659
+ return attachDragScroll(el)
1660
+ }, [])
1661
+ const perPageSelect = (
1662
+ <Select
1663
+ value={String(pageSize)}
1664
+ onValueChange={(v) => table.setPageSize(Number(v))}
1665
+ >
1666
+ <SelectTrigger className="h-8 w-[72px]">
1667
+ <SelectValue/>
1668
+ </SelectTrigger>
1669
+ <SelectContent>
1670
+ {PAGE_SIZES.map((s) => (
1671
+ <SelectItem key={s} value={String(s)}>
1672
+ {s}
1673
+ </SelectItem>
1674
+ ))}
1675
+ </SelectContent>
1676
+ </Select>
1677
+ )
1678
+ return (
1679
+ <div className="flex w-full min-w-0 flex-col gap-3 px-1 sm:flex-row sm:items-center sm:justify-between">
1680
+ {/* Top row on mobile (records count + per-page select side-by-side);
1681
+ on desktop just the records-count label on the left. */}
1682
+ <div className="flex items-center justify-between gap-3 sm:justify-start">
1683
+ <div className="text-sm text-muted-foreground">
1684
+ {t('common:recordsCount', { count: total })}
1685
+ </div>
1686
+ {/* Mobile-only per-page select inline with records count. */}
1687
+ <div className="sm:hidden">{perPageSelect}</div>
1688
+ </div>
1689
+ {/* `min-w-0` on the right block is critical: without it, the flex item
1690
+ takes its content's intrinsic width on mobile (the buttons row is
1691
+ ~460px) and overflows the panel to the left. */}
1692
+ <div className="flex min-w-0 flex-col items-center gap-2 sm:flex-row">
1693
+ {/* Desktop-only per-page label + select next to the pagination buttons. */}
1694
+ <div className="hidden items-center gap-2 sm:flex">
1695
+ <span className="text-sm text-muted-foreground">
1696
+ {t('common:rowsPerPage')}
1697
+ </span>
1698
+ {perPageSelect}
1699
+ </div>
1700
+ {/* Page navigation — scrollable + drag-scrollable on narrow screens.
1701
+ `max-w-full` clamps to parent width so overflow-x-auto actually
1702
+ activates; without it the inner buttons row would expand the
1703
+ container instead of scrolling internally. `cursor-grab` hints the
1704
+ drag affordance; buttons keep their own `cursor-pointer`. */}
1705
+ <div ref={paginationScrollRef} className="max-w-full cursor-grab overflow-x-auto">
1706
+ <div className="flex items-center gap-1">
1707
+ <Button
1708
+ variant="outline"
1709
+ size="sm"
1710
+ onClick={() => table.setPageIndex(0)}
1711
+ disabled={!table.getCanPreviousPage()}
1712
+ >
1713
+ <ChevronsLeft className="size-4"/>
1714
+ </Button>
1715
+ <Button
1716
+ variant="outline"
1717
+ size="sm"
1718
+ onClick={() => table.previousPage()}
1719
+ disabled={!table.getCanPreviousPage()}
1720
+ >
1721
+ <ChevronLeft className="size-4"/>
1722
+ </Button>
1723
+ {pages.map((p) => (
1724
+ <Button
1725
+ key={p}
1726
+ variant={p === pageIndex ? 'default' : 'outline'}
1727
+ size="sm"
1728
+ className="text-xs"
1729
+ onClick={() => table.setPageIndex(p)}
1730
+ aria-current={p === pageIndex ? 'page' : undefined}
1731
+ >
1732
+ {p + 1}
1733
+ </Button>
1734
+ ))}
1735
+ <Button
1736
+ variant="outline"
1737
+ size="sm"
1738
+ onClick={() => table.nextPage()}
1739
+ disabled={!table.getCanNextPage()}
1740
+ >
1741
+ <ChevronRight className="size-4"/>
1742
+ </Button>
1743
+ <Button
1744
+ variant="outline"
1745
+ size="sm"
1746
+ onClick={() => table.setPageIndex(pageCount - 1)}
1747
+ disabled={!table.getCanNextPage()}
1748
+ >
1749
+ <ChevronsRight className="size-4"/>
1750
+ </Button>
1751
+ </div>
1752
+ </div>
1753
+ </div>
1754
+ </div>
1755
+ )
1756
+ }
1757
+
1758
+ // ─── Filter operator helpers ─────────────────────────────────────────────────
1759
+ // Operators are encoded in the filter value string: `OPERATOR:VALUE`.
1760
+ // Legacy values (no prefix) default to `co` (contains) for strings.
1761
+
1762
+ type StringFilterOp = 'co' | 'nco' | 'sw' | 'ew' | 'eq' | 'neq' | 'empty' | 'nempty' | 'in'
1763
+
1764
+ const STRING_OPS: ReadonlySet<string> = new Set(['co', 'nco', 'sw', 'ew', 'eq', 'neq', 'empty', 'nempty', 'in'])
1765
+ const ALL_STRING_OPS: StringFilterOp[] = ['co', 'nco', 'sw', 'ew', 'in', 'empty', 'nempty']
1766
+ const NULLARY_OPS: ReadonlySet<string> = new Set(['empty', 'nempty'])
1767
+
1768
+ function parseFilterString(raw: string): { op: StringFilterOp; val: string } {
1769
+ if (!raw) return { op: 'co', val: '' }
1770
+ const colonIdx = raw.indexOf(':')
1771
+ if (colonIdx === -1) return { op: 'co', val: raw }
1772
+ const prefix = raw.slice(0, colonIdx)
1773
+ if (STRING_OPS.has(prefix)) return { op: prefix as StringFilterOp, val: raw.slice(colonIdx + 1) }
1774
+ return { op: 'co', val: raw }
1775
+ }
1776
+
1777
+ function encodeFilter(op: StringFilterOp, val: string): string {
1778
+ if (op === 'empty' || op === 'nempty') return `${op}:`
1779
+ // Unchecking the last item in the "Is one of" picker ⇒ no filter.
1780
+ // We deliberately do NOT emit `in:` here: it would survive
1781
+ // `setDraftFilter`'s empty-string guard and ship a phantom
1782
+ // `filters[col]=in:` URL param (and a "1 active filter" badge) while
1783
+ // the adapter layer drops the clause anyway. The operator resets to
1784
+ // `co` on close, but `StringFilterField`'s auto-switch re-promotes
1785
+ // low-cardinality fields back to `in` the next time the panel opens.
1786
+ if (op === 'in') return val ? `in:${val}` : ''
1787
+ if (!val) return ''
1788
+ return `${op}:${val}`
1789
+ }
1790
+
1791
+ // ─── Numeric filter with operator selector ────────────────────────────────────
1792
+
1793
+ type NumericFilterOp = 'eq' | 'neq' | 'gt' | 'lt' | 'between' | 'empty' | 'nempty'
1794
+
1795
+ const NUMERIC_OP_SET = new Set<string>(['eq', 'neq', 'gt', 'lt', 'between', 'empty', 'nempty'])
1796
+ const ALL_NUMERIC_OPS: NumericFilterOp[] = ['eq', 'neq', 'gt', 'lt', 'between', 'empty', 'nempty']
1797
+ const NUMERIC_NULLARY: ReadonlySet<string> = new Set(['empty', 'nempty'])
1798
+
1799
+ function parseNumericFilter(raw: string): { op: NumericFilterOp; from: string; to: string } {
1800
+ if (!raw) return { op: 'eq', from: '', to: '' }
1801
+ const colonIdx = raw.indexOf(':')
1802
+ if (colonIdx === -1) return { op: 'eq', from: raw, to: '' }
1803
+ const prefix = raw.slice(0, colonIdx)
1804
+ if (!NUMERIC_OP_SET.has(prefix)) return { op: 'eq', from: raw, to: '' }
1805
+ const rest = raw.slice(colonIdx + 1)
1806
+ if (prefix === 'between') {
1807
+ const commaIdx = rest.indexOf(',')
1808
+ return commaIdx !== -1
1809
+ ? { op: 'between', from: rest.slice(0, commaIdx), to: rest.slice(commaIdx + 1) }
1810
+ : { op: 'between', from: rest, to: '' }
1811
+ }
1812
+ return { op: prefix as NumericFilterOp, from: rest, to: '' }
1813
+ }
1814
+
1815
+ function encodeNumericFilter(op: NumericFilterOp, from: string, to: string): string {
1816
+ if (op === 'empty' || op === 'nempty') return `${op}:`
1817
+ if (op === 'between') return (from || to) ? `between:${from},${to}` : ''
1818
+ return from ? `${op}:${from}` : ''
1819
+ }
1820
+
1821
+ function NumericFilterField({
1822
+ value,
1823
+ onChange,
1824
+ t,
1825
+ }: {
1826
+ value: string
1827
+ onChange(v: unknown): void
1828
+ t: (key: string, params?: Record<string, string | number>) => string
1829
+ }): React.ReactElement {
1830
+ const parsed = parseNumericFilter(value)
1831
+ const [op, setOp] = React.useState<NumericFilterOp>(parsed.op)
1832
+ const [from, setFrom] = React.useState(parsed.from)
1833
+ const [to, setTo] = React.useState(parsed.to)
1834
+
1835
+ React.useEffect(() => {
1836
+ const next = parseNumericFilter(value)
1837
+ setOp(next.op)
1838
+ setFrom(next.from)
1839
+ setTo(next.to)
1840
+ }, [value])
1841
+
1842
+ const emit = (nextOp: NumericFilterOp, nextFrom: string, nextTo: string) => {
1843
+ setOp(nextOp)
1844
+ setFrom(nextFrom)
1845
+ setTo(nextTo)
1846
+ onChange(encodeNumericFilter(nextOp, nextFrom, nextTo))
1847
+ }
1848
+
1849
+ const handleOpChange = (nextOp: NumericFilterOp) => {
1850
+ if (NUMERIC_NULLARY.has(nextOp)) {
1851
+ emit(nextOp, '', '')
1852
+ } else {
1853
+ emit(nextOp, from, nextOp === 'between' ? to : '')
1854
+ }
1855
+ }
1856
+
1857
+ return (
1858
+ <div className="space-y-2">
1859
+ <Select value={op} onValueChange={(v) => handleOpChange(v as NumericFilterOp)}>
1860
+ <SelectTrigger className="h-7 text-xs">
1861
+ <SelectValue />
1862
+ </SelectTrigger>
1863
+ <SelectContent>
1864
+ {ALL_NUMERIC_OPS.map((o) => (
1865
+ <SelectItem key={o} value={o} className="text-xs">
1866
+ {t(`filter:op.${o}`)}
1867
+ </SelectItem>
1868
+ ))}
1869
+ </SelectContent>
1870
+ </Select>
1871
+ {op === 'between' ? (
1872
+ <div className="flex gap-2">
1873
+ <Input
1874
+ type="number"
1875
+ className="h-8"
1876
+ value={from}
1877
+ placeholder={t('common:from')}
1878
+ onChange={(e) => emit('between', e.target.value, to)}
1879
+ />
1880
+ <Input
1881
+ type="number"
1882
+ className="h-8"
1883
+ value={to}
1884
+ placeholder={t('common:to')}
1885
+ onChange={(e) => emit('between', from, e.target.value)}
1886
+ />
1887
+ </div>
1888
+ ) : !NUMERIC_NULLARY.has(op) ? (
1889
+ <Input
1890
+ type="number"
1891
+ className="h-8"
1892
+ value={from}
1893
+ placeholder={t('common:any')}
1894
+ onChange={(e) => emit(op, e.target.value, '')}
1895
+ />
1896
+ ) : null}
1897
+ </div>
1898
+ )
1899
+ }
1900
+
1901
+ // ─── Filter panel (side sheet) ───────────────────────────────────────────────
1902
+
1903
+ function FilterPanel({
1904
+ open,
1905
+ onOpenChange,
1906
+ properties,
1907
+ filters,
1908
+ onChange,
1909
+ resourceId,
1910
+ t,
1911
+ }: {
1912
+ open: boolean
1913
+ onOpenChange(open: boolean): void
1914
+ properties: PropertyJSON[]
1915
+ filters: ColumnFiltersState
1916
+ onChange(next: ColumnFiltersState): void
1917
+ resourceId: string
1918
+ t: (key: string, params?: Record<string, string | number>) => string
1919
+ }): React.ReactElement {
1920
+ const [draft, setDraft] = React.useState<ColumnFiltersState>(filters)
1921
+ React.useEffect(() => {
1922
+ if (open) setDraft(filters)
1923
+ }, [open, filters])
1924
+ const draftMap = new Map(draft.map((f) => [f.id, f.value]))
1925
+
1926
+ const setDraftFilter = (id: string, value: unknown) => {
1927
+ const without = draft.filter((f) => f.id !== id)
1928
+ setDraft(value != null && value !== '' ? [...without, { id, value }] : without)
1929
+ }
1930
+
1931
+ const handleApply = () => {
1932
+ onChange(draft)
1933
+ onOpenChange(false)
1934
+ }
1935
+
1936
+ const handleClearAll = () => {
1937
+ setDraft([])
1938
+ onChange([])
1939
+ }
1940
+
1941
+ return (
1942
+ <Sheet open={open} onOpenChange={onOpenChange}>
1943
+ <SheetContent
1944
+ side="right"
1945
+ className="flex w-full flex-col gap-0 p-0 sm:max-w-md"
1946
+ aria-describedby={undefined}
1947
+ >
1948
+ <SheetHeader
1949
+ className="flex-none flex-row items-center justify-between space-y-0 border-b border-border px-4 py-3 pr-12">
1950
+ <div className="flex items-center gap-2">
1951
+ <SheetTitle>{t('common:filters')}</SheetTitle>
1952
+ {draft.length > 0 && (
1953
+ <Badge className="h-5 rounded-full px-1.5 text-xs">{draft.length}</Badge>
1954
+ )}
1955
+ </div>
1956
+ {draft.length > 0 && (
1957
+ <Button variant="ghost" size="sm" onClick={handleClearAll}>
1958
+ {t('common:clearAll')}
1959
+ </Button>
1960
+ )}
1961
+ </SheetHeader>
1962
+
1963
+ <ScrollArea className="min-h-0 flex-1">
1964
+ <div className="space-y-5 px-4 py-4">
1965
+ {properties.map((p) => (
1966
+ <FilterField
1967
+ key={p.path}
1968
+ property={p}
1969
+ value={draftMap.get(p.path) as string | undefined}
1970
+ onChange={(v) => setDraftFilter(p.path, v)}
1971
+ valueFrom={draftMap.get(p.path + '~~from') as string | undefined}
1972
+ valueTo={draftMap.get(p.path + '~~to') as string | undefined}
1973
+ onChangeFrom={(v) => setDraftFilter(p.path + '~~from', v)}
1974
+ onChangeTo={(v) => setDraftFilter(p.path + '~~to', v)}
1975
+ resourceId={resourceId}
1976
+ t={t}
1977
+ />
1978
+ ))}
1979
+ </div>
1980
+ </ScrollArea>
1981
+
1982
+ <div className="flex flex-none border-t border-border p-4">
1983
+ <Button className="w-full" onClick={handleApply}>
1984
+ {t('common:applyFilters')}
1985
+ </Button>
1986
+ </div>
1987
+ </SheetContent>
1988
+ </Sheet>
1989
+ )
1990
+ }
1991
+
1992
+ // ─── Filter field (generic wrapper per property) ─────────────────────────────
1993
+
1994
+ function FilterField({
1995
+ property,
1996
+ value,
1997
+ onChange,
1998
+ valueFrom,
1999
+ valueTo,
2000
+ onChangeFrom,
2001
+ onChangeTo,
2002
+ resourceId,
2003
+ t,
2004
+ }: {
2005
+ property: PropertyJSON
2006
+ value: string | undefined
2007
+ onChange(v: unknown): void
2008
+ valueFrom?: string
2009
+ valueTo?: string
2010
+ onChangeFrom?(v: unknown): void
2011
+ onChangeTo?(v: unknown): void
2012
+ resourceId: string
2013
+ t: (key: string, params?: Record<string, string | number>) => string
2014
+ }): React.ReactElement {
2015
+ const isDateType = property.type === 'date' || property.type === 'datetime'
2016
+ return (
2017
+ <div className="space-y-1.5">
2018
+ <Label className="text-sm font-medium">{property.label}</Label>
2019
+ {isDateType ? (
2020
+ <DateRangeFilter
2021
+ mode={property.type as 'date' | 'datetime'}
2022
+ from={valueFrom}
2023
+ to={valueTo}
2024
+ onFromChange={onChangeFrom ?? onChange}
2025
+ onToChange={onChangeTo ?? onChange}
2026
+ t={t}
2027
+ />
2028
+ ) : (
2029
+ <FilterInput
2030
+ property={property}
2031
+ value={value ?? ''}
2032
+ onChange={onChange}
2033
+ resourceId={resourceId}
2034
+ t={t}
2035
+ />
2036
+ )}
2037
+ </div>
2038
+ )
2039
+ }
2040
+
2041
+ function DateRangeFilter({
2042
+ mode,
2043
+ from,
2044
+ to,
2045
+ onFromChange,
2046
+ onToChange,
2047
+ t,
2048
+ }: {
2049
+ mode: 'date' | 'datetime'
2050
+ from: string | undefined
2051
+ to: string | undefined
2052
+ onFromChange(v: unknown): void
2053
+ onToChange(v: unknown): void
2054
+ t: (key: string, params?: Record<string, string | number>) => string
2055
+ }): React.ReactElement {
2056
+ return (
2057
+ <div className="space-y-2">
2058
+ <div className="space-y-1">
2059
+ <span className="text-xs text-muted-foreground">{t('common:from')}</span>
2060
+ <DatePicker
2061
+ mode={mode}
2062
+ value={from ?? ''}
2063
+ onChange={(v) => onFromChange(v)}
2064
+ ariaLabel={t('common:from')}
2065
+ />
2066
+ </div>
2067
+ <div className="space-y-1">
2068
+ <span className="text-xs text-muted-foreground">{t('common:to')}</span>
2069
+ <DatePicker
2070
+ mode={mode}
2071
+ value={to ?? ''}
2072
+ onChange={(v) => onToChange(v)}
2073
+ ariaLabel={t('common:to')}
2074
+ />
2075
+ </div>
2076
+ </div>
2077
+ )
2078
+ }
2079
+
2080
+ // ─── Filter input (dispatches to type-specific UIs) ──────────────────────────
2081
+
2082
+ function FilterInput({
2083
+ property,
2084
+ value,
2085
+ onChange,
2086
+ resourceId,
2087
+ t,
2088
+ }: {
2089
+ property: PropertyJSON
2090
+ value: string
2091
+ onChange(v: unknown): void
2092
+ resourceId: string
2093
+ t: (key: string, params?: Record<string, string | number>) => string
2094
+ }): React.ReactElement {
2095
+ // Reference field → combobox backed by the referenced resource's search action
2096
+ if (property.reference && !property.isArray) {
2097
+ return (
2098
+ <ReferenceCombobox
2099
+ referenceResourceId={property.reference}
2100
+ value={value || null}
2101
+ onChange={(v) => onChange(v ?? '')}
2102
+ placeholder={t('common:any')}
2103
+ />
2104
+ )
2105
+ }
2106
+
2107
+ // Enum / available values → Select
2108
+ if (property.availableValues?.length) {
2109
+ return (
2110
+ <Select value={value || '_any_'} onValueChange={(v) => onChange(v === '_any_' ? '' : v)}>
2111
+ <SelectTrigger className="h-8">
2112
+ <SelectValue placeholder={t('common:any')}/>
2113
+ </SelectTrigger>
2114
+ <SelectContent>
2115
+ <SelectItem value="_any_">{t('common:any')}</SelectItem>
2116
+ {property.availableValues.map((opt) => (
2117
+ <SelectItem key={opt.value} value={opt.value}>
2118
+ {opt.label}
2119
+ </SelectItem>
2120
+ ))}
2121
+ </SelectContent>
2122
+ </Select>
2123
+ )
2124
+ }
2125
+
2126
+ switch (property.type) {
2127
+ case 'boolean':
2128
+ return (
2129
+ <Select value={value || '_any_'} onValueChange={(v) => onChange(v === '_any_' ? '' : v)}>
2130
+ <SelectTrigger className="h-8">
2131
+ <SelectValue placeholder={t('common:any')}/>
2132
+ </SelectTrigger>
2133
+ <SelectContent>
2134
+ <SelectItem value="_any_">{t('common:any')}</SelectItem>
2135
+ <SelectItem value="true">{t('common:yes')}</SelectItem>
2136
+ <SelectItem value="false">{t('common:no')}</SelectItem>
2137
+ </SelectContent>
2138
+ </Select>
2139
+ )
2140
+ case 'number':
2141
+ case 'float':
2142
+ case 'money':
2143
+ case 'currency':
2144
+ return (
2145
+ <NumericFilterField
2146
+ value={value}
2147
+ onChange={onChange}
2148
+ t={t}
2149
+ />
2150
+ )
2151
+ default:
2152
+ return (
2153
+ <StringFilterField
2154
+ property={property}
2155
+ value={value}
2156
+ onChange={onChange}
2157
+ resourceId={resourceId}
2158
+ t={t}
2159
+ />
2160
+ )
2161
+ }
2162
+ }
2163
+
2164
+ // ─── String filter with operator selector + value picker ─────────────────────
2165
+
2166
+ function StringFilterField({
2167
+ property,
2168
+ value,
2169
+ onChange,
2170
+ resourceId,
2171
+ t,
2172
+ }: {
2173
+ property: PropertyJSON
2174
+ value: string
2175
+ onChange(v: unknown): void
2176
+ resourceId: string
2177
+ t: (key: string, params?: Record<string, string | number>) => string
2178
+ }): React.ReactElement {
2179
+ const parsed = parseFilterString(value)
2180
+ const [op, setOp] = React.useState<StringFilterOp>(parsed.op)
2181
+ const [val, setVal] = React.useState(parsed.val)
2182
+
2183
+ // Sync local state when external value changes (e.g. from URL update).
2184
+ React.useEffect(() => {
2185
+ const next = parseFilterString(value)
2186
+ setOp(next.op)
2187
+ setVal(next.val)
2188
+ }, [value])
2189
+
2190
+ // Auto-detect: fetch distinct values to see if field is low-cardinality.
2191
+ const { data: distinctData } = useDistinctValues(resourceId, property.path, {
2192
+ limit: 101,
2193
+ })
2194
+ const isLowCardinality = distinctData != null && !distinctData.hasMore
2195
+ const distinctValues = distinctData?.values ?? []
2196
+
2197
+ // If low cardinality, no existing filter, and default op (co with empty val):
2198
+ // auto-switch to "is one of" mode to match Metabase behavior.
2199
+ const autoSwitchedRef = React.useRef(false)
2200
+ React.useEffect(() => {
2201
+ if (autoSwitchedRef.current) return
2202
+ if (isLowCardinality && !value && op === 'co' && val === '') {
2203
+ autoSwitchedRef.current = true
2204
+ setOp('in')
2205
+ }
2206
+ }, [isLowCardinality, value, op, val])
2207
+
2208
+ const emit = (nextOp: StringFilterOp, nextVal: string) => {
2209
+ setOp(nextOp)
2210
+ setVal(nextVal)
2211
+ onChange(encodeFilter(nextOp, nextVal))
2212
+ }
2213
+
2214
+ const handleOpChange = (nextOp: StringFilterOp) => {
2215
+ if (NULLARY_OPS.has(nextOp)) {
2216
+ emit(nextOp, '')
2217
+ } else if (nextOp === 'in') {
2218
+ // Switching to multi-select: clear text value
2219
+ emit(nextOp, '')
2220
+ } else {
2221
+ // Switching from multi-select to text: clear value
2222
+ emit(nextOp, op === 'in' ? '' : val)
2223
+ }
2224
+ }
2225
+
2226
+ return (
2227
+ <div className="space-y-2">
2228
+ {/* Operator selector */}
2229
+ <Select value={op} onValueChange={(v) => handleOpChange(v as StringFilterOp)}>
2230
+ <SelectTrigger className="h-7 text-xs">
2231
+ <SelectValue/>
2232
+ </SelectTrigger>
2233
+ <SelectContent>
2234
+ {ALL_STRING_OPS.map((o) => (
2235
+ <SelectItem key={o} value={o} className="text-xs">
2236
+ {t(`filter:op.${o}`)}
2237
+ </SelectItem>
2238
+ ))}
2239
+ </SelectContent>
2240
+ </Select>
2241
+
2242
+ {/* Value input based on operator */}
2243
+ {op === 'in' ? (
2244
+ <FilterValuePicker
2245
+ resourceId={resourceId}
2246
+ field={property.path}
2247
+ selected={val ? val.split(',') : []}
2248
+ onChange={(selected) => emit('in', selected.join(','))}
2249
+ preloadedValues={isLowCardinality ? distinctValues : undefined}
2250
+ t={t}
2251
+ />
2252
+ ) : !NULLARY_OPS.has(op) ? (
2253
+ <Input
2254
+ className="h-8"
2255
+ value={val}
2256
+ placeholder={t('common:filterPlaceholder')}
2257
+ onChange={(e) => emit(op, e.target.value)}
2258
+ />
2259
+ ) : null}
2260
+ </div>
2261
+ )
2262
+ }
2263
+
2264
+ // ─── Value picker (checkbox list with search, Metabase-style) ────────────────
2265
+
2266
+ function FilterValuePicker({
2267
+ resourceId,
2268
+ field,
2269
+ selected,
2270
+ onChange,
2271
+ preloadedValues,
2272
+ t,
2273
+ }: {
2274
+ resourceId: string
2275
+ field: string
2276
+ selected: string[]
2277
+ onChange(selected: string[]): void
2278
+ /** Pre-fetched values for low-cardinality fields (avoids duplicate request). */
2279
+ preloadedValues?: string[]
2280
+ t: (key: string, params?: Record<string, string | number>) => string
2281
+ }): React.ReactElement {
2282
+ const [search, setSearch] = React.useState('')
2283
+ const selectedSet = React.useMemo(() => new Set(selected), [selected])
2284
+
2285
+ // Fetch values from server (skipped when preloaded values are available).
2286
+ const needsServerSearch = preloadedValues == null
2287
+ const { data: serverData, isLoading } = useDistinctValues(
2288
+ resourceId,
2289
+ field,
2290
+ { search: needsServerSearch ? search : undefined, limit: 100, enabled: needsServerSearch },
2291
+ )
2292
+
2293
+ // Client-side filter when using preloaded values, falling back to the
2294
+ // server-fetched distinct values otherwise.
2295
+ const displayValues = React.useMemo(() => {
2296
+ const allValues = preloadedValues ?? serverData?.values ?? []
2297
+ if (!preloadedValues || !search) return allValues
2298
+ const lower = search.toLowerCase()
2299
+ return allValues.filter((v) => v.toLowerCase().includes(lower))
2300
+ }, [preloadedValues, serverData?.values, search])
2301
+
2302
+ const toggle = (val: string) => {
2303
+ if (selectedSet.has(val)) {
2304
+ onChange(selected.filter((v) => v !== val))
2305
+ } else {
2306
+ onChange([...selected, val])
2307
+ }
2308
+ }
2309
+
2310
+ const handleSelectAll = () => {
2311
+ const allSelected = displayValues.length > 0 && displayValues.every((v) => selectedSet.has(v))
2312
+ if (allSelected) {
2313
+ // Deselect all currently visible values
2314
+ const visibleSet = new Set(displayValues)
2315
+ onChange(selected.filter((v) => !visibleSet.has(v)))
2316
+ } else {
2317
+ const allSet = new Set([...selected, ...displayValues])
2318
+ onChange(Array.from(allSet))
2319
+ }
2320
+ }
2321
+
2322
+ return (
2323
+ <div className="space-y-2">
2324
+ {/* Search input */}
2325
+ <div className="relative">
2326
+ <Search
2327
+ className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"/>
2328
+ <Input
2329
+ className="h-7 pl-7 text-xs"
2330
+ value={search}
2331
+ placeholder={t('filter:searchValues')}
2332
+ onChange={(e) => setSearch(e.target.value)}
2333
+ />
2334
+ </div>
2335
+
2336
+ {/* Select all */}
2337
+ {displayValues.length > 0 && (
2338
+ <button
2339
+ type="button"
2340
+ className="flex w-full items-center gap-2 rounded px-1 py-0.5 text-xs text-muted-foreground hover:text-foreground"
2341
+ onClick={handleSelectAll}
2342
+ >
2343
+ <Checkbox
2344
+ className="size-3.5"
2345
+ checked={
2346
+ displayValues.length > 0 && displayValues.every((v) => selectedSet.has(v))
2347
+ ? true
2348
+ : displayValues.some((v) => selectedSet.has(v))
2349
+ ? 'indeterminate'
2350
+ : false
2351
+ }
2352
+ />
2353
+ {t('filter:selectAll')}
2354
+ </button>
2355
+ )}
2356
+
2357
+ {/* Value list */}
2358
+ <div className="max-h-48 overflow-y-auto">
2359
+ <div className="space-y-0.5">
2360
+ {isLoading && !preloadedValues ? (
2361
+ <div className="py-2 text-center text-xs text-muted-foreground">
2362
+ {t('common:loading')}
2363
+ </div>
2364
+ ) : displayValues.length === 0 ? (
2365
+ <div className="py-2 text-center text-xs text-muted-foreground">
2366
+ {t('filter:noValues')}
2367
+ </div>
2368
+ ) : (
2369
+ displayValues.map((v) => (
2370
+ <button
2371
+ key={v}
2372
+ type="button"
2373
+ className="flex w-full items-center gap-2 rounded px-1 py-0.5 text-left text-sm hover:bg-accent"
2374
+ onClick={() => toggle(v)}
2375
+ >
2376
+ <Checkbox className="size-3.5" checked={selectedSet.has(v)}/>
2377
+ <span className="truncate">{v}</span>
2378
+ </button>
2379
+ ))
2380
+ )}
2381
+ </div>
2382
+ </div>
2383
+
2384
+ {/* Selected count */}
2385
+ {selected.length > 0 && (
2386
+ <div className="text-xs text-muted-foreground">
2387
+ {t('common:selectedCount', { count: selected.length })}
2388
+ </div>
2389
+ )}
2390
+ </div>
2391
+ )
2392
+ }
2393
+
2394
+ // ─── Per-column filter popover in table header ───────────────────────────────
2395
+ // A magnifying-glass icon sits next to the sort label. Clicking it opens a
2396
+ // Popover with the same full filter controls as the side panel (FilterField).
2397
+ // The icon is highlighted when a filter for this column is active.
2398
+ function ColumnFilterPopover({
2399
+ property,
2400
+ getFilters,
2401
+ onApply,
2402
+ resourceId,
2403
+ t,
2404
+ }: {
2405
+ property: PropertyJSON
2406
+ getFilters(): ColumnFiltersState
2407
+ onApply(updates: Record<string, string>): void
2408
+ resourceId: string
2409
+ t: (key: string, params?: Record<string, string | number>) => string
2410
+ }): React.ReactElement {
2411
+ const [open, setOpen] = React.useState(false)
2412
+ const isDateType = property.type === 'date' || property.type === 'datetime'
2413
+
2414
+ const [value, setValue] = React.useState('')
2415
+ const [valueFrom, setValueFrom] = React.useState('')
2416
+ const [valueTo, setValueTo] = React.useState('')
2417
+
2418
+ // Initialise draft from current URL filters each time the popover opens.
2419
+ React.useEffect(() => {
2420
+ if (!open) return
2421
+ const map = new Map(getFilters().map((f) => [f.id, String(f.value ?? '')]))
2422
+ if (isDateType) {
2423
+ setValueFrom(map.get(property.path + '~~from') ?? '')
2424
+ setValueTo(map.get(property.path + '~~to') ?? '')
2425
+ } else {
2426
+ setValue(map.get(property.path) ?? '')
2427
+ }
2428
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2429
+ }, [open])
2430
+
2431
+ // Icon is highlighted when any filter for this property is set.
2432
+ const isActive = (() => {
2433
+ const map = new Map(getFilters().map((f) => [f.id, String(f.value ?? '')]))
2434
+ return isDateType
2435
+ ? !!(map.get(property.path + '~~from') || map.get(property.path + '~~to'))
2436
+ : !!map.get(property.path)
2437
+ })()
2438
+
2439
+ const handleApply = () => {
2440
+ const updates: Record<string, string> = {}
2441
+ if (isDateType) {
2442
+ updates[property.path + '~~from'] = valueFrom
2443
+ updates[property.path + '~~to'] = valueTo
2444
+ } else {
2445
+ updates[property.path] = value
2446
+ }
2447
+ onApply(updates)
2448
+ setOpen(false)
2449
+ }
2450
+
2451
+ const handleClear = () => {
2452
+ const updates: Record<string, string> = {}
2453
+ if (isDateType) {
2454
+ updates[property.path + '~~from'] = ''
2455
+ updates[property.path + '~~to'] = ''
2456
+ } else {
2457
+ updates[property.path] = ''
2458
+ }
2459
+ onApply(updates)
2460
+ setOpen(false)
2461
+ }
2462
+
2463
+ return (
2464
+ <Popover open={open} onOpenChange={setOpen}>
2465
+ <PopoverTrigger asChild>
2466
+ <button
2467
+ type="button"
2468
+ className={cn(
2469
+ 'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded hover:bg-accent',
2470
+ isActive ? 'text-primary' : 'text-muted-foreground opacity-50 hover:opacity-100',
2471
+ )}
2472
+ aria-label={t('common:filter', { label: property.label })}
2473
+ >
2474
+ <ListFilter className="size-3.5"/>
2475
+ </button>
2476
+ </PopoverTrigger>
2477
+ <PopoverContent className="w-72 border-border p-3" align="start">
2478
+ <div className="space-y-3">
2479
+ <FilterField
2480
+ property={property}
2481
+ value={value}
2482
+ onChange={(v) => setValue(String(v ?? ''))}
2483
+ valueFrom={valueFrom}
2484
+ valueTo={valueTo}
2485
+ onChangeFrom={(v) => setValueFrom(String(v ?? ''))}
2486
+ onChangeTo={(v) => setValueTo(String(v ?? ''))}
2487
+ resourceId={resourceId}
2488
+ t={t}
2489
+ />
2490
+ <div className="flex gap-2">
2491
+ <Button size="sm" className="flex-1" onClick={handleApply}>
2492
+ {t('common:apply')}
2493
+ </Button>
2494
+ <Button size="sm" variant="outline" onClick={handleClear}>
2495
+ {t('common:clear')}
2496
+ </Button>
2497
+ </div>
2498
+ </div>
2499
+ </PopoverContent>
2500
+ </Popover>
2501
+ )
2502
+ }
2503
+
2504
+ // ─── Mobile record card ──────────────────────────────────────────────────────
2505
+ // Renders a single record as a tap-to-edit card with a header (avatar + title +
2506
+ // id), a 2-column grid of property values, and a contextual menu.
2507
+ function RecordCard({
2508
+ record,
2509
+ properties,
2510
+ resourceId,
2511
+ showSelect,
2512
+ selected,
2513
+ onToggleSelect,
2514
+ onView,
2515
+ onEdit,
2516
+ onDelete,
2517
+ customActions = [],
2518
+ onInvokeAction,
2519
+ t,
2520
+ }: {
2521
+ record: RecordJSON
2522
+ properties: PropertyJSON[]
2523
+ resourceId: string
2524
+ showSelect: boolean
2525
+ selected: boolean
2526
+ onToggleSelect(value: boolean): void
2527
+ onView(): void
2528
+ onEdit(): void
2529
+ onDelete(): void
2530
+ customActions?: ActionDescriptor[]
2531
+ onInvokeAction?(action: ActionDescriptor): void
2532
+ t: (key: string, params?: Record<string, string | number>) => string
2533
+ }): React.ReactElement {
2534
+ const openInNewTab = useOpenInNewTab()
2535
+ const idProperty = properties.find((p) => p.isId)
2536
+ const titleProperty = properties.find((p) => !p.isId && p.type === 'string')
2537
+ const titleText =
2538
+ record.title ||
2539
+ (titleProperty ? String(record.params[titleProperty.path] ?? '') : '') ||
2540
+ `#${record.id}`
2541
+
2542
+ // Body shows non-id, non-title properties. On mobile we want maximum
2543
+ // information density, so render up to 8 — enough to surface most fields
2544
+ // without scrolling each card.
2545
+ const bodyProps = properties
2546
+ .filter((p) => !p.isId && p.path !== titleProperty?.path)
2547
+ .slice(0, 8)
2548
+
2549
+ // Card uses a clickable div (not <button>) because it nests interactive
2550
+ // children (the RowActions DropdownMenuTrigger and reference links). HTML
2551
+ // forbids button-in-button. Behavior is mirrored from the desktop TableRow.
2552
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
2553
+ if (e.key === 'Enter' || e.key === ' ') {
2554
+ const target = e.target as HTMLElement
2555
+ if (target.closest('a, button, [role="menuitem"]')) return
2556
+ e.preventDefault()
2557
+ onEdit()
2558
+ }
2559
+ }
2560
+ const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
2561
+ const target = e.target as HTMLElement
2562
+ if (target.closest('a, button, [role="menuitem"]')) return
2563
+ onEdit()
2564
+ }
2565
+ const handleAuxClick = (e: React.MouseEvent<HTMLDivElement>) => {
2566
+ if (e.button !== 1) return
2567
+ const target = e.target as HTMLElement
2568
+ if (target.closest('a, button, [role="menuitem"]')) return
2569
+ e.preventDefault()
2570
+ openInNewTab({ name: 'edit', resourceId, recordId: record.id })
2571
+ }
2572
+ const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
2573
+ if (e.button !== 1) return
2574
+ const target = e.target as HTMLElement
2575
+ if (target.closest('a, button, [role="menuitem"]')) return
2576
+ e.preventDefault()
2577
+ }
2578
+ return (
2579
+ <div
2580
+ role="button"
2581
+ tabIndex={0}
2582
+ onClick={handleClick}
2583
+ onAuxClick={handleAuxClick}
2584
+ onMouseDown={handleMouseDown}
2585
+ onKeyDown={handleKeyDown}
2586
+ data-state={selected ? 'selected' : undefined}
2587
+ className="block w-full cursor-pointer rounded-lg border border-border bg-card p-2.5 text-left transition-colors hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=selected]:border-primary/50 data-[state=selected]:bg-primary/5"
2588
+ >
2589
+ <div className="flex items-start gap-2">
2590
+ {showSelect && (
2591
+ <div
2592
+ className="flex flex-none items-center pt-0.5"
2593
+ onClick={(e) => e.stopPropagation()}
2594
+ >
2595
+ <Checkbox
2596
+ checked={selected}
2597
+ onCheckedChange={(v) => onToggleSelect(!!v)}
2598
+ aria-label={t('common:selectRow')}
2599
+ />
2600
+ </div>
2601
+ )}
2602
+ <div className="min-w-0 flex-1">
2603
+ <div className="flex items-start justify-between gap-2">
2604
+ <div className="min-w-0 flex-1">
2605
+ <div className="truncate text-sm font-semibold leading-tight">{titleText}</div>
2606
+ {idProperty && (
2607
+ <div className="truncate text-[11px] leading-tight text-muted-foreground">
2608
+ #{String(record.params[idProperty.path] ?? record.id)}
2609
+ </div>
2610
+ )}
2611
+ </div>
2612
+ <RowActions
2613
+ onView={onView}
2614
+ onEdit={onEdit}
2615
+ onDelete={onDelete}
2616
+ customActions={customActions}
2617
+ onInvokeAction={onInvokeAction}
2618
+ t={t}
2619
+ />
2620
+ </div>
2621
+ {bodyProps.length > 0 && (
2622
+ <div className="mt-2 grid grid-cols-2 gap-x-2 gap-y-1.5">
2623
+ {bodyProps.map((p) => (
2624
+ <div key={p.path} className="min-w-0">
2625
+ <div className="truncate text-[10px] uppercase tracking-wide leading-tight text-muted-foreground">
2626
+ {p.label}
2627
+ </div>
2628
+ <div className="truncate text-sm leading-tight">
2629
+ <CellContent
2630
+ resourceId={resourceId}
2631
+ recordId={record.id}
2632
+ property={p}
2633
+ value={record.params[p.path]}
2634
+ populated={record.populated}
2635
+ />
2636
+ </div>
2637
+ </div>
2638
+ ))}
2639
+ </div>
2640
+ )}
2641
+ </div>
2642
+ </div>
2643
+ </div>
2644
+ )
2645
+ }