@asteby/metacore-runtime-react 23.0.0 → 23.2.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/CHANGELOG.md +64 -0
- package/dist/dynamic-kanban.d.ts +28 -19
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +134 -82
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +189 -36
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- package/dist/use-infinite-scroll.d.ts +27 -0
- package/dist/use-infinite-scroll.d.ts.map +1 -0
- package/dist/use-infinite-scroll.js +60 -0
- package/package.json +4 -4
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-kanban.test.tsx +40 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/dynamic-kanban.tsx +210 -149
- package/src/dynamic-table.tsx +216 -14
- package/src/filter-chips.tsx +175 -0
- package/src/use-infinite-scroll.ts +94 -0
package/src/dynamic-table.tsx
CHANGED
|
@@ -67,6 +67,8 @@ import { useApi, useCurrentBranch } from './api-context'
|
|
|
67
67
|
import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
|
|
68
68
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
69
69
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
70
|
+
import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
|
|
71
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
70
72
|
import { OptionsContext } from './options-context'
|
|
71
73
|
import type { TableMetadata, ApiResponse } from './types'
|
|
72
74
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
@@ -111,6 +113,14 @@ export interface DynamicTableProps {
|
|
|
111
113
|
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
112
114
|
*/
|
|
113
115
|
currency?: string
|
|
116
|
+
/**
|
|
117
|
+
* Opt into infinite scroll: instead of the classic pager, rows accumulate as
|
|
118
|
+
* the user scrolls (a sentinel at the bottom fetches + appends the next
|
|
119
|
+
* page, deduped by id, respecting the active filters/search). Changing any
|
|
120
|
+
* filter/sort/search resets to page 1. Default false — existing hosts keep
|
|
121
|
+
* the classic pagination untouched.
|
|
122
|
+
*/
|
|
123
|
+
infiniteScroll?: boolean
|
|
114
124
|
}
|
|
115
125
|
|
|
116
126
|
export function DynamicTable({
|
|
@@ -126,6 +136,7 @@ export function DynamicTable({
|
|
|
126
136
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
127
137
|
timeZone,
|
|
128
138
|
currency,
|
|
139
|
+
infiniteScroll = false,
|
|
129
140
|
}: DynamicTableProps) {
|
|
130
141
|
const { t, i18n } = useTranslation()
|
|
131
142
|
const api = useApi()
|
|
@@ -143,6 +154,10 @@ export function DynamicTable({
|
|
|
143
154
|
const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
|
|
144
155
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
145
156
|
const [loadingData, setLoadingData] = useState(true)
|
|
157
|
+
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
158
|
+
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
159
|
+
const [loadingMore, setLoadingMore] = useState(false)
|
|
160
|
+
const infPageRef = useRef(1)
|
|
146
161
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
147
162
|
|
|
148
163
|
const [exportOpen, setExportOpen] = useState(false)
|
|
@@ -471,9 +486,69 @@ export function DynamicTable({
|
|
|
471
486
|
}
|
|
472
487
|
}, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api])
|
|
473
488
|
|
|
489
|
+
// ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ----
|
|
490
|
+
const infPageSize = 30
|
|
491
|
+
const fetchPage = useCallback(
|
|
492
|
+
async (page: number, append: boolean) => {
|
|
493
|
+
if (!metadata) return
|
|
494
|
+
if (append) setLoadingMore(true)
|
|
495
|
+
else setLoadingData(true)
|
|
496
|
+
try {
|
|
497
|
+
const params: Record<string, any> = {
|
|
498
|
+
page,
|
|
499
|
+
per_page: infPageSize,
|
|
500
|
+
...buildFilterParams(),
|
|
501
|
+
}
|
|
502
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
503
|
+
params,
|
|
504
|
+
})) as { data: ApiResponse<any[]> }
|
|
505
|
+
if (res.data.success) {
|
|
506
|
+
const rows = res.data.data || []
|
|
507
|
+
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
508
|
+
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error('Error al cargar los datos', error)
|
|
512
|
+
} finally {
|
|
513
|
+
if (append) setLoadingMore(false)
|
|
514
|
+
else setLoadingData(false)
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
// Signature of everything that must reset the incremental list to page 1:
|
|
521
|
+
// the filters/search AND the sort (both live in buildFilterParams).
|
|
522
|
+
const filterSignature = useMemo(
|
|
523
|
+
() => JSON.stringify(buildFilterParams()),
|
|
524
|
+
[buildFilterParams],
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
const loadNextPage = useCallback(() => {
|
|
528
|
+
if (loadingMore || loadingData) return
|
|
529
|
+
if (data.length >= rowCount) return
|
|
530
|
+
infPageRef.current += 1
|
|
531
|
+
void fetchPage(infPageRef.current, true)
|
|
532
|
+
}, [loadingMore, loadingData, data.length, rowCount, fetchPage])
|
|
533
|
+
|
|
534
|
+
// Infinite-scroll sentinels. There are two scroll containers (desktop
|
|
535
|
+
// table + mobile card list) but only one is laid out at a time — the CSS
|
|
536
|
+
// `hidden`/`sm:hidden` container has no box, so its observer never fires.
|
|
537
|
+
// Each sentinel drives the SAME `loadNextPage`; its internal guards + the
|
|
538
|
+
// `disabled` flag keep concurrent/exhausted fetches from doubling up.
|
|
539
|
+
const infScrollDisabled =
|
|
540
|
+
!infiniteScroll || loadingMore || loadingData || data.length >= rowCount
|
|
541
|
+
const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } =
|
|
542
|
+
useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
|
|
543
|
+
const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } =
|
|
544
|
+
useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
|
|
545
|
+
|
|
474
546
|
const initialFetchDone = useRef(false)
|
|
475
547
|
useEffect(() => {
|
|
476
548
|
if (!metadata) return
|
|
549
|
+
// Infinite mode owns its own fetching (reset-to-page-1 effect below);
|
|
550
|
+
// the classic pagination-driven path is skipped entirely.
|
|
551
|
+
if (infiniteScroll) return
|
|
477
552
|
if (!initialFetchDone.current) {
|
|
478
553
|
initialFetchDone.current = true
|
|
479
554
|
fetchData()
|
|
@@ -485,9 +560,41 @@ export function DynamicTable({
|
|
|
485
560
|
fetchAggregates()
|
|
486
561
|
}, 300)
|
|
487
562
|
return () => clearTimeout(timeoutId)
|
|
488
|
-
}, [fetchData, fetchAggregates, metadata])
|
|
563
|
+
}, [fetchData, fetchAggregates, metadata, infiniteScroll])
|
|
564
|
+
|
|
565
|
+
// Infinite mode: (re)load page 1 on mount and whenever the filters/sort/
|
|
566
|
+
// search change (or an explicit refreshTrigger) — replacing the accumulated
|
|
567
|
+
// rows and resetting the cursor.
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (!infiniteScroll || !metadata) return
|
|
570
|
+
infPageRef.current = 1
|
|
571
|
+
const first = !initialFetchDone.current
|
|
572
|
+
initialFetchDone.current = true
|
|
573
|
+
if (first) {
|
|
574
|
+
void fetchPage(1, false)
|
|
575
|
+
void fetchAggregates()
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
const timeoutId = setTimeout(() => {
|
|
579
|
+
void fetchPage(1, false)
|
|
580
|
+
void fetchAggregates()
|
|
581
|
+
}, 300)
|
|
582
|
+
return () => clearTimeout(timeoutId)
|
|
583
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
584
|
+
}, [infiniteScroll, metadata, filterSignature])
|
|
489
585
|
|
|
490
|
-
const handleRefresh = useCallback(() => {
|
|
586
|
+
const handleRefresh = useCallback(() => {
|
|
587
|
+
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
|
588
|
+
// accumulated pages (a classic fetchData would collapse it to one small
|
|
589
|
+
// pagination page). Classic mode keeps the pagination-driven refetch.
|
|
590
|
+
if (infiniteScroll) {
|
|
591
|
+
infPageRef.current = 1
|
|
592
|
+
void fetchPage(1, false)
|
|
593
|
+
} else {
|
|
594
|
+
fetchData()
|
|
595
|
+
}
|
|
596
|
+
fetchAggregates()
|
|
597
|
+
}, [infiniteScroll, fetchPage, fetchData, fetchAggregates])
|
|
491
598
|
|
|
492
599
|
// Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
|
|
493
600
|
// in the shared hook so DynamicKanban's card menu behaves identically.
|
|
@@ -536,11 +643,15 @@ export function DynamicTable({
|
|
|
536
643
|
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
537
644
|
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
538
645
|
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null
|
|
539
|
-
const { getFacetLoader } = useFacetLoaders(facetsBase)
|
|
646
|
+
const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase)
|
|
540
647
|
|
|
541
648
|
const columnFilterConfigs = useMemo(() => {
|
|
542
649
|
const map = new Map<string, ColumnFilterConfig>()
|
|
543
650
|
if (!metadata) return map
|
|
651
|
+
// Option labels arrive as manifest i18n keys; translate them here (the ui
|
|
652
|
+
// package has no i18n) so header filters, chips and value summaries show
|
|
653
|
+
// localized text. A raw value with no key falls through via defaultValue.
|
|
654
|
+
const tr = (label: string) => t(label, { defaultValue: label })
|
|
544
655
|
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
545
656
|
label: s.label,
|
|
546
657
|
value: s.key,
|
|
@@ -574,18 +685,23 @@ export function DynamicTable({
|
|
|
574
685
|
if (loader) {
|
|
575
686
|
fType = 'facet'
|
|
576
687
|
loadOptions = loader
|
|
688
|
+
// Prewarmed values (from prefetchFacets) → the header filter
|
|
689
|
+
// opens with the list already there, no "Cargando…" flash.
|
|
690
|
+
options = facetOptions.get(f.column || f.key) ?? []
|
|
577
691
|
}
|
|
578
692
|
}
|
|
579
693
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint) continue
|
|
580
694
|
map.set(f.key, {
|
|
581
695
|
filterType: fType,
|
|
582
696
|
filterKey: f.column || f.key,
|
|
583
|
-
options,
|
|
697
|
+
options: translateOptionLabels(options, tr),
|
|
584
698
|
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
585
699
|
onFilterChange: handleDynamicFilterChange,
|
|
586
700
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
587
701
|
searchEndpoint: f.searchEndpoint,
|
|
588
|
-
loadOptions
|
|
702
|
+
loadOptions: loadOptions
|
|
703
|
+
? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr))
|
|
704
|
+
: undefined,
|
|
589
705
|
})
|
|
590
706
|
}
|
|
591
707
|
for (const c of metadata.columns ?? []) {
|
|
@@ -635,22 +751,64 @@ export function DynamicTable({
|
|
|
635
751
|
if (loader) {
|
|
636
752
|
filterType = 'facet'
|
|
637
753
|
loadOptions = loader
|
|
754
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
755
|
+
options = facetOptions.get(c.key) ?? []
|
|
638
756
|
}
|
|
639
757
|
}
|
|
640
758
|
|
|
641
759
|
map.set(c.key, {
|
|
642
760
|
filterType,
|
|
643
761
|
filterKey: c.key,
|
|
644
|
-
options,
|
|
762
|
+
options: translateOptionLabels(options, tr),
|
|
645
763
|
selectedValues: dynamicFilters[c.key] || [],
|
|
646
764
|
onFilterChange: handleDynamicFilterChange,
|
|
647
765
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint!),
|
|
648
766
|
searchEndpoint: c.searchEndpoint,
|
|
649
|
-
loadOptions
|
|
767
|
+
loadOptions: loadOptions
|
|
768
|
+
? (q?: string) => loadOptions!(q).then((o) => translateOptionLabels(o, tr))
|
|
769
|
+
: undefined,
|
|
650
770
|
})
|
|
651
771
|
}
|
|
652
772
|
return map
|
|
653
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader])
|
|
773
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t])
|
|
774
|
+
|
|
775
|
+
// Prewarm every facet field once the configs settle, so a text column's
|
|
776
|
+
// header filter opens instantly with values + counts (same as the kanban).
|
|
777
|
+
const facetFieldsSig = useMemo(() => {
|
|
778
|
+
const keys: string[] = []
|
|
779
|
+
for (const config of columnFilterConfigs.values()) {
|
|
780
|
+
if (config.filterType === 'facet') keys.push(config.filterKey)
|
|
781
|
+
}
|
|
782
|
+
return keys.join('|')
|
|
783
|
+
}, [columnFilterConfigs])
|
|
784
|
+
useEffect(() => {
|
|
785
|
+
if (!facetFieldsSig) return
|
|
786
|
+
prefetchFacets(facetFieldsSig.split('|'))
|
|
787
|
+
}, [facetFieldsSig, prefetchFacets])
|
|
788
|
+
|
|
789
|
+
// Active-filter chips (shared row with the kanban). One chip per field with a
|
|
790
|
+
// selection; the label is the translated filter/column label.
|
|
791
|
+
const activeFilterChips = useMemo<FilterChipField[]>(() => {
|
|
792
|
+
if (!metadata) return []
|
|
793
|
+
const out: FilterChipField[] = []
|
|
794
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
795
|
+
if ((config.selectedValues?.length ?? 0) === 0) continue
|
|
796
|
+
const f = metadata.filters?.find((x) => x.key === key)
|
|
797
|
+
const c = metadata.columns?.find((x) => x.key === key)
|
|
798
|
+
const rawLabel = f?.label || c?.label || key
|
|
799
|
+
out.push({
|
|
800
|
+
key,
|
|
801
|
+
label: t(rawLabel, { defaultValue: rawLabel }),
|
|
802
|
+
config,
|
|
803
|
+
})
|
|
804
|
+
}
|
|
805
|
+
return out
|
|
806
|
+
}, [metadata, columnFilterConfigs, t])
|
|
807
|
+
|
|
808
|
+
const clearAllDynamicFilters = useCallback(() => {
|
|
809
|
+
setDynamicFilters({})
|
|
810
|
+
setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
|
|
811
|
+
}, [])
|
|
654
812
|
|
|
655
813
|
const columns = useMemo(() => {
|
|
656
814
|
if (!viewMetadata) return []
|
|
@@ -771,11 +929,22 @@ export function DynamicTable({
|
|
|
771
929
|
</>
|
|
772
930
|
}
|
|
773
931
|
/>
|
|
932
|
+
{/* Active-filter chips — shared with the kanban. Quick
|
|
933
|
+
visibility + one-click removal of the header filters. */}
|
|
934
|
+
{activeFilterChips.length > 0 && (
|
|
935
|
+
<div className='pt-2'>
|
|
936
|
+
<FilterChipsRow
|
|
937
|
+
fields={activeFilterChips}
|
|
938
|
+
onClearAll={clearAllDynamicFilters}
|
|
939
|
+
data-testid='table-filter-chips'
|
|
940
|
+
/>
|
|
941
|
+
</div>
|
|
942
|
+
)}
|
|
774
943
|
</div>
|
|
775
944
|
{/* Desktop: classic horizontal-scroll table. Hidden on phones —
|
|
776
945
|
a 7-column table forces a wide horizontal scroll there, so we
|
|
777
946
|
render a card-per-row list instead (see MobileCards below). */}
|
|
778
|
-
<div className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
|
|
947
|
+
<div ref={infDesktopRoot} className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
|
|
779
948
|
<Table noWrapper className={cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full')}>
|
|
780
949
|
<TableHeader className='sticky top-0 z-10'>
|
|
781
950
|
{table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
|
|
@@ -891,12 +1060,25 @@ export function DynamicTable({
|
|
|
891
1060
|
</TableFooter>
|
|
892
1061
|
)}
|
|
893
1062
|
</Table>
|
|
1063
|
+
{infiniteScroll && (
|
|
1064
|
+
<>
|
|
1065
|
+
{loadingMore && (
|
|
1066
|
+
<div className='p-2'>
|
|
1067
|
+
<Skeleton className='h-8 w-full' data-testid='table-loading-more' />
|
|
1068
|
+
</div>
|
|
1069
|
+
)}
|
|
1070
|
+
{/* Sentinel stays mounted for the whole infinite-scroll
|
|
1071
|
+
session so its observer attaches once; `disabled`
|
|
1072
|
+
(no more pages / load in flight) gates the fetch. */}
|
|
1073
|
+
<div ref={infDesktopSentinel} className='h-1 w-full' aria-hidden />
|
|
1074
|
+
</>
|
|
1075
|
+
)}
|
|
894
1076
|
</div>
|
|
895
1077
|
|
|
896
1078
|
{/* Mobile: one card per row — no horizontal scroll. Each card
|
|
897
1079
|
stacks its columns as label : value pairs with the row actions
|
|
898
1080
|
pinned at the bottom. */}
|
|
899
|
-
<div className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
|
|
1081
|
+
<div ref={infMobileRoot} className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
|
|
900
1082
|
{loadingData && data.length === 0 ? (
|
|
901
1083
|
Array.from({ length: 5 }).map((_, i) => (
|
|
902
1084
|
<div key={i} className='rounded-lg border bg-card p-3'>
|
|
@@ -951,13 +1133,33 @@ export function DynamicTable({
|
|
|
951
1133
|
<p className='text-sm text-muted-foreground'>No hay datos para mostrar en este momento.</p>
|
|
952
1134
|
</div>
|
|
953
1135
|
)}
|
|
1136
|
+
{infiniteScroll && (
|
|
1137
|
+
<>
|
|
1138
|
+
{loadingMore && (
|
|
1139
|
+
<Skeleton className='h-16 w-full shrink-0' data-testid='table-loading-more-mobile' />
|
|
1140
|
+
)}
|
|
1141
|
+
<div ref={infMobileSentinel} className='h-1 w-full shrink-0' aria-hidden />
|
|
1142
|
+
</>
|
|
1143
|
+
)}
|
|
954
1144
|
</div>
|
|
955
1145
|
|
|
956
1146
|
<div className='shrink-0 pt-4'>
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1147
|
+
{infiniteScroll ? (
|
|
1148
|
+
data.length > 0 && (
|
|
1149
|
+
<p className='text-center text-xs text-muted-foreground tabular-nums'>
|
|
1150
|
+
{t('common.showingCount', {
|
|
1151
|
+
defaultValue: '{{count}} de {{total}}',
|
|
1152
|
+
count: data.length,
|
|
1153
|
+
total: rowCount,
|
|
1154
|
+
})}
|
|
1155
|
+
</p>
|
|
1156
|
+
)
|
|
1157
|
+
) : (
|
|
1158
|
+
<DataTablePagination
|
|
1159
|
+
table={table}
|
|
1160
|
+
pageSizeOptions={metadata.perPageOptions}
|
|
1161
|
+
/>
|
|
1162
|
+
)}
|
|
961
1163
|
</div>
|
|
962
1164
|
</div>
|
|
963
1165
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Shared filter presentation: the removable active-filter chip row + the pure
|
|
2
|
+
// helpers that summarize a filter's value and resolve its color. Used by BOTH
|
|
3
|
+
// DynamicKanban and DynamicTable so the two surfaces render identical chips
|
|
4
|
+
// (one component, no drift).
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { X } from 'lucide-react'
|
|
7
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives'
|
|
8
|
+
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Human-readable summary of a field's selected filter values for a chip / row.
|
|
12
|
+
* Resolves option labels, unwraps the wire operators
|
|
13
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
14
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
15
|
+
*/
|
|
16
|
+
export function summarizeFilterValues(
|
|
17
|
+
values: string[] | undefined,
|
|
18
|
+
options: { label: string; value: string }[] | undefined,
|
|
19
|
+
maxShown = 2,
|
|
20
|
+
): string {
|
|
21
|
+
if (!values || values.length === 0) return ''
|
|
22
|
+
const opts = options ?? []
|
|
23
|
+
const labelFor = (v: string) => opts.find((o) => o.value === v)?.label ?? v
|
|
24
|
+
const first = values[0]
|
|
25
|
+
if (values.length === 1) {
|
|
26
|
+
if (first.startsWith('ILIKE:')) return `"${first.slice(6)}"`
|
|
27
|
+
if (first.startsWith('IN:')) {
|
|
28
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown)
|
|
29
|
+
}
|
|
30
|
+
if (first.startsWith('RANGE:')) {
|
|
31
|
+
const [min, max] = first.slice(6).split(',')
|
|
32
|
+
return `${min || '…'} – ${max || '…'}`
|
|
33
|
+
}
|
|
34
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return first.replace('_', ' – ')
|
|
35
|
+
}
|
|
36
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
37
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? ''
|
|
38
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? ''
|
|
39
|
+
return `${min || '…'} – ${max || '…'}`
|
|
40
|
+
}
|
|
41
|
+
return summarizeList(values, labelFor, maxShown)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function summarizeList(
|
|
45
|
+
items: string[],
|
|
46
|
+
labelFor: (v: string) => string,
|
|
47
|
+
maxShown: number,
|
|
48
|
+
): string {
|
|
49
|
+
const labels = items.map(labelFor)
|
|
50
|
+
if (labels.length <= maxShown) return labels.join(', ')
|
|
51
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
56
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
57
|
+
* operator/range/free-text values that carry no option color.
|
|
58
|
+
*/
|
|
59
|
+
export function chipValueColor(config: {
|
|
60
|
+
selectedValues: string[]
|
|
61
|
+
options: { value: string; color?: string }[]
|
|
62
|
+
}): string | undefined {
|
|
63
|
+
const sel = config.selectedValues
|
|
64
|
+
if (!sel || sel.length === 0) return undefined
|
|
65
|
+
const first = sel[0]
|
|
66
|
+
let value = first
|
|
67
|
+
if (first.startsWith('IN:')) value = first.slice(3).split(',')[0]
|
|
68
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first)) return undefined
|
|
69
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first)) return undefined
|
|
70
|
+
const opt = config.options.find((o) => o.value === value)
|
|
71
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
76
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
77
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
78
|
+
*/
|
|
79
|
+
export function translateOptionLabels<T extends { label: string }>(
|
|
80
|
+
options: T[],
|
|
81
|
+
translate: (key: string) => string,
|
|
82
|
+
): T[] {
|
|
83
|
+
return options.map((o) => ({ ...o, label: translate(o.label) }))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface FilterChipField {
|
|
87
|
+
key: string
|
|
88
|
+
label: string
|
|
89
|
+
config: {
|
|
90
|
+
selectedValues: string[]
|
|
91
|
+
options: { label: string; value: string; color?: string }[]
|
|
92
|
+
filterKey: string
|
|
93
|
+
onFilterChange: (filterKey: string, values: string[]) => void
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface FilterChipsRowProps {
|
|
98
|
+
/** Fields with an active selection (one chip each). */
|
|
99
|
+
fields: FilterChipField[]
|
|
100
|
+
/** Clears every filter (the trailing "Limpiar todo"). */
|
|
101
|
+
onClearAll: () => void
|
|
102
|
+
className?: string
|
|
103
|
+
'data-testid'?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
108
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
109
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
110
|
+
* field is active.
|
|
111
|
+
*/
|
|
112
|
+
export function FilterChipsRow({
|
|
113
|
+
fields,
|
|
114
|
+
onClearAll,
|
|
115
|
+
className,
|
|
116
|
+
'data-testid': testId,
|
|
117
|
+
}: FilterChipsRowProps) {
|
|
118
|
+
const { t } = useTranslation()
|
|
119
|
+
if (fields.length === 0) return null
|
|
120
|
+
return (
|
|
121
|
+
<div
|
|
122
|
+
className={`flex flex-wrap items-center gap-1.5${className ? ` ${className}` : ''}`}
|
|
123
|
+
data-testid={testId}
|
|
124
|
+
>
|
|
125
|
+
{fields.map((field) => {
|
|
126
|
+
const summary = summarizeFilterValues(
|
|
127
|
+
field.config.selectedValues,
|
|
128
|
+
field.config.options,
|
|
129
|
+
)
|
|
130
|
+
const dot = chipValueColor(field.config)
|
|
131
|
+
return (
|
|
132
|
+
<Badge
|
|
133
|
+
key={field.key}
|
|
134
|
+
variant="secondary"
|
|
135
|
+
className="h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal"
|
|
136
|
+
>
|
|
137
|
+
{dot && (
|
|
138
|
+
<span
|
|
139
|
+
className="size-2 shrink-0 rounded-full"
|
|
140
|
+
style={{ backgroundColor: dot }}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
<span className="font-medium">{field.label}:</span>
|
|
144
|
+
<span className="max-w-[180px] truncate text-muted-foreground">
|
|
145
|
+
{summary}
|
|
146
|
+
</span>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() =>
|
|
150
|
+
field.config.onFilterChange(
|
|
151
|
+
field.config.filterKey,
|
|
152
|
+
[],
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
className="ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20"
|
|
156
|
+
aria-label={t('filters.removeFilter', {
|
|
157
|
+
defaultValue: 'Quitar filtro',
|
|
158
|
+
})}
|
|
159
|
+
>
|
|
160
|
+
<X className="h-3 w-3" />
|
|
161
|
+
</button>
|
|
162
|
+
</Badge>
|
|
163
|
+
)
|
|
164
|
+
})}
|
|
165
|
+
<Button
|
|
166
|
+
variant="ghost"
|
|
167
|
+
size="sm"
|
|
168
|
+
className="h-6 gap-1 px-2 text-xs text-muted-foreground"
|
|
169
|
+
onClick={onClearAll}
|
|
170
|
+
>
|
|
171
|
+
{t('filters.clearAll', { defaultValue: 'Limpiar todo' })}
|
|
172
|
+
</Button>
|
|
173
|
+
</div>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shared incremental-loading primitives for DynamicTable and DynamicKanban.
|
|
2
|
+
// - `dedupeById` appends a page of rows to the accumulated set, dropping any
|
|
3
|
+
// id already present (the server may re-paginate / overlap between a global
|
|
4
|
+
// page and a scoped top-up).
|
|
5
|
+
// - `useInfiniteScrollSentinel` wires an IntersectionObserver: attach
|
|
6
|
+
// `rootRef` to the scroll container and `sentinelRef` to a sentinel at its
|
|
7
|
+
// bottom; when the sentinel enters view (and it's enabled) `onLoadMore`
|
|
8
|
+
// fires. Degrades to a no-op where IntersectionObserver is unavailable
|
|
9
|
+
// (older/SSR/happy-dom) so callers still render.
|
|
10
|
+
import { useEffect, useRef } from 'react'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns a NEW array = `existing` followed by every row in `incoming` whose
|
|
14
|
+
* `id` is not already present. Stable on identity/order of `existing`. Pure.
|
|
15
|
+
*/
|
|
16
|
+
export function dedupeById<T extends { id?: any }>(
|
|
17
|
+
existing: T[],
|
|
18
|
+
incoming: T[],
|
|
19
|
+
): T[] {
|
|
20
|
+
if (incoming.length === 0) return existing
|
|
21
|
+
const seen = new Set(existing.map((r) => String(r?.id)))
|
|
22
|
+
const additions: T[] = []
|
|
23
|
+
for (const row of incoming) {
|
|
24
|
+
const key = String(row?.id)
|
|
25
|
+
if (seen.has(key)) continue
|
|
26
|
+
seen.add(key)
|
|
27
|
+
additions.push(row)
|
|
28
|
+
}
|
|
29
|
+
return additions.length === 0 ? existing : [...existing, ...additions]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseInfiniteScrollOptions {
|
|
33
|
+
/** Fired when the sentinel scrolls into view and loading is enabled. */
|
|
34
|
+
onLoadMore: () => void
|
|
35
|
+
/** When true, the observer is inert (no more pages, or a load in flight). */
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
/** Pixels of pre-fetch margin below the viewport. Default 200. */
|
|
38
|
+
rootMargin?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InfiniteScrollRefs<
|
|
42
|
+
R extends HTMLElement = HTMLDivElement,
|
|
43
|
+
S extends HTMLElement = HTMLDivElement,
|
|
44
|
+
> {
|
|
45
|
+
rootRef: React.RefObject<R | null>
|
|
46
|
+
sentinelRef: React.RefObject<S | null>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
|
|
51
|
+
* scrollable container and `sentinelRef` to a small element at its bottom. The
|
|
52
|
+
* latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
|
|
53
|
+
* torn down and rebuilt on every render.
|
|
54
|
+
*/
|
|
55
|
+
export function useInfiniteScrollSentinel<
|
|
56
|
+
R extends HTMLElement = HTMLDivElement,
|
|
57
|
+
S extends HTMLElement = HTMLDivElement,
|
|
58
|
+
>({
|
|
59
|
+
onLoadMore,
|
|
60
|
+
disabled = false,
|
|
61
|
+
rootMargin = 200,
|
|
62
|
+
}: UseInfiniteScrollOptions): InfiniteScrollRefs<R, S> {
|
|
63
|
+
const rootRef = useRef<R | null>(null)
|
|
64
|
+
const sentinelRef = useRef<S | null>(null)
|
|
65
|
+
const cb = useRef(onLoadMore)
|
|
66
|
+
const disabledRef = useRef(disabled)
|
|
67
|
+
cb.current = onLoadMore
|
|
68
|
+
disabledRef.current = disabled
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const sentinel = sentinelRef.current
|
|
72
|
+
if (
|
|
73
|
+
!sentinel ||
|
|
74
|
+
typeof IntersectionObserver === 'undefined'
|
|
75
|
+
) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const observer = new IntersectionObserver(
|
|
79
|
+
(entries) => {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.isIntersecting && !disabledRef.current) {
|
|
82
|
+
cb.current()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ root: rootRef.current ?? null, rootMargin: `0px 0px ${rootMargin}px 0px` },
|
|
87
|
+
)
|
|
88
|
+
observer.observe(sentinel)
|
|
89
|
+
return () => observer.disconnect()
|
|
90
|
+
// Re-create only when the margin changes; onLoadMore/disabled are read live.
|
|
91
|
+
}, [rootMargin])
|
|
92
|
+
|
|
93
|
+
return { rootRef, sentinelRef }
|
|
94
|
+
}
|