@asteby/metacore-runtime-react 8.0.0 → 9.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 +73 -0
- package/dist/column-visibility.d.ts +22 -0
- package/dist/column-visibility.d.ts.map +1 -0
- package/dist/column-visibility.js +40 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +4 -1
- package/dist/dynamic-form-schema.d.ts +7 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -0
- package/dist/dynamic-form-schema.js +68 -0
- package/dist/dynamic-form.d.ts +2 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +28 -9
- package/dist/dynamic-relation-helpers.d.ts +77 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -0
- package/dist/dynamic-relation-helpers.js +186 -0
- package/dist/dynamic-relation.d.ts +64 -0
- package/dist/dynamic-relation.d.ts.map +1 -0
- package/dist/dynamic-relation.js +226 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +17 -3
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/relations.md +290 -0
- package/package.json +9 -3
- package/src/__tests__/column-visibility.test.ts +116 -0
- package/src/__tests__/dynamic-form.test.ts +104 -0
- package/src/__tests__/dynamic-relation.test.ts +293 -0
- package/src/column-visibility.ts +43 -0
- package/src/dynamic-columns.tsx +4 -1
- package/src/dynamic-form-schema.ts +66 -0
- package/src/dynamic-form.tsx +34 -9
- package/src/dynamic-relation-helpers.ts +226 -0
- package/src/dynamic-relation.tsx +497 -0
- package/src/dynamic-table.tsx +20 -2
- package/src/index.ts +14 -0
- package/src/types.ts +49 -0
- package/tsconfig.json +2 -1
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildCreatePayload,
|
|
4
|
+
buildPivotAttachPayload,
|
|
5
|
+
buildPivotRowIndex,
|
|
6
|
+
buildRelationFilterParams,
|
|
7
|
+
deriveRelationFormFields,
|
|
8
|
+
diffSelection,
|
|
9
|
+
extractSelectedTargetIds,
|
|
10
|
+
pickOptionLabel,
|
|
11
|
+
relationRowKey,
|
|
12
|
+
} from '../dynamic-relation-helpers'
|
|
13
|
+
import type { ColumnDefinition, TableMetadata } from '../types'
|
|
14
|
+
|
|
15
|
+
describe('buildRelationFilterParams', () => {
|
|
16
|
+
it('produce el filtro f_<fk>=eq:<id> con string parentId', () => {
|
|
17
|
+
expect(buildRelationFilterParams('invoice_id', 'inv_42')).toEqual({
|
|
18
|
+
f_invoice_id: 'eq:inv_42',
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('coerce parentId numérico a string en el query', () => {
|
|
23
|
+
expect(buildRelationFilterParams('invoice_id', 42)).toEqual({
|
|
24
|
+
f_invoice_id: 'eq:42',
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('rechaza foreignKey vacío', () => {
|
|
29
|
+
expect(() => buildRelationFilterParams('', 'x')).toThrow(/foreignKey/)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('rechaza parentId vacío / null / undefined', () => {
|
|
33
|
+
expect(() => buildRelationFilterParams('invoice_id', '')).toThrow(/parentId/)
|
|
34
|
+
// @ts-expect-error testing runtime guard
|
|
35
|
+
expect(() => buildRelationFilterParams('invoice_id', null)).toThrow(/parentId/)
|
|
36
|
+
// @ts-expect-error testing runtime guard
|
|
37
|
+
expect(() => buildRelationFilterParams('invoice_id', undefined)).toThrow(/parentId/)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('buildCreatePayload', () => {
|
|
42
|
+
it('inyecta el foreign key sobre los valores del form', () => {
|
|
43
|
+
const payload = buildCreatePayload('invoice_id', 'inv_42', { qty: 3, sku: 'A' })
|
|
44
|
+
expect(payload).toEqual({ qty: 3, sku: 'A', invoice_id: 'inv_42' })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('el foreign key sobreescribe lo que venga del form', () => {
|
|
48
|
+
const payload = buildCreatePayload('invoice_id', 'inv_42', { invoice_id: 'inv_OTHER', qty: 1 })
|
|
49
|
+
expect(payload.invoice_id).toBe('inv_42')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('preserva el parentId numérico tal cual', () => {
|
|
53
|
+
const payload = buildCreatePayload('invoice_id', 7, { qty: 1 })
|
|
54
|
+
expect(payload.invoice_id).toBe(7)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('rechaza foreignKey vacío', () => {
|
|
58
|
+
expect(() => buildCreatePayload('', 'x', {})).toThrow(/foreignKey/)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('deriveRelationFormFields', () => {
|
|
63
|
+
const baseMeta: Pick<TableMetadata, 'columns'> = {
|
|
64
|
+
columns: [
|
|
65
|
+
{ key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
|
|
66
|
+
{ key: 'invoice_id', label: 'Factura', type: 'text', sortable: false, filterable: false },
|
|
67
|
+
{ key: 'sku', label: 'SKU', type: 'text', sortable: true, filterable: true },
|
|
68
|
+
{ key: 'qty', label: 'Cantidad', type: 'number', sortable: true, filterable: false },
|
|
69
|
+
{ key: 'taxable', label: 'Aplica IVA', type: 'boolean', sortable: false, filterable: false },
|
|
70
|
+
{ key: 'category', label: 'Categoría', type: 'select', sortable: false, filterable: true, options: [
|
|
71
|
+
{ value: 'a', label: 'A' }, { value: 'b', label: 'B' },
|
|
72
|
+
] },
|
|
73
|
+
],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('omite la foreign key porque está fija al parentId', () => {
|
|
77
|
+
const fields = deriveRelationFormFields(baseMeta, 'invoice_id')
|
|
78
|
+
expect(fields.find(f => f.key === 'invoice_id')).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('omite columnas marcadas hidden', () => {
|
|
82
|
+
const fields = deriveRelationFormFields(baseMeta, 'invoice_id')
|
|
83
|
+
expect(fields.find(f => f.key === 'id')).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('mapea types de ColumnDefinition al ActionFieldDef.type', () => {
|
|
87
|
+
const fields = deriveRelationFormFields(baseMeta, 'invoice_id')
|
|
88
|
+
const byKey = Object.fromEntries(fields.map(f => [f.key, f]))
|
|
89
|
+
expect(byKey['sku']?.type).toBe('string')
|
|
90
|
+
expect(byKey['qty']?.type).toBe('number')
|
|
91
|
+
expect(byKey['taxable']?.type).toBe('boolean')
|
|
92
|
+
expect(byKey['category']?.type).toBe('select')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('propaga options con value coerced a string', () => {
|
|
96
|
+
const fields = deriveRelationFormFields(baseMeta, 'invoice_id')
|
|
97
|
+
const cat = fields.find(f => f.key === 'category')
|
|
98
|
+
expect(cat?.options).toEqual([
|
|
99
|
+
{ value: 'a', label: 'A' },
|
|
100
|
+
{ value: 'b', label: 'B' },
|
|
101
|
+
])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('devuelve [] cuando no hay metadata', () => {
|
|
105
|
+
expect(deriveRelationFormFields(null, 'invoice_id')).toEqual([])
|
|
106
|
+
expect(deriveRelationFormFields(undefined, 'invoice_id')).toEqual([])
|
|
107
|
+
expect(deriveRelationFormFields({ columns: [] }, 'invoice_id')).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('relationRowKey', () => {
|
|
112
|
+
it('usa row.id como key cuando existe', () => {
|
|
113
|
+
expect(relationRowKey({ id: 'abc' }, 0, 'invoice_id')).toBe('abc')
|
|
114
|
+
expect(relationRowKey({ id: 7 }, 5, 'invoice_id')).toBe('7')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('cae a synthetic key cuando id falta o es vacío', () => {
|
|
118
|
+
expect(relationRowKey({}, 2, 'invoice_id')).toBe('__rel-invoice_id-2')
|
|
119
|
+
expect(relationRowKey({ id: '' }, 3, 'invoice_id')).toBe('__rel-invoice_id-3')
|
|
120
|
+
expect(relationRowKey({ id: null }, 1, 'invoice_id')).toBe('__rel-invoice_id-1')
|
|
121
|
+
expect(relationRowKey(undefined, 0, 'invoice_id')).toBe('__rel-invoice_id-0')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// many_to_many helpers
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
describe('buildPivotAttachPayload', () => {
|
|
130
|
+
it('produce el body con los dos FKs fijos', () => {
|
|
131
|
+
const body = buildPivotAttachPayload('org_id', 'org_1', 'user_id', 'user_42')
|
|
132
|
+
expect(body).toEqual({ org_id: 'org_1', user_id: 'user_42' })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('mezcla campos extra del pivot sin pisar los FKs', () => {
|
|
136
|
+
const body = buildPivotAttachPayload('org_id', 1, 'user_id', 2, {
|
|
137
|
+
role: 'owner',
|
|
138
|
+
org_id: 'evil',
|
|
139
|
+
user_id: 'evil',
|
|
140
|
+
})
|
|
141
|
+
expect(body.org_id).toBe(1)
|
|
142
|
+
expect(body.user_id).toBe(2)
|
|
143
|
+
expect(body.role).toBe('owner')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('rechaza foreignKey / referencesKey vacíos', () => {
|
|
147
|
+
expect(() => buildPivotAttachPayload('', 'p', 'r', 't')).toThrow(/foreignKey/)
|
|
148
|
+
expect(() => buildPivotAttachPayload('f', 'p', '', 't')).toThrow(/referencesKey/)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rechaza parentId / targetId vacíos', () => {
|
|
152
|
+
expect(() => buildPivotAttachPayload('f', '', 'r', 't')).toThrow(/parentId/)
|
|
153
|
+
expect(() => buildPivotAttachPayload('f', 'p', 'r', '')).toThrow(/targetId/)
|
|
154
|
+
// @ts-expect-error testing runtime guard
|
|
155
|
+
expect(() => buildPivotAttachPayload('f', 'p', 'r', null)).toThrow(/targetId/)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('extractSelectedTargetIds', () => {
|
|
160
|
+
it('mapea pivot rows al set de target ids como strings', () => {
|
|
161
|
+
const ids = extractSelectedTargetIds(
|
|
162
|
+
[
|
|
163
|
+
{ id: 1, org_id: 'org_1', user_id: 'u_1' },
|
|
164
|
+
{ id: 2, org_id: 'org_1', user_id: 7 },
|
|
165
|
+
],
|
|
166
|
+
'user_id',
|
|
167
|
+
)
|
|
168
|
+
expect(ids).toEqual(['u_1', '7'])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('omite filas sin valor en el referencesKey', () => {
|
|
172
|
+
const ids = extractSelectedTargetIds(
|
|
173
|
+
[
|
|
174
|
+
{ id: 1, user_id: 'u_1' },
|
|
175
|
+
{ id: 2, user_id: null },
|
|
176
|
+
{ id: 3, user_id: '' },
|
|
177
|
+
{ id: 4 },
|
|
178
|
+
],
|
|
179
|
+
'user_id',
|
|
180
|
+
)
|
|
181
|
+
expect(ids).toEqual(['u_1'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('devuelve [] cuando no hay rows o referencesKey', () => {
|
|
185
|
+
expect(extractSelectedTargetIds(null, 'user_id')).toEqual([])
|
|
186
|
+
expect(extractSelectedTargetIds(undefined, 'user_id')).toEqual([])
|
|
187
|
+
expect(extractSelectedTargetIds([{ user_id: 'x' }], '')).toEqual([])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('buildPivotRowIndex', () => {
|
|
192
|
+
it('mapea targetId -> pivotRowId', () => {
|
|
193
|
+
const idx = buildPivotRowIndex(
|
|
194
|
+
[
|
|
195
|
+
{ id: 'p1', user_id: 'u_1' },
|
|
196
|
+
{ id: 'p2', user_id: 7 },
|
|
197
|
+
],
|
|
198
|
+
'user_id',
|
|
199
|
+
)
|
|
200
|
+
expect(idx.get('u_1')).toBe('p1')
|
|
201
|
+
expect(idx.get('7')).toBe('p2')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('omite filas sin id pivot o sin target', () => {
|
|
205
|
+
const idx = buildPivotRowIndex(
|
|
206
|
+
[
|
|
207
|
+
{ id: 'p1', user_id: 'u_1' },
|
|
208
|
+
{ user_id: 'u_2' },
|
|
209
|
+
{ id: 'p3' },
|
|
210
|
+
{ id: 'p4', user_id: null },
|
|
211
|
+
],
|
|
212
|
+
'user_id',
|
|
213
|
+
)
|
|
214
|
+
expect(Array.from(idx.keys())).toEqual(['u_1'])
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('última fila gana cuando hay duplicados', () => {
|
|
218
|
+
const idx = buildPivotRowIndex(
|
|
219
|
+
[
|
|
220
|
+
{ id: 'p1', user_id: 'u_1' },
|
|
221
|
+
{ id: 'p2', user_id: 'u_1' },
|
|
222
|
+
],
|
|
223
|
+
'user_id',
|
|
224
|
+
)
|
|
225
|
+
expect(idx.get('u_1')).toBe('p2')
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
describe('diffSelection', () => {
|
|
230
|
+
it('detecta toAdd y toRemove respecto al estado previo', () => {
|
|
231
|
+
const { toAdd, toRemove } = diffSelection(['a', 'b', 'c'], ['b', 'c', 'd'])
|
|
232
|
+
expect(toAdd).toEqual(['d'])
|
|
233
|
+
expect(toRemove).toEqual(['a'])
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('preserva el orden de aparición en next/prev', () => {
|
|
237
|
+
const { toAdd, toRemove } = diffSelection(['a', 'b'], ['c', 'a', 'd'])
|
|
238
|
+
expect(toAdd).toEqual(['c', 'd'])
|
|
239
|
+
expect(toRemove).toEqual(['b'])
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('devuelve arrays vacíos cuando no cambia nada', () => {
|
|
243
|
+
const { toAdd, toRemove } = diffSelection(['a', 'b'], ['b', 'a'])
|
|
244
|
+
expect(toAdd).toEqual([])
|
|
245
|
+
expect(toRemove).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('caso vacío -> next agrega todo', () => {
|
|
249
|
+
const { toAdd, toRemove } = diffSelection([], ['a', 'b'])
|
|
250
|
+
expect(toAdd).toEqual(['a', 'b'])
|
|
251
|
+
expect(toRemove).toEqual([])
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('caso prev -> [] remueve todo', () => {
|
|
255
|
+
const { toAdd, toRemove } = diffSelection(['a', 'b'], [])
|
|
256
|
+
expect(toAdd).toEqual([])
|
|
257
|
+
expect(toRemove).toEqual(['a', 'b'])
|
|
258
|
+
})
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
describe('pickOptionLabel', () => {
|
|
262
|
+
const cols: ColumnDefinition[] = [
|
|
263
|
+
{ key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false, hidden: true },
|
|
264
|
+
{ key: 'name', label: 'Nombre', type: 'text', sortable: true, filterable: true },
|
|
265
|
+
{ key: 'email', label: 'Email', type: 'text', sortable: false, filterable: true },
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
it('respeta displayKey cuando existe en la fila', () => {
|
|
269
|
+
expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, 'email', cols)).toBe('a@x')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('cae al primer column no-id no-hidden cuando displayKey falta', () => {
|
|
273
|
+
expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, undefined, cols)).toBe('Alice')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('salta valores nulos / vacíos al inferir', () => {
|
|
277
|
+
expect(pickOptionLabel({ id: 1, name: '', email: 'a@x' }, undefined, cols)).toBe('a@x')
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('cae a row.id cuando no hay match en columns', () => {
|
|
281
|
+
expect(pickOptionLabel({ id: 7 }, undefined, cols)).toBe('7')
|
|
282
|
+
expect(pickOptionLabel({ id: 7 }, undefined, undefined)).toBe('7')
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('devuelve "—" cuando no hay nada usable', () => {
|
|
286
|
+
expect(pickOptionLabel(null, undefined, cols)).toBe('—')
|
|
287
|
+
expect(pickOptionLabel({}, undefined, cols)).toBe('—')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('ignora valores object al inferir', () => {
|
|
291
|
+
expect(pickOptionLabel({ id: 1, name: { nested: true }, email: 'a@x' }, undefined, cols)).toBe('a@x')
|
|
292
|
+
})
|
|
293
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Pure helpers that map kernel `manifest.ColumnDef` metadata flags
|
|
2
|
+
// (Visibility, Searchable) into client-side decisions:
|
|
3
|
+
// - which columns the dynamic table should render
|
|
4
|
+
// - which column keys are in scope for the global search
|
|
5
|
+
//
|
|
6
|
+
// Kept side-effect free and free of React/UI imports so the same logic can
|
|
7
|
+
// be tested with plain unit tests against mock metadata.
|
|
8
|
+
|
|
9
|
+
import type { ColumnDefinition, TableMetadata } from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a column should render in a list/index table view.
|
|
13
|
+
*
|
|
14
|
+
* A column is hidden when its `visibility` is scoped away from the table
|
|
15
|
+
* (`'modal'`: only the create/edit dialog; `'list'`: only API payloads) or
|
|
16
|
+
* when the legacy `hidden` boolean is set. Empty / `'all'` / `'table'` keep
|
|
17
|
+
* the column visible — preserving zero-value behaviour for metadata emitted
|
|
18
|
+
* by older kernels that don't set `visibility` at all.
|
|
19
|
+
*/
|
|
20
|
+
export function isColumnVisibleInTable(col: ColumnDefinition): boolean {
|
|
21
|
+
if (col.hidden) return false
|
|
22
|
+
const v = col.visibility
|
|
23
|
+
if (!v) return true
|
|
24
|
+
return v === 'all' || v === 'table'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns the keys of columns that opt into the model's full-text search,
|
|
29
|
+
* or `null` when no column declares `searchable` at all.
|
|
30
|
+
*
|
|
31
|
+
* `null` is the legacy signal: the host should NOT narrow the search request
|
|
32
|
+
* (every column participates, matching pre-Searchable kernels). An empty
|
|
33
|
+
* array is meaningful — it means every column has been explicitly opted out
|
|
34
|
+
* and the host should disable the global search input.
|
|
35
|
+
*/
|
|
36
|
+
export function getSearchableColumnKeys(
|
|
37
|
+
metadata: Pick<TableMetadata, 'columns'>,
|
|
38
|
+
): string[] | null {
|
|
39
|
+
const cols = metadata.columns ?? []
|
|
40
|
+
const declared = cols.some(c => typeof c.searchable === 'boolean')
|
|
41
|
+
if (!declared) return null
|
|
42
|
+
return cols.filter(c => c.searchable === true).map(c => c.key)
|
|
43
|
+
}
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -35,6 +35,7 @@ import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib'
|
|
|
35
35
|
import { OptionsContext } from './options-context'
|
|
36
36
|
import { DynamicIcon } from './dynamic-icon'
|
|
37
37
|
import type { TableMetadata, ColumnDefinition } from './types'
|
|
38
|
+
import { isColumnVisibleInTable } from './column-visibility'
|
|
38
39
|
import type {
|
|
39
40
|
ColumnFilterConfig,
|
|
40
41
|
GetDynamicColumns,
|
|
@@ -221,7 +222,9 @@ export function makeDefaultGetDynamicColumns(
|
|
|
221
222
|
]
|
|
222
223
|
|
|
223
224
|
metadata.columns.forEach((col) => {
|
|
224
|
-
|
|
225
|
+
// Honors both the legacy `hidden` boolean and the kernel's
|
|
226
|
+
// `visibility` scope (skips `'modal'` and `'list'`).
|
|
227
|
+
if (!isColumnVisibleInTable(col)) return
|
|
225
228
|
|
|
226
229
|
const translatedLabel = col.label
|
|
227
230
|
const filterConfig = filterConfigs?.get(col.key)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Pure schema-building helpers for DynamicForm. Lives in its own module so
|
|
2
|
+
// callers (and unit tests) can use the zod schema without pulling in React or
|
|
3
|
+
// metacore-ui primitives.
|
|
4
|
+
import { z, type ZodTypeAny } from 'zod'
|
|
5
|
+
import type { ActionFieldDef, FieldValidation } from './types'
|
|
6
|
+
|
|
7
|
+
// Builds a zod object schema from an ActionFieldDef[]. Required fields stay
|
|
8
|
+
// non-empty; optional fields accept undefined / "". Validation rules
|
|
9
|
+
// (regex/min/max) layer on top: for numeric columns they bound the value, for
|
|
10
|
+
// strings they bound length — same dual semantics the kernel uses.
|
|
11
|
+
export function buildZodSchema(fields: ActionFieldDef[]) {
|
|
12
|
+
const shape: Record<string, ZodTypeAny> = {}
|
|
13
|
+
for (const field of fields) {
|
|
14
|
+
shape[field.key] = fieldToZod(field)
|
|
15
|
+
}
|
|
16
|
+
return z.object(shape)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fieldToZod(field: ActionFieldDef): ZodTypeAny {
|
|
20
|
+
const v = field.validation ?? ({} as FieldValidation)
|
|
21
|
+
const isNumeric = field.type === 'number'
|
|
22
|
+
const isBool = field.type === 'boolean'
|
|
23
|
+
|
|
24
|
+
if (isBool) {
|
|
25
|
+
const base = z.boolean()
|
|
26
|
+
return field.required ? base : base.optional()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (isNumeric) {
|
|
30
|
+
let s = z.coerce.number()
|
|
31
|
+
if (typeof v.min === 'number') s = s.min(v.min, `Debe ser ≥ ${v.min}`)
|
|
32
|
+
if (typeof v.max === 'number') s = s.max(v.max, `Debe ser ≤ ${v.max}`)
|
|
33
|
+
if (field.required) return s
|
|
34
|
+
return z.preprocess((val) => (val === '' || val == null ? undefined : val), s.optional())
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let s = z.string()
|
|
38
|
+
if (typeof v.min === 'number') s = s.min(v.min, `Mínimo ${v.min} caracteres`)
|
|
39
|
+
if (typeof v.max === 'number') s = s.max(v.max, `Máximo ${v.max} caracteres`)
|
|
40
|
+
if (v.regex) {
|
|
41
|
+
try { s = s.regex(new RegExp(v.regex), `Formato inválido`) }
|
|
42
|
+
catch { /* malformed regex from manifest — skip rather than throw at render time */ }
|
|
43
|
+
}
|
|
44
|
+
if (field.type === 'email') s = s.email('Email inválido')
|
|
45
|
+
if (field.type === 'url') s = s.url('URL inválida')
|
|
46
|
+
|
|
47
|
+
if (field.required) {
|
|
48
|
+
return s.min(Math.max(typeof v.min === 'number' ? v.min : 1, 1), `${field.label} es requerido`)
|
|
49
|
+
}
|
|
50
|
+
return s.optional().or(z.literal(''))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Resolves the renderer widget for a field. Explicit `widget` wins; otherwise
|
|
54
|
+
// it is inferred from `type` to preserve the legacy behaviour (zero-value =
|
|
55
|
+
// same render as before).
|
|
56
|
+
export function resolveWidget(field: ActionFieldDef): string {
|
|
57
|
+
if (field.widget) return field.widget
|
|
58
|
+
switch (field.type) {
|
|
59
|
+
case 'textarea': return 'textarea'
|
|
60
|
+
case 'select': return 'select'
|
|
61
|
+
case 'boolean': return 'switch'
|
|
62
|
+
case 'number': return 'number'
|
|
63
|
+
case 'date': return 'date'
|
|
64
|
+
default: return 'text'
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
|
|
2
2
|
// pattern + ActionFieldDef renderer so callers can reuse the form layout
|
|
3
3
|
// outside the full record-edit modal.
|
|
4
|
-
import { useEffect, useState } from 'react'
|
|
4
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
5
5
|
import {
|
|
6
6
|
Input,
|
|
7
7
|
Textarea,
|
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
SelectValue,
|
|
16
16
|
} from '@asteby/metacore-ui/primitives'
|
|
17
17
|
import type { ActionFieldDef } from './types'
|
|
18
|
+
import { buildZodSchema, resolveWidget } from './dynamic-form-schema'
|
|
19
|
+
|
|
20
|
+
export { buildZodSchema, resolveWidget }
|
|
18
21
|
|
|
19
22
|
export interface DynamicFormProps {
|
|
20
23
|
fields: ActionFieldDef[]
|
|
@@ -36,28 +39,38 @@ export function DynamicForm({
|
|
|
36
39
|
disabled = false,
|
|
37
40
|
}: DynamicFormProps) {
|
|
38
41
|
const [values, setValues] = useState<Record<string, any>>({})
|
|
42
|
+
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
39
43
|
const [submitting, setSubmitting] = useState(false)
|
|
40
44
|
|
|
45
|
+
const schema = useMemo(() => buildZodSchema(fields), [fields])
|
|
46
|
+
|
|
41
47
|
useEffect(() => {
|
|
42
48
|
const defaults: Record<string, any> = {}
|
|
43
49
|
for (const f of fields) {
|
|
44
50
|
defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
|
|
45
51
|
}
|
|
46
52
|
setValues(defaults)
|
|
53
|
+
setErrors({})
|
|
47
54
|
}, [fields, initialValues])
|
|
48
55
|
|
|
49
|
-
const update = (k: string, v: any) =>
|
|
56
|
+
const update = (k: string, v: any) =>
|
|
57
|
+
setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
|
|
50
58
|
|
|
51
59
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
52
60
|
e.preventDefault()
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
61
|
+
const result = schema.safeParse(values)
|
|
62
|
+
if (!result.success) {
|
|
63
|
+
const next: Record<string, string> = {}
|
|
64
|
+
for (const issue of result.error.issues) {
|
|
65
|
+
const key = issue.path[0]
|
|
66
|
+
if (typeof key === 'string' && !next[key]) next[key] = issue.message
|
|
57
67
|
}
|
|
68
|
+
setErrors(next)
|
|
69
|
+
return
|
|
58
70
|
}
|
|
71
|
+
setErrors({})
|
|
59
72
|
setSubmitting(true)
|
|
60
|
-
try { await onSubmit(
|
|
73
|
+
try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
return (
|
|
@@ -69,6 +82,9 @@ export function DynamicForm({
|
|
|
69
82
|
{field.required && <span className="text-red-500 ml-1">*</span>}
|
|
70
83
|
</Label>
|
|
71
84
|
{renderField(field, values[field.key], (v: any) => update(field.key, v))}
|
|
85
|
+
{errors[field.key] && (
|
|
86
|
+
<span className="text-red-500 text-sm" role="alert">{errors[field.key]}</span>
|
|
87
|
+
)}
|
|
72
88
|
</div>
|
|
73
89
|
))}
|
|
74
90
|
<div className="flex justify-end gap-2 pt-2">
|
|
@@ -84,9 +100,17 @@ export function DynamicForm({
|
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => void) {
|
|
87
|
-
|
|
103
|
+
const widget = resolveWidget(field)
|
|
104
|
+
switch (widget) {
|
|
88
105
|
case 'textarea':
|
|
89
106
|
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
107
|
+
case 'richtext':
|
|
108
|
+
// Until a real rich-text primitive lands in metacore-ui this maps
|
|
109
|
+
// to a tagged Textarea. The data attribute lets app-level theming
|
|
110
|
+
// / future MDX editor pick it up without breaking the contract.
|
|
111
|
+
return <Textarea id={field.key} data-widget="richtext" value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
112
|
+
case 'color':
|
|
113
|
+
return <Input id={field.key} type="color" value={value || '#000000'} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} />
|
|
90
114
|
case 'select':
|
|
91
115
|
return (
|
|
92
116
|
<Select value={value || ''} onValueChange={onChange}>
|
|
@@ -96,7 +120,7 @@ function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => vo
|
|
|
96
120
|
</SelectContent>
|
|
97
121
|
</Select>
|
|
98
122
|
)
|
|
99
|
-
case '
|
|
123
|
+
case 'switch':
|
|
100
124
|
return <Switch id={field.key} checked={!!value} onCheckedChange={onChange} />
|
|
101
125
|
case 'number':
|
|
102
126
|
return <Input id={field.key} type="number" value={value ?? ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')} placeholder={field.placeholder} />
|
|
@@ -106,3 +130,4 @@ function renderField(field: ActionFieldDef, value: any, onChange: (v: any) => vo
|
|
|
106
130
|
return <Input id={field.key} type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'} value={value || ''} onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
107
131
|
}
|
|
108
132
|
}
|
|
133
|
+
|