@open-mercato/ui 0.6.8-develop.7100.1.fbf66fca35 → 0.7.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 (71) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/records/ActivityCard.js +3 -4
  3. package/dist/ai/records/ActivityCard.js.map +2 -2
  4. package/dist/ai/records/DealCard.js +3 -4
  5. package/dist/ai/records/DealCard.js.map +2 -2
  6. package/dist/ai/records/ProductCard.js +2 -3
  7. package/dist/ai/records/ProductCard.js.map +2 -2
  8. package/dist/backend/AppShell.js +106 -129
  9. package/dist/backend/AppShell.js.map +2 -2
  10. package/dist/backend/DataTable.js +9 -22
  11. package/dist/backend/DataTable.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +20 -26
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/icons/lucideRegistry.generated.js +0 -4
  15. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  16. package/dist/backend/injection/useRegisteredComponent.js +6 -24
  17. package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
  18. package/dist/backend/inputs/ComboboxInput.js +4 -8
  19. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +9 -9
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
  23. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  24. package/dist/backend/utils/crud.js.map +2 -2
  25. package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
  26. package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
  27. package/dist/primitives/alert.js +1 -1
  28. package/dist/primitives/alert.js.map +2 -2
  29. package/dist/primitives/icon-button.js +1 -20
  30. package/dist/primitives/icon-button.js.map +2 -2
  31. package/dist/primitives/pagination.js +9 -14
  32. package/dist/primitives/pagination.js.map +2 -2
  33. package/dist/primitives/rich-editor.js +7 -14
  34. package/dist/primitives/rich-editor.js.map +2 -2
  35. package/dist/utils/format.js +5 -5
  36. package/dist/utils/format.js.map +2 -2
  37. package/package.json +4 -5
  38. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
  39. package/src/ai/records/ActivityCard.tsx +3 -4
  40. package/src/ai/records/DealCard.tsx +3 -4
  41. package/src/ai/records/ProductCard.tsx +2 -3
  42. package/src/backend/AppShell.tsx +58 -89
  43. package/src/backend/DataTable.tsx +12 -46
  44. package/src/backend/__tests__/AppShell.test.tsx +1 -162
  45. package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
  46. package/src/backend/__tests__/NotesSection.test.tsx +0 -89
  47. package/src/backend/detail/NotesSection.tsx +67 -92
  48. package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
  49. package/src/backend/injection/useRegisteredComponent.tsx +8 -49
  50. package/src/backend/inputs/ComboboxInput.tsx +5 -21
  51. package/src/backend/inputs/LookupSelect.tsx +14 -10
  52. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
  53. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
  54. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
  55. package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
  56. package/src/backend/utils/crud.ts +0 -2
  57. package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
  58. package/src/primitives/__tests__/alert.test.tsx +1 -41
  59. package/src/primitives/__tests__/icon-button.test.tsx +0 -70
  60. package/src/primitives/__tests__/notification.test.tsx +1 -1
  61. package/src/primitives/__tests__/pagination.test.tsx +0 -54
  62. package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
  63. package/src/primitives/alert.tsx +2 -5
  64. package/src/primitives/icon-button.tsx +1 -20
  65. package/src/primitives/pagination.tsx +9 -40
  66. package/src/primitives/rich-editor.tsx +4 -9
  67. package/src/utils/__tests__/format.test.ts +14 -27
  68. package/src/utils/format.ts +5 -10
  69. package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
  70. package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
  71. package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +0 -305
@@ -411,19 +411,6 @@ function resolveItemKey(item: { id?: string; href: string }): string {
411
411
  return item.href
412
412
  }
413
413
 
414
- // Horizontal padding lives on each sidebar block rather than on the <aside>, so the scroll
415
- // container never needs a negative-margin bleed to reach the panel edges. The transition keeps the
416
- // compact/expanded padding step in sync with the aside's width animation.
417
- function sidebarBlockPadding(compact: boolean): string {
418
- return `${compact ? 'px-2' : 'px-3'} transition-[padding] duration-200 ease-out`
419
- }
420
-
421
- // An InjectionSpot renders nothing when no widget is registered, so its wrapper must collapse
422
- // instead of surviving as an empty flex child that still costs a full `gap-3`.
423
- function sidebarInjectionWrapper(compact: boolean): string {
424
- return `${sidebarBlockPadding(compact)} empty:hidden`
425
- }
426
-
427
414
  function SerializedIcon({ markup }: { markup: string }) {
428
415
  return <span aria-hidden="true" dangerouslySetInnerHTML={{ __html: markup }} />
429
416
  }
@@ -785,9 +772,17 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
785
772
  setOpenGroups((prev) => (prev[key] === false ? { ...prev, [key]: true } : prev))
786
773
  // eslint-disable-next-line react-hooks/exhaustive-deps
787
774
  }, [pathname, navGroups])
788
- // Reset stale header state before applying server props and before the new
789
- // page's passive ApplyBreadcrumb effect. The ordering matters for layouts
790
- // that persist across client-side navigation.
775
+ // Keep header state in sync with props (server-side updates)
776
+ React.useEffect(() => {
777
+ setHeaderTitle(currentTitle)
778
+ setHeaderBreadcrumb(breadcrumb)
779
+ }, [currentTitle, breadcrumb])
780
+ // Clear breadcrumb on client-side navigation so stale state doesn't persist;
781
+ // the new page's ApplyBreadcrumb (if any) will set the correct values.
782
+ // Must be a layout effect: when a prefetched navigation commits the new
783
+ // pathname and the new page together, child passive effects (ApplyBreadcrumb)
784
+ // run before parent ones, so a passive clear here would wipe the value the
785
+ // incoming page just set.
791
786
  const prevPathname = React.useRef(pathname)
792
787
  useIsomorphicLayoutEffect(() => {
793
788
  if (pathname !== prevPathname.current) {
@@ -796,12 +791,6 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
796
791
  setHeaderBreadcrumb(undefined)
797
792
  }
798
793
  }, [pathname])
799
- // Keep header state in sync with props (server-side updates). A child page's
800
- // ApplyBreadcrumb effect runs afterward and may refine this static trail.
801
- useIsomorphicLayoutEffect(() => {
802
- setHeaderTitle(currentTitle)
803
- setHeaderBreadcrumb(breadcrumb)
804
- }, [currentTitle, breadcrumb])
805
794
 
806
795
  // Keep navGroups in sync when server-provided groups change
807
796
  React.useEffect(() => {
@@ -817,12 +806,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
817
806
  ) {
818
807
  const sortedSections = [...sections].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
819
808
  const lastVisibleIndex = sortedSections.length - 1
820
- const blockPadding = sidebarBlockPadding(compact)
821
809
 
822
810
  return (
823
811
  <div className="flex h-full flex-col gap-3">
824
812
  {!hideHeader && (
825
- <div className={`mb-2 ${blockPadding}`}>
813
+ <div className="mb-2">
826
814
  <Link
827
815
  href="/backend"
828
816
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
@@ -834,18 +822,16 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
834
822
  </div>
835
823
  )}
836
824
  {!compact && !hideSearch && (
837
- <div className={blockPadding}>
838
- <SearchInput
839
- value={navQuery}
840
- onChange={setNavQuery}
841
- placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
842
- aria-label={t('appShell.searchNavAria', 'Search navigation')}
843
- clearLabel={t('appShell.searchNavClear', 'Clear search')}
844
- className="mb-2"
845
- />
846
- </div>
825
+ <SearchInput
826
+ value={navQuery}
827
+ onChange={setNavQuery}
828
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
829
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
830
+ clearLabel={t('appShell.searchNavClear', 'Clear search')}
831
+ className="mb-2"
832
+ />
847
833
  )}
848
- <div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
834
+ <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
849
835
  <nav className="flex flex-col gap-2">
850
836
  {sortedSections.map((section, sectionIndex) => {
851
837
  const sectionNavQueryActive = hideSearch ? false : navQueryActive
@@ -925,11 +911,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
925
911
  }
926
912
 
927
913
  return (
928
- <div
929
- key={section.id}
930
- className={`${blockPadding} ${sectionIndex !== lastVisibleIndex ? 'border-b pb-2' : ''}`}
931
- data-sidebar-group="true"
932
- >
914
+ <div key={section.id}>
933
915
  {!compact && (
934
916
  <Button
935
917
  variant="muted"
@@ -946,6 +928,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
946
928
  {sortedItems.map((item) => renderSectionItem(item))}
947
929
  </div>
948
930
  )}
931
+ {sectionIndex !== lastVisibleIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
949
932
  </div>
950
933
  )
951
934
  })}
@@ -956,12 +939,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
956
939
  }
957
940
 
958
941
  function renderSidebar(compact: boolean, hideHeader?: boolean, forceMainOnly?: boolean) {
959
- const blockPadding = sidebarBlockPadding(compact)
960
- const injectionWrapper = sidebarInjectionWrapper(compact)
961
-
962
942
  if (!isChromeReady && isChromeLoading) {
963
943
  return (
964
- <div className={`flex min-h-full flex-col gap-3 ${blockPadding}`} data-testid="backend-chrome-loading">
944
+ <div className="flex flex-col min-h-full gap-3" data-testid="backend-chrome-loading">
965
945
  {!hideHeader ? (
966
946
  <div className="mb-2">
967
947
  <Link
@@ -974,7 +954,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
974
954
  </Link>
975
955
  </div>
976
956
  ) : null}
977
- <div className="flex flex-1 flex-col gap-3">
957
+ <div className="flex flex-1 flex-col gap-3 pr-1">
978
958
  <div className="space-y-3">
979
959
  <div className="h-8 rounded bg-muted/50" />
980
960
  <div className="space-y-2 pl-1">
@@ -1029,7 +1009,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1029
1009
  return (
1030
1010
  <div className="flex h-full flex-col gap-3">
1031
1011
  {!hideHeader && (
1032
- <div className={`mb-2 ${blockPadding}`}>
1012
+ <div className="mb-2">
1033
1013
  <Link
1034
1014
  href="/backend"
1035
1015
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
@@ -1041,26 +1021,22 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1041
1021
  </div>
1042
1022
  )}
1043
1023
  {shouldRenderSidebarInjectionSpots ? (
1044
- <div className={injectionWrapper} data-sidebar-injection-wrapper="true">
1045
- <InjectionSpot
1046
- spotId={BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID}
1047
- context={injectionContext}
1048
- />
1049
- </div>
1024
+ <InjectionSpot
1025
+ spotId={BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID}
1026
+ context={injectionContext}
1027
+ />
1050
1028
  ) : null}
1051
1029
  {!compact && (
1052
- <div className={blockPadding}>
1053
- <SearchInput
1054
- value={navQuery}
1055
- onChange={setNavQuery}
1056
- placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
1057
- aria-label={t('appShell.searchNavAria', 'Search navigation')}
1058
- clearLabel={t('appShell.searchNavClear', 'Clear search')}
1059
- className="mb-2"
1060
- />
1061
- </div>
1030
+ <SearchInput
1031
+ value={navQuery}
1032
+ onChange={setNavQuery}
1033
+ placeholder={t('appShell.searchNavPlaceholder', 'Search...')}
1034
+ aria-label={t('appShell.searchNavAria', 'Search navigation')}
1035
+ clearLabel={t('appShell.searchNavClear', 'Clear search')}
1036
+ className="mb-2"
1037
+ />
1062
1038
  )}
1063
- <div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
1039
+ <div data-sidebar-scroll="true" className={`flex flex-1 flex-col gap-3 overflow-y-auto scrollbar-hide pr-1 ${compact ? '-ml-2 pl-2' : '-ml-3 pl-3'}`}>
1064
1040
  {(() => {
1065
1041
  const isSettingsPath = (href: string) => {
1066
1042
  if (href === '/backend/settings') return true
@@ -1089,12 +1065,10 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1089
1065
  <>
1090
1066
  <nav className="flex flex-col gap-2" data-testid="sidebar">
1091
1067
  {shouldRenderSidebarInjectionSpots ? (
1092
- <div className={injectionWrapper} data-sidebar-injection-wrapper="true">
1093
- <InjectionSpot
1094
- spotId={BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID}
1095
- context={injectionContext}
1096
- />
1097
- </div>
1068
+ <InjectionSpot
1069
+ spotId={BACKEND_SIDEBAR_NAV_INJECTION_SPOT_ID}
1070
+ context={injectionContext}
1071
+ />
1098
1072
  ) : null}
1099
1073
  {mainGroups.map((g, gi) => {
1100
1074
  const groupId = resolveGroupKey(g)
@@ -1108,11 +1082,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1108
1082
  })
1109
1083
  if (visibleItems.length === 0) return null
1110
1084
  return (
1111
- <div
1112
- className={`${blockPadding} ${gi !== mainLastVisibleGroupIndex ? 'border-b pb-2' : ''}`}
1113
- key={groupId}
1114
- data-sidebar-group="true"
1115
- >
1085
+ <div key={groupId}>
1116
1086
  {!compact && (
1117
1087
  <Button
1118
1088
  variant="muted"
@@ -1205,6 +1175,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1205
1175
  })}
1206
1176
  </div>
1207
1177
  )}
1178
+ {gi !== mainLastVisibleGroupIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
1208
1179
  </div>
1209
1180
  )
1210
1181
  })}
@@ -1213,7 +1184,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1213
1184
  )
1214
1185
  })()}
1215
1186
  </div>
1216
- <div className={`sticky bottom-0 bg-background pb-1 ${injectionWrapper}`} data-sidebar-injection-wrapper="true">
1187
+ <div className="sticky bottom-0 bg-background pb-1">
1217
1188
  {shouldRenderSidebarInjectionSpots ? (
1218
1189
  <InjectionSpot
1219
1190
  spotId={BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID}
@@ -1258,17 +1229,15 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1258
1229
  if (!sections) return null
1259
1230
  return (
1260
1231
  <div className="flex h-full flex-col gap-2">
1261
- <div className={sidebarBlockPadding(false)}>
1262
- <Link
1263
- href="/backend"
1264
- className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
1265
- data-testid="appshell-section-back-to-main"
1266
- aria-label={t('backend.nav.backToMain', 'Back to Main')}
1267
- >
1268
- <ChevronLeft className="size-4 shrink-0" aria-hidden />
1269
- <span className="truncate">{title}</span>
1270
- </Link>
1271
- </div>
1232
+ <Link
1233
+ href="/backend"
1234
+ className="inline-flex items-center gap-2 rounded-lg px-2 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
1235
+ data-testid="appshell-section-back-to-main"
1236
+ aria-label={t('backend.nav.backToMain', 'Back to Main')}
1237
+ >
1238
+ <ChevronLeft className="size-4 shrink-0" aria-hidden />
1239
+ <span className="truncate">{title}</span>
1240
+ </Link>
1272
1241
  <div className="min-h-0 flex-1">
1273
1242
  {renderSectionSidebar(sections, title, false, true, true)}
1274
1243
  </div>
@@ -1337,7 +1306,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1337
1306
  {effectiveCollapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
1338
1307
  </button>
1339
1308
  {/* Desktop main sidebar */}
1340
- <aside ref={sidebarAsideRef} className={`${asideClassesBase} px-0 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width] duration-200 ease-out`} style={{ width: asideWidth }}>
1309
+ <aside ref={sidebarAsideRef} className={`${asideClassesBase} ${effectiveCollapsed ? 'px-2' : 'px-3'} hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative transition-[width,padding] duration-200 ease-out`} style={{ width: asideWidth }}>
1341
1310
  {renderSidebar(effectiveCollapsed, false, isSectionView)}
1342
1311
  {/* Scroll affordance — gradient fade + clickable chevron that flips up when
1343
1312
  the user reaches the bottom and disappears when nothing is scrollable
@@ -1382,7 +1351,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1382
1351
  original swap behavior to fit the narrow width. */}
1383
1352
  {isSectionView ? (
1384
1353
  <aside
1385
- className={`${asideClassesBase} px-0 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
1354
+ className={`${asideClassesBase} px-3 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
1386
1355
  style={{ width: '240px' }}
1387
1356
  data-testid="appshell-section-sidebar"
1388
1357
  >
@@ -1604,7 +1573,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1604
1573
  ? `mobile-drawer-tab-${mobileDrawerView === 'main' ? 'main' : 'section'}`
1605
1574
  : undefined
1606
1575
  }
1607
- className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3"
1576
+ className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden p-3"
1608
1577
  >
1609
1578
  {/* Force expanded sidebar in mobile drawer, hide its header and collapse toggle */}
1610
1579
  {renderSidebar(false, true, mobileDrawerView === 'main')}
@@ -128,14 +128,6 @@ export type PaginationProps = {
128
128
  pageSize: number
129
129
  total: number
130
130
  totalPages: number
131
- /**
132
- * `total` (and the `totalPages` derived from it) is a floor, not an exact
133
- * count — the server capped the list count (`totalIsCapped: true` on the
134
- * list payload). Capped totals render as "{total}+" and pagination stays
135
- * open past the floor via short-page detection instead of ending at
136
- * `ceil(total / pageSize)`.
137
- */
138
- totalIsCapped?: boolean
139
131
  onPageChange: (page: number) => void
140
132
  durationMs?: number | null
141
133
  cacheStatus?: 'hit' | 'miss' | null
@@ -309,7 +301,7 @@ export type DataTableProps<T extends RowData> = {
309
301
  emptyState?: React.ReactNode
310
302
  error?: React.ReactNode | string | null
311
303
  rowActions?: (row: T) => React.ReactNode
312
- onRowClick?: (row: T, event: React.MouseEvent<HTMLTableRowElement>) => void
304
+ onRowClick?: (row: T) => void
313
305
  rowClickActionIds?: string[]
314
306
  disableRowClick?: boolean
315
307
  bulkActions?: BulkAction<T>[]
@@ -2621,21 +2613,8 @@ export function DataTable<T extends RowData>({
2621
2613
  if (!pagination || pagination.total === 0) return null
2622
2614
 
2623
2615
  const { page, totalPages, onPageChange, durationMs, cacheStatus } = pagination
2624
- const totalIsCapped = pagination.totalIsCapped === true
2625
- // Short-page detection: a full current page means a next page may exist,
2626
- // even past the capped floor. `data` holds exactly the rendered page's rows.
2627
- const pageIsFull = data.length >= pagination.pageSize
2628
2616
  const startItem = (page - 1) * pagination.pageSize + 1
2629
- // Past a capped floor, `total` can sit below the window — derive the end
2630
- // of the range from the rows actually shown instead.
2631
- const endItem = totalIsCapped
2632
- ? Math.max(startItem, startItem + data.length - 1)
2633
- : Math.min(page * pagination.pageSize, pagination.total)
2634
- // Short-page detection false-positives when the true row count is an exact
2635
- // multiple of `pageSize`: Next stays enabled on the last full page and the
2636
- // page after it comes back empty. `total` is the cap rather than 0, so the
2637
- // pager still renders — claim no range rather than "X to X" over no rows.
2638
- const pageIsEmpty = data.length === 0
2617
+ const endItem = Math.min(page * pagination.pageSize, pagination.total)
2639
2618
  const effectiveDuration = (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0)
2640
2619
  ? durationMs
2641
2620
  : measuredDurationMs ?? undefined
@@ -2674,27 +2653,17 @@ export function DataTable<T extends RowData>({
2674
2653
  page={page}
2675
2654
  pageSize={pagination.pageSize}
2676
2655
  total={pagination.total}
2677
- totalIsCapped={totalIsCapped}
2678
- hasNextPage={totalIsCapped ? pageIsFull : undefined}
2679
2656
  onPageChange={(next) => { onPageChange(next); scrollTableIntoView() }}
2680
2657
  onPageSizeChange={pagination.onPageSizeChange ? (next) => {
2681
2658
  pagination.onPageSizeChange!(next)
2682
2659
  scrollTableIntoView()
2683
2660
  } : undefined}
2684
2661
  pageSizeOptions={pageSizeOptions}
2685
- formatPageInfo={() => {
2686
- if (totalIsCapped) {
2687
- if (pageIsEmpty) {
2688
- return t('ui.dataTable.pagination.resultsCappedNoRows', 'No further results past {total}', { total: pagination.total })
2689
- }
2690
- return durationLabel
2691
- ? t('ui.dataTable.pagination.resultsCappedWithDuration', 'Showing {start} to {end} of {total}+ results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
2692
- : t('ui.dataTable.pagination.resultsCapped', 'Showing {start} to {end} of {total}+ results', { start: startItem, end: endItem, total: pagination.total })
2693
- }
2694
- return durationLabel
2662
+ formatPageInfo={() =>
2663
+ durationLabel
2695
2664
  ? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
2696
2665
  : t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
2697
- }}
2666
+ }
2698
2667
  formatPageSizeLabel={(size) =>
2699
2668
  `${size} ${t('ui.dataTable.pagination.perPage', 'per page')}`
2700
2669
  }
@@ -2703,7 +2672,7 @@ export function DataTable<T extends RowData>({
2703
2672
  />
2704
2673
  </div>
2705
2674
  )
2706
- }, [pagination, data, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
2675
+ }, [pagination, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
2707
2676
 
2708
2677
  // Auto filters: fetch custom field defs when requested
2709
2678
  const resolvedEntityIds = React.useMemo(() => {
@@ -2854,10 +2823,10 @@ export function DataTable<T extends RowData>({
2854
2823
  return table.getAllLeafColumns().map((col) => ({
2855
2824
  key: col.id,
2856
2825
  label: resolveColumnLabel(col),
2857
- group: 'Columns',
2826
+ group: t('ui.columnChooser.defaultGroup', 'Columns'),
2858
2827
  alwaysVisible: !col.getCanHide(),
2859
2828
  }))
2860
- }, [resolvedColumnChooserFields, table, resolveColumnLabel, columns])
2829
+ }, [resolvedColumnChooserFields, table, resolveColumnLabel, columns, t])
2861
2830
 
2862
2831
  const visibleColumnKeys = React.useMemo(
2863
2832
  () => table.getAllLeafColumns().filter((c) => c.getIsVisible()).map((c) => c.id),
@@ -3224,10 +3193,7 @@ export function DataTable<T extends RowData>({
3224
3193
  const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow
3225
3194
  const containerClassName = embedded ? '' : 'rounded-lg border bg-card mx-1 sm:mx-2'
3226
3195
  const headerWrapperClassName = embedded ? 'pb-3' : 'px-4 py-3 border-b'
3227
- // The header row wraps once the actions no longer fit beside the title (the
3228
- // title keeps a 12rem floor); before, the title collapsed to a sliver and
3229
- // the wrapped action buttons rendered over it on narrow layouts.
3230
- const headerContentClassName = 'flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between'
3196
+ const headerContentClassName = 'flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'
3231
3197
  const toolbarWrapperClassName = embedded ? 'mt-2' : 'mt-3 pt-3 border-t'
3232
3198
  const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
3233
3199
 
@@ -3283,11 +3249,11 @@ export function DataTable<T extends RowData>({
3283
3249
  <div className={headerWrapperClassName}>
3284
3250
  {(hasTitle || shouldRenderActionsWrapper || renderToolbarInline) && (
3285
3251
  <div className={headerContentClassName}>
3286
- <div className="flex-1 min-w-0 sm:basis-48">
3252
+ <div className="flex-1 min-w-0">
3287
3253
  {renderToolbarInline ? builtToolbar : titleContent}
3288
3254
  </div>
3289
3255
  {shouldRenderActionsWrapper ? (
3290
- <div className="flex flex-wrap items-center gap-2 min-h-[2.25rem] sm:ml-auto sm:justify-end">
3256
+ <div className="flex flex-wrap items-center gap-2 min-h-[2.25rem]">
3291
3257
  {refreshButtonConfig ? (
3292
3258
  <Button
3293
3259
  type="button"
@@ -3498,7 +3464,7 @@ export function DataTable<T extends RowData>({
3498
3464
  }
3499
3465
 
3500
3466
  if (onRowClick) {
3501
- onRowClick(row.original as T, e)
3467
+ onRowClick(row.original as T)
3502
3468
  } else if (defaultRowAction) {
3503
3469
  if (defaultRowAction.href) {
3504
3470
  router.push(defaultRowAction.href)
@@ -9,7 +9,6 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
9
9
 
10
10
  const mockInjectionSpot = jest.fn()
11
11
  let mockPathname = '/backend/users'
12
- let mockInjectionSpotRendersNothing = false
13
12
 
14
13
  jest.mock('next/link', () => {
15
14
  const React = require('react')
@@ -40,7 +39,6 @@ jest.mock('next/navigation', () => ({
40
39
  jest.mock('../injection/InjectionSpot', () => ({
41
40
  InjectionSpot: (props: { spotId: string; context?: Record<string, unknown> }) => {
42
41
  mockInjectionSpot(props)
43
- if (mockInjectionSpotRendersNothing) return null
44
42
  return <div data-testid={`injection-spot:${props.spotId}`} />
45
43
  },
46
44
  }))
@@ -57,9 +55,7 @@ jest.mock('../injection/eventBridge', () => ({
57
55
  }))
58
56
 
59
57
  jest.mock('../injection/StatusBadgeInjectionSpot', () => ({
60
- StatusBadgeInjectionSpot: () => (
61
- mockInjectionSpotRendersNothing ? null : <div data-testid="status-badge-injection-spot" />
62
- ),
58
+ StatusBadgeInjectionSpot: () => <div data-testid="status-badge-injection-spot" />,
63
59
  }))
64
60
 
65
61
  jest.mock('../operations/LastOperationBanner', () => ({
@@ -121,7 +117,6 @@ describe('AppShell', () => {
121
117
  beforeEach(() => {
122
118
  mockInjectionSpot.mockClear()
123
119
  mockPathname = '/backend/users'
124
- mockInjectionSpotRendersNothing = false
125
120
  })
126
121
 
127
122
  beforeAll(() => {
@@ -476,52 +471,6 @@ describe('AppShell', () => {
476
471
  })
477
472
  })
478
473
 
479
- it('keeps the new page breadcrumb after client-side navigation', async () => {
480
- mockPathname = '/backend/documents'
481
-
482
- const { rerender } = renderWithProviders(
483
- <AppShell
484
- email="demo@example.com"
485
- groups={groups}
486
- currentTitle="Documents"
487
- breadcrumb={[{ label: 'Documents' }]}
488
- >
489
- <ApplyBreadcrumb title="Documents" breadcrumb={[{ label: 'Documents' }]} />
490
- <div>Documents list</div>
491
- </AppShell>,
492
- { dict },
493
- )
494
-
495
- await waitFor(() => {
496
- expect(within(screen.getByRole('navigation', { name: 'Breadcrumb' })).getByText('Documents')).toBeInTheDocument()
497
- })
498
-
499
- mockPathname = '/backend/documents/document-id'
500
- rerender(
501
- <AppShell
502
- email="demo@example.com"
503
- groups={groups}
504
- currentTitle="Documents"
505
- breadcrumb={[{ label: 'Documents' }]}
506
- >
507
- <ApplyBreadcrumb
508
- title="Document"
509
- breadcrumb={[
510
- { label: 'Documents', href: '/backend/documents' },
511
- { label: 'Document' },
512
- ]}
513
- />
514
- <div>Document detail</div>
515
- </AppShell>,
516
- )
517
-
518
- await waitFor(() => {
519
- const breadcrumb = screen.getByRole('navigation', { name: 'Breadcrumb' })
520
- expect(within(breadcrumb).getByRole('link', { name: 'Documents' })).toHaveAttribute('href', '/backend/documents')
521
- expect(within(breadcrumb).getByText('Document')).toHaveAttribute('aria-current', 'page')
522
- })
523
- })
524
-
525
474
  it('keeps settings parent item active on descendant routes outside explicit child list', async () => {
526
475
  mockPathname = '/backend/entities/user/example%3Acalendar_entity'
527
476
 
@@ -628,116 +577,6 @@ describe('AppShell', () => {
628
577
  }
629
578
  })
630
579
 
631
- describe('sidebar layout', () => {
632
- const multiGroups = [
633
- groups[0],
634
- {
635
- id: 'commerce',
636
- name: 'Commerce',
637
- items: [{ href: '/backend/orders', title: 'Orders' }],
638
- },
639
- {
640
- id: 'ops',
641
- name: 'Ops',
642
- items: [{ href: '/backend/jobs', title: 'Jobs' }],
643
- },
644
- ]
645
-
646
- it('collapses sidebar injection-spot wrappers when their spots render nothing', async () => {
647
- mockInjectionSpotRendersNothing = true
648
-
649
- const { container } = renderWithProviders(
650
- <AppShell email="demo@example.com" groups={groups}>
651
- <div>Child content</div>
652
- </AppShell>,
653
- { dict },
654
- )
655
-
656
- await waitFor(() => {
657
- expect(screen.getByText('Users List')).toBeInTheDocument()
658
- })
659
-
660
- const wrappers = Array.from(container.querySelectorAll('[data-sidebar-injection-wrapper="true"]'))
661
- expect(wrappers.length).toBeGreaterThan(0)
662
- for (const wrapper of wrappers) {
663
- // The spot rendered nothing, so the wrapper is an empty flex child of the sidebar's
664
- // `gap-3` column and would still cost a full gap unless it collapses itself.
665
- expect(wrapper.children).toHaveLength(0)
666
- expect(wrapper).toHaveClass('empty:hidden')
667
- }
668
- })
669
-
670
- it('keeps every sidebar injection spot inside a self-collapsing wrapper', async () => {
671
- const { container } = renderWithProviders(
672
- <AppShell email="demo@example.com" groups={groups}>
673
- <div>Child content</div>
674
- </AppShell>,
675
- { dict },
676
- )
677
-
678
- await waitFor(() => {
679
- expect(screen.getByText('Users List')).toBeInTheDocument()
680
- })
681
-
682
- const sidebarSpotIds = ['backend:sidebar:top', 'backend:sidebar:nav', 'backend:sidebar:nav:footer', 'backend:sidebar:footer']
683
- for (const spotId of sidebarSpotIds) {
684
- const spot = container.querySelector(`[data-testid="injection-spot:${spotId}"]`)
685
- expect(spot).not.toBeNull()
686
- expect(spot!.closest('[data-sidebar-injection-wrapper="true"]')).not.toBeNull()
687
- }
688
- })
689
-
690
- it('renders group separators on the group wrapper itself, skipping the last visible group', async () => {
691
- const { container } = renderWithProviders(
692
- <AppShell email="demo@example.com" groups={multiGroups}>
693
- <div>Child content</div>
694
- </AppShell>,
695
- { dict },
696
- )
697
-
698
- await waitFor(() => {
699
- expect(screen.getByText('Orders')).toBeInTheDocument()
700
- })
701
-
702
- const sidebar = screen.getByTestId('sidebar')
703
- const groupWrappers = Array.from(sidebar.querySelectorAll('[data-sidebar-group="true"]'))
704
- expect(groupWrappers).toHaveLength(multiGroups.length)
705
- groupWrappers.forEach((wrapper, index) => {
706
- const isLast = index === groupWrappers.length - 1
707
- expect(wrapper).toHaveClass(isLast ? 'px-3' : 'border-b')
708
- expect(wrapper.classList.contains('border-b')).toBe(!isLast)
709
- expect(wrapper.classList.contains('pb-2')).toBe(!isLast)
710
- })
711
-
712
- // The old standalone bleeding separator elements must not come back — they were what
713
- // forced the scroll container's negative-margin bleed in the first place.
714
- expect(container.querySelectorAll('[data-sidebar-scroll="true"] div.border-t')).toHaveLength(0)
715
- })
716
-
717
- it('pads sidebar content blocks instead of bleeding the scroll container out of the aside', async () => {
718
- const { container } = renderWithProviders(
719
- <AppShell email="demo@example.com" groups={groups}>
720
- <div>Child content</div>
721
- </AppShell>,
722
- { dict },
723
- )
724
-
725
- await waitFor(() => {
726
- expect(screen.getByText('Users List')).toBeInTheDocument()
727
- })
728
-
729
- const scrollContainers = Array.from(container.querySelectorAll('[data-sidebar-scroll="true"]'))
730
- expect(scrollContainers.length).toBeGreaterThan(0)
731
- for (const scroller of scrollContainers) {
732
- expect(scroller).toHaveClass('overflow-x-hidden')
733
- for (const className of Array.from(scroller.classList)) {
734
- expect(className.startsWith('-ml-')).toBe(false)
735
- expect(className.startsWith('-mr-')).toBe(false)
736
- }
737
- }
738
- })
739
- })
740
-
741
580
  describe('two-level sidebar (settings/profile mode)', () => {
742
581
  it('renders main + section sidebars side-by-side when on a settings path', async () => {
743
582
  mockPathname = '/backend/entities/user'
@@ -128,41 +128,4 @@ describe('DataTable SSR render', () => {
128
128
  queryClient.clear()
129
129
  }
130
130
  })
131
-
132
- it('forwards the click event to onRowClick, and skips it when clicking the actions cell', () => {
133
- const columns: ColumnDef<Row>[] = [
134
- { accessorKey: 'name', header: 'Name' },
135
- ]
136
- const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
137
- let capturedRowTagName: string | null = null
138
- const onRowClick = jest.fn((_row: Row, event: React.MouseEvent<HTMLTableRowElement>) => {
139
- capturedRowTagName = event.currentTarget.tagName
140
- })
141
- try {
142
- render(
143
- <QueryClientProvider client={queryClient}>
144
- <I18nProvider locale="en" dict={{}}>
145
- <DataTable
146
- columns={columns}
147
- data={[{ id: '1', name: 'Ada' }]}
148
- onRowClick={onRowClick}
149
- rowActions={() => <button type="button">Edit</button>}
150
- />
151
- </I18nProvider>
152
- </QueryClientProvider>,
153
- )
154
-
155
- fireEvent.click(screen.getByText('Ada'))
156
- expect(onRowClick).toHaveBeenCalledTimes(1)
157
- const [row, event] = onRowClick.mock.calls[0]
158
- expect(row).toEqual({ id: '1', name: 'Ada' })
159
- expect(event.type).toBe('click')
160
- expect(capturedRowTagName).toBe('TR')
161
-
162
- fireEvent.click(screen.getByText('Edit'))
163
- expect(onRowClick).toHaveBeenCalledTimes(1)
164
- } finally {
165
- queryClient.clear()
166
- }
167
- })
168
131
  })