@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/dist/dynamic-table.js
CHANGED
|
@@ -25,13 +25,15 @@ import { useMetadataCache } from './metadata-cache';
|
|
|
25
25
|
import { useApi, useCurrentBranch } from './api-context';
|
|
26
26
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
|
|
27
27
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
|
|
28
|
+
import { FilterChipsRow, translateOptionLabels } from './filter-chips';
|
|
29
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
|
|
28
30
|
import { OptionsContext } from './options-context';
|
|
29
31
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
30
32
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
|
|
31
33
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
32
34
|
import { ExportDialog } from './dialogs/export';
|
|
33
35
|
import { ImportDialog } from './dialogs/import';
|
|
34
|
-
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, }) {
|
|
36
|
+
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
|
|
35
37
|
const { t, i18n } = useTranslation();
|
|
36
38
|
const api = useApi();
|
|
37
39
|
const currentBranch = useCurrentBranch();
|
|
@@ -45,6 +47,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
45
47
|
const [footerTotals, setFooterTotals] = useState({});
|
|
46
48
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
47
49
|
const [loadingData, setLoadingData] = useState(true);
|
|
50
|
+
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
51
|
+
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
52
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
53
|
+
const infPageRef = useRef(1);
|
|
48
54
|
const [optionsMap, setOptionsMap] = useState(new Map());
|
|
49
55
|
const [exportOpen, setExportOpen] = useState(false);
|
|
50
56
|
const [importOpen, setImportOpen] = useState(false);
|
|
@@ -391,10 +397,68 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
391
397
|
console.error('Error al cargar los totales', error);
|
|
392
398
|
}
|
|
393
399
|
}, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api]);
|
|
400
|
+
// ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ----
|
|
401
|
+
const infPageSize = 30;
|
|
402
|
+
const fetchPage = useCallback(async (page, append) => {
|
|
403
|
+
if (!metadata)
|
|
404
|
+
return;
|
|
405
|
+
if (append)
|
|
406
|
+
setLoadingMore(true);
|
|
407
|
+
else
|
|
408
|
+
setLoadingData(true);
|
|
409
|
+
try {
|
|
410
|
+
const params = {
|
|
411
|
+
page,
|
|
412
|
+
per_page: infPageSize,
|
|
413
|
+
...buildFilterParams(),
|
|
414
|
+
};
|
|
415
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
416
|
+
params,
|
|
417
|
+
}));
|
|
418
|
+
if (res.data.success) {
|
|
419
|
+
const rows = res.data.data || [];
|
|
420
|
+
setData((prev) => (append ? dedupeById(prev, rows) : rows));
|
|
421
|
+
if (res.data.meta)
|
|
422
|
+
setRowCount(res.data.meta.total);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
catch (error) {
|
|
426
|
+
console.error('Error al cargar los datos', error);
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
if (append)
|
|
430
|
+
setLoadingMore(false);
|
|
431
|
+
else
|
|
432
|
+
setLoadingData(false);
|
|
433
|
+
}
|
|
434
|
+
}, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id]);
|
|
435
|
+
// Signature of everything that must reset the incremental list to page 1:
|
|
436
|
+
// the filters/search AND the sort (both live in buildFilterParams).
|
|
437
|
+
const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
|
|
438
|
+
const loadNextPage = useCallback(() => {
|
|
439
|
+
if (loadingMore || loadingData)
|
|
440
|
+
return;
|
|
441
|
+
if (data.length >= rowCount)
|
|
442
|
+
return;
|
|
443
|
+
infPageRef.current += 1;
|
|
444
|
+
void fetchPage(infPageRef.current, true);
|
|
445
|
+
}, [loadingMore, loadingData, data.length, rowCount, fetchPage]);
|
|
446
|
+
// Infinite-scroll sentinels. There are two scroll containers (desktop
|
|
447
|
+
// table + mobile card list) but only one is laid out at a time — the CSS
|
|
448
|
+
// `hidden`/`sm:hidden` container has no box, so its observer never fires.
|
|
449
|
+
// Each sentinel drives the SAME `loadNextPage`; its internal guards + the
|
|
450
|
+
// `disabled` flag keep concurrent/exhausted fetches from doubling up.
|
|
451
|
+
const infScrollDisabled = !infiniteScroll || loadingMore || loadingData || data.length >= rowCount;
|
|
452
|
+
const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled });
|
|
453
|
+
const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } = useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled });
|
|
394
454
|
const initialFetchDone = useRef(false);
|
|
395
455
|
useEffect(() => {
|
|
396
456
|
if (!metadata)
|
|
397
457
|
return;
|
|
458
|
+
// Infinite mode owns its own fetching (reset-to-page-1 effect below);
|
|
459
|
+
// the classic pagination-driven path is skipped entirely.
|
|
460
|
+
if (infiniteScroll)
|
|
461
|
+
return;
|
|
398
462
|
if (!initialFetchDone.current) {
|
|
399
463
|
initialFetchDone.current = true;
|
|
400
464
|
fetchData();
|
|
@@ -406,8 +470,41 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
406
470
|
fetchAggregates();
|
|
407
471
|
}, 300);
|
|
408
472
|
return () => clearTimeout(timeoutId);
|
|
409
|
-
}, [fetchData, fetchAggregates, metadata]);
|
|
410
|
-
|
|
473
|
+
}, [fetchData, fetchAggregates, metadata, infiniteScroll]);
|
|
474
|
+
// Infinite mode: (re)load page 1 on mount and whenever the filters/sort/
|
|
475
|
+
// search change (or an explicit refreshTrigger) — replacing the accumulated
|
|
476
|
+
// rows and resetting the cursor.
|
|
477
|
+
useEffect(() => {
|
|
478
|
+
if (!infiniteScroll || !metadata)
|
|
479
|
+
return;
|
|
480
|
+
infPageRef.current = 1;
|
|
481
|
+
const first = !initialFetchDone.current;
|
|
482
|
+
initialFetchDone.current = true;
|
|
483
|
+
if (first) {
|
|
484
|
+
void fetchPage(1, false);
|
|
485
|
+
void fetchAggregates();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const timeoutId = setTimeout(() => {
|
|
489
|
+
void fetchPage(1, false);
|
|
490
|
+
void fetchAggregates();
|
|
491
|
+
}, 300);
|
|
492
|
+
return () => clearTimeout(timeoutId);
|
|
493
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
494
|
+
}, [infiniteScroll, metadata, filterSignature]);
|
|
495
|
+
const handleRefresh = useCallback(() => {
|
|
496
|
+
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
|
497
|
+
// accumulated pages (a classic fetchData would collapse it to one small
|
|
498
|
+
// pagination page). Classic mode keeps the pagination-driven refetch.
|
|
499
|
+
if (infiniteScroll) {
|
|
500
|
+
infPageRef.current = 1;
|
|
501
|
+
void fetchPage(1, false);
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
fetchData();
|
|
505
|
+
}
|
|
506
|
+
fetchAggregates();
|
|
507
|
+
}, [infiniteScroll, fetchPage, fetchData, fetchAggregates]);
|
|
411
508
|
// Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
|
|
412
509
|
// in the shared hook so DynamicKanban's card menu behaves identically.
|
|
413
510
|
const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
|
|
@@ -462,11 +559,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
462
559
|
// getDynamicColumns forwards `loadOptions` into the column meta). Facets base
|
|
463
560
|
// derived off the list endpoint exactly like the aggregate endpoint below.
|
|
464
561
|
const facetsBase = endpoint ? `${endpoint}/facets` : model ? `/data/${model}/facets` : null;
|
|
465
|
-
const { getFacetLoader } = useFacetLoaders(facetsBase);
|
|
562
|
+
const { getFacetLoader, prefetchFacets, facetOptions } = useFacetLoaders(facetsBase);
|
|
466
563
|
const columnFilterConfigs = useMemo(() => {
|
|
467
564
|
const map = new Map();
|
|
468
565
|
if (!metadata)
|
|
469
566
|
return map;
|
|
567
|
+
// Option labels arrive as manifest i18n keys; translate them here (the ui
|
|
568
|
+
// package has no i18n) so header filters, chips and value summaries show
|
|
569
|
+
// localized text. A raw value with no key falls through via defaultValue.
|
|
570
|
+
const tr = (label) => t(label, { defaultValue: label });
|
|
470
571
|
const stageOptions = (metadata.stages ?? []).map((s) => ({
|
|
471
572
|
label: s.label,
|
|
472
573
|
value: s.key,
|
|
@@ -500,6 +601,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
500
601
|
if (loader) {
|
|
501
602
|
fType = 'facet';
|
|
502
603
|
loadOptions = loader;
|
|
604
|
+
// Prewarmed values (from prefetchFacets) → the header filter
|
|
605
|
+
// opens with the list already there, no "Cargando…" flash.
|
|
606
|
+
options = facetOptions.get(f.column || f.key) ?? [];
|
|
503
607
|
}
|
|
504
608
|
}
|
|
505
609
|
if (fType === 'select' && options.length === 0 && !f.searchEndpoint)
|
|
@@ -507,12 +611,14 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
507
611
|
map.set(f.key, {
|
|
508
612
|
filterType: fType,
|
|
509
613
|
filterKey: f.column || f.key,
|
|
510
|
-
options,
|
|
614
|
+
options: translateOptionLabels(options, tr),
|
|
511
615
|
selectedValues: dynamicFilters[f.column || f.key] || [],
|
|
512
616
|
onFilterChange: handleDynamicFilterChange,
|
|
513
617
|
loading: f.searchEndpoint ? !filterOptionsMap.has(f.searchEndpoint) : false,
|
|
514
618
|
searchEndpoint: f.searchEndpoint,
|
|
515
|
-
loadOptions
|
|
619
|
+
loadOptions: loadOptions
|
|
620
|
+
? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
|
|
621
|
+
: undefined,
|
|
516
622
|
});
|
|
517
623
|
}
|
|
518
624
|
for (const c of metadata.columns ?? []) {
|
|
@@ -569,21 +675,64 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
569
675
|
if (loader) {
|
|
570
676
|
filterType = 'facet';
|
|
571
677
|
loadOptions = loader;
|
|
678
|
+
// Prewarmed values (from prefetchFacets) → instant open.
|
|
679
|
+
options = facetOptions.get(c.key) ?? [];
|
|
572
680
|
}
|
|
573
681
|
}
|
|
574
682
|
map.set(c.key, {
|
|
575
683
|
filterType,
|
|
576
684
|
filterKey: c.key,
|
|
577
|
-
options,
|
|
685
|
+
options: translateOptionLabels(options, tr),
|
|
578
686
|
selectedValues: dynamicFilters[c.key] || [],
|
|
579
687
|
onFilterChange: handleDynamicFilterChange,
|
|
580
688
|
loading: hasEndpoint && !filterOptionsMap.has(c.searchEndpoint),
|
|
581
689
|
searchEndpoint: c.searchEndpoint,
|
|
582
|
-
loadOptions
|
|
690
|
+
loadOptions: loadOptions
|
|
691
|
+
? (q) => loadOptions(q).then((o) => translateOptionLabels(o, tr))
|
|
692
|
+
: undefined,
|
|
583
693
|
});
|
|
584
694
|
}
|
|
585
695
|
return map;
|
|
586
|
-
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader]);
|
|
696
|
+
}, [metadata, filterOptionsMap, dynamicFilters, handleDynamicFilterChange, facetsBase, getFacetLoader, facetOptions, t]);
|
|
697
|
+
// Prewarm every facet field once the configs settle, so a text column's
|
|
698
|
+
// header filter opens instantly with values + counts (same as the kanban).
|
|
699
|
+
const facetFieldsSig = useMemo(() => {
|
|
700
|
+
const keys = [];
|
|
701
|
+
for (const config of columnFilterConfigs.values()) {
|
|
702
|
+
if (config.filterType === 'facet')
|
|
703
|
+
keys.push(config.filterKey);
|
|
704
|
+
}
|
|
705
|
+
return keys.join('|');
|
|
706
|
+
}, [columnFilterConfigs]);
|
|
707
|
+
useEffect(() => {
|
|
708
|
+
if (!facetFieldsSig)
|
|
709
|
+
return;
|
|
710
|
+
prefetchFacets(facetFieldsSig.split('|'));
|
|
711
|
+
}, [facetFieldsSig, prefetchFacets]);
|
|
712
|
+
// Active-filter chips (shared row with the kanban). One chip per field with a
|
|
713
|
+
// selection; the label is the translated filter/column label.
|
|
714
|
+
const activeFilterChips = useMemo(() => {
|
|
715
|
+
if (!metadata)
|
|
716
|
+
return [];
|
|
717
|
+
const out = [];
|
|
718
|
+
for (const [key, config] of columnFilterConfigs) {
|
|
719
|
+
if ((config.selectedValues?.length ?? 0) === 0)
|
|
720
|
+
continue;
|
|
721
|
+
const f = metadata.filters?.find((x) => x.key === key);
|
|
722
|
+
const c = metadata.columns?.find((x) => x.key === key);
|
|
723
|
+
const rawLabel = f?.label || c?.label || key;
|
|
724
|
+
out.push({
|
|
725
|
+
key,
|
|
726
|
+
label: t(rawLabel, { defaultValue: rawLabel }),
|
|
727
|
+
config,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
return out;
|
|
731
|
+
}, [metadata, columnFilterConfigs, t]);
|
|
732
|
+
const clearAllDynamicFilters = useCallback(() => {
|
|
733
|
+
setDynamicFilters({});
|
|
734
|
+
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
|
735
|
+
}, []);
|
|
587
736
|
const columns = useMemo(() => {
|
|
588
737
|
if (!viewMetadata)
|
|
589
738
|
return [];
|
|
@@ -629,31 +778,35 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
629
778
|
if (!metadata) {
|
|
630
779
|
return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
|
|
631
780
|
}
|
|
632
|
-
return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
781
|
+
return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsxs("div", { ref: infDesktopRoot, className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: [_jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
|
|
782
|
+
const isActionsColumn = header.id === 'actions';
|
|
783
|
+
return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
|
|
784
|
+
}) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {
|
|
785
|
+
const isActionsColumn = cell.column.id === 'actions';
|
|
786
|
+
const isSelectColumn = cell.column.id === 'select';
|
|
787
|
+
return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), onClick: (isActionsColumn || isSelectColumn) ? (e) => e.stopPropagation() : undefined, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
|
|
788
|
+
}) }, row.id))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsxs("div", { className: "flex flex-col items-center gap-1", children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
|
|
789
|
+
const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
|
|
790
|
+
const isFirst = idx === 0;
|
|
791
|
+
const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold';
|
|
792
|
+
// Aggregate cell: render the SUM formatted like the body cell.
|
|
793
|
+
if (col && aggregateOf(col)) {
|
|
794
|
+
return (_jsx(TableCell, { className: `${stickyBase} text-right tabular-nums`, children: formatAggregateTotal(col, footerTotals[leaf.id], currency, i18n.language) }, leaf.id));
|
|
795
|
+
}
|
|
796
|
+
// First non-aggregate column carries the "Total" label.
|
|
797
|
+
return (_jsx(TableCell, { className: stickyBase, children: isFirst ? t('common.total', 'Total') : '' }, leaf.id));
|
|
798
|
+
}) }) }))] }), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx("div", { className: 'p-2', children: _jsx(Skeleton, { className: 'h-8 w-full', "data-testid": 'table-loading-more' }) })), _jsx("div", { ref: infDesktopSentinel, className: 'h-1 w-full', "aria-hidden": true })] }))] }), _jsxs("div", { ref: infMobileRoot, className: 'flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden', children: [loadingData && data.length === 0 ? (Array.from({ length: 5 }).map((_, i) => (_jsxs("div", { className: 'rounded-lg border bg-card p-3', children: [_jsx(Skeleton, { className: 'h-4 w-24' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-40' }), _jsx(Skeleton, { className: 'mt-2 h-4 w-32' })] }, i)))) : table.getRowModel().rows?.length ? (table.getRowModel().rows.map((row) => {
|
|
799
|
+
const cells = row.getVisibleCells();
|
|
800
|
+
const actionsCell = cells.find((c) => c.column.id === 'actions');
|
|
801
|
+
const dataCells = cells.filter((c) => c.column.id !== 'actions' && c.column.id !== 'select');
|
|
802
|
+
return (_jsxs("div", { "data-state": row.getIsSelected() && 'selected', className: cn('flex flex-col gap-1.5 rounded-lg border bg-card p-3 data-[state=selected]:border-primary/40', onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: [dataCells.map((cell) => {
|
|
803
|
+
const header = cell.column.columnDef.header;
|
|
804
|
+
const label = typeof header === 'string' ? header : cell.column.id;
|
|
805
|
+
return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 break-words text-right font-medium', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
|
|
806
|
+
}), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
|
|
807
|
+
})) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), _jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
|
|
808
|
+
defaultValue: '{{count}} de {{total}}',
|
|
809
|
+
count: data.length,
|
|
810
|
+
total: rowCount,
|
|
811
|
+
}) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), viewMetadata?.canExport && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), viewMetadata?.canImport && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }));
|
|
659
812
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable summary of a field's selected filter values for a chip / row.
|
|
3
|
+
* Resolves option labels, unwraps the wire operators
|
|
4
|
+
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
5
|
+
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
6
|
+
*/
|
|
7
|
+
export declare function summarizeFilterValues(values: string[] | undefined, options: {
|
|
8
|
+
label: string;
|
|
9
|
+
value: string;
|
|
10
|
+
}[] | undefined, maxShown?: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
13
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
14
|
+
* operator/range/free-text values that carry no option color.
|
|
15
|
+
*/
|
|
16
|
+
export declare function chipValueColor(config: {
|
|
17
|
+
selectedValues: string[];
|
|
18
|
+
options: {
|
|
19
|
+
value: string;
|
|
20
|
+
color?: string;
|
|
21
|
+
}[];
|
|
22
|
+
}): string | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
25
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
26
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
27
|
+
*/
|
|
28
|
+
export declare function translateOptionLabels<T extends {
|
|
29
|
+
label: string;
|
|
30
|
+
}>(options: T[], translate: (key: string) => string): T[];
|
|
31
|
+
export interface FilterChipField {
|
|
32
|
+
key: string;
|
|
33
|
+
label: string;
|
|
34
|
+
config: {
|
|
35
|
+
selectedValues: string[];
|
|
36
|
+
options: {
|
|
37
|
+
label: string;
|
|
38
|
+
value: string;
|
|
39
|
+
color?: string;
|
|
40
|
+
}[];
|
|
41
|
+
filterKey: string;
|
|
42
|
+
onFilterChange: (filterKey: string, values: string[]) => void;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface FilterChipsRowProps {
|
|
46
|
+
/** Fields with an active selection (one chip each). */
|
|
47
|
+
fields: FilterChipField[];
|
|
48
|
+
/** Clears every filter (the trailing "Limpiar todo"). */
|
|
49
|
+
onClearAll: () => void;
|
|
50
|
+
className?: string;
|
|
51
|
+
'data-testid'?: string;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
55
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
56
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
57
|
+
* field is active.
|
|
58
|
+
*/
|
|
59
|
+
export declare function FilterChipsRow({ fields, onClearAll, className, 'data-testid': testId, }: FilterChipsRowProps): import("react").JSX.Element | null;
|
|
60
|
+
//# sourceMappingURL=filter-chips.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filter-chips.d.ts","sourceRoot":"","sources":["../src/filter-chips.tsx"],"names":[],"mappings":"AASA;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,MAAM,EAAE,MAAM,EAAE,GAAG,SAAS,EAC5B,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,SAAS,EACvD,QAAQ,SAAI,GACb,MAAM,CAsBR;AAYD;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE;IACnC,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,OAAO,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC/C,GAAG,MAAM,GAAG,SAAS,CAUrB;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,EAC7D,OAAO,EAAE,CAAC,EAAE,EACZ,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GACnC,CAAC,EAAE,CAEL;AAED,MAAM,WAAW,eAAe;IAC5B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,EAAE;QACJ,cAAc,EAAE,MAAM,EAAE,CAAA;QACxB,OAAO,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,EAAE,MAAM,CAAC;YAAC,KAAK,CAAC,EAAE,MAAM,CAAA;SAAE,EAAE,CAAA;QAC3D,SAAS,EAAE,MAAM,CAAA;QACjB,cAAc,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,IAAI,CAAA;KAChE,CAAA;CACJ;AAED,MAAM,WAAW,mBAAmB;IAChC,uDAAuD;IACvD,MAAM,EAAE,eAAe,EAAE,CAAA;IACzB,yDAAyD;IACzD,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,aAAa,CAAC,EAAE,MAAM,CAAA;CACzB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAC3B,MAAM,EACN,UAAU,EACV,SAAS,EACT,aAAa,EAAE,MAAM,GACxB,EAAE,mBAAmB,sCA0DrB"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Shared filter presentation: the removable active-filter chip row + the pure
|
|
3
|
+
// helpers that summarize a filter's value and resolve its color. Used by BOTH
|
|
4
|
+
// DynamicKanban and DynamicTable so the two surfaces render identical chips
|
|
5
|
+
// (one component, no drift).
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { X } from 'lucide-react';
|
|
8
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives';
|
|
9
|
+
import { resolveColorCss } from '@asteby/metacore-ui/lib';
|
|
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(values, options, maxShown = 2) {
|
|
17
|
+
if (!values || values.length === 0)
|
|
18
|
+
return '';
|
|
19
|
+
const opts = options ?? [];
|
|
20
|
+
const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
|
|
21
|
+
const first = values[0];
|
|
22
|
+
if (values.length === 1) {
|
|
23
|
+
if (first.startsWith('ILIKE:'))
|
|
24
|
+
return `"${first.slice(6)}"`;
|
|
25
|
+
if (first.startsWith('IN:')) {
|
|
26
|
+
return summarizeList(first.slice(3).split(','), labelFor, maxShown);
|
|
27
|
+
}
|
|
28
|
+
if (first.startsWith('RANGE:')) {
|
|
29
|
+
const [min, max] = first.slice(6).split(',');
|
|
30
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
31
|
+
}
|
|
32
|
+
if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
33
|
+
return first.replace('_', ' – ');
|
|
34
|
+
}
|
|
35
|
+
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
36
|
+
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
|
|
37
|
+
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
38
|
+
return `${min || '…'} – ${max || '…'}`;
|
|
39
|
+
}
|
|
40
|
+
return summarizeList(values, labelFor, maxShown);
|
|
41
|
+
}
|
|
42
|
+
function summarizeList(items, labelFor, maxShown) {
|
|
43
|
+
const labels = items.map(labelFor);
|
|
44
|
+
if (labels.length <= maxShown)
|
|
45
|
+
return labels.join(', ');
|
|
46
|
+
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
50
|
+
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
51
|
+
* operator/range/free-text values that carry no option color.
|
|
52
|
+
*/
|
|
53
|
+
export function chipValueColor(config) {
|
|
54
|
+
const sel = config.selectedValues;
|
|
55
|
+
if (!sel || sel.length === 0)
|
|
56
|
+
return undefined;
|
|
57
|
+
const first = sel[0];
|
|
58
|
+
let value = first;
|
|
59
|
+
if (first.startsWith('IN:'))
|
|
60
|
+
value = first.slice(3).split(',')[0];
|
|
61
|
+
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
|
|
62
|
+
return undefined;
|
|
63
|
+
else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
64
|
+
return undefined;
|
|
65
|
+
const opt = config.options.find((o) => o.value === value);
|
|
66
|
+
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Translates option labels through the app translator (manifest i18n keys →
|
|
70
|
+
* localized text). A raw value with no matching key falls through to itself via
|
|
71
|
+
* `defaultValue`. Pure — exported for unit tests.
|
|
72
|
+
*/
|
|
73
|
+
export function translateOptionLabels(options, translate) {
|
|
74
|
+
return options.map((o) => ({ ...o, label: translate(o.label) }));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The removable active-filter chip row shown under a board/table toolbar. Each
|
|
78
|
+
* chip: an optional value-color dot, "Campo: valor(es)" (capped + `+n`), and an
|
|
79
|
+
* X that clears that field; a trailing "Limpiar todo". Renders nothing when no
|
|
80
|
+
* field is active.
|
|
81
|
+
*/
|
|
82
|
+
export function FilterChipsRow({ fields, onClearAll, className, 'data-testid': testId, }) {
|
|
83
|
+
const { t } = useTranslation();
|
|
84
|
+
if (fields.length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return (_jsxs("div", { className: `flex flex-wrap items-center gap-1.5${className ? ` ${className}` : ''}`, "data-testid": testId, children: [fields.map((field) => {
|
|
87
|
+
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
88
|
+
const dot = chipValueColor(field.config);
|
|
89
|
+
return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('filters.removeFilter', {
|
|
90
|
+
defaultValue: 'Quitar filtro',
|
|
91
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
92
|
+
}), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: onClearAll, children: t('filters.clearAll', { defaultValue: 'Limpiar todo' }) })] }));
|
|
93
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns a NEW array = `existing` followed by every row in `incoming` whose
|
|
3
|
+
* `id` is not already present. Stable on identity/order of `existing`. Pure.
|
|
4
|
+
*/
|
|
5
|
+
export declare function dedupeById<T extends {
|
|
6
|
+
id?: any;
|
|
7
|
+
}>(existing: T[], incoming: T[]): T[];
|
|
8
|
+
export interface UseInfiniteScrollOptions {
|
|
9
|
+
/** Fired when the sentinel scrolls into view and loading is enabled. */
|
|
10
|
+
onLoadMore: () => void;
|
|
11
|
+
/** When true, the observer is inert (no more pages, or a load in flight). */
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
/** Pixels of pre-fetch margin below the viewport. Default 200. */
|
|
14
|
+
rootMargin?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface InfiniteScrollRefs<R extends HTMLElement = HTMLDivElement, S extends HTMLElement = HTMLDivElement> {
|
|
17
|
+
rootRef: React.RefObject<R | null>;
|
|
18
|
+
sentinelRef: React.RefObject<S | null>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
|
|
22
|
+
* scrollable container and `sentinelRef` to a small element at its bottom. The
|
|
23
|
+
* latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
|
|
24
|
+
* torn down and rebuilt on every render.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useInfiniteScrollSentinel<R extends HTMLElement = HTMLDivElement, S extends HTMLElement = HTMLDivElement>({ onLoadMore, disabled, rootMargin, }: UseInfiniteScrollOptions): InfiniteScrollRefs<R, S>;
|
|
27
|
+
//# sourceMappingURL=use-infinite-scroll.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-infinite-scroll.d.ts","sourceRoot":"","sources":["../src/use-infinite-scroll.ts"],"names":[],"mappings":"AAWA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS;IAAE,EAAE,CAAC,EAAE,GAAG,CAAA;CAAE,EAC/C,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,CAAC,EAAE,GACZ,CAAC,EAAE,CAWL;AAED,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,kBAAkB,CACjC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc;IAEtC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IAClC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,EACA,UAAU,EACV,QAAgB,EAChB,UAAgB,GACjB,EAAE,wBAAwB,GAAG,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CAgCrD"}
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
* Returns a NEW array = `existing` followed by every row in `incoming` whose
|
|
13
|
+
* `id` is not already present. Stable on identity/order of `existing`. Pure.
|
|
14
|
+
*/
|
|
15
|
+
export function dedupeById(existing, incoming) {
|
|
16
|
+
if (incoming.length === 0)
|
|
17
|
+
return existing;
|
|
18
|
+
const seen = new Set(existing.map((r) => String(r?.id)));
|
|
19
|
+
const additions = [];
|
|
20
|
+
for (const row of incoming) {
|
|
21
|
+
const key = String(row?.id);
|
|
22
|
+
if (seen.has(key))
|
|
23
|
+
continue;
|
|
24
|
+
seen.add(key);
|
|
25
|
+
additions.push(row);
|
|
26
|
+
}
|
|
27
|
+
return additions.length === 0 ? existing : [...existing, ...additions];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
|
|
31
|
+
* scrollable container and `sentinelRef` to a small element at its bottom. The
|
|
32
|
+
* latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
|
|
33
|
+
* torn down and rebuilt on every render.
|
|
34
|
+
*/
|
|
35
|
+
export function useInfiniteScrollSentinel({ onLoadMore, disabled = false, rootMargin = 200, }) {
|
|
36
|
+
const rootRef = useRef(null);
|
|
37
|
+
const sentinelRef = useRef(null);
|
|
38
|
+
const cb = useRef(onLoadMore);
|
|
39
|
+
const disabledRef = useRef(disabled);
|
|
40
|
+
cb.current = onLoadMore;
|
|
41
|
+
disabledRef.current = disabled;
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const sentinel = sentinelRef.current;
|
|
44
|
+
if (!sentinel ||
|
|
45
|
+
typeof IntersectionObserver === 'undefined') {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const observer = new IntersectionObserver((entries) => {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
if (entry.isIntersecting && !disabledRef.current) {
|
|
51
|
+
cb.current();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, { root: rootRef.current ?? null, rootMargin: `0px 0px ${rootMargin}px 0px` });
|
|
55
|
+
observer.observe(sentinel);
|
|
56
|
+
return () => observer.disconnect();
|
|
57
|
+
// Re-create only when the margin changes; onLoadMore/disabled are read live.
|
|
58
|
+
}, [rootMargin]);
|
|
59
|
+
return { rootRef, sentinelRef };
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.2.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.9.
|
|
41
|
+
"@asteby/metacore-ui": "^2.9.1"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -67,8 +67,8 @@
|
|
|
67
67
|
"typescript": "^6.0.0",
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
|
-
"@asteby/metacore-
|
|
71
|
-
"@asteby/metacore-
|
|
70
|
+
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
+
"@asteby/metacore-ui": "2.9.1"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|