@asteby/metacore-runtime-react 23.1.0 → 23.3.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 +56 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +146 -31
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +2 -53
- package/dist/dynamic-kanban.d.ts +19 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +114 -8
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +132 -32
- 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 +3 -3
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +217 -40
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +6 -102
- package/src/dynamic-kanban.tsx +168 -8
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +149 -10
- package/src/use-infinite-scroll.ts +94 -0
package/dist/dynamic-table.js
CHANGED
|
@@ -26,13 +26,14 @@ 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
28
|
import { FilterChipsRow, translateOptionLabels } from './filter-chips';
|
|
29
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
|
|
29
30
|
import { OptionsContext } from './options-context';
|
|
30
31
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
31
32
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context';
|
|
32
33
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
33
34
|
import { ExportDialog } from './dialogs/export';
|
|
34
35
|
import { ImportDialog } from './dialogs/import';
|
|
35
|
-
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, }) {
|
|
36
37
|
const { t, i18n } = useTranslation();
|
|
37
38
|
const api = useApi();
|
|
38
39
|
const currentBranch = useCurrentBranch();
|
|
@@ -46,6 +47,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
46
47
|
const [footerTotals, setFooterTotals] = useState({});
|
|
47
48
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
48
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);
|
|
49
54
|
const [optionsMap, setOptionsMap] = useState(new Map());
|
|
50
55
|
const [exportOpen, setExportOpen] = useState(false);
|
|
51
56
|
const [importOpen, setImportOpen] = useState(false);
|
|
@@ -392,10 +397,68 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
392
397
|
console.error('Error al cargar los totales', error);
|
|
393
398
|
}
|
|
394
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 });
|
|
395
454
|
const initialFetchDone = useRef(false);
|
|
396
455
|
useEffect(() => {
|
|
397
456
|
if (!metadata)
|
|
398
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;
|
|
399
462
|
if (!initialFetchDone.current) {
|
|
400
463
|
initialFetchDone.current = true;
|
|
401
464
|
fetchData();
|
|
@@ -407,8 +470,41 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
407
470
|
fetchAggregates();
|
|
408
471
|
}, 300);
|
|
409
472
|
return () => clearTimeout(timeoutId);
|
|
410
|
-
}, [fetchData, fetchAggregates, metadata]);
|
|
411
|
-
|
|
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]);
|
|
412
508
|
// Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
|
|
413
509
|
// in the shared hook so DynamicKanban's card menu behaves identically.
|
|
414
510
|
const { handleInternalAction, dialogs: rowActionDialogs } = useDynamicRowActions({
|
|
@@ -449,9 +545,9 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
449
545
|
setBulkDeleteTotal(0);
|
|
450
546
|
setRowSelection({});
|
|
451
547
|
if (successCount > 0)
|
|
452
|
-
toast.success(
|
|
548
|
+
toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' }));
|
|
453
549
|
if (errorCount > 0)
|
|
454
|
-
toast.error(
|
|
550
|
+
toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' }));
|
|
455
551
|
handleRefresh();
|
|
456
552
|
};
|
|
457
553
|
const handleDynamicFilterChange = useCallback((filterKey, values) => {
|
|
@@ -682,31 +778,35 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
682
778
|
if (!metadata) {
|
|
683
779
|
return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
|
|
684
780
|
}
|
|
685
|
-
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' }) }))] }),
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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"] }) })] }));
|
|
712
812
|
}
|
|
@@ -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.3.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": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.9.
|
|
71
|
+
"@asteby/metacore-ui": "2.9.1"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicKanban incremental (per-lane) loading:
|
|
4
|
+
// 1. applyLaneTotalsOnMove — the pure count adjustment that keeps a partial
|
|
5
|
+
// lane's `count/total` header truthful across an optimistic drag.
|
|
6
|
+
// 2. A lane tops up its OWN stage on scroll: firing the sentinel issues
|
|
7
|
+
// `f_<group_by>=<stage>&page=1&per_page=25` (on top of the active filters)
|
|
8
|
+
// and appends the rows, with the header total taken from the response meta.
|
|
9
|
+
// 3. Changing the filters re-fetches the initial board page (resets the
|
|
10
|
+
// per-lane pagination).
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
13
|
+
|
|
14
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
15
|
+
useNavigate: () => () => {},
|
|
16
|
+
}))
|
|
17
|
+
const I18N = { t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k, i18n: { language: 'es' } }
|
|
18
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
19
|
+
|
|
20
|
+
import { applyLaneTotalsOnMove, DynamicKanban } from '../dynamic-kanban'
|
|
21
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
22
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
23
|
+
import type { TableMetadata } from '../types'
|
|
24
|
+
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// 1. Pure count adjustment
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
describe('applyLaneTotalsOnMove', () => {
|
|
32
|
+
it('decrements the source total and increments the destination total', () => {
|
|
33
|
+
const out = applyLaneTotalsOnMove(
|
|
34
|
+
{ backlog: { total: 5 }, done: { total: 2 } },
|
|
35
|
+
'backlog',
|
|
36
|
+
'done',
|
|
37
|
+
)
|
|
38
|
+
expect(out.backlog.total).toBe(4)
|
|
39
|
+
expect(out.done.total).toBe(3)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('leaves lanes with an unknown (null) total alone', () => {
|
|
43
|
+
const out = applyLaneTotalsOnMove(
|
|
44
|
+
{ backlog: { total: null }, done: { total: 2 } },
|
|
45
|
+
'backlog',
|
|
46
|
+
'done',
|
|
47
|
+
)
|
|
48
|
+
expect(out.backlog.total).toBeNull()
|
|
49
|
+
expect(out.done.total).toBe(3)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('never drives a total below zero', () => {
|
|
53
|
+
const out = applyLaneTotalsOnMove(
|
|
54
|
+
{ backlog: { total: 0 }, done: { total: 1 } },
|
|
55
|
+
'backlog',
|
|
56
|
+
'done',
|
|
57
|
+
)
|
|
58
|
+
expect(out.backlog.total).toBe(0)
|
|
59
|
+
expect(out.done.total).toBe(2)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not mutate the input map', () => {
|
|
63
|
+
const input = { backlog: { total: 5 }, done: { total: 2 } }
|
|
64
|
+
applyLaneTotalsOnMove(input, 'backlog', 'done')
|
|
65
|
+
expect(input.backlog.total).toBe(5)
|
|
66
|
+
expect(input.done.total).toBe(2)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// 2 + 3. Per-lane top-up + reset
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
const STAGES = [
|
|
75
|
+
{ key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
|
|
76
|
+
{ key: 'done', label: 'Done', color: 'green', order: 1 },
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
function meta(over: Partial<TableMetadata> = {}): TableMetadata {
|
|
80
|
+
return {
|
|
81
|
+
title: 'Issues',
|
|
82
|
+
endpoint: '/data/issue',
|
|
83
|
+
view_type: 'kanban',
|
|
84
|
+
group_by: 'stage',
|
|
85
|
+
stages: STAGES,
|
|
86
|
+
columns: [
|
|
87
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
88
|
+
{ key: 'stage', label: 'Stage', type: 'status', sortable: false, filterable: true, options: STAGES.map((s) => ({ value: s.key, label: s.label })) },
|
|
89
|
+
],
|
|
90
|
+
actions: [],
|
|
91
|
+
perPageOptions: [50],
|
|
92
|
+
defaultPerPage: 50,
|
|
93
|
+
searchPlaceholder: 'Buscar...',
|
|
94
|
+
enableCRUDActions: false,
|
|
95
|
+
hasActions: false,
|
|
96
|
+
...over,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Initial global page: one backlog card. A backlog top-up (f_stage=backlog)
|
|
101
|
+
// returns two more backlog cards with a server total of 5.
|
|
102
|
+
function fakeApi(): ApiClient {
|
|
103
|
+
const ok = (data: unknown, meta?: Record<string, unknown>) => ({
|
|
104
|
+
data: { success: true, data, ...(meta ? { meta } : {}) },
|
|
105
|
+
})
|
|
106
|
+
return {
|
|
107
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
108
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
109
|
+
const stage = cfg?.params?.f_stage
|
|
110
|
+
if (stage === 'backlog') {
|
|
111
|
+
return ok(
|
|
112
|
+
[
|
|
113
|
+
{ id: 2, title: 'Backlog Two', stage: 'backlog' },
|
|
114
|
+
{ id: 3, title: 'Backlog Three', stage: 'backlog' },
|
|
115
|
+
],
|
|
116
|
+
{ total: 5 },
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
if (stage === 'done') return ok([], { total: 0 })
|
|
120
|
+
// initial global board page
|
|
121
|
+
return ok([{ id: 1, title: 'Backlog One', stage: 'backlog' }])
|
|
122
|
+
}),
|
|
123
|
+
post: vi.fn(async () => ok(null)),
|
|
124
|
+
put: vi.fn(async () => ok(null)),
|
|
125
|
+
delete: vi.fn(async () => ok(null)),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stageCalls(api: ApiClient, stage: string) {
|
|
130
|
+
return (api.get as any).mock.calls.filter(
|
|
131
|
+
(c: any[]) => c[1]?.params?.f_stage === stage,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
function globalDataCalls(api: ApiClient) {
|
|
135
|
+
return (api.get as any).mock.calls.filter(
|
|
136
|
+
(c: any[]) =>
|
|
137
|
+
!String(c[0]).startsWith('/metadata/table/') &&
|
|
138
|
+
c[1]?.params?.f_stage === undefined,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Controllable IntersectionObserver.
|
|
143
|
+
type IOEntry = { isIntersecting: boolean }
|
|
144
|
+
let observers: Array<{ fire: (v: boolean) => void }> = []
|
|
145
|
+
class FakeIO {
|
|
146
|
+
cb: (e: IOEntry[]) => void
|
|
147
|
+
constructor(cb: (e: IOEntry[]) => void) {
|
|
148
|
+
this.cb = cb
|
|
149
|
+
observers.push({ fire: (v: boolean) => this.cb([{ isIntersecting: v }]) })
|
|
150
|
+
}
|
|
151
|
+
observe() {}
|
|
152
|
+
disconnect() {}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe('DynamicKanban per-lane infinite scroll', () => {
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
observers = []
|
|
158
|
+
;(globalThis as any).IntersectionObserver = FakeIO
|
|
159
|
+
})
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
delete (globalThis as any).IntersectionObserver
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('tops up ONE lane by stage on scroll and shows the server total', async () => {
|
|
165
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
166
|
+
const api = fakeApi()
|
|
167
|
+
render(
|
|
168
|
+
<ApiProvider client={api}>
|
|
169
|
+
<DynamicKanban model="issue" />
|
|
170
|
+
</ApiProvider>,
|
|
171
|
+
)
|
|
172
|
+
// Initial board page painted its single backlog card.
|
|
173
|
+
expect(await screen.findByText('Backlog One')).toBeTruthy()
|
|
174
|
+
expect(stageCalls(api, 'backlog')).toHaveLength(0)
|
|
175
|
+
|
|
176
|
+
// Sentinels enter view → each declared lane tops up its OWN stage.
|
|
177
|
+
expect(observers.length).toBeGreaterThan(0)
|
|
178
|
+
observers.forEach((o) => o.fire(true))
|
|
179
|
+
|
|
180
|
+
// The backlog top-up ran with the right scoped params...
|
|
181
|
+
await waitFor(() => expect(stageCalls(api, 'backlog').length).toBeGreaterThan(0))
|
|
182
|
+
const call = stageCalls(api, 'backlog')[0]
|
|
183
|
+
expect(call[1].params).toMatchObject({ f_stage: 'backlog', page: 1, per_page: 25 })
|
|
184
|
+
|
|
185
|
+
// ...its rows were appended and the header shows count/serverTotal (3/5).
|
|
186
|
+
expect(await screen.findByText('Backlog Two')).toBeTruthy()
|
|
187
|
+
expect(await screen.findByText('3/5')).toBeTruthy()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('re-fetches the initial board page when the filters change (reset)', async () => {
|
|
191
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
192
|
+
const api = fakeApi()
|
|
193
|
+
const { rerender } = render(
|
|
194
|
+
<ApiProvider client={api}>
|
|
195
|
+
<DynamicKanban model="issue" />
|
|
196
|
+
</ApiProvider>,
|
|
197
|
+
)
|
|
198
|
+
await screen.findByText('Backlog One')
|
|
199
|
+
const before = globalDataCalls(api).length
|
|
200
|
+
|
|
201
|
+
rerender(
|
|
202
|
+
<ApiProvider client={api}>
|
|
203
|
+
<DynamicKanban model="issue" defaultFilters={{ priority: 'high' }} />
|
|
204
|
+
</ApiProvider>,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
// A fresh (un-scoped) board fetch fires — the per-lane pagination resets.
|
|
208
|
+
await waitFor(() => expect(globalDataCalls(api).length).toBeGreaterThan(before))
|
|
209
|
+
})
|
|
210
|
+
})
|