@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
|
@@ -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>
|
package/src/dashboard-grid.tsx
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
825
|
-
|
|
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
|
|
968
|
+
className="grid gap-y-4"
|
|
832
969
|
>
|
|
833
|
-
{
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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:
|
|
863
|
-
<
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
{
|
|
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"
|