@asteby/metacore-runtime-react 28.0.3 → 28.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.
@@ -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
+ })
@@ -0,0 +1,67 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // IconPickerField — the `icon` form widget: lucide search grid (icon mode) and
4
+ // delegation to UploadField (image mode). UploadField is stubbed so the image
5
+ // mode asserts delegation without needing an ApiProvider.
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { cleanup, render, screen, fireEvent } from '@testing-library/react'
8
+
9
+ vi.mock('../upload-field', () => ({
10
+ UploadField: ({ field, value }: any) => (
11
+ <div data-testid="upload-field" data-field-key={field.key} data-value={String(value ?? '')} />
12
+ ),
13
+ }))
14
+
15
+ import { IconPickerField, looksLikeImageValue } from '../icon-picker-field'
16
+ import type { ActionFieldDef } from '../types'
17
+
18
+ afterEach(cleanup)
19
+
20
+ const field: ActionFieldDef = { key: 'icon', label: 'Ícono', type: 'text', widget: 'icon' }
21
+
22
+ describe('IconPickerField', () => {
23
+ it('busca "credit", muestra CreditCard y al click emite onChange con el nombre', () => {
24
+ const onChange = vi.fn()
25
+ render(<IconPickerField field={field} value="" onChange={onChange} />)
26
+ fireEvent.change(screen.getByLabelText('Buscar ícono'), { target: { value: 'credit' } })
27
+ const option = screen.getByRole('option', { name: 'CreditCard' })
28
+ fireEvent.click(option)
29
+ expect(onChange).toHaveBeenCalledWith('CreditCard')
30
+ })
31
+
32
+ it('resalta el seleccionado y muestra el preview grande', () => {
33
+ render(<IconPickerField field={field} value="CreditCard" onChange={() => {}} />)
34
+ expect(screen.getByTestId('icon-picker-preview')).toBeTruthy()
35
+ expect(
36
+ screen.getByRole('option', { name: 'CreditCard' }).getAttribute('aria-selected'),
37
+ ).toBe('true')
38
+ })
39
+
40
+ it('limita el grid a 48 resultados', () => {
41
+ render(<IconPickerField field={field} value="" onChange={() => {}} />)
42
+ expect(screen.getAllByRole('option').length).toBeLessThanOrEqual(48)
43
+ })
44
+
45
+ it('modo imagen delega a UploadField con los mismos props', () => {
46
+ render(<IconPickerField field={field} value="" onChange={() => {}} />)
47
+ fireEvent.click(screen.getByRole('tab', { name: 'Imagen' }))
48
+ const stub = screen.getByTestId('upload-field')
49
+ expect(stub.getAttribute('data-field-key')).toBe('icon')
50
+ })
51
+
52
+ it('arranca en modo Imagen cuando el valor parece path/URL', () => {
53
+ render(<IconPickerField field={field} value="/uploads/logo.png" onChange={() => {}} />)
54
+ expect(screen.getByTestId('upload-field').getAttribute('data-value')).toBe('/uploads/logo.png')
55
+ })
56
+ })
57
+
58
+ describe('looksLikeImageValue', () => {
59
+ it('distingue paths/URLs de nombres lucide', () => {
60
+ expect(looksLikeImageValue('/uploads/a.png')).toBe(true)
61
+ expect(looksLikeImageValue('logo.png')).toBe(true)
62
+ expect(looksLikeImageValue('https://x/y')).toBe(true)
63
+ expect(looksLikeImageValue('CreditCard')).toBe(false)
64
+ expect(looksLikeImageValue('credit-card')).toBe(false)
65
+ expect(looksLikeImageValue('')).toBe(false)
66
+ })
67
+ })
@@ -0,0 +1,52 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // ImageCell — an `image` cell whose value is a lucide icon name (PascalCase or
4
+ // kebab, as stored by the `icon` form widget) renders the glyph instead of a
5
+ // broken <img>; real paths/urls keep rendering an <img>.
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+ import { cleanup, render } from '@testing-library/react'
8
+ import { ImageCell } from '../dynamic-columns'
9
+ import { isLucideIconName, resolveLucideIconName } from '../dynamic-icon'
10
+
11
+ afterEach(cleanup)
12
+
13
+ const getImageUrl = (p: string) => `/img${p}`
14
+
15
+ describe('ImageCell con nombre lucide', () => {
16
+ it('renderiza el ícono (svg) para un nombre PascalCase, sin <img>', () => {
17
+ const { container } = render(<ImageCell value="CreditCard" getImageUrl={getImageUrl} />)
18
+ expect(container.querySelector('svg')).toBeTruthy()
19
+ expect(container.querySelector('img')).toBeNull()
20
+ })
21
+
22
+ it('renderiza el ícono también para el slug kebab "credit-card"', () => {
23
+ const { container } = render(<ImageCell value="credit-card" getImageUrl={getImageUrl} />)
24
+ expect(container.querySelector('svg')).toBeTruthy()
25
+ expect(container.querySelector('img')).toBeNull()
26
+ })
27
+
28
+ it('un path real sigue renderizando <img> con getImageUrl aplicado', () => {
29
+ const { container } = render(<ImageCell value="/uploads/x.png" getImageUrl={getImageUrl} />)
30
+ const img = container.querySelector('img')
31
+ expect(img?.getAttribute('src')).toBe('/img/uploads/x.png')
32
+ })
33
+
34
+ it('valor vacío cae al guion', () => {
35
+ const { container } = render(<ImageCell value="" getImageUrl={getImageUrl} />)
36
+ expect(container.textContent).toBe('-')
37
+ })
38
+ })
39
+
40
+ describe('resolveLucideIconName / isLucideIconName', () => {
41
+ it('normaliza kebab a PascalCase', () => {
42
+ expect(resolveLucideIconName('credit-card')).toBe('CreditCard')
43
+ expect(resolveLucideIconName('CreditCard')).toBe('CreditCard')
44
+ })
45
+ it('rechaza paths, vacíos y el base "Icon"', () => {
46
+ expect(resolveLucideIconName('/a/b.png')).toBeNull()
47
+ expect(resolveLucideIconName('logo.png')).toBeNull()
48
+ expect(resolveLucideIconName('')).toBeNull()
49
+ expect(resolveLucideIconName('Icon')).toBeNull()
50
+ expect(isLucideIconName('no-such-icon-xyz')).toBe(false)
51
+ })
52
+ })
@@ -664,6 +664,38 @@ const AvatarCell: React.FC<{
664
664
  * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
665
665
  * URL resolution.
666
666
  */
667
+ /**
668
+ * `image`-type cell body. A value that is a lucide icon name (PascalCase or
669
+ * kebab slug, e.g. "Banknote" / "credit-card") — the convention the `icon`
670
+ * form widget stores — renders the glyph instead of an <img> that would 404
671
+ * into an empty grey box. Exported for tests.
672
+ */
673
+ export const ImageCell: React.FC<{
674
+ value: unknown
675
+ getImageUrl: (path: string) => string
676
+ }> = ({ value, getImageUrl }) => {
677
+ if (!value) return <span className="text-muted-foreground">-</span>
678
+ if (isLucideIconName(value)) {
679
+ return (
680
+ <div className="h-10 w-10 flex items-center justify-center rounded bg-muted">
681
+ <DynamicIcon name={value} className="h-5 w-5" />
682
+ </div>
683
+ )
684
+ }
685
+ return (
686
+ <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
687
+ <img
688
+ src={getImageUrl(String(value))}
689
+ alt="Thumbnail"
690
+ className="h-full w-full object-contain"
691
+ onError={(e) => {
692
+ ;(e.currentTarget as HTMLImageElement).style.display = 'none'
693
+ }}
694
+ />
695
+ </div>
696
+ )
697
+ }
698
+
667
699
  export function makeDefaultGetDynamicColumns(
668
700
  helpers: DynamicColumnsHelpers = {},
669
701
  ): GetDynamicColumns {
@@ -1155,29 +1187,7 @@ export function makeDefaultGetDynamicColumns(
1155
1187
  (Array.isArray(row.original.media)
1156
1188
  ? row.original.media.find((m: any) => m.type === 'image')?.url
1157
1189
  : null)
1158
- if (!imageValue) return <span className="text-muted-foreground">-</span>
1159
- // Lucide icon name, not an image path (e.g. an addon's
1160
- // `icon` column seeded as "Banknote") — render the glyph;
1161
- // an <img> here would 404 into an empty grey box.
1162
- if (isLucideIconName(imageValue)) {
1163
- return (
1164
- <div className="h-10 w-10 flex items-center justify-center rounded bg-muted">
1165
- <DynamicIcon name={imageValue} className="h-5 w-5" />
1166
- </div>
1167
- )
1168
- }
1169
- return (
1170
- <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
1171
- <img
1172
- src={getImageUrl(String(imageValue))}
1173
- alt="Thumbnail"
1174
- className="h-full w-full object-contain"
1175
- onError={(e) => {
1176
- ;(e.currentTarget as HTMLImageElement).style.display = 'none'
1177
- }}
1178
- />
1179
- </div>
1180
- )
1190
+ return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1181
1191
  }
1182
1192
 
1183
1193
  default: {
@@ -28,12 +28,14 @@ import { DynamicLineItems } from './dynamic-line-items'
28
28
  import { DynamicSelectField } from './dynamic-select-field'
29
29
  import { DynamicDateField } from './dynamic-date-field'
30
30
  import { UploadField } from './upload-field'
31
+ import { IconPickerField } from './icon-picker-field'
31
32
 
32
33
  export { buildZodSchema, resolveWidget }
33
34
  export { DynamicLineItems } from './dynamic-line-items'
34
35
  export { DynamicSelectField } from './dynamic-select-field'
35
36
  export { DynamicDateField } from './dynamic-date-field'
36
37
  export { UploadField } from './upload-field'
38
+ export { IconPickerField } from './icon-picker-field'
37
39
 
38
40
  export interface DynamicFormProps {
39
41
  fields: ActionFieldDef[]
@@ -264,6 +266,12 @@ function FieldRenderer({
264
266
  if (widget === 'upload') {
265
267
  return <UploadField field={field} value={value} onChange={onChange} />
266
268
  }
269
+ // Icon picker → lucide glyph search grid, with an "Imagen" mode that
270
+ // delegates to the same UploadField as `upload`. Stores a plain string:
271
+ // lucide name or uploaded url/path.
272
+ if (widget === 'icon') {
273
+ return <IconPickerField field={field} value={value} onChange={onChange} />
274
+ }
267
275
  // Ref-driven select: hook into useOptionsResolver so the canonical
268
276
  // /api/options/<ref>?field=id endpoint feeds the dropdown. This is
269
277
  // the path the kernel auto-derives for FK columns; legacy callers
@@ -9,19 +9,37 @@ export interface DynamicIconProps {
9
9
  }
10
10
 
11
11
  export function DynamicIcon({ name, className }: DynamicIconProps) {
12
- const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[name]
12
+ const resolved = resolveLucideIconName(name) ?? name
13
+ const Icon = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[resolved]
13
14
  if (!Icon) return null
14
15
  return <Icon className={className} />
15
16
  }
16
17
 
18
+ // resolveLucideIconName — canonical PascalCase lucide name for a value that is
19
+ // either already PascalCase ("CreditCard") or the kebab slug lucide documents
20
+ // ("credit-card"). Returns null for anything that is not a real glyph: empty,
21
+ // path-like strings (slash, dot, scheme), or the generic "Icon" base export.
22
+ export function resolveLucideIconName(value: unknown): string | null {
23
+ if (typeof value !== 'string' || value === '' || value === 'Icon') return null
24
+ if (/[/\\.:\s]/.test(value)) return null
25
+ let name = value
26
+ if (/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) {
27
+ name = value
28
+ .split('-')
29
+ .map((p) => p.charAt(0).toUpperCase() + p.slice(1))
30
+ .join('')
31
+ }
32
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(name)) return null
33
+ return (icons as unknown as Record<string, unknown>)[name] ? name : null
34
+ }
35
+
17
36
  // isLucideIconName — true when a string is a lucide-react icon name
18
- // ("Banknote", "CreditCard"). Lets image-ish renderers tell an icon name apart
19
- // from an image path/URL: addons declare icons by lucide slug (same convention
20
- // as OptionDef.icon), so a column inferred as `image` may carry one. Path-like
21
- // strings (slash, dot, scheme) are rejected before the registry lookup; "Icon"
22
- // itself is the generic base component, not a real glyph.
37
+ // ("Banknote", "CreditCard", or the kebab slug "credit-card"). Lets image-ish
38
+ // renderers tell an icon name apart from an image path/URL: addons declare
39
+ // icons by lucide slug (same convention as OptionDef.icon), so a column
40
+ // inferred as `image` may carry one. Path-like strings (slash, dot, scheme)
41
+ // are rejected before the registry lookup; "Icon" itself is the generic base
42
+ // component, not a real glyph.
23
43
  export function isLucideIconName(value: unknown): value is string {
24
- if (typeof value !== 'string' || value === '' || value === 'Icon') return false
25
- if (!/^[A-Z][A-Za-z0-9]*$/.test(value)) return false
26
- return Boolean((icons as unknown as Record<string, unknown>)[value])
44
+ return resolveLucideIconName(value) !== null
27
45
  }
@@ -37,7 +37,7 @@ import {
37
37
  } from '@asteby/metacore-ui/primitives'
38
38
  import { Check, ChevronsUpDown, Loader2, Plus } from 'lucide-react'
39
39
  import { resolveColorCss } from '@asteby/metacore-ui/lib'
40
- import { DynamicIcon } from './dynamic-icon'
40
+ import { DynamicIcon, isLucideIconName } from './dynamic-icon'
41
41
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
42
42
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
43
43
  import type { ActionFieldDef } from './types'
@@ -71,6 +71,15 @@ export function OptionThumb({
71
71
  if (!image) {
72
72
  return <InitialsAvatar name={name} size={size} rounded="sm" tone="neutral" />
73
73
  }
74
+ // A lucide icon name stored where an image url/path is expected (the `icon`
75
+ // form widget's icon mode) → render the glyph instead of a broken <img>.
76
+ if (isLucideIconName(image)) {
77
+ return (
78
+ <span className="flex shrink-0 items-center justify-center rounded-sm bg-muted" style={box} aria-hidden>
79
+ <DynamicIcon name={image} className="size-4" />
80
+ </span>
81
+ )
82
+ }
74
83
  return (
75
84
  <img
76
85
  src={image}
@@ -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