@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/action-modal-dispatcher.js +1 -1
  3. package/dist/dashboard-grid.d.ts.map +1 -1
  4. package/dist/dashboard-grid.js +5 -1
  5. package/dist/dialogs/dynamic-record.d.ts +20 -1
  6. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  7. package/dist/dialogs/dynamic-record.js +95 -23
  8. package/dist/dynamic-form-schema.d.ts +26 -1
  9. package/dist/dynamic-form-schema.d.ts.map +1 -1
  10. package/dist/dynamic-form-schema.js +41 -0
  11. package/dist/dynamic-form.d.ts +10 -1
  12. package/dist/dynamic-form.d.ts.map +1 -1
  13. package/dist/dynamic-form.js +60 -7
  14. package/dist/dynamic-relation.d.ts +12 -0
  15. package/dist/dynamic-relation.d.ts.map +1 -1
  16. package/dist/dynamic-relation.js +12 -6
  17. package/dist/dynamic-relations.d.ts +9 -1
  18. package/dist/dynamic-relations.d.ts.map +1 -1
  19. package/dist/dynamic-relations.js +5 -4
  20. package/dist/form-layout-ui.d.ts +25 -0
  21. package/dist/form-layout-ui.d.ts.map +1 -0
  22. package/dist/form-layout-ui.js +39 -0
  23. package/dist/form-layout.d.ts +54 -0
  24. package/dist/form-layout.d.ts.map +1 -0
  25. package/dist/form-layout.js +81 -0
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -1
  29. package/dist/types.d.ts +47 -0
  30. package/dist/types.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/__tests__/dynamic-record-hidden-submit.test.tsx +78 -0
  33. package/src/__tests__/dynamic-relation-subtable-context.test.tsx +74 -0
  34. package/src/__tests__/form-layout.test.ts +113 -0
  35. package/src/__tests__/visible-when.test.ts +63 -0
  36. package/src/action-modal-dispatcher.tsx +1 -1
  37. package/src/dashboard-grid.tsx +5 -1
  38. package/src/dialogs/dynamic-record.tsx +189 -50
  39. package/src/dynamic-form-schema.ts +47 -1
  40. package/src/dynamic-form.tsx +126 -17
  41. package/src/dynamic-relation.tsx +25 -8
  42. package/src/dynamic-relations.tsx +16 -1
  43. package/src/form-layout-ui.tsx +117 -0
  44. package/src/form-layout.ts +116 -0
  45. package/src/index.ts +4 -0
  46. package/src/types.ts +48 -0
@@ -0,0 +1,74 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // `lineSubtable` scopes the audit/system-column hiding to the view-modal
4
+ // line-subtable context. On a standalone detail page (default, lineSubtable
5
+ // omitted) the relation panel keeps the previous behaviour and still renders
6
+ // audit columns (created_at, created_by, …). Inside the view modal the host
7
+ // passes `lineSubtable` and those redundant columns are dropped.
8
+ //
9
+ // Guards against the regression CodeRabbit flagged on SDK #659: the audit
10
+ // filter was applied unconditionally to every DynamicRelation, hiding those
11
+ // columns on full detail pages too.
12
+ import { afterEach, describe, expect, it, vi } from 'vitest'
13
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
14
+ import { DynamicRelation } from '../dynamic-relation'
15
+ import { ApiProvider, type ApiClient } from '../api-context'
16
+
17
+ afterEach(cleanup)
18
+
19
+ const META = {
20
+ name: 'line_item',
21
+ columns: [
22
+ { key: 'id', label: 'ID', type: 'text', sortable: true, filterable: false, hidden: true },
23
+ { key: 'invoice_id', label: 'Factura', type: 'text', sortable: false, filterable: false },
24
+ { key: 'sku', label: 'SKU', type: 'text', sortable: true, filterable: true },
25
+ { key: 'created_at', label: 'Creado', type: 'datetime', sortable: true, filterable: false },
26
+ ],
27
+ actions: [],
28
+ hasActions: false,
29
+ enableCRUDActions: false,
30
+ }
31
+
32
+ const DATA = [{ id: 'li_1', invoice_id: 'inv_42', sku: 'A-1', created_at: '2026-01-01T00:00:00Z' }]
33
+
34
+ function mockApi(): ApiClient {
35
+ return {
36
+ get: vi.fn((url: string) => {
37
+ if (url.startsWith('/metadata/table/')) {
38
+ return Promise.resolve({ data: { success: true, data: META } })
39
+ }
40
+ return Promise.resolve({ data: { success: true, data: DATA } })
41
+ }),
42
+ post: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
43
+ put: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
44
+ delete: vi.fn(() => Promise.resolve({ data: { success: true, data: {} } })),
45
+ }
46
+ }
47
+
48
+ function renderPanel(extra: Record<string, unknown>) {
49
+ return render(
50
+ <ApiProvider client={mockApi()}>
51
+ <DynamicRelation
52
+ kind="one_to_many"
53
+ model="line_item"
54
+ foreignKey="invoice_id"
55
+ parentId="inv_42"
56
+ {...extra}
57
+ />
58
+ </ApiProvider>,
59
+ )
60
+ }
61
+
62
+ describe('DynamicRelation — lineSubtable context', () => {
63
+ it('página de detalle (default): conserva la columna de auditoría created_at', async () => {
64
+ renderPanel({})
65
+ await waitFor(() => expect(screen.getByText('A-1')).toBeTruthy())
66
+ expect(screen.getByText('Creado')).toBeTruthy()
67
+ })
68
+
69
+ it('sub-tabla del modal de vista (lineSubtable): oculta created_at', async () => {
70
+ renderPanel({ lineSubtable: true })
71
+ await waitFor(() => expect(screen.getByText('A-1')).toBeTruthy())
72
+ expect(screen.queryByText('Creado')).toBeNull()
73
+ })
74
+ })
@@ -0,0 +1,113 @@
1
+ // Declarative form_layout grouping contract (kernel PR #230). The two renderers
2
+ // (dynamic-form.tsx, dialogs/dynamic-record.tsx) both drive their sections /
3
+ // wizard steps off `groupFieldsBySection`, so — following PR #673 — we test the
4
+ // pure grouping helper directly instead of booting the full modal under
5
+ // happy-dom (which hangs on the async metadata/options fetches).
6
+ import { describe, expect, it } from 'vitest'
7
+ import {
8
+ groupFieldsBySection,
9
+ DEFAULT_SECTION_KEY,
10
+ type FormLayout,
11
+ } from '../form-layout'
12
+
13
+ interface F {
14
+ key: string
15
+ section?: string
16
+ }
17
+
18
+ const layout: FormLayout = {
19
+ mode: 'sections',
20
+ sections: [
21
+ { key: 'general', title: 'General' },
22
+ { key: 'billing', title: 'Facturación', collapsed: true },
23
+ { key: 'notes', title: 'Notas' }, // will end up empty → hidden
24
+ ],
25
+ }
26
+
27
+ describe('groupFieldsBySection — sections', () => {
28
+ it('groups visible fields by section respecting the sections order', () => {
29
+ const fields: F[] = [
30
+ { key: 'tax_id', section: 'billing' },
31
+ { key: 'name', section: 'general' },
32
+ { key: 'email', section: 'general' },
33
+ { key: 'currency', section: 'billing' },
34
+ ]
35
+ const groups = groupFieldsBySection(fields, layout)
36
+
37
+ // No orphans → no default group; sections in authored order; the empty
38
+ // `notes` section is omitted entirely.
39
+ expect(groups.map(g => g.key)).toEqual(['general', 'billing'])
40
+ expect(groups[0].fields.map(f => f.key)).toEqual(['name', 'email'])
41
+ expect(groups[1].fields.map(f => f.key)).toEqual(['tax_id', 'currency'])
42
+ // `collapsed` is carried through so the chrome can start collapsed.
43
+ expect(groups[1].collapsed).toBe(true)
44
+ })
45
+
46
+ it('puts section-less and unknown-section fields in a default group placed first', () => {
47
+ const fields: F[] = [
48
+ { key: 'name', section: 'general' },
49
+ { key: 'orphan' }, // no section
50
+ { key: 'stray', section: 'does_not_exist' }, // unknown section
51
+ ]
52
+ const groups = groupFieldsBySection(fields, layout)
53
+
54
+ expect(groups[0].key).toBe(DEFAULT_SECTION_KEY)
55
+ expect(groups[0].isDefault).toBe(true)
56
+ expect(groups[0].fields.map(f => f.key)).toEqual(['orphan', 'stray'])
57
+ expect(groups[1].key).toBe('general')
58
+ })
59
+
60
+ it('hides a section whose only members are filtered out (visible_when)', () => {
61
+ // The caller passes the ALREADY visibility-filtered list, so a section
62
+ // left with no fields must not render an empty shell / empty step.
63
+ const fields: F[] = [{ key: 'name', section: 'general' }]
64
+ const groups = groupFieldsBySection(fields, layout)
65
+ expect(groups.map(g => g.key)).toEqual(['general'])
66
+ })
67
+
68
+ it('without a layout returns a single default group carrying every field in order', () => {
69
+ const fields: F[] = [{ key: 'a' }, { key: 'b' }, { key: 'c', section: 'general' }]
70
+ const groups = groupFieldsBySection(fields, undefined)
71
+ expect(groups).toHaveLength(1)
72
+ expect(groups[0].key).toBe(DEFAULT_SECTION_KEY)
73
+ expect(groups[0].isDefault).toBe(true)
74
+ expect(groups[0].fields.map(f => f.key)).toEqual(['a', 'b', 'c'])
75
+ })
76
+ })
77
+
78
+ describe('groupFieldsBySection — steps', () => {
79
+ const stepsLayout: FormLayout = {
80
+ mode: 'steps',
81
+ sections: [
82
+ { key: 'who', title: 'Cliente' },
83
+ { key: 'what', title: 'Productos' },
84
+ { key: 'pay', title: 'Pago' },
85
+ ],
86
+ }
87
+
88
+ it('yields one group per non-empty step in order, ready for wizard navigation', () => {
89
+ const fields: F[] = [
90
+ { key: 'amount', section: 'pay' },
91
+ { key: 'customer_id', section: 'who' },
92
+ { key: 'items', section: 'what' },
93
+ ]
94
+ const groups = groupFieldsBySection(fields, stepsLayout)
95
+
96
+ // Ordered as authored → step 0 = who, last = pay, so a wizard advances
97
+ // who → what → pay and submits on `pay`.
98
+ expect(groups.map(g => g.key)).toEqual(['who', 'what', 'pay'])
99
+ const last = groups.length - 1
100
+ expect(groups[last].key).toBe('pay')
101
+ expect(groups[last].title).toBe('Pago')
102
+ })
103
+
104
+ it('drops an empty step so the wizard never stops on a blank page', () => {
105
+ const fields: F[] = [
106
+ { key: 'customer_id', section: 'who' },
107
+ { key: 'amount', section: 'pay' },
108
+ ]
109
+ const groups = groupFieldsBySection(fields, stepsLayout)
110
+ // `what` has no visible field → omitted; wizard is who → pay.
111
+ expect(groups.map(g => g.key)).toEqual(['who', 'pay'])
112
+ })
113
+ })
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { evaluateVisibleWhen, getVisibleWhen } from '../dynamic-form-schema'
3
+ import type { VisibleWhen } from '../types'
4
+
5
+ // The discount_rules use case: a `rule_scope` field decides which picker shows.
6
+ const equalsProduct: VisibleWhen = { field: 'rule_scope', equals: 'product' }
7
+ const inCategory: VisibleWhen = { field: 'rule_scope', in: ['category'] }
8
+ const inMulti: VisibleWhen = { field: 'scope', in: ['a', 'b'] }
9
+
10
+ describe('evaluateVisibleWhen', () => {
11
+ it('no predicate → always visible', () => {
12
+ expect(evaluateVisibleWhen(undefined, { rule_scope: 'all' })).toBe(true)
13
+ expect(evaluateVisibleWhen(null, {})).toBe(true)
14
+ })
15
+
16
+ it('empty field → always visible (no-op)', () => {
17
+ expect(evaluateVisibleWhen({ field: '' } as VisibleWhen, { rule_scope: 'x' })).toBe(true)
18
+ })
19
+
20
+ it('`equals` matches the sibling value exactly', () => {
21
+ expect(evaluateVisibleWhen(equalsProduct, { rule_scope: 'product' })).toBe(true)
22
+ expect(evaluateVisibleWhen(equalsProduct, { rule_scope: 'category' })).toBe(false)
23
+ expect(evaluateVisibleWhen(equalsProduct, { rule_scope: 'all' })).toBe(false)
24
+ })
25
+
26
+ it('`in` matches membership; wins over `equals` when both present', () => {
27
+ expect(evaluateVisibleWhen(inCategory, { rule_scope: 'category' })).toBe(true)
28
+ expect(evaluateVisibleWhen(inCategory, { rule_scope: 'product' })).toBe(false)
29
+ expect(evaluateVisibleWhen(inMulti, { scope: 'b' })).toBe(true)
30
+ const both: VisibleWhen = { field: 'rule_scope', equals: 'product', in: ['category'] }
31
+ // `in` wins: category matches, product does not.
32
+ expect(evaluateVisibleWhen(both, { rule_scope: 'category' })).toBe(true)
33
+ expect(evaluateVisibleWhen(both, { rule_scope: 'product' })).toBe(false)
34
+ })
35
+
36
+ it('missing / null sibling value compares as empty string', () => {
37
+ expect(evaluateVisibleWhen(equalsProduct, {})).toBe(false)
38
+ expect(evaluateVisibleWhen(equalsProduct, { rule_scope: null })).toBe(false)
39
+ expect(evaluateVisibleWhen({ field: 'x', equals: '' } as VisibleWhen, {})).toBe(true)
40
+ })
41
+
42
+ it('coerces non-string sibling values before comparison', () => {
43
+ expect(evaluateVisibleWhen({ field: 'n', equals: '5' } as VisibleWhen, { n: 5 })).toBe(true)
44
+ expect(evaluateVisibleWhen({ field: 'b', in: ['true'] } as VisibleWhen, { b: true })).toBe(true)
45
+ })
46
+
47
+ it('predicate naming a field but no comparison → visible', () => {
48
+ expect(evaluateVisibleWhen({ field: 'rule_scope' } as VisibleWhen, { rule_scope: 'all' })).toBe(true)
49
+ })
50
+ })
51
+
52
+ describe('getVisibleWhen', () => {
53
+ it('reads snake_case and camelCase aliases', () => {
54
+ expect(getVisibleWhen({ visible_when: equalsProduct })).toEqual(equalsProduct)
55
+ expect(getVisibleWhen({ visibleWhen: inCategory })).toEqual(inCategory)
56
+ })
57
+
58
+ it('returns undefined for none / malformed', () => {
59
+ expect(getVisibleWhen(undefined)).toBeUndefined()
60
+ expect(getVisibleWhen({})).toBeUndefined()
61
+ expect(getVisibleWhen({ visible_when: { equals: 'x' } as unknown as VisibleWhen })).toBeUndefined()
62
+ })
63
+ })
@@ -686,7 +686,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
686
686
  })}
687
687
  {relations.length > 0 && (
688
688
  <FieldCell fullWidth>
689
- <DynamicRelations record={record} relations={relations} />
689
+ <DynamicRelations record={record} relations={relations} lineSubtable />
690
690
  </FieldCell>
691
691
  )}
692
692
  </FieldGrid>
@@ -164,7 +164,11 @@ export function DashboardGrid({
164
164
  // box auto-height and the tiles collapse into a strip — so we own
165
165
  // the height here instead of hoping the dashboard layout supplies
166
166
  // one.
167
- 'h-[clamp(420px,60vh,720px)] w-full rounded-xl border border-dashed border-border/60 p-4',
167
+ // No dashed border / padding wrapper: the skeleton tiles ARE
168
+ // cards (same anatomy as the real widgets), so the mockup
169
+ // should float exactly like the loaded dashboard, not sit
170
+ // inside a boxed placeholder. Keep only the definite height.
171
+ 'h-[clamp(420px,60vh,720px)] w-full',
168
172
  className,
169
173
  )}
170
174
  >
@@ -52,7 +52,10 @@ import { toastServerError, extractFieldErrors, localizeFieldIssue } from '../ser
52
52
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
53
53
  import { DynamicRelations } from '../dynamic-relations'
54
54
  import { useOptionsResolver, type ResolvedOption } from '../use-options-resolver'
55
- import { getFieldRef } from '../dynamic-form-schema'
55
+ import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-form-schema'
56
+ import type { VisibleWhen } from '../types'
57
+ import { groupFieldsBySection, type FormLayout } from '../form-layout'
58
+ import { FieldSection, WizardProgress } from '../form-layout-ui'
56
59
  import { FieldCell } from '../field-grid'
57
60
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
58
61
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
@@ -146,6 +149,23 @@ export interface FieldDef {
146
149
  itemFields?: ItemField[]
147
150
  /** snake_case alias served by the kernel for `itemFields`. */
148
151
  item_fields?: ItemField[]
152
+ /**
153
+ * Conditional visibility: render — and required-check — this field only
154
+ * while a sibling field's current value matches the predicate. Mirrors the
155
+ * kernel v3 `visible_when` (projected onto the served modal FieldDef).
156
+ * Tolerates the camelCase alias. Absent = always visible; a hidden field is
157
+ * dropped from the required-gate so it never blocks submit. Evaluated by
158
+ * `evaluateVisibleWhen` against the live form values.
159
+ */
160
+ visible_when?: VisibleWhen
161
+ /** camelCase alias for `visible_when`. */
162
+ visibleWhen?: VisibleWhen
163
+ /**
164
+ * Form-layout membership: the key of the `form_layout` section this field
165
+ * belongs to (kernel PR #230). Absent → the default group. See
166
+ * `groupFieldsBySection`.
167
+ */
168
+ section?: string
149
169
  }
150
170
 
151
171
  // Permissive shape: the wire payload may omit some fields (e.g. `title` is
@@ -163,6 +183,15 @@ interface ModalMetadata {
163
183
  createTitle?: string
164
184
  editTitle?: string
165
185
  fields?: FieldDef[]
186
+ /**
187
+ * Declarative form layout (kernel PR #230): groups the fields into named
188
+ * sections rendered stacked (`mode:"sections"`) or as a wizard
189
+ * (`mode:"steps"`). Absent → the legacy flat two-column grid. Tolerates the
190
+ * camelCase alias an app might author.
191
+ */
192
+ form_layout?: FormLayout
193
+ /** camelCase alias for `form_layout`. */
194
+ formLayout?: FormLayout
166
195
  /**
167
196
  * Backend-localized CRUD success messages (modal metadata). Preferred over
168
197
  * the raw response message which is not localized.
@@ -458,17 +487,49 @@ export function isMoneyField(field: FieldDef, value: any): boolean {
458
487
  // given mode. `hidden` fields never render. A `readonly` (server/system-
459
488
  // generated) field is EXCLUDED on create — the user can't set a value the
460
489
  // server will overwrite — but stays visible on edit/view (rendered disabled).
490
+ //
491
+ // `formValues`, when provided, additionally applies each field's `visible_when`
492
+ // predicate against the live form values (conditional visibility): a field is
493
+ // dropped while the referenced sibling's value does not match. Driving both the
494
+ // render and the required-gate off this same list means a hidden field never
495
+ // blocks submit. Omitting `formValues` keeps the legacy (always-visible)
496
+ // behaviour for callers that only gate on mode.
461
497
  export function filterVisibleFields(
462
498
  fields: FieldDef[] | undefined,
463
499
  mode: 'view' | 'edit' | 'create',
500
+ formValues?: Record<string, any>,
464
501
  ): FieldDef[] {
465
502
  return (fields ?? []).filter(f => {
466
503
  if (f.hidden) return false
467
504
  if (mode === 'create' && f.readonly) return false
505
+ if (formValues && !evaluateVisibleWhen(getVisibleWhen(f), formValues)) return false
468
506
  return true
469
507
  })
470
508
  }
471
509
 
510
+ // stripHiddenFieldValues drops from a flat form-values object the keys of any
511
+ // declared field currently hidden by its `visible_when` predicate, so the
512
+ // submit never POSTs a value the form isn't showing (e.g. a DiscountRule with
513
+ // rule_scope=category must not send the product_id / customer_id it hides).
514
+ // This mirrors dynamic-form.tsx, which builds its Zod schema only over the
515
+ // visible fields — hidden fields are dropped from BOTH the render/required-gate
516
+ // AND the submitted values. Keys with no matching declared field, or whose
517
+ // field carries no `visible_when`, always pass through (retrocompat).
518
+ export function stripHiddenFieldValues(
519
+ values: Record<string, any>,
520
+ fields: FieldDef[] | undefined,
521
+ mode: 'view' | 'edit' | 'create',
522
+ ): Record<string, any> {
523
+ const visibleKeys = new Set(filterVisibleFields(fields, mode, values).map(f => f.key))
524
+ const out: Record<string, any> = {}
525
+ for (const [key, value] of Object.entries(values)) {
526
+ const field = (fields ?? []).find(f => f.key === key)
527
+ if (field && !visibleKeys.has(key) && getVisibleWhen(field)) continue
528
+ out[key] = value
529
+ }
530
+ return out
531
+ }
532
+
472
533
  export function DynamicRecordDialog({
473
534
  open,
474
535
  onOpenChange,
@@ -505,6 +566,8 @@ export function DynamicRecordDialog({
505
566
  const [loading, setLoading] = useState(false)
506
567
  const [saving, setSaving] = useState(false)
507
568
  const [deleting, setDeleting] = useState(false)
569
+ // Wizard step cursor (form_layout mode:"steps" only).
570
+ const [stepIndex, setStepIndex] = useState(0)
508
571
 
509
572
  const isCreate = mode === 'create'
510
573
  const isView = mode === 'view'
@@ -516,8 +579,10 @@ export function DynamicRecordDialog({
516
579
  if (!open) return
517
580
  if (!isCreate && !recordId) return
518
581
 
519
- // Fresh open → drop any validation errors from a prior submit.
582
+ // Fresh open → drop any validation errors from a prior submit and reset
583
+ // the wizard to its first step.
520
584
  setFieldErrors({})
585
+ setStepIndex(0)
521
586
 
522
587
  let cancelled = false
523
588
 
@@ -711,9 +776,12 @@ export function DynamicRecordDialog({
711
776
 
712
777
  if (isEditable) {
713
778
  // Collect ALL missing required fields (not just the first) and mark
714
- // each inline instead of a single toast.
779
+ // each inline instead of a single toast. Only CURRENTLY-VISIBLE
780
+ // fields are gated: a field hidden by its `visible_when` predicate
781
+ // must not block submit even when it is declared required (matching
782
+ // the render, which drops it via the same filter).
715
783
  const missing: Record<string, string> = {}
716
- for (const field of modalMeta.fields ?? []) {
784
+ for (const field of filterVisibleFields(modalMeta.fields, mode, formValues)) {
717
785
  if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
718
786
  missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
719
787
  }
@@ -728,9 +796,16 @@ export function DynamicRecordDialog({
728
796
  // Required check passed → clear any prior validation errors.
729
797
  setFieldErrors({})
730
798
 
799
+ // Fields hidden by their `visible_when` predicate must not be POSTed:
800
+ // the render, the required-gate and the payload all drive off the same
801
+ // filter, so a DiscountRule with scope=category never submits the
802
+ // product_id / customer_id it isn't showing. Mirrors dynamic-form.tsx,
803
+ // which builds its Zod only over visibleFields.
804
+ const submittedValues = stripHiddenFieldValues(formValues, modalMeta.fields, mode)
805
+
731
806
  // Empty reference pickers → null (not "" / nil-UUID) so nullable FK
732
807
  // columns accept them instead of raising a 23503 FK violation.
733
- const payload = normalizeRefFieldsForSubmit(formValues, modalMeta.fields)
808
+ const payload = normalizeRefFieldsForSubmit(submittedValues, modalMeta.fields)
734
809
 
735
810
  setSaving(true)
736
811
  try {
@@ -803,7 +878,66 @@ export function DynamicRecordDialog({
803
878
 
804
879
  const title = modalMeta ? config.getTitle(modalMeta, t) : ''
805
880
 
806
- const visibleFields = filterVisibleFields(modalMeta?.fields, mode)
881
+ const visibleFields = filterVisibleFields(modalMeta?.fields, mode, formValues)
882
+
883
+ // Declarative form layout: group the (already visibility-filtered) fields by
884
+ // their section. Empty sections drop out for free. Steps mode only drives a
885
+ // wizard in editable modes — view mode always stacks the sections.
886
+ const formLayout = modalMeta?.form_layout ?? modalMeta?.formLayout
887
+ const groups = groupFieldsBySection(visibleFields, formLayout)
888
+ const isSteps = isEditable && formLayout?.mode === 'steps' && groups.length > 1
889
+ const clampedStep = Math.min(stepIndex, Math.max(groups.length - 1, 0))
890
+ const isLastStep = clampedStep === groups.length - 1
891
+
892
+ // Renders a list of fields into the shared two-column grid (each FieldCell
893
+ // gives min-w-0 so long values can't blow the columns past the dialog).
894
+ const renderFields = (groupFields: FieldDef[]) =>
895
+ groupFields.map(field => {
896
+ const isFullWidth =
897
+ field.type === 'textarea' ||
898
+ field.widget === 'textarea' ||
899
+ field.widget === 'richtext'
900
+ return (
901
+ <FieldCell key={field.key} fullWidth={isFullWidth}>
902
+ <FieldRow
903
+ field={field}
904
+ record={record}
905
+ value={formValues[field.key] ?? ''}
906
+ mode={mode}
907
+ error={fieldErrors[field.key]}
908
+ onChange={val => {
909
+ setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
910
+ setFieldErrors(prev => {
911
+ if (!prev[field.key]) return prev
912
+ const next = { ...prev }
913
+ delete next[field.key]
914
+ return next
915
+ })
916
+ }}
917
+ />
918
+ </FieldCell>
919
+ )
920
+ })
921
+
922
+ // Wizard "Siguiente": gate only the CURRENT step's required (visible) fields,
923
+ // then advance. Mirrors handleSubmit's required check but scoped to the step.
924
+ const goNextStep = () => {
925
+ const step = groups[clampedStep]
926
+ const missing: Record<string, string> = {}
927
+ for (const field of step?.fields ?? []) {
928
+ if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
929
+ missing[field.key] = localizeFieldIssue({ code: 'required' }, field.label, t)
930
+ }
931
+ }
932
+ if (Object.keys(missing).length) {
933
+ setFieldErrors(missing)
934
+ toast.error(t('dynamic.validation_failed', { defaultValue: 'Revisa los campos marcados' }))
935
+ return
936
+ }
937
+ setFieldErrors({})
938
+ setStepIndex(Math.min(clampedStep + 1, groups.length - 1))
939
+ }
940
+ const goBackStep = () => setStepIndex(Math.max(clampedStep - 1, 0))
807
941
 
808
942
  return (
809
943
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -821,54 +955,42 @@ export function DynamicRecordDialog({
821
955
  <ImageUrlContext.Provider value={getImageUrl}>
822
956
  <TimeZoneContext.Provider value={timeZone}>
823
957
  <CurrencyContext.Provider value={currency}>
824
- {/* The grid IS the form element (the footer submit
825
- button targets it by id). FieldCell gives each
958
+ {/* The form element groups its fields by the declared
959
+ form_layout (sections stacked, or the current
960
+ wizard step). Without a layout this is a single
961
+ default group rendered with no chrome — the legacy
962
+ two-column grid, unchanged. FieldCell gives each
826
963
  cell `min-w-0` so a long select/input value can't
827
964
  blow the two columns past the dialog width. */}
828
965
  <form
829
966
  id="dynamic-record-form"
830
967
  onSubmit={handleSubmit}
831
- className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2"
968
+ className="grid gap-y-4"
832
969
  >
833
- {visibleFields.map(field => {
834
- const isFullWidth =
835
- field.type === 'textarea' ||
836
- field.widget === 'textarea' ||
837
- field.widget === 'richtext'
838
- return (
839
- <FieldCell key={field.key} fullWidth={isFullWidth}>
840
- <FieldRow
841
- field={field}
842
- record={record}
843
- value={formValues[field.key] ?? ''}
844
- mode={mode}
845
- error={fieldErrors[field.key]}
846
- onChange={val => {
847
- setFormValues((prev: Record<string, any>) => ({ ...prev, [field.key]: val }))
848
- // Clear this field's error as soon as the user edits it.
849
- setFieldErrors(prev => {
850
- if (!prev[field.key]) return prev
851
- const next = { ...prev }
852
- delete next[field.key]
853
- return next
854
- })
855
- }}
856
- />
857
- </FieldCell>
858
- )
859
- })}
970
+ {isSteps && (
971
+ <WizardProgress groups={groups} stepIndex={clampedStep} />
972
+ )}
973
+ {(isSteps ? [groups[clampedStep]] : groups).map(group => (
974
+ <FieldSection key={group.key} group={group}>
975
+ <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
976
+ {renderFields(group.fields)}
977
+ </div>
978
+ </FieldSection>
979
+ ))}
860
980
 
861
981
  {record?.external_url && (
862
- <div className="sm:col-span-2 min-w-0">
863
- <a
864
- href={record.external_url}
865
- target="_blank"
866
- rel="noreferrer"
867
- className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
868
- >
869
- <ExternalLink className="h-3.5 w-3.5" />
870
- Ver en {record.external_provider ?? 'proveedor externo'}
871
- </a>
982
+ <div className="grid grid-cols-1 sm:grid-cols-2">
983
+ <div className="sm:col-span-2 min-w-0">
984
+ <a
985
+ href={record.external_url}
986
+ target="_blank"
987
+ rel="noreferrer"
988
+ className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
989
+ >
990
+ <ExternalLink className="h-3.5 w-3.5" />
991
+ Ver en {record.external_provider ?? 'proveedor externo'}
992
+ </a>
993
+ </div>
872
994
  </div>
873
995
  )}
874
996
  </form>
@@ -881,6 +1003,7 @@ export function DynamicRecordDialog({
881
1003
  <DynamicRelations
882
1004
  record={record}
883
1005
  relations={relations}
1006
+ lineSubtable
884
1007
  canCreate={mode === 'edit'}
885
1008
  canEdit={mode === 'edit'}
886
1009
  canDelete={mode === 'edit'}
@@ -908,9 +1031,17 @@ export function DynamicRecordDialog({
908
1031
  </Button>
909
1032
  ) : <span />}
910
1033
  <div className="flex items-center gap-2">
911
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
912
- {config.cancelLabel}
913
- </Button>
1034
+ {/* Wizard "Anterior" replaces Cancel from the second step
1035
+ on; the first step still shows Cancel. */}
1036
+ {isSteps && clampedStep > 0 ? (
1037
+ <Button variant="outline" onClick={goBackStep} disabled={saving || deleting}>
1038
+ Anterior
1039
+ </Button>
1040
+ ) : (
1041
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
1042
+ {config.cancelLabel}
1043
+ </Button>
1044
+ )}
914
1045
  {isView && onDelete && (
915
1046
  <Button
916
1047
  variant="destructive"
@@ -926,7 +1057,15 @@ export function DynamicRecordDialog({
926
1057
  Editar
927
1058
  </Button>
928
1059
  )}
929
- {isEditable && (
1060
+ {/* Non-final wizard step → "Siguiente" validates the step
1061
+ and advances instead of submitting. The last step (and
1062
+ every non-wizard form) keeps the real submit button. */}
1063
+ {isEditable && isSteps && !isLastStep && (
1064
+ <Button type="button" onClick={goNextStep} disabled={saving || loading}>
1065
+ Siguiente
1066
+ </Button>
1067
+ )}
1068
+ {isEditable && (!isSteps || isLastStep) && (
930
1069
  <Button
931
1070
  type="submit"
932
1071
  form="dynamic-record-form"