@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/column-visibility.d.ts +22 -0
  3. package/dist/column-visibility.d.ts.map +1 -0
  4. package/dist/column-visibility.js +40 -0
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +4 -1
  7. package/dist/dynamic-form-schema.d.ts +7 -0
  8. package/dist/dynamic-form-schema.d.ts.map +1 -0
  9. package/dist/dynamic-form-schema.js +68 -0
  10. package/dist/dynamic-form.d.ts +2 -0
  11. package/dist/dynamic-form.d.ts.map +1 -1
  12. package/dist/dynamic-form.js +28 -9
  13. package/dist/dynamic-relation-helpers.d.ts +77 -0
  14. package/dist/dynamic-relation-helpers.d.ts.map +1 -0
  15. package/dist/dynamic-relation-helpers.js +186 -0
  16. package/dist/dynamic-relation.d.ts +64 -0
  17. package/dist/dynamic-relation.d.ts.map +1 -0
  18. package/dist/dynamic-relation.js +226 -0
  19. package/dist/dynamic-table.d.ts.map +1 -1
  20. package/dist/dynamic-table.js +17 -3
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/types.d.ts +33 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/docs/relations.md +290 -0
  27. package/package.json +9 -3
  28. package/src/__tests__/column-visibility.test.ts +116 -0
  29. package/src/__tests__/dynamic-form.test.ts +104 -0
  30. package/src/__tests__/dynamic-relation.test.ts +293 -0
  31. package/src/column-visibility.ts +43 -0
  32. package/src/dynamic-columns.tsx +4 -1
  33. package/src/dynamic-form-schema.ts +66 -0
  34. package/src/dynamic-form.tsx +34 -9
  35. package/src/dynamic-relation-helpers.ts +226 -0
  36. package/src/dynamic-relation.tsx +497 -0
  37. package/src/dynamic-table.tsx +20 -2
  38. package/src/index.ts +14 -0
  39. package/src/types.ts +49 -0
  40. package/tsconfig.json +2 -1
  41. 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
+ }
@@ -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
- if (col.hidden) return
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
+ }
@@ -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) => setValues((prev: Record<string, any>) => ({ ...prev, [k]: v }))
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
- for (const f of fields) {
54
- if (f.required && !values[f.key] && values[f.key] !== false) {
55
- alert(`${f.label} es requerido`)
56
- return
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(values) } finally { setSubmitting(false) }
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
- switch (field.type) {
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 'boolean':
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
+