@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
@@ -37,13 +37,26 @@ export interface DynamicTableProps {
37
37
  */
38
38
  currency?: string;
39
39
  /**
40
- * Opt into infinite scroll: instead of the classic pager, rows accumulate as
41
- * the user scrolls (a sentinel at the bottom fetches + appends the next
42
- * page, deduped by id, respecting the active filters/search). Changing any
43
- * filter/sort/search resets to page 1. Default false — existing hosts keep
44
- * the classic pagination untouched.
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;AAiEnF,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;;;;;;OAMG;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,cAAsB,GACzB,EAAE,iBAAiB,+BA2rCnB"}
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"}
@@ -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
- export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
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
- const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
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",
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-sdk": "3.4.0",
71
- "@asteby/metacore-ui": "2.12.1"
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
+ })
@@ -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
- * Opt into infinite scroll: instead of the classic pager, rows accumulate as
170
- * the user scrolls (a sentinel at the bottom fetches + appends the next
171
- * page, deduped by id, respecting the active filters/search). Changing any
172
- * filter/sort/search resets to page 1. Default false — existing hosts keep
173
- * the classic pagination untouched.
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
- infiniteScroll = false,
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
- const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
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