@asteby/metacore-runtime-react 7.1.5 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 9.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d51ef45: feat(runtime-react): `DynamicForm` aplica `Validation` (regex/min/max) al schema zod generado y soporta widgets `textarea`/`richtext`/`color`.
8
+ - `ActionFieldDef` extendido con `validation?: FieldValidation` (regex/min/max/custom — espejo del `ValidationRule` del manifest del kernel) y `widget?: FieldWidget | string`.
9
+ - `DynamicForm` ahora deriva un schema zod por field y valida en el submit, mostrando errores inline en lugar del `alert()` previo. Min/max aplica como longitud para strings y como bound para numéricos (mismo dual semantics que el kernel). Regex malformada del manifest se ignora silenciosamente para no tirar el render.
10
+ - Nuevo export `buildZodSchema(fields)` para que callers reutilicen el mismo schema fuera del form.
11
+ - Renderer mapea widgets explícitos a primitivos de `@asteby/metacore-ui`:
12
+ - `textarea` → `Textarea`
13
+ - `richtext` → `Textarea` con `data-widget="richtext"` (puente hasta que aterrice un primitivo MDX/rich; mantiene el contrato sin romper consumers).
14
+ - `color` → `Input type="color"`.
15
+ - Backwards compat: zero-value (sin `validation`/`widget`) preserva el comportamiento previo (widget inferido por `type`, sin reglas de validación más allá de `required`).
16
+
17
+ - 88b176c: feat(runtime-react): `<DynamicRelation kind="many_to_many">` — multi-select sobre la tabla destino, sync transparente contra la tabla pivote (`through`).
18
+
19
+ API mínima:
20
+
21
+ ```tsx
22
+ <DynamicRelation
23
+ kind="many_to_many"
24
+ through="org_members" // tabla pivote
25
+ references="users" // tabla destino sobre la que se hace multi-select
26
+ foreignKey="organization_id" // FK del pivot al padre
27
+ parentId={org.id}
28
+ />
29
+ ```
30
+
31
+ - `referencesKey` por default es `${references}_id` (override opcional). Endpoints `/data/${through}` y `/data/${references}` con override por prop si la app expone rutas custom.
32
+ - Lectura: lista pivot rows filtradas por `f_<foreignKey>=eq:<parentId>` (mismo envelope kernel `{success, data, meta}` que `<DynamicTable>`); lista target rows del modelo `references`.
33
+ - Escritura: el `<MultiSelect>` dispara un diff entre la selección previa y la nueva. Cada nuevo target → `POST /data/${through}` con `{[foreignKey]: parentId, [referencesKey]: targetId}`. Cada target removido → `DELETE /data/${through}/<pivotRowId>`.
34
+ - Permisos por prop (`canCreate` controla attach, `canDelete` controla detach — default `true`).
35
+ - Label de cada opción: `displayKey` prop si está; si no se infiere de la metadata (primer column no-id no-hidden); fallback al `id`.
36
+ - Nuevos helpers puros exportados: `buildPivotAttachPayload`, `extractSelectedTargetIds`, `buildPivotRowIndex`, `diffSelection`, `pickOptionLabel`.
37
+
38
+ `kind="one_to_many"` no cambia.
39
+
40
+ - 88b176c: feat(runtime-react): nuevo `<DynamicRelation kind="one_to_many">` — lista inline editable que cuelga del registro padre.
41
+
42
+ API mínima:
43
+
44
+ ```tsx
45
+ <DynamicRelation
46
+ kind="one_to_many"
47
+ model="line_items"
48
+ foreignKey="invoice_id"
49
+ parentId={id}
50
+ />
51
+ ```
52
+
53
+ - Lista filas del modelo hijo filtradas por `f_<foreignKey>=eq:<parentId>` (envelope kernel `{success, data, meta}`).
54
+ - Crear/Editar via `<DynamicForm>` derivado del `TableMetadata.columns` del modelo; la FK queda fija al `parentId` y se oculta automáticamente del form y de la lista.
55
+ - Quitar via `DELETE /data/<model>/<id>` con confirm dialog.
56
+ - Permisos por prop (`canCreate` / `canEdit` / `canDelete` — default `true`) y strings traducibles via prop `strings`.
57
+ - Helpers puros exportados (`buildRelationFilterParams`, `buildCreatePayload`, `deriveRelationFormFields`, `relationRowKey`) para que callers reutilicen las convenciones fuera del componente.
58
+ - `kind="many_to_many"` queda stubbed (renderiza `not-implemented`) — sigue como follow-up; la RFC completa vive en `packages/runtime-react/docs/relations.md`.
59
+ - Ejemplo end-to-end en `examples/dynamic-relation-one-to-many/`.
60
+
61
+ ### Patch Changes
62
+
63
+ - Updated dependencies [ec9ad56]
64
+ - @asteby/metacore-sdk@2.4.0
65
+
66
+ ## 8.0.0
67
+
68
+ ### Patch Changes
69
+
70
+ - Updated dependencies [c91d778]
71
+ - Updated dependencies [64de425]
72
+ - @asteby/metacore-sdk@2.3.0
73
+ - @asteby/metacore-ui@2.0.0
74
+
3
75
  ## 7.1.5
4
76
 
5
77
  ### Patch Changes
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dynamic-form.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-form.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dynamic-form.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildZodSchema, resolveWidget } from '../dynamic-form-schema';
3
+ describe('buildZodSchema', () => {
4
+ it('aplica regex de Validation a strings', () => {
5
+ const fields = [
6
+ { key: 'sku', label: 'SKU', type: 'string', required: true, validation: { regex: '^[A-Z]{3}-\\d{3}$' } },
7
+ ];
8
+ const schema = buildZodSchema(fields);
9
+ expect(schema.safeParse({ sku: 'ABC-123' }).success).toBe(true);
10
+ expect(schema.safeParse({ sku: 'abc-123' }).success).toBe(false);
11
+ expect(schema.safeParse({ sku: 'ABCD-123' }).success).toBe(false);
12
+ });
13
+ it('aplica min/max como longitud sobre strings', () => {
14
+ const fields = [
15
+ { key: 'name', label: 'Nombre', type: 'string', required: true, validation: { min: 3, max: 8 } },
16
+ ];
17
+ const schema = buildZodSchema(fields);
18
+ expect(schema.safeParse({ name: 'ab' }).success).toBe(false);
19
+ expect(schema.safeParse({ name: 'abc' }).success).toBe(true);
20
+ expect(schema.safeParse({ name: 'abcdefgh' }).success).toBe(true);
21
+ expect(schema.safeParse({ name: 'abcdefghi' }).success).toBe(false);
22
+ });
23
+ it('aplica min/max como bounds sobre números', () => {
24
+ const fields = [
25
+ { key: 'age', label: 'Edad', type: 'number', required: true, validation: { min: 18, max: 99 } },
26
+ ];
27
+ const schema = buildZodSchema(fields);
28
+ expect(schema.safeParse({ age: 17 }).success).toBe(false);
29
+ expect(schema.safeParse({ age: 18 }).success).toBe(true);
30
+ expect(schema.safeParse({ age: 99 }).success).toBe(true);
31
+ expect(schema.safeParse({ age: 100 }).success).toBe(false);
32
+ });
33
+ it('marca campos requeridos vacíos como inválidos', () => {
34
+ const fields = [
35
+ { key: 'title', label: 'Título', type: 'string', required: true },
36
+ ];
37
+ const schema = buildZodSchema(fields);
38
+ expect(schema.safeParse({ title: '' }).success).toBe(false);
39
+ expect(schema.safeParse({ title: 'ok' }).success).toBe(true);
40
+ });
41
+ it('campos opcionales aceptan vacío o ausente', () => {
42
+ const fields = [
43
+ { key: 'note', label: 'Nota', type: 'string' },
44
+ { key: 'qty', label: 'Cantidad', type: 'number' },
45
+ ];
46
+ const schema = buildZodSchema(fields);
47
+ expect(schema.safeParse({ note: '', qty: '' }).success).toBe(true);
48
+ expect(schema.safeParse({}).success).toBe(true);
49
+ });
50
+ it('valida email y url por type', () => {
51
+ const fields = [
52
+ { key: 'mail', label: 'Email', type: 'email', required: true },
53
+ { key: 'site', label: 'URL', type: 'url', required: true },
54
+ ];
55
+ const schema = buildZodSchema(fields);
56
+ expect(schema.safeParse({ mail: 'a@b.co', site: 'https://x.test' }).success).toBe(true);
57
+ expect(schema.safeParse({ mail: 'no-email', site: 'https://x.test' }).success).toBe(false);
58
+ expect(schema.safeParse({ mail: 'a@b.co', site: 'no-url' }).success).toBe(false);
59
+ });
60
+ it('regex inválida no rompe el build (silently skipped)', () => {
61
+ const fields = [
62
+ { key: 'x', label: 'X', type: 'string', required: true, validation: { regex: '[invalid(' } },
63
+ ];
64
+ expect(() => buildZodSchema(fields)).not.toThrow();
65
+ const schema = buildZodSchema(fields);
66
+ expect(schema.safeParse({ x: 'anything' }).success).toBe(true);
67
+ });
68
+ it('booleans requeridos exigen un valor explícito', () => {
69
+ const fields = [
70
+ { key: 'agree', label: 'Acepto', type: 'boolean', required: true },
71
+ ];
72
+ const schema = buildZodSchema(fields);
73
+ expect(schema.safeParse({ agree: true }).success).toBe(true);
74
+ expect(schema.safeParse({ agree: false }).success).toBe(true);
75
+ expect(schema.safeParse({}).success).toBe(false);
76
+ });
77
+ });
78
+ describe('resolveWidget', () => {
79
+ it('respeta widget explícito', () => {
80
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'textarea' })).toBe('textarea');
81
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'richtext' })).toBe('richtext');
82
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', widget: 'color' })).toBe('color');
83
+ });
84
+ it('cae al inferido por type cuando widget no está', () => {
85
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'textarea' })).toBe('textarea');
86
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'select' })).toBe('select');
87
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'boolean' })).toBe('switch');
88
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'number' })).toBe('number');
89
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'date' })).toBe('date');
90
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string' })).toBe('text');
91
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'email' })).toBe('text');
92
+ });
93
+ });
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=dynamic-relation.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-relation.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/dynamic-relation.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,228 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildCreatePayload, buildPivotAttachPayload, buildPivotRowIndex, buildRelationFilterParams, deriveRelationFormFields, diffSelection, extractSelectedTargetIds, pickOptionLabel, relationRowKey, } from '../dynamic-relation-helpers';
3
+ describe('buildRelationFilterParams', () => {
4
+ it('produce el filtro f_<fk>=eq:<id> con string parentId', () => {
5
+ expect(buildRelationFilterParams('invoice_id', 'inv_42')).toEqual({
6
+ f_invoice_id: 'eq:inv_42',
7
+ });
8
+ });
9
+ it('coerce parentId numérico a string en el query', () => {
10
+ expect(buildRelationFilterParams('invoice_id', 42)).toEqual({
11
+ f_invoice_id: 'eq:42',
12
+ });
13
+ });
14
+ it('rechaza foreignKey vacío', () => {
15
+ expect(() => buildRelationFilterParams('', 'x')).toThrow(/foreignKey/);
16
+ });
17
+ it('rechaza parentId vacío / null / undefined', () => {
18
+ expect(() => buildRelationFilterParams('invoice_id', '')).toThrow(/parentId/);
19
+ // @ts-expect-error testing runtime guard
20
+ expect(() => buildRelationFilterParams('invoice_id', null)).toThrow(/parentId/);
21
+ // @ts-expect-error testing runtime guard
22
+ expect(() => buildRelationFilterParams('invoice_id', undefined)).toThrow(/parentId/);
23
+ });
24
+ });
25
+ describe('buildCreatePayload', () => {
26
+ it('inyecta el foreign key sobre los valores del form', () => {
27
+ const payload = buildCreatePayload('invoice_id', 'inv_42', { qty: 3, sku: 'A' });
28
+ expect(payload).toEqual({ qty: 3, sku: 'A', invoice_id: 'inv_42' });
29
+ });
30
+ it('el foreign key sobreescribe lo que venga del form', () => {
31
+ const payload = buildCreatePayload('invoice_id', 'inv_42', { invoice_id: 'inv_OTHER', qty: 1 });
32
+ expect(payload.invoice_id).toBe('inv_42');
33
+ });
34
+ it('preserva el parentId numérico tal cual', () => {
35
+ const payload = buildCreatePayload('invoice_id', 7, { qty: 1 });
36
+ expect(payload.invoice_id).toBe(7);
37
+ });
38
+ it('rechaza foreignKey vacío', () => {
39
+ expect(() => buildCreatePayload('', 'x', {})).toThrow(/foreignKey/);
40
+ });
41
+ });
42
+ describe('deriveRelationFormFields', () => {
43
+ const baseMeta = {
44
+ columns: [
45
+ { key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
46
+ { key: 'invoice_id', label: 'Factura', type: 'text', sortable: false, filterable: false },
47
+ { key: 'sku', label: 'SKU', type: 'text', sortable: true, filterable: true },
48
+ { key: 'qty', label: 'Cantidad', type: 'number', sortable: true, filterable: false },
49
+ { key: 'taxable', label: 'Aplica IVA', type: 'boolean', sortable: false, filterable: false },
50
+ { key: 'category', label: 'Categoría', type: 'select', sortable: false, filterable: true, options: [
51
+ { value: 'a', label: 'A' }, { value: 'b', label: 'B' },
52
+ ] },
53
+ ],
54
+ };
55
+ it('omite la foreign key porque está fija al parentId', () => {
56
+ const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
57
+ expect(fields.find(f => f.key === 'invoice_id')).toBeUndefined();
58
+ });
59
+ it('omite columnas marcadas hidden', () => {
60
+ const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
61
+ expect(fields.find(f => f.key === 'id')).toBeUndefined();
62
+ });
63
+ it('mapea types de ColumnDefinition al ActionFieldDef.type', () => {
64
+ const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
65
+ const byKey = Object.fromEntries(fields.map(f => [f.key, f]));
66
+ expect(byKey['sku']?.type).toBe('string');
67
+ expect(byKey['qty']?.type).toBe('number');
68
+ expect(byKey['taxable']?.type).toBe('boolean');
69
+ expect(byKey['category']?.type).toBe('select');
70
+ });
71
+ it('propaga options con value coerced a string', () => {
72
+ const fields = deriveRelationFormFields(baseMeta, 'invoice_id');
73
+ const cat = fields.find(f => f.key === 'category');
74
+ expect(cat?.options).toEqual([
75
+ { value: 'a', label: 'A' },
76
+ { value: 'b', label: 'B' },
77
+ ]);
78
+ });
79
+ it('devuelve [] cuando no hay metadata', () => {
80
+ expect(deriveRelationFormFields(null, 'invoice_id')).toEqual([]);
81
+ expect(deriveRelationFormFields(undefined, 'invoice_id')).toEqual([]);
82
+ expect(deriveRelationFormFields({ columns: [] }, 'invoice_id')).toEqual([]);
83
+ });
84
+ });
85
+ describe('relationRowKey', () => {
86
+ it('usa row.id como key cuando existe', () => {
87
+ expect(relationRowKey({ id: 'abc' }, 0, 'invoice_id')).toBe('abc');
88
+ expect(relationRowKey({ id: 7 }, 5, 'invoice_id')).toBe('7');
89
+ });
90
+ it('cae a synthetic key cuando id falta o es vacío', () => {
91
+ expect(relationRowKey({}, 2, 'invoice_id')).toBe('__rel-invoice_id-2');
92
+ expect(relationRowKey({ id: '' }, 3, 'invoice_id')).toBe('__rel-invoice_id-3');
93
+ expect(relationRowKey({ id: null }, 1, 'invoice_id')).toBe('__rel-invoice_id-1');
94
+ expect(relationRowKey(undefined, 0, 'invoice_id')).toBe('__rel-invoice_id-0');
95
+ });
96
+ });
97
+ // ---------------------------------------------------------------------------
98
+ // many_to_many helpers
99
+ // ---------------------------------------------------------------------------
100
+ describe('buildPivotAttachPayload', () => {
101
+ it('produce el body con los dos FKs fijos', () => {
102
+ const body = buildPivotAttachPayload('org_id', 'org_1', 'user_id', 'user_42');
103
+ expect(body).toEqual({ org_id: 'org_1', user_id: 'user_42' });
104
+ });
105
+ it('mezcla campos extra del pivot sin pisar los FKs', () => {
106
+ const body = buildPivotAttachPayload('org_id', 1, 'user_id', 2, {
107
+ role: 'owner',
108
+ org_id: 'evil',
109
+ user_id: 'evil',
110
+ });
111
+ expect(body.org_id).toBe(1);
112
+ expect(body.user_id).toBe(2);
113
+ expect(body.role).toBe('owner');
114
+ });
115
+ it('rechaza foreignKey / referencesKey vacíos', () => {
116
+ expect(() => buildPivotAttachPayload('', 'p', 'r', 't')).toThrow(/foreignKey/);
117
+ expect(() => buildPivotAttachPayload('f', 'p', '', 't')).toThrow(/referencesKey/);
118
+ });
119
+ it('rechaza parentId / targetId vacíos', () => {
120
+ expect(() => buildPivotAttachPayload('f', '', 'r', 't')).toThrow(/parentId/);
121
+ expect(() => buildPivotAttachPayload('f', 'p', 'r', '')).toThrow(/targetId/);
122
+ // @ts-expect-error testing runtime guard
123
+ expect(() => buildPivotAttachPayload('f', 'p', 'r', null)).toThrow(/targetId/);
124
+ });
125
+ });
126
+ describe('extractSelectedTargetIds', () => {
127
+ it('mapea pivot rows al set de target ids como strings', () => {
128
+ const ids = extractSelectedTargetIds([
129
+ { id: 1, org_id: 'org_1', user_id: 'u_1' },
130
+ { id: 2, org_id: 'org_1', user_id: 7 },
131
+ ], 'user_id');
132
+ expect(ids).toEqual(['u_1', '7']);
133
+ });
134
+ it('omite filas sin valor en el referencesKey', () => {
135
+ const ids = extractSelectedTargetIds([
136
+ { id: 1, user_id: 'u_1' },
137
+ { id: 2, user_id: null },
138
+ { id: 3, user_id: '' },
139
+ { id: 4 },
140
+ ], 'user_id');
141
+ expect(ids).toEqual(['u_1']);
142
+ });
143
+ it('devuelve [] cuando no hay rows o referencesKey', () => {
144
+ expect(extractSelectedTargetIds(null, 'user_id')).toEqual([]);
145
+ expect(extractSelectedTargetIds(undefined, 'user_id')).toEqual([]);
146
+ expect(extractSelectedTargetIds([{ user_id: 'x' }], '')).toEqual([]);
147
+ });
148
+ });
149
+ describe('buildPivotRowIndex', () => {
150
+ it('mapea targetId -> pivotRowId', () => {
151
+ const idx = buildPivotRowIndex([
152
+ { id: 'p1', user_id: 'u_1' },
153
+ { id: 'p2', user_id: 7 },
154
+ ], 'user_id');
155
+ expect(idx.get('u_1')).toBe('p1');
156
+ expect(idx.get('7')).toBe('p2');
157
+ });
158
+ it('omite filas sin id pivot o sin target', () => {
159
+ const idx = buildPivotRowIndex([
160
+ { id: 'p1', user_id: 'u_1' },
161
+ { user_id: 'u_2' },
162
+ { id: 'p3' },
163
+ { id: 'p4', user_id: null },
164
+ ], 'user_id');
165
+ expect(Array.from(idx.keys())).toEqual(['u_1']);
166
+ });
167
+ it('última fila gana cuando hay duplicados', () => {
168
+ const idx = buildPivotRowIndex([
169
+ { id: 'p1', user_id: 'u_1' },
170
+ { id: 'p2', user_id: 'u_1' },
171
+ ], 'user_id');
172
+ expect(idx.get('u_1')).toBe('p2');
173
+ });
174
+ });
175
+ describe('diffSelection', () => {
176
+ it('detecta toAdd y toRemove respecto al estado previo', () => {
177
+ const { toAdd, toRemove } = diffSelection(['a', 'b', 'c'], ['b', 'c', 'd']);
178
+ expect(toAdd).toEqual(['d']);
179
+ expect(toRemove).toEqual(['a']);
180
+ });
181
+ it('preserva el orden de aparición en next/prev', () => {
182
+ const { toAdd, toRemove } = diffSelection(['a', 'b'], ['c', 'a', 'd']);
183
+ expect(toAdd).toEqual(['c', 'd']);
184
+ expect(toRemove).toEqual(['b']);
185
+ });
186
+ it('devuelve arrays vacíos cuando no cambia nada', () => {
187
+ const { toAdd, toRemove } = diffSelection(['a', 'b'], ['b', 'a']);
188
+ expect(toAdd).toEqual([]);
189
+ expect(toRemove).toEqual([]);
190
+ });
191
+ it('caso vacío -> next agrega todo', () => {
192
+ const { toAdd, toRemove } = diffSelection([], ['a', 'b']);
193
+ expect(toAdd).toEqual(['a', 'b']);
194
+ expect(toRemove).toEqual([]);
195
+ });
196
+ it('caso prev -> [] remueve todo', () => {
197
+ const { toAdd, toRemove } = diffSelection(['a', 'b'], []);
198
+ expect(toAdd).toEqual([]);
199
+ expect(toRemove).toEqual(['a', 'b']);
200
+ });
201
+ });
202
+ describe('pickOptionLabel', () => {
203
+ const cols = [
204
+ { key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false, hidden: true },
205
+ { key: 'name', label: 'Nombre', type: 'text', sortable: true, filterable: true },
206
+ { key: 'email', label: 'Email', type: 'text', sortable: false, filterable: true },
207
+ ];
208
+ it('respeta displayKey cuando existe en la fila', () => {
209
+ expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, 'email', cols)).toBe('a@x');
210
+ });
211
+ it('cae al primer column no-id no-hidden cuando displayKey falta', () => {
212
+ expect(pickOptionLabel({ id: 1, name: 'Alice', email: 'a@x' }, undefined, cols)).toBe('Alice');
213
+ });
214
+ it('salta valores nulos / vacíos al inferir', () => {
215
+ expect(pickOptionLabel({ id: 1, name: '', email: 'a@x' }, undefined, cols)).toBe('a@x');
216
+ });
217
+ it('cae a row.id cuando no hay match en columns', () => {
218
+ expect(pickOptionLabel({ id: 7 }, undefined, cols)).toBe('7');
219
+ expect(pickOptionLabel({ id: 7 }, undefined, undefined)).toBe('7');
220
+ });
221
+ it('devuelve "—" cuando no hay nada usable', () => {
222
+ expect(pickOptionLabel(null, undefined, cols)).toBe('—');
223
+ expect(pickOptionLabel({}, undefined, cols)).toBe('—');
224
+ });
225
+ it('ignora valores object al inferir', () => {
226
+ expect(pickOptionLabel({ id: 1, name: { nested: true }, email: 'a@x' }, undefined, cols)).toBe('a@x');
227
+ });
228
+ });
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ import type { ActionFieldDef } from './types';
3
+ export declare function buildZodSchema(fields: ActionFieldDef[]): z.ZodObject<{
4
+ [x: string]: z.ZodType<unknown, unknown, z.core.$ZodTypeInternals<unknown, unknown>>;
5
+ }, z.core.$strip>;
6
+ export declare function resolveWidget(field: ActionFieldDef): string;
7
+ //# sourceMappingURL=dynamic-form-schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,CAAC,EAAmB,MAAM,KAAK,CAAA;AACxC,OAAO,KAAK,EAAE,cAAc,EAAmB,MAAM,SAAS,CAAA;AAM9D,wBAAgB,cAAc,CAAC,MAAM,EAAE,cAAc,EAAE;;kBAMtD;AAuCD,wBAAgB,aAAa,CAAC,KAAK,EAAE,cAAc,GAAG,MAAM,CAU3D"}
@@ -0,0 +1,68 @@
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 } from 'zod';
5
+ // Builds a zod object schema from an ActionFieldDef[]. Required fields stay
6
+ // non-empty; optional fields accept undefined / "". Validation rules
7
+ // (regex/min/max) layer on top: for numeric columns they bound the value, for
8
+ // strings they bound length — same dual semantics the kernel uses.
9
+ export function buildZodSchema(fields) {
10
+ const shape = {};
11
+ for (const field of fields) {
12
+ shape[field.key] = fieldToZod(field);
13
+ }
14
+ return z.object(shape);
15
+ }
16
+ function fieldToZod(field) {
17
+ const v = field.validation ?? {};
18
+ const isNumeric = field.type === 'number';
19
+ const isBool = field.type === 'boolean';
20
+ if (isBool) {
21
+ const base = z.boolean();
22
+ return field.required ? base : base.optional();
23
+ }
24
+ if (isNumeric) {
25
+ let s = z.coerce.number();
26
+ if (typeof v.min === 'number')
27
+ s = s.min(v.min, `Debe ser ≥ ${v.min}`);
28
+ if (typeof v.max === 'number')
29
+ s = s.max(v.max, `Debe ser ≤ ${v.max}`);
30
+ if (field.required)
31
+ return s;
32
+ return z.preprocess((val) => (val === '' || val == null ? undefined : val), s.optional());
33
+ }
34
+ let s = z.string();
35
+ if (typeof v.min === 'number')
36
+ s = s.min(v.min, `Mínimo ${v.min} caracteres`);
37
+ if (typeof v.max === 'number')
38
+ s = s.max(v.max, `Máximo ${v.max} caracteres`);
39
+ if (v.regex) {
40
+ try {
41
+ s = s.regex(new RegExp(v.regex), `Formato inválido`);
42
+ }
43
+ catch { /* malformed regex from manifest — skip rather than throw at render time */ }
44
+ }
45
+ if (field.type === 'email')
46
+ s = s.email('Email inválido');
47
+ if (field.type === 'url')
48
+ s = s.url('URL inválida');
49
+ if (field.required) {
50
+ return s.min(Math.max(typeof v.min === 'number' ? v.min : 1, 1), `${field.label} es requerido`);
51
+ }
52
+ return s.optional().or(z.literal(''));
53
+ }
54
+ // Resolves the renderer widget for a field. Explicit `widget` wins; otherwise
55
+ // it is inferred from `type` to preserve the legacy behaviour (zero-value =
56
+ // same render as before).
57
+ export function resolveWidget(field) {
58
+ if (field.widget)
59
+ return field.widget;
60
+ switch (field.type) {
61
+ case 'textarea': return 'textarea';
62
+ case 'select': return 'select';
63
+ case 'boolean': return 'switch';
64
+ case 'number': return 'number';
65
+ case 'date': return 'date';
66
+ default: return 'text';
67
+ }
68
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ActionFieldDef } from './types';
2
+ import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
3
+ export { buildZodSchema, resolveWidget };
2
4
  export interface DynamicFormProps {
3
5
  fields: ActionFieldDef[];
4
6
  initialValues?: Record<string, any>;
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CA+ClB"}
1
+ {"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAErE,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AAExC,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,cAAc,EAAE,CAAA;IACxB,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACnC,QAAQ,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAC/D,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACrB;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,GACnB,EAAE,gBAAgB,2CA4DlB"}
@@ -2,44 +2,63 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // Minimal standalone DynamicForm. Factored from the dynamic-record-dialog
3
3
  // pattern + ActionFieldDef renderer so callers can reuse the form layout
4
4
  // outside the full record-edit modal.
5
- import { useEffect, useState } from 'react';
5
+ import { useEffect, useMemo, useState } from 'react';
6
6
  import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
7
+ import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
8
+ export { buildZodSchema, resolveWidget };
7
9
  export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
8
10
  const [values, setValues] = useState({});
11
+ const [errors, setErrors] = useState({});
9
12
  const [submitting, setSubmitting] = useState(false);
13
+ const schema = useMemo(() => buildZodSchema(fields), [fields]);
10
14
  useEffect(() => {
11
15
  const defaults = {};
12
16
  for (const f of fields) {
13
17
  defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '');
14
18
  }
15
19
  setValues(defaults);
20
+ setErrors({});
16
21
  }, [fields, initialValues]);
17
22
  const update = (k, v) => setValues((prev) => ({ ...prev, [k]: v }));
18
23
  const handleSubmit = async (e) => {
19
24
  e.preventDefault();
20
- for (const f of fields) {
21
- if (f.required && !values[f.key] && values[f.key] !== false) {
22
- alert(`${f.label} es requerido`);
23
- return;
25
+ const result = schema.safeParse(values);
26
+ if (!result.success) {
27
+ const next = {};
28
+ for (const issue of result.error.issues) {
29
+ const key = issue.path[0];
30
+ if (typeof key === 'string' && !next[key])
31
+ next[key] = issue.message;
24
32
  }
33
+ setErrors(next);
34
+ return;
25
35
  }
36
+ setErrors({});
26
37
  setSubmitting(true);
27
38
  try {
28
- await onSubmit(values);
39
+ await onSubmit(result.data);
29
40
  }
30
41
  finally {
31
42
  setSubmitting(false);
32
43
  }
33
44
  };
34
- return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [fields.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, values[field.key], (v) => update(field.key, v))] }, field.key))), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled, children: submitLabel })] })] }));
45
+ return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [fields.map((field) => (_jsxs("div", { className: "grid gap-2", children: [_jsxs(Label, { htmlFor: field.key, children: [field.label, field.required && _jsx("span", { className: "text-red-500 ml-1", children: "*" })] }), renderField(field, values[field.key], (v) => update(field.key, v)), errors[field.key] && (_jsx("span", { className: "text-red-500 text-sm", role: "alert", children: errors[field.key] }))] }, field.key))), _jsxs("div", { className: "flex justify-end gap-2 pt-2", children: [onCancel && (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })), _jsx(Button, { type: "submit", disabled: submitting || disabled, children: submitLabel })] })] }));
35
46
  }
36
47
  function renderField(field, value, onChange) {
37
- switch (field.type) {
48
+ const widget = resolveWidget(field);
49
+ switch (widget) {
38
50
  case 'textarea':
39
51
  return _jsx(Textarea, { id: field.key, value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
52
+ case 'richtext':
53
+ // Until a real rich-text primitive lands in metacore-ui this maps
54
+ // to a tagged Textarea. The data attribute lets app-level theming
55
+ // / future MDX editor pick it up without breaking the contract.
56
+ return _jsx(Textarea, { id: field.key, "data-widget": "richtext", value: value || '', onChange: (e) => onChange(e.target.value), placeholder: field.placeholder });
57
+ case 'color':
58
+ return _jsx(Input, { id: field.key, type: "color", value: value || '#000000', onChange: (e) => onChange(e.target.value) });
40
59
  case 'select':
41
60
  return (_jsxs(Select, { value: value || '', onValueChange: onChange, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: field.placeholder || 'Seleccionar...' }) }), _jsx(SelectContent, { children: field.options?.map((opt) => _jsx(SelectItem, { value: opt.value, children: opt.label }, opt.value)) })] }));
42
- case 'boolean':
61
+ case 'switch':
43
62
  return _jsx(Switch, { id: field.key, checked: !!value, onCheckedChange: onChange });
44
63
  case 'number':
45
64
  return _jsx(Input, { id: field.key, type: "number", value: value ?? '', onChange: (e) => onChange(e.target.valueAsNumber || ''), placeholder: field.placeholder });