@open-mercato/ui 0.7.1-develop.7193.1.910a5b0a1e → 0.7.1-develop.7194.1.ab4fc81f82

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 (49) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/backend/AppShell.js +66 -7
  3. package/dist/backend/AppShell.js.map +2 -2
  4. package/dist/backend/CrudForm.js +14 -8
  5. package/dist/backend/CrudForm.js.map +2 -2
  6. package/dist/backend/DataTable.js +108 -11
  7. package/dist/backend/DataTable.js.map +2 -2
  8. package/dist/backend/forms/ActionsDropdown.js +35 -5
  9. package/dist/backend/forms/ActionsDropdown.js.map +2 -2
  10. package/dist/backend/forms/index.js.map +2 -2
  11. package/dist/backend/icons/lucideRegistry.generated.js +6 -0
  12. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  13. package/dist/backend/injection/spotIds.js +2 -0
  14. package/dist/backend/injection/spotIds.js.map +2 -2
  15. package/dist/backend/inputs/DurationInput.js +182 -0
  16. package/dist/backend/inputs/DurationInput.js.map +7 -0
  17. package/dist/backend/inputs/EventSelect.js.map +2 -2
  18. package/dist/backend/inputs/index.js +1 -0
  19. package/dist/backend/inputs/index.js.map +2 -2
  20. package/dist/backend/nav/navBadges.js +38 -0
  21. package/dist/backend/nav/navBadges.js.map +7 -0
  22. package/dist/primitives/segmented-control.js +4 -4
  23. package/dist/primitives/segmented-control.js.map +1 -1
  24. package/dist/primitives/sheet.js +21 -11
  25. package/dist/primitives/sheet.js.map +2 -2
  26. package/dist/primitives/textarea.js +54 -4
  27. package/dist/primitives/textarea.js.map +2 -2
  28. package/package.json +3 -3
  29. package/src/backend/AppShell.tsx +106 -2
  30. package/src/backend/CrudForm.tsx +28 -9
  31. package/src/backend/DataTable.tsx +176 -22
  32. package/src/backend/__tests__/CrudForm.density.test.tsx +113 -0
  33. package/src/backend/__tests__/DataTable.propBulkActions.test.tsx +200 -0
  34. package/src/backend/__tests__/DataTable.render.test.tsx +76 -0
  35. package/src/backend/__tests__/navBadges.test.ts +57 -0
  36. package/src/backend/forms/ActionsDropdown.tsx +73 -6
  37. package/src/backend/forms/index.ts +1 -1
  38. package/src/backend/icons/lucideRegistry.generated.tsx +6 -0
  39. package/src/backend/injection/spotIds.ts +8 -0
  40. package/src/backend/inputs/DurationInput.tsx +207 -0
  41. package/src/backend/inputs/EventSelect.tsx +2 -0
  42. package/src/backend/inputs/__tests__/DurationInput.test.tsx +220 -0
  43. package/src/backend/inputs/index.ts +1 -0
  44. package/src/backend/nav/navBadges.ts +61 -0
  45. package/src/primitives/__tests__/segmented-control.test.tsx +2 -2
  46. package/src/primitives/__tests__/textarea.test.tsx +50 -0
  47. package/src/primitives/segmented-control.tsx +7 -7
  48. package/src/primitives/sheet.tsx +7 -1
  49. package/src/primitives/textarea.tsx +77 -4
@@ -378,6 +378,17 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
378
378
  embedded?: boolean
379
379
  // Hide the footer action bar (Save/Cancel/Delete) when embedding in a custom layout
380
380
  hideFooterActions?: boolean
381
+ /**
382
+ * Vertical rhythm of the form body.
383
+ *
384
+ * `default` is the page-width layout every existing host renders today and is
385
+ * unchanged byte-for-byte. `compact` tightens the between-group and
386
+ * between-field spacing and the group-card padding for narrow hosts — a docked
387
+ * inspector rail, a side panel — where the page rhythm reads as airy and costs
388
+ * the host a third of its column. It changes SPACING only: no label, helper
389
+ * text, control size or copy differs between the two.
390
+ */
391
+ density?: 'default' | 'compact'
381
392
  /**
382
393
  * Opt-in: track dirty state even when `embedded` is true, AND enable the form's built-in
383
394
  * navigation protection (beforeunload, link-click intercept, pushState/replaceState/popstate).
@@ -738,6 +749,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
738
749
  loadingMessage,
739
750
  customEntity = false,
740
751
  embedded = false,
752
+ density = 'default',
741
753
  hideFooterActions = false,
742
754
  trackDirtyWhenEmbedded = false,
743
755
  onDirtyChange,
@@ -1330,6 +1342,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1330
1342
  // handles the overflow case on its own: when content scrolls, the footer stays pinned
1331
1343
  // to the dialog's bottom and the user can scroll fields above it.
1332
1344
  const dialogFormPadding = ''
1345
+ // One step down the DS scale each, never an arbitrary value. `default` returns
1346
+ // the exact strings that were inline before this prop existed.
1347
+ const isCompactDensity = density === 'compact'
1348
+ const densityStackLg = isCompactDensity ? 'space-y-3' : 'space-y-4'
1349
+ const densityStackMd = isCompactDensity ? 'space-y-2' : 'space-y-3'
1350
+ const densityCardPadding = isCompactDensity ? 'p-3' : 'p-4'
1351
+ const densityGroupCardPadding = isCompactDensity ? 'px-3 py-2' : 'px-4 py-3'
1333
1352
 
1334
1353
  const buildCustomFieldsManageHref = React.useCallback(
1335
1354
  (targetEntityId: string | null) => {
@@ -3345,7 +3364,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3345
3364
  const sectionKey = `${entityLayout.entityId}:${section.fieldsetCode ?? 'default'}`
3346
3365
  const manageDisabled = !manageHref
3347
3366
  nodes.push(
3348
- <div key={sectionKey} className="rounded-lg border bg-card p-4 space-y-4">
3367
+ <div key={sectionKey} className={`rounded-lg border bg-card ${densityCardPadding} ${densityStackLg}`}>
3349
3368
  <div className="flex items-start justify-between gap-3">
3350
3369
  <div className="flex items-start gap-2">
3351
3370
  {FieldsetIcon ? (
@@ -3587,7 +3606,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3587
3606
  fieldCount={customFieldCount}
3588
3607
  chevronPosition={collapsibleChevronPosition}
3589
3608
  >
3590
- <div className="space-y-3">
3609
+ <div className={densityStackMd}>
3591
3610
  {customFieldsInnerNodes}
3592
3611
  </div>
3593
3612
  </CollapsibleGroup>,
@@ -3641,14 +3660,14 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3641
3660
  fieldCount={groupFields.length}
3642
3661
  chevronPosition={collapsibleChevronPosition}
3643
3662
  >
3644
- <div className="space-y-3">
3663
+ <div className={densityStackMd}>
3645
3664
  {groupContent}
3646
3665
  </div>
3647
3666
  </CollapsibleGroup>,
3648
3667
  )
3649
3668
  } else {
3650
3669
  nodes.push(
3651
- <div key={g.id} className="rounded-lg border bg-card px-4 py-3 space-y-3">
3670
+ <div key={g.id} className={`rounded-lg border bg-card ${densityGroupCardPadding} ${densityStackMd}`}>
3652
3671
  {g.title ? (
3653
3672
  <div className="text-sm font-medium">{t(g.title, g.title)}</div>
3654
3673
  ) : null}
@@ -3703,7 +3722,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3703
3722
  className={embedded ? 'min-h-[1px]' : 'min-h-[400px]'}
3704
3723
  >
3705
3724
  {wrapFormBody(
3706
- <form id={formId} onSubmit={handleSubmit} className={`space-y-4 ${dialogFormPadding}`}>
3725
+ <form id={formId} onSubmit={handleSubmit} className={`${densityStackLg} ${dialogFormPadding}`}>
3707
3726
  {resolvedInjectionSpotId ? (
3708
3727
  <InjectionSpot
3709
3728
  spotId={resolvedInjectionSpotId}
@@ -3722,13 +3741,13 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3722
3741
  {sortableGroupsEnabled ? (
3723
3742
  <DndContext sensors={sortableSensors} collisionDetection={closestCenter} onDragEnd={handleGroupDragEnd}>
3724
3743
  <SortableContext items={col1Ids} strategy={verticalListSortingStrategy}>
3725
- <div className="space-y-3">{col1Content}</div>
3744
+ <div className={densityStackMd}>{col1Content}</div>
3726
3745
  </SortableContext>
3727
3746
  </DndContext>
3728
3747
  ) : (
3729
- <div className="space-y-3">{col1Content}</div>
3748
+ <div className={densityStackMd}>{col1Content}</div>
3730
3749
  )}
3731
- {hasSecondaryColumn ? <div className="space-y-3" data-crud-injection-region>{col2Content}</div> : null}
3750
+ {hasSecondaryColumn ? <div className={densityStackMd} data-crud-injection-region>{col2Content}</div> : null}
3732
3751
  </div>
3733
3752
  {formError && !Object.keys(errors).length ? <div className="text-sm text-status-error-text">{formError}</div> : null}
3734
3753
  {hideFooterActions || formReadOnly ? null : (
@@ -3790,7 +3809,7 @@ export function CrudForm<TValues extends Record<string, unknown>>({
3790
3809
  <form
3791
3810
  id={formId}
3792
3811
  onSubmit={handleSubmit}
3793
- className={`${embedded ? 'space-y-4' : 'rounded-lg border bg-card p-4 space-y-4'} ${dialogFormPadding}`}
3812
+ className={`${embedded ? densityStackLg : `rounded-lg border bg-card ${densityCardPadding} ${densityStackLg}`} ${dialogFormPadding}`}
3794
3813
  >
3795
3814
  {resolvedInjectionSpotId ? (
3796
3815
  <InjectionSpot
@@ -1,12 +1,13 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
- import { flexRender, type RowData, type SortingState, type ColumnVisibilityState as VisibilityState, type RowSelectionState } from '@tanstack/react-table'
5
- import { useLegacyTable, getCoreRowModel, getSortedRowModel, type LegacyColumnDef as ColumnDef, type LegacyColumn as TableColumn } from '@tanstack/react-table/legacy'
4
+ import { flexRender, type RowData, type SortingState, type ColumnVisibilityState as VisibilityState, type RowSelectionState, type ExpandedState, type OnChangeFn } from '@tanstack/react-table'
5
+ import { useLegacyTable, getCoreRowModel, getSortedRowModel, getExpandedRowModel, type LegacyColumnDef as ColumnDef, type LegacyColumn as TableColumn } from '@tanstack/react-table/legacy'
6
6
  import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
7
- import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox, Save } from 'lucide-react'
7
+ import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3, ChevronUp, ChevronDown, ChevronRight, ChevronsUpDown, Check, Inbox, Save } from 'lucide-react'
8
8
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table'
9
9
  import { Button } from '../primitives/button'
10
+ import { IconButton } from '../primitives/icon-button'
10
11
  import { Checkbox } from '../primitives/checkbox'
11
12
  import {
12
13
  Select,
@@ -222,6 +223,13 @@ export type DataTableExportConfig = {
222
223
 
223
224
  export type DataTablePerspectiveConfig = {
224
225
  tableId: string
226
+ /**
227
+ * Where to render the "Views" perspective switcher. Defaults to 'left'
228
+ * (inside the filter toolbar). When 'right', the switcher moves to the
229
+ * right-hand actions area and the redundant standalone "Customize columns"
230
+ * (•••) button is suppressed — the "Views" control already opens that panel.
231
+ */
232
+ align?: 'left' | 'right'
225
233
  initialState?: {
226
234
  response?: PerspectivesIndexResponse
227
235
  activePerspectiveId?: string | null
@@ -456,6 +464,23 @@ export type DataTableProps<T extends RowData> = {
456
464
  onClearAll: () => void
457
465
  onRemoveLast: () => void
458
466
  }
467
+ /**
468
+ * Opt-in expandable/nested rows (accordion). All expansion props are
469
+ * additive and default-off — when none are passed, the table renders and
470
+ * behaves exactly as before (no toggle column, no row model change).
471
+ *
472
+ * - `getSubRows`: derive child rows for a given row (TanStack hierarchy source).
473
+ * - `expandable`: whether a row shows an expand toggle. Pass a predicate to
474
+ * show the toggle for rows whose children are not loaded yet (lazy loading);
475
+ * defaults to "has sub-rows" when omitted but `getSubRows` is set.
476
+ * - `expanded` / `onExpandedChange`: controlled expansion state. Provide both
477
+ * for lazy-loading hosts that fetch children when a row is expanded; omit
478
+ * for self-managed expansion.
479
+ */
480
+ getSubRows?: (row: T) => T[] | undefined
481
+ expandable?: boolean | ((row: T) => boolean)
482
+ expanded?: ExpandedState
483
+ onExpandedChange?: OnChangeFn<ExpandedState>
459
484
  }
460
485
 
461
486
  const DEFAULT_EXPORT_FORMATS: DataTableExportFormat[] = ['csv', 'json', 'xml', 'markdown']
@@ -485,7 +510,7 @@ const STICKY_RIGHT_SHADOW_CLASS =
485
510
  const STICKY_LEFT_SHADOW_CLASS =
486
511
  'md:after:absolute md:after:inset-y-0 md:after:-right-2 md:after:w-2 md:after:bg-gradient-to-r md:after:from-foreground/8 md:after:to-transparent md:after:pointer-events-none'
487
512
 
488
- type BulkActionExecuteResult = {
513
+ export type BulkActionExecuteResult = {
489
514
  ok: boolean
490
515
  message?: string
491
516
  affectedCount?: number
@@ -1282,6 +1307,10 @@ export function DataTable<T extends RowData>({
1282
1307
  columnChooser,
1283
1308
  activeFilterChips,
1284
1309
  filterAwareEmptyState,
1310
+ getSubRows,
1311
+ expandable,
1312
+ expanded: expandedProp,
1313
+ onExpandedChange,
1285
1314
  }: DataTableProps<T>) {
1286
1315
  const t = useT()
1287
1316
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
@@ -1353,6 +1382,7 @@ export function DataTable<T extends RowData>({
1353
1382
  const queryClient = useQueryClient()
1354
1383
  const perspectiveConfig = perspective ?? null
1355
1384
  const perspectiveTableId = perspectiveConfig?.tableId ?? null
1385
+ const perspectiveAlign: 'left' | 'right' = perspectiveConfig?.align ?? 'left'
1356
1386
  const perspectiveEnabled = Boolean(perspectiveTableId)
1357
1387
  // Snapshot from localStorage is read post-mount via useLayoutEffect to avoid SSR/CSR
1358
1388
  // hydration mismatch. Initial render uses only props-derived state (identical on both sides).
@@ -1783,14 +1813,36 @@ export function DataTable<T extends RowData>({
1783
1813
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({})
1784
1814
  const selectionScopeKeyRef = React.useRef<string | undefined>(selectionScopeKey)
1785
1815
  const enableClientSorting = sortable && !manualSorting
1816
+ // Opt-in expansion: enabled only when the host passes any expansion prop, so
1817
+ // existing tables keep their exact row model and rendering.
1818
+ const expansionEnabled = getSubRows !== undefined || expandable !== undefined
1819
+ const [internalExpanded, setInternalExpanded] = React.useState<ExpandedState>({})
1820
+ const expandedState = expandedProp !== undefined ? expandedProp : internalExpanded
1821
+ const handleExpandedChange = React.useCallback<OnChangeFn<ExpandedState>>((updater) => {
1822
+ if (onExpandedChange) {
1823
+ onExpandedChange(updater)
1824
+ return
1825
+ }
1826
+ setInternalExpanded((prev) => (typeof updater === 'function' ? updater(prev) : updater))
1827
+ }, [onExpandedChange])
1786
1828
  const table = useLegacyTable<T>({
1787
1829
  data: clientFilteredData,
1788
1830
  columns: mergedColumns,
1789
1831
  getCoreRowModel: getCoreRowModel(),
1790
1832
  ...(enableClientSorting ? { getSortedRowModel: getSortedRowModel() } : {}),
1833
+ ...(expansionEnabled ? {
1834
+ getSubRows: getSubRows as ((row: T) => T[] | undefined) | undefined,
1835
+ getExpandedRowModel: getExpandedRowModel(),
1836
+ getRowCanExpand: (row) => {
1837
+ if (typeof expandable === 'function') return expandable(row.original as T)
1838
+ if (expandable === true) return true
1839
+ return (row.subRows?.length ?? 0) > 0
1840
+ },
1841
+ onExpandedChange: handleExpandedChange,
1842
+ } : {}),
1791
1843
  manualSorting: manualSorting === true,
1792
1844
  getRowId: resolveDataTableRowId,
1793
- state: { sorting, columnVisibility, columnOrder, rowSelection },
1845
+ state: { sorting, columnVisibility, columnOrder, rowSelection, ...(expansionEnabled ? { expanded: expandedState } : {}) },
1794
1846
  enableRowSelection: hasInjectedBulkActions,
1795
1847
  onSortingChange: (updater) => {
1796
1848
  const next = typeof updater === 'function' ? updater(sorting) : updater
@@ -3074,14 +3126,61 @@ export function DataTable<T extends RowData>({
3074
3126
  [confirm, extensionTableId, refreshButton, resolvedInjectionContext, router, selectedRows, t],
3075
3127
  )
3076
3128
 
3129
+ /**
3130
+ * Host-owned bulk actions (the `bulkActions` prop) used to DISCARD whatever
3131
+ * they returned. An action that queued server-side work and returned
3132
+ * `{ ok: true, progressJobId }` — the contract `progress/AGENTS.md` requires
3133
+ * — was answered with silence: no toast, no top-bar tracking, and a rejected
3134
+ * promise surfaced as an unhandled rejection instead of an error flash.
3135
+ *
3136
+ * The `void` / `true` / `false` returns keep their exact previous behaviour,
3137
+ * because the callers that use them (customers, messages) already flash from
3138
+ * their own `runBulkMutation` and a second toast would be a regression. Only
3139
+ * a RESULT OBJECT is now acted on.
3140
+ */
3077
3141
  const runPropBulkAction = React.useCallback(
3078
3142
  async (action: BulkAction<T>) => {
3079
- const result = await action.onExecute(selectedRows)
3080
- if (result !== false) {
3143
+ try {
3144
+ const result = await action.onExecute(selectedRows)
3145
+ if (result === false) return
3146
+ if (!result || typeof result !== 'object') {
3147
+ setRowSelection({})
3148
+ return
3149
+ }
3150
+
3151
+ if (result.ok === false) {
3152
+ if (result.message === undefined) return
3153
+ flash(result.message, 'error')
3154
+ return
3155
+ }
3156
+
3081
3157
  setRowSelection({})
3158
+ if (result.progressJobId) {
3159
+ trackedBulkProgressJobIdsRef.current.add(result.progressJobId)
3160
+ flash(
3161
+ result.message
3162
+ ?? t('ui.dataTable.bulkAction.started', 'Bulk action started. Track progress in the top bar.'),
3163
+ 'success',
3164
+ )
3165
+ return
3166
+ }
3167
+ flash(
3168
+ result.message ?? t('ui.dataTable.bulkAction.success', 'Bulk action completed.'),
3169
+ 'success',
3170
+ )
3171
+ if (refreshButton?.onRefresh) {
3172
+ refreshButton.onRefresh()
3173
+ } else {
3174
+ scheduleRouterRefresh(router)
3175
+ }
3176
+ } catch (error) {
3177
+ flash(
3178
+ error instanceof Error ? error.message : t('ui.dataTable.bulkAction.error', 'Bulk action failed.'),
3179
+ 'error',
3180
+ )
3082
3181
  }
3083
3182
  },
3084
- [selectedRows],
3183
+ [refreshButton, router, selectedRows, t],
3085
3184
  )
3086
3185
 
3087
3186
  const builtToolbar = React.useMemo(() => {
@@ -3180,6 +3279,7 @@ export function DataTable<T extends RowData>({
3180
3279
  </div>
3181
3280
  )
3182
3281
  : null
3282
+ const perspectiveButtonLeading = perspectiveAlign === 'right' ? null : perspectiveButton
3183
3283
  const saveViewButton = showSaveViewButton && canUsePerspectives ? (
3184
3284
  <Button
3185
3285
  type="button"
@@ -3203,10 +3303,10 @@ export function DataTable<T extends RowData>({
3203
3303
  ) : null}
3204
3304
  </Button>
3205
3305
  ) : null
3206
- const leadingItems = advancedFilterButton || perspectiveButton || saveViewButton ? (
3306
+ const leadingItems = advancedFilterButton || perspectiveButtonLeading || saveViewButton ? (
3207
3307
  <div className="flex items-center gap-2">
3208
3308
  {advancedFilterButton}
3209
- {perspectiveButton}
3309
+ {perspectiveButtonLeading}
3210
3310
  {saveViewButton}
3211
3311
  </div>
3212
3312
  ) : null
@@ -3255,6 +3355,9 @@ export function DataTable<T extends RowData>({
3255
3355
  const searchTrailingNode = searchTrailingInjectionSpotId && onSearchChange ? (
3256
3356
  <InjectionSpot spotId={searchTrailingInjectionSpotId} context={resolvedInjectionContext} />
3257
3357
  ) : null
3358
+ // With the Views switcher moved to the right-hand actions area, the filter
3359
+ // toolbar has nothing left to render — drop it so no empty band appears.
3360
+ if (perspectiveAlign === 'right' && !anySearch && combined.length === 0 && !advancedFilterButton && !saveViewButton && !trailingItems && !fieldsetSelector) return null
3258
3361
  return (
3259
3362
  <FilterBar
3260
3363
  searchValue={searchValue}
@@ -3286,6 +3389,7 @@ export function DataTable<T extends RowData>({
3286
3389
  onFiltersApply,
3287
3390
  onFiltersClear,
3288
3391
  canUsePerspectives,
3392
+ perspectiveAlign,
3289
3393
  embedded,
3290
3394
  supportsCustomFieldFilterFieldsets,
3291
3395
  resolvedEntityIds,
@@ -3415,17 +3519,29 @@ export function DataTable<T extends RowData>({
3415
3519
  </Button>
3416
3520
  ) : null}
3417
3521
  {canUsePerspectives ? (
3418
- <Button
3419
- type="button"
3420
- variant="ghost"
3421
- size="icon"
3422
- onClick={() => setPerspectiveOpen(true)}
3423
- aria-label={t('ui.dataTable.customizeColumns.ariaLabel', 'Customize columns')}
3424
- title={t('ui.dataTable.customizeColumns.title', 'Customize columns')}
3425
- >
3426
- <MoreHorizontal className="h-4 w-4" />
3427
- <span className="sr-only">{t('ui.dataTable.customizeColumns.srOnly', 'Customize columns')}</span>
3428
- </Button>
3522
+ perspectiveAlign === 'right' ? (
3523
+ <ViewSwitcherDropdown
3524
+ activePerspectiveId={activePerspectiveId}
3525
+ perspectives={perspectiveData?.perspectives ?? []}
3526
+ rolePerspectives={perspectiveData?.rolePerspectives ?? []}
3527
+ onClear={() => applyPerspectiveSettings({}, null)}
3528
+ onActivate={handlePerspectiveActivate}
3529
+ onOpenSidebar={() => setPerspectiveOpen(true)}
3530
+ t={t}
3531
+ />
3532
+ ) : (
3533
+ <Button
3534
+ type="button"
3535
+ variant="ghost"
3536
+ size="icon"
3537
+ onClick={() => setPerspectiveOpen(true)}
3538
+ aria-label={t('ui.dataTable.customizeColumns.ariaLabel', 'Customize columns')}
3539
+ title={t('ui.dataTable.customizeColumns.title', 'Customize columns')}
3540
+ >
3541
+ <MoreHorizontal className="h-4 w-4" />
3542
+ <span className="sr-only">{t('ui.dataTable.customizeColumns.srOnly', 'Customize columns')}</span>
3543
+ </Button>
3544
+ )
3429
3545
  ) : null}
3430
3546
  {exportConfig && hasExport ? <ExportMenu config={exportConfig} sections={resolvedExportSections} /> : null}
3431
3547
  {toolbarInjectionSpotId ? (
@@ -3677,13 +3793,51 @@ export function DataTable<T extends RowData>({
3677
3793
  </TruncatedCell>
3678
3794
  ) : content
3679
3795
 
3796
+ // Expansion affordance: only on the first data cell when
3797
+ // expansion is enabled. Indent by nesting depth so children
3798
+ // read as nested under their parent; render a fixed-width
3799
+ // spacer for non-expandable rows to keep columns aligned.
3800
+ const expandAffordance = expansionEnabled && cellIndex === 0 ? (
3801
+ <span
3802
+ className="inline-flex shrink-0 items-center"
3803
+ style={{ paddingLeft: `${row.depth * 1.25}rem` }}
3804
+ >
3805
+ {row.getCanExpand() ? (
3806
+ <IconButton
3807
+ type="button"
3808
+ variant="ghost"
3809
+ size="xs"
3810
+ aria-label={row.getIsExpanded()
3811
+ ? t('ui.dataTable.expand.collapseRow', 'Collapse row')
3812
+ : t('ui.dataTable.expand.expandRow', 'Expand row')}
3813
+ aria-expanded={row.getIsExpanded()}
3814
+ onClick={(event) => {
3815
+ event.stopPropagation()
3816
+ row.toggleExpanded()
3817
+ }}
3818
+ >
3819
+ {row.getIsExpanded()
3820
+ ? <ChevronDown className="size-4" aria-hidden="true" />
3821
+ : <ChevronRight className="size-4" aria-hidden="true" />}
3822
+ </IconButton>
3823
+ ) : (
3824
+ <span className="inline-block size-6" aria-hidden="true" />
3825
+ )}
3826
+ </span>
3827
+ ) : null
3828
+
3680
3829
  return (
3681
3830
  <TableCell
3682
3831
  key={cell.id}
3683
3832
  className={responsiveClass(priority, columnMeta?.hidden) + (isStickyCell ? ` md:sticky md:left-0 md:z-10 md:bg-background ${STICKY_LEFT_SHADOW_CLASS}` : '')}
3684
3833
  style={typeof sizedWidth === 'number' ? { width: sizedWidth, minWidth: sizedWidth, maxWidth: sizedWidth } : undefined}
3685
3834
  >
3686
- {wrappedContent}
3835
+ {expandAffordance ? (
3836
+ <span className="flex items-center gap-1.5">
3837
+ {expandAffordance}
3838
+ <span className="min-w-0 flex-1">{wrappedContent}</span>
3839
+ </span>
3840
+ ) : wrappedContent}
3687
3841
  </TableCell>
3688
3842
  )
3689
3843
  })}
@@ -0,0 +1,113 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ /**
4
+ * `density` exists for narrow hosts — a docked inspector rail, a side panel —
5
+ * where the page-width rhythm reads as airy and costs the host a third of its
6
+ * column.
7
+ *
8
+ * The contract under test is deliberately narrow, because this prop lands on a
9
+ * component every form in the product renders through:
10
+ *
11
+ * 1. omitting it changes NOTHING (the default renders the same class strings
12
+ * that were inline before the prop existed), and
13
+ * 2. `compact` changes SPACING only — the same fields, labels, helper text and
14
+ * controls, one step down the DS scale.
15
+ */
16
+
17
+ jest.mock('next/navigation', () => ({
18
+ useRouter: () => ({ push: jest.fn() }),
19
+ usePathname: () => '/',
20
+ useSearchParams: () => new URLSearchParams(),
21
+ }))
22
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
23
+ jest.mock('../confirm-dialog', () => ({
24
+ useConfirmDialog: () => ({ confirm: jest.fn().mockResolvedValue(true), ConfirmDialogElement: null }),
25
+ }))
26
+ jest.mock('../custom-fields/FieldDefinitionsManager', () => {
27
+ const React = require('react')
28
+ return { __esModule: true, FieldDefinitionsManager: React.forwardRef(() => <div />) }
29
+ })
30
+ jest.mock('../utils/customFieldForms', () => ({
31
+ __esModule: true,
32
+ buildFormFieldFromCustomFieldDef: jest.fn(),
33
+ buildFormFieldsFromCustomFields: jest.fn(() => []),
34
+ fetchCustomFieldFormStructure: jest.fn(),
35
+ }))
36
+
37
+ import * as React from 'react'
38
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
39
+ import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
40
+
41
+ const fields: CrudField[] = [
42
+ { id: 'name', label: 'Name', type: 'text', description: 'What to call it' },
43
+ { id: 'notes', label: 'Notes', type: 'text' },
44
+ ]
45
+
46
+ const groups: CrudFormGroup[] = [{ id: 'basic', title: 'Basic', column: 1, fields: ['name', 'notes'] }]
47
+
48
+ function renderForm(density?: 'default' | 'compact') {
49
+ const { container } = renderWithProviders(
50
+ <CrudForm
51
+ fields={fields}
52
+ groups={groups}
53
+ initialValues={{ name: 'x' }}
54
+ onSubmit={jest.fn()}
55
+ embedded
56
+ {...(density ? { density } : {})}
57
+ />,
58
+ )
59
+ return container
60
+ }
61
+
62
+ function classesOf(container: HTMLElement): string[] {
63
+ return Array.from(container.querySelectorAll<HTMLElement>('*')).map((node) => node.className.toString())
64
+ }
65
+
66
+ describe('CrudForm density', () => {
67
+ it('renders identically whether the prop is omitted or set to its default', () => {
68
+ // React's `useId` counter advances between renders in one file, so the
69
+ // generated form id is normalised away; everything else must match exactly.
70
+ const normalise = (html: string) => html.replace(/_r_[0-9a-z]+_/g, '_r_id_')
71
+ const omitted = normalise(renderForm().innerHTML)
72
+ const explicit = normalise(renderForm('default').innerHTML)
73
+
74
+ expect(explicit).toBe(omitted)
75
+ })
76
+
77
+ it('steps the form rhythm down one DS step when compact', () => {
78
+ const form = renderForm().querySelector('form')
79
+ const compactForm = renderForm('compact').querySelector('form')
80
+
81
+ expect(form?.className).toContain('space-y-4')
82
+ expect(compactForm?.className).toContain('space-y-3')
83
+ expect(compactForm?.className).not.toContain('space-y-4')
84
+ })
85
+
86
+ it('tightens the group card padding when compact', () => {
87
+ const defaultCard = renderForm().querySelector('.rounded-lg.border')
88
+ const compactCard = renderForm('compact').querySelector('.rounded-lg.border')
89
+
90
+ expect(defaultCard?.className).toContain('px-4')
91
+ expect(defaultCard?.className).toContain('py-3')
92
+ expect(compactCard?.className).toContain('px-3')
93
+ expect(compactCard?.className).toContain('py-2')
94
+ })
95
+
96
+ it('never reaches for an arbitrary Tailwind value', () => {
97
+ // The DS ban is on values like `p-[13px]`; density must stay on the scale.
98
+ for (const className of classesOf(renderForm('compact'))) {
99
+ expect(className).not.toMatch(/\b(?:p|px|py|m|mx|my|space-[xy])-\[/)
100
+ }
101
+ })
102
+
103
+ it('changes spacing only — the same fields, labels and helper text survive', () => {
104
+ const compact = renderForm('compact')
105
+
106
+ expect(compact.textContent).toContain('Name')
107
+ expect(compact.textContent).toContain('Notes')
108
+ expect(compact.textContent).toContain('What to call it')
109
+ expect(compact.querySelectorAll('[data-crud-field-id]')).toHaveLength(
110
+ renderForm().querySelectorAll('[data-crud-field-id]').length,
111
+ )
112
+ })
113
+ })