@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/records/ActivityCard.js +3 -4
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +3 -4
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -3
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +106 -129
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +9 -22
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +20 -26
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +0 -4
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +6 -24
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +4 -8
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +9 -9
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +30 -36
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.js.map +2 -2
- package/dist/backend/utils/useAutoDiscoveredFields.js +8 -4
- package/dist/backend/utils/useAutoDiscoveredFields.js.map +2 -2
- package/dist/primitives/alert.js +1 -1
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/icon-button.js +1 -20
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +9 -14
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +7 -14
- package/dist/primitives/rich-editor.js.map +2 -2
- package/dist/utils/format.js +5 -5
- package/dist/utils/format.js.map +2 -2
- package/package.json +4 -5
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +0 -2
- package/src/ai/records/ActivityCard.tsx +3 -4
- package/src/ai/records/DealCard.tsx +3 -4
- package/src/ai/records/ProductCard.tsx +2 -3
- package/src/backend/AppShell.tsx +58 -89
- package/src/backend/DataTable.tsx +12 -46
- package/src/backend/__tests__/AppShell.test.tsx +1 -162
- package/src/backend/__tests__/DataTable.render.test.tsx +0 -37
- package/src/backend/__tests__/NotesSection.test.tsx +0 -89
- package/src/backend/detail/NotesSection.tsx +67 -92
- package/src/backend/icons/lucideRegistry.generated.tsx +0 -4
- package/src/backend/injection/useRegisteredComponent.tsx +8 -49
- package/src/backend/inputs/ComboboxInput.tsx +5 -21
- package/src/backend/inputs/LookupSelect.tsx +14 -10
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +0 -142
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +40 -63
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +11 -14
- package/src/backend/utils/__tests__/useAutoDiscoveredFields.groupLabels.test.tsx +59 -0
- package/src/backend/utils/crud.ts +0 -2
- package/src/backend/utils/useAutoDiscoveredFields.ts +10 -4
- package/src/primitives/__tests__/alert.test.tsx +1 -41
- package/src/primitives/__tests__/icon-button.test.tsx +0 -70
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +0 -54
- package/src/primitives/__tests__/rich-editor.test.tsx +0 -43
- package/src/primitives/alert.tsx +2 -5
- package/src/primitives/icon-button.tsx +1 -20
- package/src/primitives/pagination.tsx +9 -40
- package/src/primitives/rich-editor.tsx +4 -9
- package/src/utils/__tests__/format.test.ts +14 -27
- package/src/utils/format.ts +5 -10
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +0 -122
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +0 -111
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +0 -305
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
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=
|
|
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
|
-
<
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
<
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
/>
|
|
1049
|
-
</div>
|
|
1024
|
+
<InjectionSpot
|
|
1025
|
+
spotId={BACKEND_SIDEBAR_TOP_INJECTION_SPOT_ID}
|
|
1026
|
+
context={injectionContext}
|
|
1027
|
+
/>
|
|
1050
1028
|
) : null}
|
|
1051
1029
|
{!compact && (
|
|
1052
|
-
<
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
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=
|
|
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
|
-
<
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
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=
|
|
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
|
-
<
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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]
|
|
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
|
|
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
|
})
|