@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.
- package/CHANGELOG.md +12 -0
- package/dist/dynamic-columns.d.ts +11 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +18 -12
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +8 -0
- package/dist/dynamic-icon.d.ts +1 -0
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +29 -11
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +6 -1
- package/dist/dynamic-table.d.ts +19 -6
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +76 -4
- package/dist/icon-picker-field.d.ts +10 -0
- package/dist/icon-picker-field.d.ts.map +1 -0
- package/dist/icon-picker-field.js +43 -0
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-table-pages.test.tsx +160 -0
- package/src/__tests__/icon-picker-field.test.tsx +67 -0
- package/src/__tests__/image-cell-lucide.test.tsx +52 -0
- package/src/dynamic-columns.tsx +33 -23
- package/src/dynamic-form.tsx +8 -0
- package/src/dynamic-icon.tsx +27 -9
- package/src/dynamic-select-field.tsx +10 -1
- package/src/dynamic-table.tsx +91 -9
- package/src/icon-picker-field.tsx +134 -0
- package/src/types.ts +1 -0
|
@@ -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
|
+
})
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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
|
-
|
|
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: {
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -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
|
package/src/dynamic-icon.tsx
CHANGED
|
@@ -9,19 +9,37 @@ export interface DynamicIconProps {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export function DynamicIcon({ name, className }: DynamicIconProps) {
|
|
12
|
-
const
|
|
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
|
|
19
|
-
// from an image path/URL: addons declare
|
|
20
|
-
// as OptionDef.icon), so a column
|
|
21
|
-
// strings (slash, dot, scheme)
|
|
22
|
-
//
|
|
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
|
-
|
|
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}
|
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
|