@open-mercato/ui 0.7.0 → 0.7.1-develop.7102.1.b41f7e3e51
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 +4 -3
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +4 -3
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +3 -2
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/backend/AppShell.js +129 -106
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/DataTable.js +20 -7
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +26 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +4 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useRegisteredComponent.js +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +8 -4
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +8 -8
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +36 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/crud.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 +20 -1
- package/dist/primitives/icon-button.js.map +2 -2
- package/dist/primitives/pagination.js +14 -9
- package/dist/primitives/pagination.js.map +2 -2
- package/dist/primitives/rich-editor.js +14 -7
- 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 +5 -4
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +2 -0
- package/src/ai/records/ActivityCard.tsx +4 -3
- package/src/ai/records/DealCard.tsx +4 -3
- package/src/ai/records/ProductCard.tsx +3 -2
- package/src/backend/AppShell.tsx +89 -58
- package/src/backend/DataTable.tsx +44 -10
- package/src/backend/__tests__/AppShell.test.tsx +162 -1
- package/src/backend/__tests__/DataTable.cappedPagination.test.tsx +122 -0
- package/src/backend/__tests__/DataTable.headerWrap.test.tsx +111 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +37 -0
- package/src/backend/__tests__/NotesSection.test.tsx +89 -0
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/detail/NotesSection.tsx +92 -67
- package/src/backend/icons/lucideRegistry.generated.tsx +4 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
- package/src/backend/inputs/ComboboxInput.tsx +21 -5
- package/src/backend/inputs/LookupSelect.tsx +8 -8
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +142 -0
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +75 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +14 -11
- package/src/backend/utils/crud.ts +2 -0
- package/src/primitives/__tests__/alert.test.tsx +41 -1
- package/src/primitives/__tests__/icon-button.test.tsx +70 -0
- package/src/primitives/__tests__/notification.test.tsx +1 -1
- package/src/primitives/__tests__/pagination.test.tsx +54 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +43 -0
- package/src/primitives/alert.tsx +5 -2
- package/src/primitives/icon-button.tsx +20 -1
- package/src/primitives/pagination.tsx +40 -9
- package/src/primitives/rich-editor.tsx +9 -4
- package/src/utils/__tests__/format.test.ts +27 -14
- package/src/utils/format.ts +10 -5
package/src/backend/AppShell.tsx
CHANGED
|
@@ -411,6 +411,19 @@ 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
|
+
|
|
414
427
|
function SerializedIcon({ markup }: { markup: string }) {
|
|
415
428
|
return <span aria-hidden="true" dangerouslySetInnerHTML={{ __html: markup }} />
|
|
416
429
|
}
|
|
@@ -772,17 +785,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
772
785
|
setOpenGroups((prev) => (prev[key] === false ? { ...prev, [key]: true } : prev))
|
|
773
786
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
774
787
|
}, [pathname, navGroups])
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
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.
|
|
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.
|
|
786
791
|
const prevPathname = React.useRef(pathname)
|
|
787
792
|
useIsomorphicLayoutEffect(() => {
|
|
788
793
|
if (pathname !== prevPathname.current) {
|
|
@@ -791,6 +796,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
791
796
|
setHeaderBreadcrumb(undefined)
|
|
792
797
|
}
|
|
793
798
|
}, [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])
|
|
794
805
|
|
|
795
806
|
// Keep navGroups in sync when server-provided groups change
|
|
796
807
|
React.useEffect(() => {
|
|
@@ -806,11 +817,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
806
817
|
) {
|
|
807
818
|
const sortedSections = [...sections].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
808
819
|
const lastVisibleIndex = sortedSections.length - 1
|
|
820
|
+
const blockPadding = sidebarBlockPadding(compact)
|
|
809
821
|
|
|
810
822
|
return (
|
|
811
823
|
<div className="flex h-full flex-col gap-3">
|
|
812
824
|
{!hideHeader && (
|
|
813
|
-
<div className=
|
|
825
|
+
<div className={`mb-2 ${blockPadding}`}>
|
|
814
826
|
<Link
|
|
815
827
|
href="/backend"
|
|
816
828
|
className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
|
|
@@ -822,16 +834,18 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
822
834
|
</div>
|
|
823
835
|
)}
|
|
824
836
|
{!compact && !hideSearch && (
|
|
825
|
-
<
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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>
|
|
833
847
|
)}
|
|
834
|
-
<div data-sidebar-scroll="true" className=
|
|
848
|
+
<div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
|
|
835
849
|
<nav className="flex flex-col gap-2">
|
|
836
850
|
{sortedSections.map((section, sectionIndex) => {
|
|
837
851
|
const sectionNavQueryActive = hideSearch ? false : navQueryActive
|
|
@@ -911,7 +925,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
911
925
|
}
|
|
912
926
|
|
|
913
927
|
return (
|
|
914
|
-
<div
|
|
928
|
+
<div
|
|
929
|
+
key={section.id}
|
|
930
|
+
className={`${blockPadding} ${sectionIndex !== lastVisibleIndex ? 'border-b pb-2' : ''}`}
|
|
931
|
+
data-sidebar-group="true"
|
|
932
|
+
>
|
|
915
933
|
{!compact && (
|
|
916
934
|
<Button
|
|
917
935
|
variant="muted"
|
|
@@ -928,7 +946,6 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
928
946
|
{sortedItems.map((item) => renderSectionItem(item))}
|
|
929
947
|
</div>
|
|
930
948
|
)}
|
|
931
|
-
{sectionIndex !== lastVisibleIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
|
|
932
949
|
</div>
|
|
933
950
|
)
|
|
934
951
|
})}
|
|
@@ -939,9 +956,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
939
956
|
}
|
|
940
957
|
|
|
941
958
|
function renderSidebar(compact: boolean, hideHeader?: boolean, forceMainOnly?: boolean) {
|
|
959
|
+
const blockPadding = sidebarBlockPadding(compact)
|
|
960
|
+
const injectionWrapper = sidebarInjectionWrapper(compact)
|
|
961
|
+
|
|
942
962
|
if (!isChromeReady && isChromeLoading) {
|
|
943
963
|
return (
|
|
944
|
-
<div className=
|
|
964
|
+
<div className={`flex min-h-full flex-col gap-3 ${blockPadding}`} data-testid="backend-chrome-loading">
|
|
945
965
|
{!hideHeader ? (
|
|
946
966
|
<div className="mb-2">
|
|
947
967
|
<Link
|
|
@@ -954,7 +974,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
954
974
|
</Link>
|
|
955
975
|
</div>
|
|
956
976
|
) : null}
|
|
957
|
-
<div className="flex flex-1 flex-col gap-3
|
|
977
|
+
<div className="flex flex-1 flex-col gap-3">
|
|
958
978
|
<div className="space-y-3">
|
|
959
979
|
<div className="h-8 rounded bg-muted/50" />
|
|
960
980
|
<div className="space-y-2 pl-1">
|
|
@@ -1009,7 +1029,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1009
1029
|
return (
|
|
1010
1030
|
<div className="flex h-full flex-col gap-3">
|
|
1011
1031
|
{!hideHeader && (
|
|
1012
|
-
<div className=
|
|
1032
|
+
<div className={`mb-2 ${blockPadding}`}>
|
|
1013
1033
|
<Link
|
|
1014
1034
|
href="/backend"
|
|
1015
1035
|
className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
|
|
@@ -1021,22 +1041,26 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1021
1041
|
</div>
|
|
1022
1042
|
)}
|
|
1023
1043
|
{shouldRenderSidebarInjectionSpots ? (
|
|
1024
|
-
<
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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>
|
|
1028
1050
|
) : null}
|
|
1029
1051
|
{!compact && (
|
|
1030
|
-
<
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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>
|
|
1038
1062
|
)}
|
|
1039
|
-
<div data-sidebar-scroll="true" className=
|
|
1063
|
+
<div data-sidebar-scroll="true" className="flex flex-1 flex-col gap-3 overflow-x-hidden overflow-y-auto scrollbar-hide">
|
|
1040
1064
|
{(() => {
|
|
1041
1065
|
const isSettingsPath = (href: string) => {
|
|
1042
1066
|
if (href === '/backend/settings') return true
|
|
@@ -1065,10 +1089,12 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1065
1089
|
<>
|
|
1066
1090
|
<nav className="flex flex-col gap-2" data-testid="sidebar">
|
|
1067
1091
|
{shouldRenderSidebarInjectionSpots ? (
|
|
1068
|
-
<
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
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>
|
|
1072
1098
|
) : null}
|
|
1073
1099
|
{mainGroups.map((g, gi) => {
|
|
1074
1100
|
const groupId = resolveGroupKey(g)
|
|
@@ -1082,7 +1108,11 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1082
1108
|
})
|
|
1083
1109
|
if (visibleItems.length === 0) return null
|
|
1084
1110
|
return (
|
|
1085
|
-
<div
|
|
1111
|
+
<div
|
|
1112
|
+
className={`${blockPadding} ${gi !== mainLastVisibleGroupIndex ? 'border-b pb-2' : ''}`}
|
|
1113
|
+
key={groupId}
|
|
1114
|
+
data-sidebar-group="true"
|
|
1115
|
+
>
|
|
1086
1116
|
{!compact && (
|
|
1087
1117
|
<Button
|
|
1088
1118
|
variant="muted"
|
|
@@ -1175,7 +1205,6 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1175
1205
|
})}
|
|
1176
1206
|
</div>
|
|
1177
1207
|
)}
|
|
1178
|
-
{gi !== mainLastVisibleGroupIndex && <div className={`my-2 border-t ${compact ? '-ml-2 -mr-3' : '-ml-3 -mr-4'}`} />}
|
|
1179
1208
|
</div>
|
|
1180
1209
|
)
|
|
1181
1210
|
})}
|
|
@@ -1184,7 +1213,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1184
1213
|
)
|
|
1185
1214
|
})()}
|
|
1186
1215
|
</div>
|
|
1187
|
-
<div className=
|
|
1216
|
+
<div className={`sticky bottom-0 bg-background pb-1 ${injectionWrapper}`} data-sidebar-injection-wrapper="true">
|
|
1188
1217
|
{shouldRenderSidebarInjectionSpots ? (
|
|
1189
1218
|
<InjectionSpot
|
|
1190
1219
|
spotId={BACKEND_SIDEBAR_NAV_FOOTER_INJECTION_SPOT_ID}
|
|
@@ -1229,15 +1258,17 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1229
1258
|
if (!sections) return null
|
|
1230
1259
|
return (
|
|
1231
1260
|
<div className="flex h-full flex-col gap-2">
|
|
1232
|
-
<
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
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>
|
|
1241
1272
|
<div className="min-h-0 flex-1">
|
|
1242
1273
|
{renderSectionSidebar(sections, title, false, true, true)}
|
|
1243
1274
|
</div>
|
|
@@ -1306,7 +1337,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1306
1337
|
{effectiveCollapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
|
|
1307
1338
|
</button>
|
|
1308
1339
|
{/* Desktop main sidebar */}
|
|
1309
|
-
<aside ref={sidebarAsideRef} className={`${asideClassesBase}
|
|
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 }}>
|
|
1310
1341
|
{renderSidebar(effectiveCollapsed, false, isSectionView)}
|
|
1311
1342
|
{/* Scroll affordance — gradient fade + clickable chevron that flips up when
|
|
1312
1343
|
the user reaches the bottom and disappears when nothing is scrollable
|
|
@@ -1351,7 +1382,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1351
1382
|
original swap behavior to fit the narrow width. */}
|
|
1352
1383
|
{isSectionView ? (
|
|
1353
1384
|
<aside
|
|
1354
|
-
className={`${asideClassesBase} px-
|
|
1385
|
+
className={`${asideClassesBase} px-0 hidden lg:block lg:sticky lg:top-0 lg:h-svh lg:self-start lg:overflow-hidden lg:relative`}
|
|
1355
1386
|
style={{ width: '240px' }}
|
|
1356
1387
|
data-testid="appshell-section-sidebar"
|
|
1357
1388
|
>
|
|
@@ -1573,7 +1604,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
|
|
|
1573
1604
|
? `mobile-drawer-tab-${mobileDrawerView === 'main' ? 'main' : 'section'}`
|
|
1574
1605
|
: undefined
|
|
1575
1606
|
}
|
|
1576
|
-
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden
|
|
1607
|
+
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden py-3"
|
|
1577
1608
|
>
|
|
1578
1609
|
{/* Force expanded sidebar in mobile drawer, hide its header and collapse toggle */}
|
|
1579
1610
|
{renderSidebar(false, true, mobileDrawerView === 'main')}
|
|
@@ -128,6 +128,14 @@ 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
|
|
131
139
|
onPageChange: (page: number) => void
|
|
132
140
|
durationMs?: number | null
|
|
133
141
|
cacheStatus?: 'hit' | 'miss' | null
|
|
@@ -301,7 +309,7 @@ export type DataTableProps<T extends RowData> = {
|
|
|
301
309
|
emptyState?: React.ReactNode
|
|
302
310
|
error?: React.ReactNode | string | null
|
|
303
311
|
rowActions?: (row: T) => React.ReactNode
|
|
304
|
-
onRowClick?: (row: T) => void
|
|
312
|
+
onRowClick?: (row: T, event: React.MouseEvent<HTMLTableRowElement>) => void
|
|
305
313
|
rowClickActionIds?: string[]
|
|
306
314
|
disableRowClick?: boolean
|
|
307
315
|
bulkActions?: BulkAction<T>[]
|
|
@@ -2613,8 +2621,21 @@ export function DataTable<T extends RowData>({
|
|
|
2613
2621
|
if (!pagination || pagination.total === 0) return null
|
|
2614
2622
|
|
|
2615
2623
|
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
|
|
2616
2628
|
const startItem = (page - 1) * pagination.pageSize + 1
|
|
2617
|
-
|
|
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
|
|
2618
2639
|
const effectiveDuration = (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0)
|
|
2619
2640
|
? durationMs
|
|
2620
2641
|
: measuredDurationMs ?? undefined
|
|
@@ -2653,17 +2674,27 @@ export function DataTable<T extends RowData>({
|
|
|
2653
2674
|
page={page}
|
|
2654
2675
|
pageSize={pagination.pageSize}
|
|
2655
2676
|
total={pagination.total}
|
|
2677
|
+
totalIsCapped={totalIsCapped}
|
|
2678
|
+
hasNextPage={totalIsCapped ? pageIsFull : undefined}
|
|
2656
2679
|
onPageChange={(next) => { onPageChange(next); scrollTableIntoView() }}
|
|
2657
2680
|
onPageSizeChange={pagination.onPageSizeChange ? (next) => {
|
|
2658
2681
|
pagination.onPageSizeChange!(next)
|
|
2659
2682
|
scrollTableIntoView()
|
|
2660
2683
|
} : undefined}
|
|
2661
2684
|
pageSizeOptions={pageSizeOptions}
|
|
2662
|
-
formatPageInfo={() =>
|
|
2663
|
-
|
|
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
|
|
2664
2695
|
? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel })
|
|
2665
2696
|
: t('ui.dataTable.pagination.results', 'Showing {start} to {end} of {total} results', { start: startItem, end: endItem, total: pagination.total })
|
|
2666
|
-
}
|
|
2697
|
+
}}
|
|
2667
2698
|
formatPageSizeLabel={(size) =>
|
|
2668
2699
|
`${size} ${t('ui.dataTable.pagination.perPage', 'per page')}`
|
|
2669
2700
|
}
|
|
@@ -2672,7 +2703,7 @@ export function DataTable<T extends RowData>({
|
|
|
2672
2703
|
/>
|
|
2673
2704
|
</div>
|
|
2674
2705
|
)
|
|
2675
|
-
}, [pagination, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
|
|
2706
|
+
}, [pagination, data, showQueryTime, measuredDurationMs, scrollTableIntoView, t])
|
|
2676
2707
|
|
|
2677
2708
|
// Auto filters: fetch custom field defs when requested
|
|
2678
2709
|
const resolvedEntityIds = React.useMemo(() => {
|
|
@@ -3193,7 +3224,10 @@ export function DataTable<T extends RowData>({
|
|
|
3193
3224
|
const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow
|
|
3194
3225
|
const containerClassName = embedded ? '' : 'rounded-lg border bg-card mx-1 sm:mx-2'
|
|
3195
3226
|
const headerWrapperClassName = embedded ? 'pb-3' : 'px-4 py-3 border-b'
|
|
3196
|
-
|
|
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'
|
|
3197
3231
|
const toolbarWrapperClassName = embedded ? 'mt-2' : 'mt-3 pt-3 border-t'
|
|
3198
3232
|
const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto'
|
|
3199
3233
|
|
|
@@ -3249,11 +3283,11 @@ export function DataTable<T extends RowData>({
|
|
|
3249
3283
|
<div className={headerWrapperClassName}>
|
|
3250
3284
|
{(hasTitle || shouldRenderActionsWrapper || renderToolbarInline) && (
|
|
3251
3285
|
<div className={headerContentClassName}>
|
|
3252
|
-
<div className="flex-1 min-w-0">
|
|
3286
|
+
<div className="flex-1 min-w-0 sm:basis-48">
|
|
3253
3287
|
{renderToolbarInline ? builtToolbar : titleContent}
|
|
3254
3288
|
</div>
|
|
3255
3289
|
{shouldRenderActionsWrapper ? (
|
|
3256
|
-
<div className="flex flex-wrap items-center gap-2 min-h-[2.25rem]">
|
|
3290
|
+
<div className="flex flex-wrap items-center gap-2 min-h-[2.25rem] sm:ml-auto sm:justify-end">
|
|
3257
3291
|
{refreshButtonConfig ? (
|
|
3258
3292
|
<Button
|
|
3259
3293
|
type="button"
|
|
@@ -3464,7 +3498,7 @@ export function DataTable<T extends RowData>({
|
|
|
3464
3498
|
}
|
|
3465
3499
|
|
|
3466
3500
|
if (onRowClick) {
|
|
3467
|
-
onRowClick(row.original as T)
|
|
3501
|
+
onRowClick(row.original as T, e)
|
|
3468
3502
|
} else if (defaultRowAction) {
|
|
3469
3503
|
if (defaultRowAction.href) {
|
|
3470
3504
|
router.push(defaultRowAction.href)
|
|
@@ -9,6 +9,7 @@ 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
|
|
12
13
|
|
|
13
14
|
jest.mock('next/link', () => {
|
|
14
15
|
const React = require('react')
|
|
@@ -39,6 +40,7 @@ jest.mock('next/navigation', () => ({
|
|
|
39
40
|
jest.mock('../injection/InjectionSpot', () => ({
|
|
40
41
|
InjectionSpot: (props: { spotId: string; context?: Record<string, unknown> }) => {
|
|
41
42
|
mockInjectionSpot(props)
|
|
43
|
+
if (mockInjectionSpotRendersNothing) return null
|
|
42
44
|
return <div data-testid={`injection-spot:${props.spotId}`} />
|
|
43
45
|
},
|
|
44
46
|
}))
|
|
@@ -55,7 +57,9 @@ jest.mock('../injection/eventBridge', () => ({
|
|
|
55
57
|
}))
|
|
56
58
|
|
|
57
59
|
jest.mock('../injection/StatusBadgeInjectionSpot', () => ({
|
|
58
|
-
StatusBadgeInjectionSpot: () =>
|
|
60
|
+
StatusBadgeInjectionSpot: () => (
|
|
61
|
+
mockInjectionSpotRendersNothing ? null : <div data-testid="status-badge-injection-spot" />
|
|
62
|
+
),
|
|
59
63
|
}))
|
|
60
64
|
|
|
61
65
|
jest.mock('../operations/LastOperationBanner', () => ({
|
|
@@ -117,6 +121,7 @@ describe('AppShell', () => {
|
|
|
117
121
|
beforeEach(() => {
|
|
118
122
|
mockInjectionSpot.mockClear()
|
|
119
123
|
mockPathname = '/backend/users'
|
|
124
|
+
mockInjectionSpotRendersNothing = false
|
|
120
125
|
})
|
|
121
126
|
|
|
122
127
|
beforeAll(() => {
|
|
@@ -471,6 +476,52 @@ describe('AppShell', () => {
|
|
|
471
476
|
})
|
|
472
477
|
})
|
|
473
478
|
|
|
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
|
+
|
|
474
525
|
it('keeps settings parent item active on descendant routes outside explicit child list', async () => {
|
|
475
526
|
mockPathname = '/backend/entities/user/example%3Acalendar_entity'
|
|
476
527
|
|
|
@@ -577,6 +628,116 @@ describe('AppShell', () => {
|
|
|
577
628
|
}
|
|
578
629
|
})
|
|
579
630
|
|
|
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
|
+
|
|
580
741
|
describe('two-level sidebar (settings/profile mode)', () => {
|
|
581
742
|
it('renders main + section sidebars side-by-side when on a settings path', async () => {
|
|
582
743
|
mockPathname = '/backend/entities/user'
|