@asteby/metacore-runtime-react 28.3.7 → 28.5.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 +57 -0
- package/dist/action-modal-dispatcher.js +1 -1
- package/dist/dashboard-grid.d.ts.map +1 -1
- package/dist/dashboard-grid.js +5 -1
- package/dist/dialogs/dynamic-record.d.ts +20 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +95 -23
- package/dist/dynamic-form-schema.d.ts +26 -1
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +41 -0
- package/dist/dynamic-form.d.ts +10 -1
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +60 -7
- package/dist/dynamic-relation.d.ts +12 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +12 -6
- package/dist/dynamic-relations.d.ts +9 -1
- package/dist/dynamic-relations.d.ts.map +1 -1
- package/dist/dynamic-relations.js +5 -4
- package/dist/form-layout-ui.d.ts +25 -0
- package/dist/form-layout-ui.d.ts.map +1 -0
- package/dist/form-layout-ui.js +39 -0
- package/dist/form-layout.d.ts +54 -0
- package/dist/form-layout.d.ts.map +1 -0
- package/dist/form-layout.js +81 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/dynamic-record-hidden-submit.test.tsx +78 -0
- package/src/__tests__/dynamic-relation-subtable-context.test.tsx +74 -0
- package/src/__tests__/form-layout.test.ts +113 -0
- package/src/__tests__/visible-when.test.ts +63 -0
- package/src/action-modal-dispatcher.tsx +1 -1
- package/src/dashboard-grid.tsx +5 -1
- package/src/dialogs/dynamic-record.tsx +189 -50
- package/src/dynamic-form-schema.ts +47 -1
- package/src/dynamic-form.tsx +126 -17
- package/src/dynamic-relation.tsx +25 -8
- package/src/dynamic-relations.tsx +16 -1
- package/src/form-layout-ui.tsx +117 -0
- package/src/form-layout.ts +116 -0
- package/src/index.ts +4 -0
- package/src/types.ts +48 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 28.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 47f6543: Los renderers de formulario (`DynamicForm` y `DynamicRecordDialog`) ahora consumen el primitivo `form_layout` del modelo (kernel PR #230): agrupan los campos por su `section` respetando el orden de `sections`. En `mode: "sections"` cada grupo se dibuja como una sección con `title`/`description` y soporte de `collapsed` (colapsable); en `mode: "steps"` se dibuja un wizard multi-paso con navegación Anterior/Siguiente y submit en el último paso. Los campos sin `section` (o con sección desconocida) caen en un grupo por defecto al inicio, y una sección sin campos visibles se oculta. Sin `form_layout` el comportamiento (lista plana) queda idéntico; `visible_when`, el gate de required y el strip de ocultos en el submit se respetan.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- b05aded: DynamicRecordDialog ya no envía en el submit los campos ocultos por `visible_when`, en paridad con DynamicForm.
|
|
12
|
+
|
|
13
|
+
## 28.4.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 0075ae0: Pulido: (1) el mockup animado del tablero vacío ya no lleva borde punteado ni
|
|
18
|
+
padding — las skeleton cards flotan como las reales; (2) las secciones de
|
|
19
|
+
relaciones apiladas en el modal de vista (ej. "Líneas del pedido" y "Facturas")
|
|
20
|
+
tienen separación (space-y-6) en vez de ir pegadas.
|
|
21
|
+
|
|
22
|
+
## 28.4.0
|
|
23
|
+
|
|
24
|
+
### Minor Changes
|
|
25
|
+
|
|
26
|
+
- f1f66cb: feat: `visible_when` conditional field visibility in both form renderers
|
|
27
|
+
|
|
28
|
+
Add support for the kernel's new `visible_when` primitive: a create/edit form
|
|
29
|
+
field is rendered — and validated — only while a sibling field's current value
|
|
30
|
+
matches the declared predicate (`{ field, equals | in }`). A hidden field never
|
|
31
|
+
gates submit (its required-check is dropped along with it).
|
|
32
|
+
|
|
33
|
+
Wired into BOTH form renderers off a shared helper `evaluateVisibleWhen`
|
|
34
|
+
(exported alongside `getVisibleWhen`):
|
|
35
|
+
|
|
36
|
+
- `DynamicForm` (`dynamic-form.tsx`) — drives the zod schema, the balance gate
|
|
37
|
+
and the render off a `visibleFields` list filtered on the live values.
|
|
38
|
+
- `DynamicRecordDialog` (`dialogs/dynamic-record.tsx`) — `filterVisibleFields`
|
|
39
|
+
gains an optional `formValues` arg applying the predicate; the render and the
|
|
40
|
+
required-gate loop both use it.
|
|
41
|
+
|
|
42
|
+
`ActionFieldDef` and `ColumnDefinition` gain `visible_when` / `visibleWhen`
|
|
43
|
+
(snake + camel aliases). Fields without `visible_when` are always visible
|
|
44
|
+
(retro-compatible). Requires a kernel/ops bump that serves the field.
|
|
45
|
+
|
|
46
|
+
### Patch Changes
|
|
47
|
+
|
|
48
|
+
- 9bc8bd1: runtime-react: scope audit-column hiding in `DynamicRelation` to the view-modal line-subtable context
|
|
49
|
+
|
|
50
|
+
PR #659 started dropping audit/system columns (`created_by`, timestamps,
|
|
51
|
+
`organization_id`) and `visibility: "table"` columns in every one-to-many
|
|
52
|
+
`DynamicRelation` panel — including standalone detail pages
|
|
53
|
+
(`/m/<model>/<id>`, `m/invoices/<id>`), where those columns are useful. Add an
|
|
54
|
+
explicit `lineSubtable` prop (default `false`) that gates
|
|
55
|
+
`isColumnVisibleInLineSubtable`; outside that context the panel keeps the
|
|
56
|
+
previous behaviour and only hides the FK, scope and `hidden` columns. The view
|
|
57
|
+
modal (`DynamicRecordDialog`, action-modal dispatcher) opts in via
|
|
58
|
+
`<DynamicRelations lineSubtable>`.
|
|
59
|
+
|
|
3
60
|
## 28.3.7
|
|
4
61
|
|
|
5
62
|
### Patch Changes
|
|
@@ -458,7 +458,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
458
458
|
resolveWidget(field) === 'textarea' ||
|
|
459
459
|
resolveWidget(field) === 'richtext';
|
|
460
460
|
return (_jsxs(FieldCell, { fullWidth: fullWidth, children: [_jsx(FieldLabel, { htmlFor: field.key, required: field.required, children: tl(field.label) }), renderField(field, formData[field.key], (v) => updateField(field.key, v), formData), fieldErrors[field.key] && (_jsx("p", { className: "text-destructive text-xs mt-1", children: fieldErrors[field.key] }))] }, field.key));
|
|
461
|
-
}), relations.length > 0 && (_jsx(FieldCell, { fullWidth: true, children: _jsx(DynamicRelations, { record: record, relations: relations }) }))] }) }), _jsxs(DialogFooter, { className: "shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), tl(action.label)] })] })] }) }));
|
|
461
|
+
}), relations.length > 0 && (_jsx(FieldCell, { fullWidth: true, children: _jsx(DynamicRelations, { record: record, relations: relations, lineSubtable: true }) }))] }) }), _jsxs(DialogFooter, { className: "shrink-0", children: [_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: executing, children: t('common.cancel') }), _jsxs(Button, { onClick: execute, disabled: executing, style: action.color ? { backgroundColor: action.color, color: 'white' } : undefined, children: [executing ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : _jsx(DynamicIcon, { name: action.icon, className: "mr-2 h-4 w-4" }), tl(action.label)] })] })] }) }));
|
|
462
462
|
}
|
|
463
463
|
// buildFieldDefaults seeds formData for a set of action fields, honoring the
|
|
464
464
|
// same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,
|
|
1
|
+
{"version":3,"file":"dashboard-grid.d.ts","sourceRoot":"","sources":["../src/dashboard-grid.tsx"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAI9B,OAAO,KAAK,EACR,kBAAkB,EAElB,oBAAoB,EACpB,mBAAmB,EACtB,MAAM,mBAAmB,CAAA;AAY1B,uEAAuE;AACvE,wBAAgB,eAAe,CAC3B,MAAM,CAAC,EAAE,oBAAoB,EAAE,EAC/B,OAAO,CAAC,EAAE,mBAAmB,EAAE,GAChC,oBAAoB,EAAE,CAgBxB;AASD,wBAAgB,aAAa,CAAC,EAC1B,MAAM,EACN,OAAO,EACP,QAAQ,EACR,OAAO,EACP,MAAM,EACN,QAAQ,EACR,SAAS,EACT,OAAO,GACV,EAAE,kBAAkB,qBAuJpB"}
|
package/dist/dashboard-grid.js
CHANGED
|
@@ -131,7 +131,11 @@ export function DashboardGrid({ groups, widgets, loadData, isAdmin, locale, curr
|
|
|
131
131
|
// box auto-height and the tiles collapse into a strip — so we own
|
|
132
132
|
// the height here instead of hoping the dashboard layout supplies
|
|
133
133
|
// one.
|
|
134
|
-
|
|
134
|
+
// No dashed border / padding wrapper: the skeleton tiles ARE
|
|
135
|
+
// cards (same anatomy as the real widgets), so the mockup
|
|
136
|
+
// should float exactly like the loaded dashboard, not sit
|
|
137
|
+
// inside a boxed placeholder. Keep only the definite height.
|
|
138
|
+
'h-[clamp(420px,60vh,720px)] w-full', className), children: _jsx(DashboardEmptyMockup, { className: "h-full" }) }));
|
|
135
139
|
}
|
|
136
140
|
return (_jsx("div", { "data-testid": "dashboard-grid", className: cn(
|
|
137
141
|
// Masonry: balanced CSS columns. Cards take their natural height
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ModelSchema } from './types';
|
|
2
2
|
import { type ResolvedOption } from '../use-options-resolver';
|
|
3
|
+
import type { VisibleWhen } from '../types';
|
|
3
4
|
import { type ItemField } from '../collection-cell';
|
|
4
5
|
import { type GetImageUrl } from '../image-url-context';
|
|
5
6
|
export type { GetImageUrl };
|
|
@@ -71,6 +72,23 @@ export interface FieldDef {
|
|
|
71
72
|
itemFields?: ItemField[];
|
|
72
73
|
/** snake_case alias served by the kernel for `itemFields`. */
|
|
73
74
|
item_fields?: ItemField[];
|
|
75
|
+
/**
|
|
76
|
+
* Conditional visibility: render — and required-check — this field only
|
|
77
|
+
* while a sibling field's current value matches the predicate. Mirrors the
|
|
78
|
+
* kernel v3 `visible_when` (projected onto the served modal FieldDef).
|
|
79
|
+
* Tolerates the camelCase alias. Absent = always visible; a hidden field is
|
|
80
|
+
* dropped from the required-gate so it never blocks submit. Evaluated by
|
|
81
|
+
* `evaluateVisibleWhen` against the live form values.
|
|
82
|
+
*/
|
|
83
|
+
visible_when?: VisibleWhen;
|
|
84
|
+
/** camelCase alias for `visible_when`. */
|
|
85
|
+
visibleWhen?: VisibleWhen;
|
|
86
|
+
/**
|
|
87
|
+
* Form-layout membership: the key of the `form_layout` section this field
|
|
88
|
+
* belongs to (kernel PR #230). Absent → the default group. See
|
|
89
|
+
* `groupFieldsBySection`.
|
|
90
|
+
*/
|
|
91
|
+
section?: string;
|
|
74
92
|
}
|
|
75
93
|
export interface DynamicRecordDialogProps {
|
|
76
94
|
open: boolean;
|
|
@@ -165,7 +183,8 @@ export declare function fieldItemFields(field: FieldDef): ItemField[] | undefine
|
|
|
165
183
|
export declare function isLineItemsField(field: FieldDef, value: any): boolean;
|
|
166
184
|
export declare function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null;
|
|
167
185
|
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
168
|
-
export declare function filterVisibleFields(fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create'): FieldDef[];
|
|
186
|
+
export declare function filterVisibleFields(fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create', formValues?: Record<string, any>): FieldDef[];
|
|
187
|
+
export declare function stripHiddenFieldValues(values: Record<string, any>, fields: FieldDef[] | undefined, mode: 'view' | 'edit' | 'create'): Record<string, any>;
|
|
169
188
|
export declare function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl, timeZone, currency, onChange, }: DynamicRecordDialogProps): import("react").JSX.Element;
|
|
170
189
|
export declare function ReadonlyEditField({ field, value }: {
|
|
171
190
|
field: FieldDef;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAwC1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -23,7 +23,9 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../ser
|
|
|
23
23
|
import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field';
|
|
24
24
|
import { DynamicRelations } from '../dynamic-relations';
|
|
25
25
|
import { useOptionsResolver } from '../use-options-resolver';
|
|
26
|
-
import { getFieldRef } from '../dynamic-form-schema';
|
|
26
|
+
import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-form-schema';
|
|
27
|
+
import { groupFieldsBySection } from '../form-layout';
|
|
28
|
+
import { FieldSection, WizardProgress } from '../form-layout-ui';
|
|
27
29
|
import { FieldCell } from '../field-grid';
|
|
28
30
|
import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
|
|
29
31
|
import { normalizeRefFieldsForSubmit } from './normalize-submit';
|
|
@@ -239,15 +241,43 @@ export function isMoneyField(field, value) {
|
|
|
239
241
|
// given mode. `hidden` fields never render. A `readonly` (server/system-
|
|
240
242
|
// generated) field is EXCLUDED on create — the user can't set a value the
|
|
241
243
|
// server will overwrite — but stays visible on edit/view (rendered disabled).
|
|
242
|
-
|
|
244
|
+
//
|
|
245
|
+
// `formValues`, when provided, additionally applies each field's `visible_when`
|
|
246
|
+
// predicate against the live form values (conditional visibility): a field is
|
|
247
|
+
// dropped while the referenced sibling's value does not match. Driving both the
|
|
248
|
+
// render and the required-gate off this same list means a hidden field never
|
|
249
|
+
// blocks submit. Omitting `formValues` keeps the legacy (always-visible)
|
|
250
|
+
// behaviour for callers that only gate on mode.
|
|
251
|
+
export function filterVisibleFields(fields, mode, formValues) {
|
|
243
252
|
return (fields ?? []).filter(f => {
|
|
244
253
|
if (f.hidden)
|
|
245
254
|
return false;
|
|
246
255
|
if (mode === 'create' && f.readonly)
|
|
247
256
|
return false;
|
|
257
|
+
if (formValues && !evaluateVisibleWhen(getVisibleWhen(f), formValues))
|
|
258
|
+
return false;
|
|
248
259
|
return true;
|
|
249
260
|
});
|
|
250
261
|
}
|
|
262
|
+
// stripHiddenFieldValues drops from a flat form-values object the keys of any
|
|
263
|
+
// declared field currently hidden by its `visible_when` predicate, so the
|
|
264
|
+
// submit never POSTs a value the form isn't showing (e.g. a DiscountRule with
|
|
265
|
+
// rule_scope=category must not send the product_id / customer_id it hides).
|
|
266
|
+
// This mirrors dynamic-form.tsx, which builds its Zod schema only over the
|
|
267
|
+
// visible fields — hidden fields are dropped from BOTH the render/required-gate
|
|
268
|
+
// AND the submitted values. Keys with no matching declared field, or whose
|
|
269
|
+
// field carries no `visible_when`, always pass through (retrocompat).
|
|
270
|
+
export function stripHiddenFieldValues(values, fields, mode) {
|
|
271
|
+
const visibleKeys = new Set(filterVisibleFields(fields, mode, values).map(f => f.key));
|
|
272
|
+
const out = {};
|
|
273
|
+
for (const [key, value] of Object.entries(values)) {
|
|
274
|
+
const field = (fields ?? []).find(f => f.key === key);
|
|
275
|
+
if (field && !visibleKeys.has(key) && getVisibleWhen(field))
|
|
276
|
+
continue;
|
|
277
|
+
out[key] = value;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
251
281
|
export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl = identityImageUrl, timeZone, currency, onChange, }) {
|
|
252
282
|
const api = useApi();
|
|
253
283
|
const { t } = useTranslation();
|
|
@@ -262,6 +292,8 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
262
292
|
const [loading, setLoading] = useState(false);
|
|
263
293
|
const [saving, setSaving] = useState(false);
|
|
264
294
|
const [deleting, setDeleting] = useState(false);
|
|
295
|
+
// Wizard step cursor (form_layout mode:"steps" only).
|
|
296
|
+
const [stepIndex, setStepIndex] = useState(0);
|
|
265
297
|
const isCreate = mode === 'create';
|
|
266
298
|
const isView = mode === 'view';
|
|
267
299
|
const isEditable = mode === 'create' || mode === 'edit';
|
|
@@ -272,8 +304,10 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
272
304
|
return;
|
|
273
305
|
if (!isCreate && !recordId)
|
|
274
306
|
return;
|
|
275
|
-
// Fresh open → drop any validation errors from a prior submit
|
|
307
|
+
// Fresh open → drop any validation errors from a prior submit and reset
|
|
308
|
+
// the wizard to its first step.
|
|
276
309
|
setFieldErrors({});
|
|
310
|
+
setStepIndex(0);
|
|
277
311
|
let cancelled = false;
|
|
278
312
|
// Seed instantly from the row the table already has so view/edit render
|
|
279
313
|
// without a spinner. The list row carries the pro siblings (resolved
|
|
@@ -462,9 +496,12 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
462
496
|
return;
|
|
463
497
|
if (isEditable) {
|
|
464
498
|
// Collect ALL missing required fields (not just the first) and mark
|
|
465
|
-
// each inline instead of a single toast.
|
|
499
|
+
// each inline instead of a single toast. Only CURRENTLY-VISIBLE
|
|
500
|
+
// fields are gated: a field hidden by its `visible_when` predicate
|
|
501
|
+
// must not block submit even when it is declared required (matching
|
|
502
|
+
// the render, which drops it via the same filter).
|
|
466
503
|
const missing = {};
|
|
467
|
-
for (const field of modalMeta.fields
|
|
504
|
+
for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
|
|
468
505
|
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
469
506
|
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t);
|
|
470
507
|
}
|
|
@@ -477,9 +514,15 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
477
514
|
}
|
|
478
515
|
// Required check passed → clear any prior validation errors.
|
|
479
516
|
setFieldErrors({});
|
|
517
|
+
// Fields hidden by their `visible_when` predicate must not be POSTed:
|
|
518
|
+
// the render, the required-gate and the payload all drive off the same
|
|
519
|
+
// filter, so a DiscountRule with scope=category never submits the
|
|
520
|
+
// product_id / customer_id it isn't showing. Mirrors dynamic-form.tsx,
|
|
521
|
+
// which builds its Zod only over visibleFields.
|
|
522
|
+
const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode);
|
|
480
523
|
// Empty reference pickers → null (not "" / nil-UUID) so nullable FK
|
|
481
524
|
// columns accept them instead of raising a 23503 FK violation.
|
|
482
|
-
const payload = normalizeRefFieldsForSubmit(
|
|
525
|
+
const payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields);
|
|
483
526
|
setSaving(true);
|
|
484
527
|
try {
|
|
485
528
|
if (isCreate && onCreate) {
|
|
@@ -550,23 +593,52 @@ export function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId,
|
|
|
550
593
|
}
|
|
551
594
|
};
|
|
552
595
|
const title = modalMeta ? config.getTitle(modalMeta, t) : '';
|
|
553
|
-
const visibleFields = filterVisibleFields(modalMeta?.fields, mode);
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
596
|
+
const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues);
|
|
597
|
+
// Declarative form layout: group the (already visibility-filtered) fields by
|
|
598
|
+
// their section. Empty sections drop out for free. Steps mode only drives a
|
|
599
|
+
// wizard in editable modes — view mode always stacks the sections.
|
|
600
|
+
const formLayout = modalMeta?.form_layout ?? modalMeta?.formLayout;
|
|
601
|
+
const groups = groupFieldsBySection(visibleFields, formLayout);
|
|
602
|
+
const isSteps = isEditable && formLayout?.mode === 'steps' && groups.length > 1;
|
|
603
|
+
const clampedStep = Math.min(stepIndex, Math.max(groups.length - 1, 0));
|
|
604
|
+
const isLastStep = clampedStep === groups.length - 1;
|
|
605
|
+
// Renders a list of fields into the shared two-column grid (each FieldCell
|
|
606
|
+
// gives min-w-0 so long values can't blow the columns past the dialog).
|
|
607
|
+
const renderFields = (groupFields) => groupFields.map(field => {
|
|
608
|
+
const isFullWidth = field.type === 'textarea' ||
|
|
609
|
+
field.widget === 'textarea' ||
|
|
610
|
+
field.widget === 'richtext';
|
|
611
|
+
return (_jsx(FieldCell, { fullWidth: isFullWidth, children: _jsx(FieldRow, { field: field, record: record, value: formValues[field.key] ?? '', mode: mode, error: fieldErrors[field.key], onChange: val => {
|
|
612
|
+
setFormValues((prev) => ({ ...prev, [field.key]: val }));
|
|
613
|
+
setFieldErrors(prev => {
|
|
614
|
+
if (!prev[field.key])
|
|
615
|
+
return prev;
|
|
616
|
+
const next = { ...prev };
|
|
617
|
+
delete next[field.key];
|
|
618
|
+
return next;
|
|
619
|
+
});
|
|
620
|
+
} }) }, field.key));
|
|
621
|
+
});
|
|
622
|
+
// Wizard "Siguiente": gate only the CURRENT step's required (visible) fields,
|
|
623
|
+
// then advance. Mirrors handleSubmit's required check but scoped to the step.
|
|
624
|
+
const goNextStep = () => {
|
|
625
|
+
const step = groups[clampedStep];
|
|
626
|
+
const missing = {};
|
|
627
|
+
for (const field of step?.fields ?? []) {
|
|
628
|
+
if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
|
|
629
|
+
missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (Object.keys(missing).length) {
|
|
633
|
+
setFieldErrors(missing);
|
|
634
|
+
toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
setFieldErrors({});
|
|
638
|
+
setStepIndex(Math.min(clampedStep + 1, groups.length - 1));
|
|
639
|
+
};
|
|
640
|
+
const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0));
|
|
641
|
+
return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "sm:max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden", children: [_jsxs(DialogHeader, { className: "p-6 pb-4 border-b shrink-0", children: [_jsx(DialogTitle, { children: title }), _jsx(DialogDescription, { children: config.description })] }), _jsx("div", { className: "flex-1 overflow-y-auto p-6", children: loading ? (_jsx(LoadingSkeleton, {})) : modalMeta ? (_jsx(ModelContext.Provider, { value: model, children: _jsx(ImageUrlContext.Provider, { value: getImageUrl, children: _jsx(TimeZoneContext.Provider, { value: timeZone, children: _jsxs(CurrencyContext.Provider, { value: currency, children: [_jsxs("form", { id: "dynamic-record-form", onSubmit: handleSubmit, className: "grid gap-y-4", children: [isSteps && (_jsx(WizardProgress, { groups: groups, stepIndex: clampedStep })), (isSteps ? [groups[clampedStep]] : groups).map(group => (_jsx(FieldSection, { group: group, children: _jsx("div", { className: "grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2", children: renderFields(group.fields) }) }, group.key))), record?.external_url && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2", children: _jsx("div", { className: "sm:col-span-2 min-w-0", children: _jsxs("a", { href: record.external_url, target: "_blank", rel: "noreferrer", className: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1", children: [_jsx(ExternalLink, { className: "h-3.5 w-3.5" }), "Ver en ", record.external_provider ?? 'proveedor externo'] }) }) }))] }), !isCreate && record && relations.length > 0 && (_jsx("div", { className: "mt-6", children: _jsx(DynamicRelations, { record: record, relations: relations, lineSubtable: true, canCreate: mode === 'edit', canEdit: mode === 'edit', canDelete: mode === 'edit', onChange: handleChildChange }) }))] }) }) }) })) : null }), _jsxs(DialogFooter, { className: "p-4 border-t shrink-0 sm:justify-between", children: [isView && onOpenFullPage ? (_jsxs(Button, { variant: "ghost", size: "sm", className: "text-muted-foreground", onClick: () => { onOpenChange(false); onOpenFullPage(); }, children: [_jsx(ExternalLink, { className: "mr-1.5 h-3.5 w-3.5" }), "Ver p\u00E1gina completa"] })) : _jsx("span", {}), _jsxs("div", { className: "flex items-center gap-2", children: [isSteps && clampedStep > 0 ? (_jsx(Button, { variant: "outline", onClick: goBackStep, disabled: saving || deleting, children: "Anterior" })) : (_jsx(Button, { variant: "outline", onClick: () => onOpenChange(false), disabled: saving || deleting, children: config.cancelLabel })), isView && onDelete && (_jsxs(Button, { variant: "destructive", onClick: handleDelete, disabled: deleting || loading, children: [deleting && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), deleting ? 'Eliminando...' : 'Eliminar'] })), isView && onEdit && (_jsx(Button, { onClick: onEdit, disabled: deleting || loading, children: "Editar" })), isEditable && isSteps && !isLastStep && (_jsx(Button, { type: "button", onClick: goNextStep, disabled: saving || loading, children: "Siguiente" })), isEditable && (!isSteps || isLastStep) && (_jsxs(Button, { type: "submit", form: "dynamic-record-form", disabled: saving || loading, children: [saving && _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }), saving ? config.submittingLabel : config.submitLabel] }))] })] })] }) }));
|
|
570
642
|
}
|
|
571
643
|
function LoadingSkeleton() {
|
|
572
644
|
return (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4", children: Array.from({ length: 6 }).map((_, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx(Skeleton, { className: "h-3.5 w-24" }), _jsx(Skeleton, { className: "h-9 w-full" })] }, i))) }));
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { ActionFieldDef, FieldOptionsConfig, OptionDef } from './types';
|
|
2
|
+
import type { ActionFieldDef, FieldOptionsConfig, OptionDef, VisibleWhen } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Apps register validator implementations by slug. The slug is the value
|
|
5
5
|
* `OrgConfig.validators[<key>]` returns for a $org.<key> reference.
|
|
@@ -97,6 +97,31 @@ export declare function resolveDependsValue(field: ActionFieldDef, formValues?:
|
|
|
97
97
|
* used as the default gating field.
|
|
98
98
|
*/
|
|
99
99
|
export declare function applyOptionWhen(options: OptionDef[] | undefined, formValues: Record<string, any> | null | undefined, dependsOn?: string): OptionDef[];
|
|
100
|
+
/**
|
|
101
|
+
* Reads a field's `visible_when` predicate, tolerating the camelCase alias
|
|
102
|
+
* (`visibleWhen`) an app may author and the snake_case (`visible_when`) the
|
|
103
|
+
* kernel serves. Returns `undefined` when the field declares neither.
|
|
104
|
+
*/
|
|
105
|
+
export declare function getVisibleWhen(field: {
|
|
106
|
+
visible_when?: VisibleWhen;
|
|
107
|
+
visibleWhen?: VisibleWhen;
|
|
108
|
+
} | null | undefined): VisibleWhen | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* Evaluates a `visible_when` predicate against the current flat form values.
|
|
111
|
+
* Pure — no React, no side effects.
|
|
112
|
+
*
|
|
113
|
+
* - No predicate → `true` (the field is always visible; retrocompat).
|
|
114
|
+
* - With a predicate: read the value of the sibling `cond.field` from
|
|
115
|
+
* `formValues` (as string, null/undefined → ''). Visible when it is a member
|
|
116
|
+
* of `cond.in` (any-of, wins when present) OR equals `cond.equals`. A
|
|
117
|
+
* predicate with an empty `field` is a no-op (visible). A predicate that
|
|
118
|
+
* names a field but declares neither `in` nor `equals` hides nothing
|
|
119
|
+
* (visible) — nothing to gate on.
|
|
120
|
+
*
|
|
121
|
+
* `cond` may be the raw block off either the snake_case or camelCase slot; use
|
|
122
|
+
* `getVisibleWhen` to normalize first.
|
|
123
|
+
*/
|
|
124
|
+
export declare function evaluateVisibleWhen(cond: VisibleWhen | null | undefined, formValues: Record<string, any> | null | undefined): boolean;
|
|
100
125
|
/**
|
|
101
126
|
* Reads a field's enriched options-resolution config, tolerating the camelCase
|
|
102
127
|
* `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
|
|
@@ -1 +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,kBAAkB,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-form-schema.d.ts","sourceRoot":"","sources":["../src/dynamic-form-schema.ts"],"names":[],"mappings":"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"}
|
|
@@ -313,6 +313,47 @@ export function applyOptionWhen(options, formValues, dependsOn) {
|
|
|
313
313
|
return true;
|
|
314
314
|
});
|
|
315
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Reads a field's `visible_when` predicate, tolerating the camelCase alias
|
|
318
|
+
* (`visibleWhen`) an app may author and the snake_case (`visible_when`) the
|
|
319
|
+
* kernel serves. Returns `undefined` when the field declares neither.
|
|
320
|
+
*/
|
|
321
|
+
export function getVisibleWhen(field) {
|
|
322
|
+
if (!field)
|
|
323
|
+
return undefined;
|
|
324
|
+
const vw = field.visible_when ?? field.visibleWhen;
|
|
325
|
+
return vw && typeof vw === 'object' && typeof vw.field === 'string' ? vw : undefined;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Evaluates a `visible_when` predicate against the current flat form values.
|
|
329
|
+
* Pure — no React, no side effects.
|
|
330
|
+
*
|
|
331
|
+
* - No predicate → `true` (the field is always visible; retrocompat).
|
|
332
|
+
* - With a predicate: read the value of the sibling `cond.field` from
|
|
333
|
+
* `formValues` (as string, null/undefined → ''). Visible when it is a member
|
|
334
|
+
* of `cond.in` (any-of, wins when present) OR equals `cond.equals`. A
|
|
335
|
+
* predicate with an empty `field` is a no-op (visible). A predicate that
|
|
336
|
+
* names a field but declares neither `in` nor `equals` hides nothing
|
|
337
|
+
* (visible) — nothing to gate on.
|
|
338
|
+
*
|
|
339
|
+
* `cond` may be the raw block off either the snake_case or camelCase slot; use
|
|
340
|
+
* `getVisibleWhen` to normalize first.
|
|
341
|
+
*/
|
|
342
|
+
export function evaluateVisibleWhen(cond, formValues) {
|
|
343
|
+
if (!cond || typeof cond.field !== 'string' || cond.field.trim() === '')
|
|
344
|
+
return true;
|
|
345
|
+
const raw = formValues ? formValues[cond.field.trim()] : undefined;
|
|
346
|
+
const current = raw == null ? '' : String(raw);
|
|
347
|
+
const inList = cond.in;
|
|
348
|
+
if (Array.isArray(inList) && inList.length > 0) {
|
|
349
|
+
return inList.some((v) => String(v) === current);
|
|
350
|
+
}
|
|
351
|
+
if (typeof cond.equals === 'string') {
|
|
352
|
+
return cond.equals === current;
|
|
353
|
+
}
|
|
354
|
+
// Named a field but declared no comparison → nothing to gate on.
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
316
357
|
/**
|
|
317
358
|
* Reads a field's enriched options-resolution config, tolerating the camelCase
|
|
318
359
|
* `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
|
package/dist/dynamic-form.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type FormLayout } from './form-layout';
|
|
1
2
|
import type { ActionFieldDef } from './types';
|
|
2
3
|
import { buildZodSchema, resolveWidget } from './dynamic-form-schema';
|
|
3
4
|
export { buildZodSchema, resolveWidget };
|
|
@@ -14,6 +15,14 @@ export interface DynamicFormProps {
|
|
|
14
15
|
submitLabel?: string;
|
|
15
16
|
cancelLabel?: string;
|
|
16
17
|
disabled?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Declarative form layout served on the model metadata (kernel PR #230).
|
|
20
|
+
* When present the visible fields are grouped by their `section` respecting
|
|
21
|
+
* `sections` order: `mode:"sections"` stacks (optionally collapsible)
|
|
22
|
+
* sections; `mode:"steps"` renders a Anterior/Siguiente wizard. Absent → the
|
|
23
|
+
* legacy flat list (unchanged).
|
|
24
|
+
*/
|
|
25
|
+
formLayout?: FormLayout;
|
|
17
26
|
}
|
|
18
|
-
export declare function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel, cancelLabel, disabled, }: DynamicFormProps): import("react").JSX.Element;
|
|
27
|
+
export declare function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel, cancelLabel, disabled, formLayout, }: DynamicFormProps): import("react").JSX.Element;
|
|
19
28
|
//# sourceMappingURL=dynamic-form.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dynamic-form.d.ts","sourceRoot":"","sources":["../src/dynamic-form.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAwB,KAAK,UAAU,EAAE,MAAM,eAAe,CAAA;AAcrE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAC7C,OAAO,EACH,cAAc,EACd,aAAa,EAOhB,MAAM,uBAAuB,CAAA;AAQ9B,OAAO,EAAE,cAAc,EAAE,aAAa,EAAE,CAAA;AACxC,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAA;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AACvD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;AAErD,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;IAClB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,UAAU,CAAA;CAC1B;AAED,wBAAgB,WAAW,CAAC,EACxB,MAAM,EACN,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,WAAuB,EACvB,WAAwB,EACxB,QAAgB,EAChB,UAAU,GACb,EAAE,gBAAgB,+BA8LlB"}
|
package/dist/dynamic-form.js
CHANGED
|
@@ -3,8 +3,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// pattern + ActionFieldDef renderer so callers can reuse the form layout
|
|
4
4
|
// outside the full record-edit modal.
|
|
5
5
|
import { useEffect, useMemo, useState } from 'react';
|
|
6
|
+
import { groupFieldsBySection } from './form-layout';
|
|
7
|
+
import { FieldSection, WizardProgress } from './form-layout-ui';
|
|
6
8
|
import { Input, Textarea, Label, Switch, Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@asteby/metacore-ui/primitives';
|
|
7
|
-
import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, applyOptionWhen, getDependsOn, } from './dynamic-form-schema';
|
|
9
|
+
import { buildZodSchema, resolveWidget, isLineItemsField, evaluateBalance, applyOptionWhen, getDependsOn, getVisibleWhen, evaluateVisibleWhen, } from './dynamic-form-schema';
|
|
8
10
|
import { useOptionsResolver } from './use-options-resolver';
|
|
9
11
|
import { DynamicLineItems } from './dynamic-line-items';
|
|
10
12
|
import { DynamicSelectField } from './dynamic-select-field';
|
|
@@ -17,7 +19,7 @@ export { DynamicSelectField } from './dynamic-select-field';
|
|
|
17
19
|
export { DynamicDateField } from './dynamic-date-field';
|
|
18
20
|
export { UploadField } from './upload-field';
|
|
19
21
|
export { IconPickerField } from './icon-picker-field';
|
|
20
|
-
export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, }) {
|
|
22
|
+
export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitLabel = 'Guardar', cancelLabel = 'Cancelar', disabled = false, formLayout, }) {
|
|
21
23
|
const [values, setValues] = useState({});
|
|
22
24
|
const [errors, setErrors] = useState({});
|
|
23
25
|
const [submitting, setSubmitting] = useState(false);
|
|
@@ -27,19 +29,38 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
27
29
|
// (ColumnDef.Readonly). They are also excluded from defaults/submit so a
|
|
28
30
|
// create never posts e.g. `status: ""` over the column default.
|
|
29
31
|
const editableFields = useMemo(() => fields.filter((f) => !f.readonly), [fields]);
|
|
30
|
-
|
|
32
|
+
// Conditional visibility: a field carrying `visible_when` is rendered — and
|
|
33
|
+
// validated — only while the referenced sibling field's current value
|
|
34
|
+
// matches the predicate. Driving BOTH the schema and the render off the same
|
|
35
|
+
// filtered list means a hidden field never blocks submit (its required-gate
|
|
36
|
+
// is dropped with it), matching the primitive's contract. Fields with no
|
|
37
|
+
// `visible_when` are always kept (retrocompat).
|
|
38
|
+
const visibleFields = useMemo(() => editableFields.filter((f) => evaluateVisibleWhen(getVisibleWhen(f), values)), [editableFields, values]);
|
|
39
|
+
const schema = useMemo(() => buildZodSchema(visibleFields), [visibleFields]);
|
|
31
40
|
// Line-items fields carrying a balance rule gate submit: an unbalanced entry
|
|
32
41
|
// (Σdebit ≠ Σcredit, or all-zero when require_nonzero) can't be saved. This
|
|
33
42
|
// is fully declarative — `evaluateBalance` returns undefined for fields with
|
|
34
43
|
// no rule, so non-balanced forms are unaffected.
|
|
35
44
|
const balanceBlocked = useMemo(() => {
|
|
36
|
-
for (const f of
|
|
45
|
+
for (const f of visibleFields) {
|
|
37
46
|
const state = evaluateBalance(f, values[f.key]);
|
|
38
47
|
if (state && !state.balanced)
|
|
39
48
|
return true;
|
|
40
49
|
}
|
|
41
50
|
return false;
|
|
42
|
-
}, [
|
|
51
|
+
}, [visibleFields, values]);
|
|
52
|
+
// Group visible fields by their form_layout section (empty sections drop out
|
|
53
|
+
// because visibleFields is already visibility-filtered). Without a layout
|
|
54
|
+
// this is a single default group → the render below collapses to the legacy
|
|
55
|
+
// flat grid, byte-for-byte.
|
|
56
|
+
const groups = useMemo(() => groupFieldsBySection(visibleFields, formLayout), [visibleFields, formLayout]);
|
|
57
|
+
const isSteps = formLayout?.mode === 'steps' && groups.length > 1;
|
|
58
|
+
// Wizard step cursor (steps mode only). Clamped whenever the group count
|
|
59
|
+
// shrinks (a visible_when flip can empty a trailing step's section).
|
|
60
|
+
const [stepIndex, setStepIndex] = useState(0);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)));
|
|
63
|
+
}, [groups.length]);
|
|
43
64
|
useEffect(() => {
|
|
44
65
|
const defaults = {};
|
|
45
66
|
for (const f of editableFields) {
|
|
@@ -77,11 +98,43 @@ export function DynamicForm({ fields, initialValues, onSubmit, onCancel, submitL
|
|
|
77
98
|
setSubmitting(false);
|
|
78
99
|
}
|
|
79
100
|
};
|
|
101
|
+
// Renders one group's fields into the responsive 2-column grid: scalar
|
|
102
|
+
// header fields flow through it; line-items grids (and textareas) span full
|
|
103
|
+
// width so the row table / memo gets room. Shared by every layout mode.
|
|
104
|
+
const renderGrid = (groupFields) => (_jsx("div", { className: "grid gap-4 sm:grid-cols-2", children: groupFields.map((field) => (_jsx(FieldRow, { field: field, value: values[field.key], onChange: (v) => update(field.key, v), values: values, error: errors[field.key], initialValues: initialValues }, field.key))) }));
|
|
105
|
+
// ── Steps (wizard) mode ────────────────────────────────────────────────
|
|
106
|
+
// One step per section; Anterior/Siguiente navigate, submit only on the
|
|
107
|
+
// last step. Advancing validates just the current step's fields so a later
|
|
108
|
+
// step can't be blocked by an earlier untouched one and vice-versa.
|
|
109
|
+
if (isSteps) {
|
|
110
|
+
const step = groups[stepIndex];
|
|
111
|
+
const isLast = stepIndex === groups.length - 1;
|
|
112
|
+
const goNext = () => {
|
|
113
|
+
const stepSchema = buildZodSchema(step.fields);
|
|
114
|
+
const result = stepSchema.safeParse(values);
|
|
115
|
+
if (!result.success) {
|
|
116
|
+
const next = {};
|
|
117
|
+
for (const issue of result.error.issues) {
|
|
118
|
+
const key = issue.path[0];
|
|
119
|
+
if (typeof key === 'string' && !next[key])
|
|
120
|
+
next[key] = issue.message;
|
|
121
|
+
}
|
|
122
|
+
setErrors(next);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
setErrors({});
|
|
126
|
+
setStepIndex((i) => Math.min(i + 1, groups.length - 1));
|
|
127
|
+
};
|
|
128
|
+
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0));
|
|
129
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [_jsx(WizardProgress, { groups: groups, stepIndex: stepIndex }), renderGrid(step.fields), _jsxs("div", { className: "flex justify-between gap-2 pt-2", children: [stepIndex > 0 ? (_jsx(Button, { type: "button", variant: "outline", onClick: goBack, disabled: submitting || disabled, children: "Anterior" })) : onCancel ? (_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, disabled: submitting || disabled, children: cancelLabel })) : (_jsx("span", {})), isLast ? (_jsx(Button, { type: "submit", disabled: submitting || disabled || balanceBlocked, children: submitLabel })) : (_jsx(Button, { type: "button", onClick: goNext, disabled: submitting || disabled, children: "Siguiente" }))] })] }));
|
|
130
|
+
}
|
|
131
|
+
// ── Sections / flat mode ───────────────────────────────────────────────
|
|
80
132
|
// Layout: scalar header fields flow through a responsive 2-column grid;
|
|
81
133
|
// line-items grids (and textareas) span the full width so the row table /
|
|
82
134
|
// memo gets room. Mirrors the pro look of the federated journal modal but
|
|
83
|
-
// stays fully declarative — driven only by field shape.
|
|
84
|
-
|
|
135
|
+
// stays fully declarative — driven only by field shape. With no layout this
|
|
136
|
+
// is a single default group rendered without section chrome (unchanged).
|
|
137
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "grid gap-4", children: [groups.map((group) => (_jsx(FieldSection, { group: group, children: renderGrid(group.fields) }, group.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 || balanceBlocked, children: submitLabel })] })] }));
|
|
85
138
|
}
|
|
86
139
|
// One form field row: label + renderer + inline error. Encapsulated as its own
|
|
87
140
|
// component so a STATIC enum select whose options are all gated out by a sibling
|
|
@@ -27,6 +27,18 @@ interface CommonProps {
|
|
|
27
27
|
filters?: Record<string, string>;
|
|
28
28
|
/** Hidden columns; el FK siempre se oculta automáticamente. */
|
|
29
29
|
hiddenColumns?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Contexto de sub-tabla de líneas dentro del MODAL de vista de un registro
|
|
32
|
+
* padre. Cuando es true se aplican las reglas de {@link isColumnVisibleInLineSubtable}
|
|
33
|
+
* — se ocultan por defecto las columnas de auditoría/sistema (created_by,
|
|
34
|
+
* timestamps, organization_id) y las scopeadas a `visibility: "table"`, que
|
|
35
|
+
* son ruido redundante bajo el registro padre.
|
|
36
|
+
*
|
|
37
|
+
* Default false: en una página de detalle autónoma (`/m/<model>/<id>`) el
|
|
38
|
+
* panel de relación conserva el comportamiento previo (solo oculta FK, scope
|
|
39
|
+
* y columnas `hidden`), donde esas columnas SÍ son útiles.
|
|
40
|
+
*/
|
|
41
|
+
lineSubtable?: boolean;
|
|
30
42
|
/** Permisos visibles. Default true. */
|
|
31
43
|
canCreate?: boolean;
|
|
32
44
|
canDelete?: boolean;
|