@asteby/metacore-runtime-react 28.0.3 → 28.1.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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 28.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 833cffb: DynamicTable: nuevo prop `pagination?: 'pages' | 'infinite'` con default `'pages'` (paginación clásica). El modo por defecto renderiza el footer `DataTablePagination` (selector de filas por página, "página X de Y", botones primera/anterior/siguiente/última) y cada cambio de página hace un fetch que REEMPLAZA las filas visibles (page/per_page contra el servidor, pageCount derivado de `meta.total`). Cambiar filtros/búsqueda/orden resetea a la página 1, y el tamaño de página elegido se persiste por tabla en localStorage (clave por `model`). El scroll infinito pasa a ser opt-in explícito con `pagination="infinite"`; el prop booleano `infiniteScroll` queda deprecado pero sigue funcionando cuando no se pasa `pagination`, así que los hosts existentes no cambian de comportamiento.
|
|
8
|
+
|
|
3
9
|
## 28.0.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -37,13 +37,26 @@ export interface DynamicTableProps {
|
|
|
37
37
|
*/
|
|
38
38
|
currency?: string;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* page,
|
|
43
|
-
*
|
|
44
|
-
* the
|
|
40
|
+
* Pagination mode.
|
|
41
|
+
* - 'pages' (default): classic pager footer (DataTablePagination — rows
|
|
42
|
+
* per page selector, "página X de Y", first/prev/next/last). Each page
|
|
43
|
+
* change fetches and REPLACES the visible rows (page/per_page against
|
|
44
|
+
* the same server params the infinite mode uses; pageCount derives
|
|
45
|
+
* from meta.total). The chosen page size persists per table in
|
|
46
|
+
* localStorage (keyed by model). Changing any filter/sort/search
|
|
47
|
+
* resets to page 1.
|
|
48
|
+
* - 'infinite': rows accumulate as the user scrolls (a sentinel at the
|
|
49
|
+
* bottom fetches + appends the next page, deduped by id, respecting
|
|
50
|
+
* the active filters/search). Changing any filter/sort/search resets
|
|
51
|
+
* to page 1.
|
|
52
|
+
*/
|
|
53
|
+
pagination?: 'pages' | 'infinite';
|
|
54
|
+
/**
|
|
55
|
+
* @deprecated Use `pagination="infinite"`. Kept for back-compat: when the
|
|
56
|
+
* new `pagination` prop is not provided, `infiniteScroll` still selects the
|
|
57
|
+
* mode exactly as before.
|
|
45
58
|
*/
|
|
46
59
|
infiniteScroll?: boolean;
|
|
47
60
|
}
|
|
48
|
-
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, infiniteScroll, }: DynamicTableProps): import("react").JSX.Element;
|
|
61
|
+
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, pagination: paginationMode, infiniteScroll: infiniteScrollProp, }: DynamicTableProps): import("react").JSX.Element;
|
|
49
62
|
//# sourceMappingURL=dynamic-table.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AA2FnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,kBAA0B,GAC7C,EAAE,iBAAiB,+BAquCnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -75,7 +75,38 @@ function writeTableDataCache(key, rows, rowCount) {
|
|
|
75
75
|
// quota / private mode — the cache is a nicety, never fatal
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// Chosen page size, persisted per table so a user's preferred density survives
|
|
79
|
+
// reloads. localStorage ON PURPOSE (unlike the row-data cache above): a page
|
|
80
|
+
// size is a UI preference, not org/user-scoped data, so outliving the tab is
|
|
81
|
+
// desired. Keyed by model.
|
|
82
|
+
const TBL_PAGE_SIZE_PREFIX = 'mc:tbl:pageSize:v1';
|
|
83
|
+
function readStoredPageSize(model) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = localStorage.getItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`);
|
|
86
|
+
if (!raw)
|
|
87
|
+
return null;
|
|
88
|
+
const n = parseInt(raw, 10);
|
|
89
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function writeStoredPageSize(model, size) {
|
|
96
|
+
try {
|
|
97
|
+
if (size === null)
|
|
98
|
+
localStorage.removeItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`);
|
|
99
|
+
else
|
|
100
|
+
localStorage.setItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`, String(size));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// quota / private mode — persistence is a nicety, never fatal
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, }) {
|
|
107
|
+
// The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
|
|
108
|
+
// still selects the mode when `pagination` is absent (back-compat).
|
|
109
|
+
const infiniteScroll = paginationMode ? paginationMode === 'infinite' : infiniteScrollProp;
|
|
79
110
|
const { t, i18n } = useTranslation();
|
|
80
111
|
const api = useApi();
|
|
81
112
|
const currentBranch = useCurrentBranch();
|
|
@@ -122,7 +153,17 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
122
153
|
return initial;
|
|
123
154
|
});
|
|
124
155
|
const [columnFilters, setColumnFilters] = useState([]);
|
|
125
|
-
|
|
156
|
+
// The user's persisted page-size preference for this table (pages mode).
|
|
157
|
+
// Read once at mount; a URL `per_page` still wins over it (deep-links stay
|
|
158
|
+
// exact), and it wins over the model's server default.
|
|
159
|
+
const storedPageSizeRef = useRef(undefined);
|
|
160
|
+
if (storedPageSizeRef.current === undefined) {
|
|
161
|
+
storedPageSizeRef.current = readStoredPageSize(model);
|
|
162
|
+
}
|
|
163
|
+
const [pagination, setPagination] = useState({
|
|
164
|
+
pageIndex: 0,
|
|
165
|
+
pageSize: storedPageSizeRef.current ?? 10,
|
|
166
|
+
});
|
|
126
167
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
127
168
|
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0);
|
|
128
169
|
const [dateRange, setDateRange] = useState(undefined);
|
|
@@ -397,7 +438,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
397
438
|
if (cached) {
|
|
398
439
|
setMetadata(cached);
|
|
399
440
|
defaultPerPage.current = cached.defaultPerPage || 10;
|
|
400
|
-
if (!urlHadPerPage.current)
|
|
441
|
+
if (!urlHadPerPage.current && storedPageSizeRef.current == null)
|
|
401
442
|
setPagination((prev) => ({ ...prev, pageSize: cached.defaultPerPage || 10 }));
|
|
402
443
|
setLoading(false);
|
|
403
444
|
}
|
|
@@ -413,7 +454,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
413
454
|
setMetadata(fresh);
|
|
414
455
|
cacheMetadata(model, fresh);
|
|
415
456
|
defaultPerPage.current = fresh.defaultPerPage || 10;
|
|
416
|
-
if (!urlHadPerPage.current)
|
|
457
|
+
if (!urlHadPerPage.current && storedPageSizeRef.current == null)
|
|
417
458
|
setPagination((prev) => ({ ...prev, pageSize: fresh.defaultPerPage || 10 }));
|
|
418
459
|
}
|
|
419
460
|
}
|
|
@@ -630,6 +671,37 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
630
671
|
// Signature of everything that must reset the incremental list to page 1:
|
|
631
672
|
// the filters/search AND the sort (both live in buildFilterParams).
|
|
632
673
|
const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
|
|
674
|
+
// Pages mode: any filter/search/sort change snaps back to page 1 (same
|
|
675
|
+
// contract as the infinite reset above — a filtered set has its own page
|
|
676
|
+
// space). Ref-compared so paging itself never triggers it, and armed only
|
|
677
|
+
// AFTER the URL adoption has settled so a deep-linked `?page=3&sortBy=...`
|
|
678
|
+
// is not immediately reset by its own sort arriving.
|
|
679
|
+
const pagesSigArmed = useRef(false);
|
|
680
|
+
const pagesPrevSig = useRef(null);
|
|
681
|
+
useEffect(() => {
|
|
682
|
+
if (infiniteScroll)
|
|
683
|
+
return;
|
|
684
|
+
if (enableUrlSync && !urlSynced)
|
|
685
|
+
return;
|
|
686
|
+
if (!pagesSigArmed.current) {
|
|
687
|
+
pagesSigArmed.current = true;
|
|
688
|
+
pagesPrevSig.current = filterSignature;
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (pagesPrevSig.current === filterSignature)
|
|
692
|
+
return;
|
|
693
|
+
pagesPrevSig.current = filterSignature;
|
|
694
|
+
setPagination((p) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }));
|
|
695
|
+
}, [infiniteScroll, enableUrlSync, urlSynced, filterSignature]);
|
|
696
|
+
// Persist the chosen page size per table (localStorage, keyed by model).
|
|
697
|
+
// The server default is stored as an explicit removal so a later backend
|
|
698
|
+
// change of `defaultPerPage` still propagates to users who never deviated.
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
if (!metadata)
|
|
701
|
+
return;
|
|
702
|
+
const chosen = pagination.pageSize;
|
|
703
|
+
writeStoredPageSize(model, chosen === defaultPerPage.current ? null : chosen);
|
|
704
|
+
}, [metadata, model, pagination.pageSize]);
|
|
633
705
|
const loadNextPage = useCallback(() => {
|
|
634
706
|
if (loadingMore || loadingData || infExhausted)
|
|
635
707
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "28.0
|
|
3
|
+
"version": "28.1.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -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-ui": "2.12.1",
|
|
71
|
+
"@asteby/metacore-sdk": "3.4.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicTable classic pagination ('pages', the default mode):
|
|
4
|
+
// 1. The pager footer renders; clicking page 2 issues a NEW query with
|
|
5
|
+
// page=2 and REPLACES the visible rows (no accumulation).
|
|
6
|
+
// 2. Changing the filters resets back to page 1.
|
|
7
|
+
// 3. The chosen page size persists per table in localStorage and is adopted
|
|
8
|
+
// on the next mount (over the model's server default).
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
11
|
+
|
|
12
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
13
|
+
useNavigate: () => () => {},
|
|
14
|
+
}))
|
|
15
|
+
const I18N = {
|
|
16
|
+
t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
|
|
17
|
+
if (o?.defaultValue && o.count != null && o.total != null) {
|
|
18
|
+
return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
|
|
19
|
+
}
|
|
20
|
+
return o?.defaultValue ?? _k
|
|
21
|
+
},
|
|
22
|
+
i18n: { language: 'es' },
|
|
23
|
+
}
|
|
24
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
|
|
25
|
+
|
|
26
|
+
import { DynamicTable } from '../dynamic-table'
|
|
27
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
28
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
29
|
+
import type { TableMetadata } from '../types'
|
|
30
|
+
|
|
31
|
+
afterEach(cleanup)
|
|
32
|
+
|
|
33
|
+
function meta(): TableMetadata {
|
|
34
|
+
return {
|
|
35
|
+
title: 'Issues',
|
|
36
|
+
endpoint: '/data/issue',
|
|
37
|
+
group_by: 'stage',
|
|
38
|
+
columns: [
|
|
39
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
40
|
+
],
|
|
41
|
+
actions: [],
|
|
42
|
+
perPageOptions: [10, 20, 50],
|
|
43
|
+
defaultPerPage: 10,
|
|
44
|
+
searchPlaceholder: 'Buscar...',
|
|
45
|
+
enableCRUDActions: false,
|
|
46
|
+
hasActions: false,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fakeApi(total: number): ApiClient {
|
|
51
|
+
const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
|
|
52
|
+
const page = (n: number, size: number) => {
|
|
53
|
+
const start = (n - 1) * size
|
|
54
|
+
const rows: Array<{ id: number; title: string }> = []
|
|
55
|
+
for (let i = start; i < Math.min(start + size, total); i++) {
|
|
56
|
+
rows.push({ id: i + 1, title: `Issue ${i + 1}` })
|
|
57
|
+
}
|
|
58
|
+
return rows
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
get: vi.fn(async (url: string, cfg?: any) => {
|
|
62
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
63
|
+
if (url.endsWith('/facets')) return ok([])
|
|
64
|
+
const p = cfg?.params?.page ?? 1
|
|
65
|
+
const size = cfg?.params?.per_page ?? 10
|
|
66
|
+
return ok(page(p, size))
|
|
67
|
+
}),
|
|
68
|
+
post: vi.fn(async () => ok(null)),
|
|
69
|
+
put: vi.fn(async () => ok(null)),
|
|
70
|
+
delete: vi.fn(async () => ok(null)),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dataCalls(api: ApiClient) {
|
|
75
|
+
return (api.get as any).mock.calls.filter((c: any[]) => c[0] === '/data/issue')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('DynamicTable classic pages mode (default)', () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
localStorage.clear()
|
|
81
|
+
sessionStorage.clear()
|
|
82
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('changes page: new query with page=2 and REPLACES the rows', async () => {
|
|
86
|
+
const api = fakeApi(45)
|
|
87
|
+
render(
|
|
88
|
+
<ApiProvider client={api}>
|
|
89
|
+
<DynamicTable model="issue" pagination="pages" enableUrlSync={false} />
|
|
90
|
+
</ApiProvider>,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Page 1 loads with the model's default size.
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
const first = dataCalls(api)[0]
|
|
96
|
+
expect(first?.[1]?.params).toMatchObject({ page: 1, per_page: 10 })
|
|
97
|
+
})
|
|
98
|
+
await waitFor(() => expect(screen.getAllByText('Issue 1').length).toBeGreaterThan(0))
|
|
99
|
+
|
|
100
|
+
// Click the pager's page "2" button.
|
|
101
|
+
const btn2 = screen.getAllByRole('button').find((b) => b.textContent?.trim().endsWith('2'))
|
|
102
|
+
expect(btn2).toBeTruthy()
|
|
103
|
+
fireEvent.click(btn2!)
|
|
104
|
+
|
|
105
|
+
await waitFor(() => {
|
|
106
|
+
const pages = dataCalls(api).map((c: any[]) => c[1]?.params?.page)
|
|
107
|
+
expect(pages).toContain(2)
|
|
108
|
+
})
|
|
109
|
+
// Rows were REPLACED, not accumulated: page-2 rows in, page-1 rows out.
|
|
110
|
+
await waitFor(() => expect(screen.getAllByText('Issue 11').length).toBeGreaterThan(0))
|
|
111
|
+
expect(screen.queryByText('Issue 1')).toBeNull()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('resets to page 1 when the filters change', async () => {
|
|
115
|
+
const api = fakeApi(45)
|
|
116
|
+
const { rerender } = render(
|
|
117
|
+
<ApiProvider client={api}>
|
|
118
|
+
<DynamicTable model="issue" pagination="pages" enableUrlSync={false} />
|
|
119
|
+
</ApiProvider>,
|
|
120
|
+
)
|
|
121
|
+
await waitFor(() => expect(screen.getAllByText('Issue 1').length).toBeGreaterThan(0))
|
|
122
|
+
const btn2 = screen.getAllByRole('button').find((b) => b.textContent?.trim().endsWith('2'))
|
|
123
|
+
fireEvent.click(btn2!)
|
|
124
|
+
await waitFor(() => expect(screen.getAllByText('Issue 11').length).toBeGreaterThan(0))
|
|
125
|
+
|
|
126
|
+
// Change the active filters via defaultFilters (part of buildFilterParams).
|
|
127
|
+
rerender(
|
|
128
|
+
<ApiProvider client={api}>
|
|
129
|
+
<DynamicTable
|
|
130
|
+
model="issue"
|
|
131
|
+
pagination="pages"
|
|
132
|
+
enableUrlSync={false}
|
|
133
|
+
defaultFilters={{ stage: 'done' }}
|
|
134
|
+
/>
|
|
135
|
+
</ApiProvider>,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
// The filtered request goes back to page 1.
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
const withFilter = dataCalls(api).filter((c: any[]) => c[1]?.params?.f_stage === 'done')
|
|
141
|
+
expect(withFilter.length).toBeGreaterThan(0)
|
|
142
|
+
expect(withFilter.every((c: any[]) => c[1]?.params?.page === 1)).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
await waitFor(() => expect(screen.getAllByText('Issue 1').length).toBeGreaterThan(0))
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('adopts the per-table page size persisted in localStorage', async () => {
|
|
148
|
+
localStorage.setItem('mc:tbl:pageSize:v1|issue', '20')
|
|
149
|
+
const api = fakeApi(45)
|
|
150
|
+
render(
|
|
151
|
+
<ApiProvider client={api}>
|
|
152
|
+
<DynamicTable model="issue" enableUrlSync={false} />
|
|
153
|
+
</ApiProvider>,
|
|
154
|
+
)
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
const first = dataCalls(api)[0]
|
|
157
|
+
expect(first?.[1]?.params).toMatchObject({ page: 1, per_page: 20 })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
})
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -129,6 +129,32 @@ function writeTableDataCache(key: string, rows: any[], rowCount: number): void {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Chosen page size, persisted per table so a user's preferred density survives
|
|
133
|
+
// reloads. localStorage ON PURPOSE (unlike the row-data cache above): a page
|
|
134
|
+
// size is a UI preference, not org/user-scoped data, so outliving the tab is
|
|
135
|
+
// desired. Keyed by model.
|
|
136
|
+
const TBL_PAGE_SIZE_PREFIX = 'mc:tbl:pageSize:v1'
|
|
137
|
+
|
|
138
|
+
function readStoredPageSize(model: string): number | null {
|
|
139
|
+
try {
|
|
140
|
+
const raw = localStorage.getItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`)
|
|
141
|
+
if (!raw) return null
|
|
142
|
+
const n = parseInt(raw, 10)
|
|
143
|
+
return Number.isFinite(n) && n > 0 ? n : null
|
|
144
|
+
} catch {
|
|
145
|
+
return null
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function writeStoredPageSize(model: string, size: number | null): void {
|
|
150
|
+
try {
|
|
151
|
+
if (size === null) localStorage.removeItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`)
|
|
152
|
+
else localStorage.setItem(`${TBL_PAGE_SIZE_PREFIX}|${model}`, String(size))
|
|
153
|
+
} catch {
|
|
154
|
+
// quota / private mode — persistence is a nicety, never fatal
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
132
158
|
export interface DynamicTableProps {
|
|
133
159
|
model: string
|
|
134
160
|
endpoint?: string
|
|
@@ -166,11 +192,24 @@ export interface DynamicTableProps {
|
|
|
166
192
|
*/
|
|
167
193
|
currency?: string
|
|
168
194
|
/**
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* page,
|
|
172
|
-
*
|
|
173
|
-
* the
|
|
195
|
+
* Pagination mode.
|
|
196
|
+
* - 'pages' (default): classic pager footer (DataTablePagination — rows
|
|
197
|
+
* per page selector, "página X de Y", first/prev/next/last). Each page
|
|
198
|
+
* change fetches and REPLACES the visible rows (page/per_page against
|
|
199
|
+
* the same server params the infinite mode uses; pageCount derives
|
|
200
|
+
* from meta.total). The chosen page size persists per table in
|
|
201
|
+
* localStorage (keyed by model). Changing any filter/sort/search
|
|
202
|
+
* resets to page 1.
|
|
203
|
+
* - 'infinite': rows accumulate as the user scrolls (a sentinel at the
|
|
204
|
+
* bottom fetches + appends the next page, deduped by id, respecting
|
|
205
|
+
* the active filters/search). Changing any filter/sort/search resets
|
|
206
|
+
* to page 1.
|
|
207
|
+
*/
|
|
208
|
+
pagination?: 'pages' | 'infinite'
|
|
209
|
+
/**
|
|
210
|
+
* @deprecated Use `pagination="infinite"`. Kept for back-compat: when the
|
|
211
|
+
* new `pagination` prop is not provided, `infiniteScroll` still selects the
|
|
212
|
+
* mode exactly as before.
|
|
174
213
|
*/
|
|
175
214
|
infiniteScroll?: boolean
|
|
176
215
|
}
|
|
@@ -188,8 +227,12 @@ export function DynamicTable({
|
|
|
188
227
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
189
228
|
timeZone,
|
|
190
229
|
currency,
|
|
191
|
-
|
|
230
|
+
pagination: paginationMode,
|
|
231
|
+
infiniteScroll: infiniteScrollProp = false,
|
|
192
232
|
}: DynamicTableProps) {
|
|
233
|
+
// The explicit `pagination` prop wins; the legacy `infiniteScroll` boolean
|
|
234
|
+
// still selects the mode when `pagination` is absent (back-compat).
|
|
235
|
+
const infiniteScroll = paginationMode ? paginationMode === 'infinite' : infiniteScrollProp
|
|
193
236
|
const { t, i18n } = useTranslation()
|
|
194
237
|
const api = useApi()
|
|
195
238
|
const currentBranch = useCurrentBranch()
|
|
@@ -247,7 +290,17 @@ export function DynamicTable({
|
|
|
247
290
|
return initial
|
|
248
291
|
})
|
|
249
292
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
250
|
-
|
|
293
|
+
// The user's persisted page-size preference for this table (pages mode).
|
|
294
|
+
// Read once at mount; a URL `per_page` still wins over it (deep-links stay
|
|
295
|
+
// exact), and it wins over the model's server default.
|
|
296
|
+
const storedPageSizeRef = useRef<number | null | undefined>(undefined)
|
|
297
|
+
if (storedPageSizeRef.current === undefined) {
|
|
298
|
+
storedPageSizeRef.current = readStoredPageSize(model)
|
|
299
|
+
}
|
|
300
|
+
const [pagination, setPagination] = useState<PaginationState>({
|
|
301
|
+
pageIndex: 0,
|
|
302
|
+
pageSize: storedPageSizeRef.current ?? 10,
|
|
303
|
+
})
|
|
251
304
|
const [globalFilter, setGlobalFilter] = useState('')
|
|
252
305
|
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
|
|
253
306
|
|
|
@@ -507,7 +560,7 @@ export function DynamicTable({
|
|
|
507
560
|
if (cached) {
|
|
508
561
|
setMetadata(cached)
|
|
509
562
|
defaultPerPage.current = cached.defaultPerPage || 10
|
|
510
|
-
if (!urlHadPerPage.current) setPagination((prev: PaginationState) => ({ ...prev, pageSize: cached.defaultPerPage || 10 }))
|
|
563
|
+
if (!urlHadPerPage.current && storedPageSizeRef.current == null) setPagination((prev: PaginationState) => ({ ...prev, pageSize: cached.defaultPerPage || 10 }))
|
|
511
564
|
setLoading(false)
|
|
512
565
|
} else {
|
|
513
566
|
setLoading(true)
|
|
@@ -521,7 +574,7 @@ export function DynamicTable({
|
|
|
521
574
|
setMetadata(fresh)
|
|
522
575
|
cacheMetadata(model, fresh)
|
|
523
576
|
defaultPerPage.current = fresh.defaultPerPage || 10
|
|
524
|
-
if (!urlHadPerPage.current) setPagination((prev: PaginationState) => ({ ...prev, pageSize: fresh.defaultPerPage || 10 }))
|
|
577
|
+
if (!urlHadPerPage.current && storedPageSizeRef.current == null) setPagination((prev: PaginationState) => ({ ...prev, pageSize: fresh.defaultPerPage || 10 }))
|
|
525
578
|
}
|
|
526
579
|
} catch (error) {
|
|
527
580
|
if (!cached) console.error('Error al cargar la configuración de la tabla', error)
|
|
@@ -738,6 +791,35 @@ export function DynamicTable({
|
|
|
738
791
|
[buildFilterParams],
|
|
739
792
|
)
|
|
740
793
|
|
|
794
|
+
// Pages mode: any filter/search/sort change snaps back to page 1 (same
|
|
795
|
+
// contract as the infinite reset above — a filtered set has its own page
|
|
796
|
+
// space). Ref-compared so paging itself never triggers it, and armed only
|
|
797
|
+
// AFTER the URL adoption has settled so a deep-linked `?page=3&sortBy=...`
|
|
798
|
+
// is not immediately reset by its own sort arriving.
|
|
799
|
+
const pagesSigArmed = useRef(false)
|
|
800
|
+
const pagesPrevSig = useRef<string | null>(null)
|
|
801
|
+
useEffect(() => {
|
|
802
|
+
if (infiniteScroll) return
|
|
803
|
+
if (enableUrlSync && !urlSynced) return
|
|
804
|
+
if (!pagesSigArmed.current) {
|
|
805
|
+
pagesSigArmed.current = true
|
|
806
|
+
pagesPrevSig.current = filterSignature
|
|
807
|
+
return
|
|
808
|
+
}
|
|
809
|
+
if (pagesPrevSig.current === filterSignature) return
|
|
810
|
+
pagesPrevSig.current = filterSignature
|
|
811
|
+
setPagination((p: PaginationState) => (p.pageIndex === 0 ? p : { ...p, pageIndex: 0 }))
|
|
812
|
+
}, [infiniteScroll, enableUrlSync, urlSynced, filterSignature])
|
|
813
|
+
|
|
814
|
+
// Persist the chosen page size per table (localStorage, keyed by model).
|
|
815
|
+
// The server default is stored as an explicit removal so a later backend
|
|
816
|
+
// change of `defaultPerPage` still propagates to users who never deviated.
|
|
817
|
+
useEffect(() => {
|
|
818
|
+
if (!metadata) return
|
|
819
|
+
const chosen = pagination.pageSize
|
|
820
|
+
writeStoredPageSize(model, chosen === defaultPerPage.current ? null : chosen)
|
|
821
|
+
}, [metadata, model, pagination.pageSize])
|
|
822
|
+
|
|
741
823
|
const loadNextPage = useCallback(() => {
|
|
742
824
|
if (loadingMore || loadingData || infExhausted) return
|
|
743
825
|
if (data.length >= rowCount) return
|