@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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// callers (and unit tests) can use the zod schema without pulling in React or
|
|
3
3
|
// metacore-ui primitives.
|
|
4
4
|
import { z, type ZodTypeAny } from 'zod'
|
|
5
|
-
import type { ActionFieldDef, FieldValidation, FieldOptionsConfig, OptionDef } from './types'
|
|
5
|
+
import type { ActionFieldDef, FieldValidation, FieldOptionsConfig, OptionDef, VisibleWhen } from './types'
|
|
6
6
|
import { resolveValidatorToken } from './use-org-config-bridge'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -335,6 +335,52 @@ export function applyOptionWhen(
|
|
|
335
335
|
})
|
|
336
336
|
}
|
|
337
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Reads a field's `visible_when` predicate, tolerating the camelCase alias
|
|
340
|
+
* (`visibleWhen`) an app may author and the snake_case (`visible_when`) the
|
|
341
|
+
* kernel serves. Returns `undefined` when the field declares neither.
|
|
342
|
+
*/
|
|
343
|
+
export function getVisibleWhen(
|
|
344
|
+
field: { visible_when?: VisibleWhen; visibleWhen?: VisibleWhen } | null | undefined,
|
|
345
|
+
): VisibleWhen | undefined {
|
|
346
|
+
if (!field) return undefined
|
|
347
|
+
const vw = field.visible_when ?? field.visibleWhen
|
|
348
|
+
return vw && typeof vw === 'object' && typeof vw.field === 'string' ? vw : undefined
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Evaluates a `visible_when` predicate against the current flat form values.
|
|
353
|
+
* Pure — no React, no side effects.
|
|
354
|
+
*
|
|
355
|
+
* - No predicate → `true` (the field is always visible; retrocompat).
|
|
356
|
+
* - With a predicate: read the value of the sibling `cond.field` from
|
|
357
|
+
* `formValues` (as string, null/undefined → ''). Visible when it is a member
|
|
358
|
+
* of `cond.in` (any-of, wins when present) OR equals `cond.equals`. A
|
|
359
|
+
* predicate with an empty `field` is a no-op (visible). A predicate that
|
|
360
|
+
* names a field but declares neither `in` nor `equals` hides nothing
|
|
361
|
+
* (visible) — nothing to gate on.
|
|
362
|
+
*
|
|
363
|
+
* `cond` may be the raw block off either the snake_case or camelCase slot; use
|
|
364
|
+
* `getVisibleWhen` to normalize first.
|
|
365
|
+
*/
|
|
366
|
+
export function evaluateVisibleWhen(
|
|
367
|
+
cond: VisibleWhen | null | undefined,
|
|
368
|
+
formValues: Record<string, any> | null | undefined,
|
|
369
|
+
): boolean {
|
|
370
|
+
if (!cond || typeof cond.field !== 'string' || cond.field.trim() === '') return true
|
|
371
|
+
const raw = formValues ? formValues[cond.field.trim()] : undefined
|
|
372
|
+
const current = raw == null ? '' : String(raw)
|
|
373
|
+
const inList = cond.in
|
|
374
|
+
if (Array.isArray(inList) && inList.length > 0) {
|
|
375
|
+
return inList.some((v) => String(v) === current)
|
|
376
|
+
}
|
|
377
|
+
if (typeof cond.equals === 'string') {
|
|
378
|
+
return cond.equals === current
|
|
379
|
+
}
|
|
380
|
+
// Named a field but declared no comparison → nothing to gate on.
|
|
381
|
+
return true
|
|
382
|
+
}
|
|
383
|
+
|
|
338
384
|
/**
|
|
339
385
|
* Reads a field's enriched options-resolution config, tolerating the camelCase
|
|
340
386
|
* `optionsConfig` (authored SDK shape) and the snake_case `options_config` the
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// pattern + ActionFieldDef renderer so callers can reuse the form layout
|
|
3
3
|
// outside the full record-edit modal.
|
|
4
4
|
import { useEffect, useMemo, useState } from 'react'
|
|
5
|
+
import { groupFieldsBySection, type FormLayout } from './form-layout'
|
|
6
|
+
import { FieldSection, WizardProgress } from './form-layout-ui'
|
|
5
7
|
import {
|
|
6
8
|
Input,
|
|
7
9
|
Textarea,
|
|
@@ -22,6 +24,8 @@ import {
|
|
|
22
24
|
evaluateBalance,
|
|
23
25
|
applyOptionWhen,
|
|
24
26
|
getDependsOn,
|
|
27
|
+
getVisibleWhen,
|
|
28
|
+
evaluateVisibleWhen,
|
|
25
29
|
} from './dynamic-form-schema'
|
|
26
30
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
27
31
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
@@ -45,6 +49,14 @@ export interface DynamicFormProps {
|
|
|
45
49
|
submitLabel?: string
|
|
46
50
|
cancelLabel?: string
|
|
47
51
|
disabled?: boolean
|
|
52
|
+
/**
|
|
53
|
+
* Declarative form layout served on the model metadata (kernel PR #230).
|
|
54
|
+
* When present the visible fields are grouped by their `section` respecting
|
|
55
|
+
* `sections` order: `mode:"sections"` stacks (optionally collapsible)
|
|
56
|
+
* sections; `mode:"steps"` renders a Anterior/Siguiente wizard. Absent → the
|
|
57
|
+
* legacy flat list (unchanged).
|
|
58
|
+
*/
|
|
59
|
+
formLayout?: FormLayout
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export function DynamicForm({
|
|
@@ -55,6 +67,7 @@ export function DynamicForm({
|
|
|
55
67
|
submitLabel = 'Guardar',
|
|
56
68
|
cancelLabel = 'Cancelar',
|
|
57
69
|
disabled = false,
|
|
70
|
+
formLayout,
|
|
58
71
|
}: DynamicFormProps) {
|
|
59
72
|
const [values, setValues] = useState<Record<string, any>>({})
|
|
60
73
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
@@ -70,19 +83,47 @@ export function DynamicForm({
|
|
|
70
83
|
[fields],
|
|
71
84
|
)
|
|
72
85
|
|
|
73
|
-
|
|
86
|
+
// Conditional visibility: a field carrying `visible_when` is rendered — and
|
|
87
|
+
// validated — only while the referenced sibling field's current value
|
|
88
|
+
// matches the predicate. Driving BOTH the schema and the render off the same
|
|
89
|
+
// filtered list means a hidden field never blocks submit (its required-gate
|
|
90
|
+
// is dropped with it), matching the primitive's contract. Fields with no
|
|
91
|
+
// `visible_when` are always kept (retrocompat).
|
|
92
|
+
const visibleFields = useMemo(
|
|
93
|
+
() => editableFields.filter((f) => evaluateVisibleWhen(getVisibleWhen(f), values)),
|
|
94
|
+
[editableFields, values],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const schema = useMemo(() => buildZodSchema(visibleFields), [visibleFields])
|
|
74
98
|
|
|
75
99
|
// Line-items fields carrying a balance rule gate submit: an unbalanced entry
|
|
76
100
|
// (Σdebit ≠ Σcredit, or all-zero when require_nonzero) can't be saved. This
|
|
77
101
|
// is fully declarative — `evaluateBalance` returns undefined for fields with
|
|
78
102
|
// no rule, so non-balanced forms are unaffected.
|
|
79
103
|
const balanceBlocked = useMemo(() => {
|
|
80
|
-
for (const f of
|
|
104
|
+
for (const f of visibleFields) {
|
|
81
105
|
const state = evaluateBalance(f, values[f.key])
|
|
82
106
|
if (state && !state.balanced) return true
|
|
83
107
|
}
|
|
84
108
|
return false
|
|
85
|
-
}, [
|
|
109
|
+
}, [visibleFields, values])
|
|
110
|
+
|
|
111
|
+
// Group visible fields by their form_layout section (empty sections drop out
|
|
112
|
+
// because visibleFields is already visibility-filtered). Without a layout
|
|
113
|
+
// this is a single default group → the render below collapses to the legacy
|
|
114
|
+
// flat grid, byte-for-byte.
|
|
115
|
+
const groups = useMemo(
|
|
116
|
+
() => groupFieldsBySection(visibleFields, formLayout),
|
|
117
|
+
[visibleFields, formLayout],
|
|
118
|
+
)
|
|
119
|
+
const isSteps = formLayout?.mode === 'steps' && groups.length > 1
|
|
120
|
+
|
|
121
|
+
// Wizard step cursor (steps mode only). Clamped whenever the group count
|
|
122
|
+
// shrinks (a visible_when flip can empty a trailing step's section).
|
|
123
|
+
const [stepIndex, setStepIndex] = useState(0)
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
setStepIndex((i) => Math.min(i, Math.max(groups.length - 1, 0)))
|
|
126
|
+
}, [groups.length])
|
|
86
127
|
|
|
87
128
|
useEffect(() => {
|
|
88
129
|
const defaults: Record<string, any> = {}
|
|
@@ -118,25 +159,93 @@ export function DynamicForm({
|
|
|
118
159
|
try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
|
|
119
160
|
}
|
|
120
161
|
|
|
162
|
+
// Renders one group's fields into the responsive 2-column grid: scalar
|
|
163
|
+
// header fields flow through it; line-items grids (and textareas) span full
|
|
164
|
+
// width so the row table / memo gets room. Shared by every layout mode.
|
|
165
|
+
const renderGrid = (groupFields: ActionFieldDef[]) => (
|
|
166
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
167
|
+
{groupFields.map((field) => (
|
|
168
|
+
<FieldRow
|
|
169
|
+
key={field.key}
|
|
170
|
+
field={field}
|
|
171
|
+
value={values[field.key]}
|
|
172
|
+
onChange={(v: any) => update(field.key, v)}
|
|
173
|
+
values={values}
|
|
174
|
+
error={errors[field.key]}
|
|
175
|
+
initialValues={initialValues}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// ── Steps (wizard) mode ────────────────────────────────────────────────
|
|
182
|
+
// One step per section; Anterior/Siguiente navigate, submit only on the
|
|
183
|
+
// last step. Advancing validates just the current step's fields so a later
|
|
184
|
+
// step can't be blocked by an earlier untouched one and vice-versa.
|
|
185
|
+
if (isSteps) {
|
|
186
|
+
const step = groups[stepIndex]
|
|
187
|
+
const isLast = stepIndex === groups.length - 1
|
|
188
|
+
|
|
189
|
+
const goNext = () => {
|
|
190
|
+
const stepSchema = buildZodSchema(step.fields)
|
|
191
|
+
const result = stepSchema.safeParse(values)
|
|
192
|
+
if (!result.success) {
|
|
193
|
+
const next: Record<string, string> = {}
|
|
194
|
+
for (const issue of result.error.issues) {
|
|
195
|
+
const key = issue.path[0]
|
|
196
|
+
if (typeof key === 'string' && !next[key]) next[key] = issue.message
|
|
197
|
+
}
|
|
198
|
+
setErrors(next)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
setErrors({})
|
|
202
|
+
setStepIndex((i) => Math.min(i + 1, groups.length - 1))
|
|
203
|
+
}
|
|
204
|
+
const goBack = () => setStepIndex((i) => Math.max(i - 1, 0))
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
208
|
+
<WizardProgress groups={groups} stepIndex={stepIndex} />
|
|
209
|
+
{renderGrid(step.fields)}
|
|
210
|
+
<div className="flex justify-between gap-2 pt-2">
|
|
211
|
+
{stepIndex > 0 ? (
|
|
212
|
+
<Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
|
|
213
|
+
Anterior
|
|
214
|
+
</Button>
|
|
215
|
+
) : onCancel ? (
|
|
216
|
+
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
|
|
217
|
+
{cancelLabel}
|
|
218
|
+
</Button>
|
|
219
|
+
) : (
|
|
220
|
+
<span />
|
|
221
|
+
)}
|
|
222
|
+
{isLast ? (
|
|
223
|
+
<Button type="submit" disabled={submitting || disabled || balanceBlocked}>
|
|
224
|
+
{submitLabel}
|
|
225
|
+
</Button>
|
|
226
|
+
) : (
|
|
227
|
+
<Button type="button" onClick={goNext} disabled={submitting || disabled}>
|
|
228
|
+
Siguiente
|
|
229
|
+
</Button>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</form>
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Sections / flat mode ───────────────────────────────────────────────
|
|
121
237
|
// Layout: scalar header fields flow through a responsive 2-column grid;
|
|
122
238
|
// line-items grids (and textareas) span the full width so the row table /
|
|
123
239
|
// memo gets room. Mirrors the pro look of the federated journal modal but
|
|
124
|
-
// stays fully declarative — driven only by field shape.
|
|
240
|
+
// stays fully declarative — driven only by field shape. With no layout this
|
|
241
|
+
// is a single default group rendered without section chrome (unchanged).
|
|
125
242
|
return (
|
|
126
243
|
<form onSubmit={handleSubmit} className="grid gap-4">
|
|
127
|
-
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
value={values[field.key]}
|
|
133
|
-
onChange={(v: any) => update(field.key, v)}
|
|
134
|
-
values={values}
|
|
135
|
-
error={errors[field.key]}
|
|
136
|
-
initialValues={initialValues}
|
|
137
|
-
/>
|
|
138
|
-
))}
|
|
139
|
-
</div>
|
|
244
|
+
{groups.map((group) => (
|
|
245
|
+
<FieldSection key={group.key} group={group}>
|
|
246
|
+
{renderGrid(group.fields)}
|
|
247
|
+
</FieldSection>
|
|
248
|
+
))}
|
|
140
249
|
<div className="flex justify-end gap-2 pt-2">
|
|
141
250
|
{onCancel && (
|
|
142
251
|
<Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
|
package/src/dynamic-relation.tsx
CHANGED
|
@@ -48,7 +48,7 @@ import { useTimeZone, useCurrency } from './org-runtime-context'
|
|
|
48
48
|
import { makeDefaultGetDynamicColumns } from './dynamic-columns'
|
|
49
49
|
import { isColumnVisibleInLineSubtable } from './column-visibility'
|
|
50
50
|
import { useOptionsResolver } from './use-options-resolver'
|
|
51
|
-
import type { ApiResponse, TableMetadata } from './types'
|
|
51
|
+
import type { ApiResponse, ColumnDefinition, TableMetadata } from './types'
|
|
52
52
|
import {
|
|
53
53
|
buildCreatePayload,
|
|
54
54
|
buildPivotAttachPayload,
|
|
@@ -120,6 +120,18 @@ interface CommonProps {
|
|
|
120
120
|
filters?: Record<string, string>
|
|
121
121
|
/** Hidden columns; el FK siempre se oculta automáticamente. */
|
|
122
122
|
hiddenColumns?: string[]
|
|
123
|
+
/**
|
|
124
|
+
* Contexto de sub-tabla de líneas dentro del MODAL de vista de un registro
|
|
125
|
+
* padre. Cuando es true se aplican las reglas de {@link isColumnVisibleInLineSubtable}
|
|
126
|
+
* — se ocultan por defecto las columnas de auditoría/sistema (created_by,
|
|
127
|
+
* timestamps, organization_id) y las scopeadas a `visibility: "table"`, que
|
|
128
|
+
* son ruido redundante bajo el registro padre.
|
|
129
|
+
*
|
|
130
|
+
* Default false: en una página de detalle autónoma (`/m/<model>/<id>`) el
|
|
131
|
+
* panel de relación conserva el comportamiento previo (solo oculta FK, scope
|
|
132
|
+
* y columnas `hidden`), donde esas columnas SÍ son útiles.
|
|
133
|
+
*/
|
|
134
|
+
lineSubtable?: boolean
|
|
123
135
|
/** Permisos visibles. Default true. */
|
|
124
136
|
canCreate?: boolean
|
|
125
137
|
canDelete?: boolean
|
|
@@ -191,6 +203,7 @@ function OneToManyRelation({
|
|
|
191
203
|
filters,
|
|
192
204
|
endpoint,
|
|
193
205
|
hiddenColumns = [],
|
|
206
|
+
lineSubtable = false,
|
|
194
207
|
canCreate = true,
|
|
195
208
|
canDelete = true,
|
|
196
209
|
canEdit = true,
|
|
@@ -266,13 +279,17 @@ function OneToManyRelation({
|
|
|
266
279
|
// Hide the FK and every scope column — they're fixed for this parent and
|
|
267
280
|
// would just render the same value on every row.
|
|
268
281
|
const hidden = new Set([foreignKey, ...Object.keys(filters || {}), ...hiddenColumns])
|
|
269
|
-
//
|
|
270
|
-
// columns AND the audit/system
|
|
271
|
-
// that's redundant under a parent
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
// In the view-modal line-subtable context, isColumnVisibleInLineSubtable
|
|
283
|
+
// additionally drops `hidden`/table-scoped columns AND the audit/system
|
|
284
|
+
// noise (created_by, timestamps, org_id) that's redundant under a parent
|
|
285
|
+
// record — see column-visibility.ts. Outside that context (standalone
|
|
286
|
+
// detail page) we keep the previous behaviour and only drop `hidden`
|
|
287
|
+
// columns, so those columns still show where they're useful.
|
|
288
|
+
const keep = lineSubtable
|
|
289
|
+
? (c: ColumnDefinition) => !hidden.has(c.key) && isColumnVisibleInLineSubtable(c)
|
|
290
|
+
: (c: ColumnDefinition) => !hidden.has(c.key) && !c.hidden
|
|
291
|
+
return metadata.columns.filter(keep)
|
|
292
|
+
}, [metadata, foreignKey, filtersKey, hiddenColumns, lineSubtable])
|
|
276
293
|
|
|
277
294
|
// Reuse the EXACT column factory the main `<DynamicTable>` uses so each cell
|
|
278
295
|
// renders identically — money in the org currency right-aligned, FK chips
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// { owner_model: "Customer" }) into the panel's `filters` so the child list
|
|
12
12
|
// is scoped by the FK AND every scope column.
|
|
13
13
|
import { useMemo } from 'react'
|
|
14
|
+
import { cn } from '@asteby/metacore-ui/lib'
|
|
14
15
|
import { DynamicRelation, type DynamicRelationStrings } from './dynamic-relation'
|
|
15
16
|
import type { RelationMeta } from './types'
|
|
16
17
|
|
|
@@ -40,6 +41,14 @@ export interface DynamicRelationsProps {
|
|
|
40
41
|
canEdit?: boolean
|
|
41
42
|
/** Translatable strings forwarded to each DynamicRelation. */
|
|
42
43
|
strings?: Partial<DynamicRelationStrings>
|
|
44
|
+
/**
|
|
45
|
+
* True cuando estos paneles se renderizan como sub-tablas de líneas dentro
|
|
46
|
+
* del MODAL de vista de un registro. Propaga `lineSubtable` a cada
|
|
47
|
+
* `<DynamicRelation>` para ocultar por defecto las columnas de auditoría/
|
|
48
|
+
* sistema redundantes bajo el padre. Default false — una página de detalle
|
|
49
|
+
* autónoma conserva todas las columnas.
|
|
50
|
+
*/
|
|
51
|
+
lineSubtable?: boolean
|
|
43
52
|
/** Bubble up when any panel's data changes (create/delete/attach/detach). */
|
|
44
53
|
onChange?: (relation: RelationMeta) => void
|
|
45
54
|
}
|
|
@@ -96,6 +105,7 @@ export function DynamicRelations({
|
|
|
96
105
|
canDelete = true,
|
|
97
106
|
canEdit = true,
|
|
98
107
|
strings,
|
|
108
|
+
lineSubtable = false,
|
|
99
109
|
onChange,
|
|
100
110
|
}: DynamicRelationsProps) {
|
|
101
111
|
const parentId = useMemo(
|
|
@@ -108,7 +118,10 @@ export function DynamicRelations({
|
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
return (
|
|
111
|
-
|
|
121
|
+
// `space-y-6` separates stacked relation panels (e.g. "Líneas del
|
|
122
|
+
// pedido" and "Facturas" in the view modal) — without it consecutive
|
|
123
|
+
// panels sat flush against each other with no breathing room.
|
|
124
|
+
<div className={cn('space-y-6', className)} data-dynamic-relations="">
|
|
112
125
|
{relations.map((rel, idx) => {
|
|
113
126
|
const filters = buildRelationFilters(rel, parentId)
|
|
114
127
|
const panelStrings: Partial<DynamicRelationStrings> = {
|
|
@@ -135,6 +148,7 @@ export function DynamicRelations({
|
|
|
135
148
|
parentId={parentId}
|
|
136
149
|
filters={filters}
|
|
137
150
|
className={panelClassName}
|
|
151
|
+
lineSubtable={lineSubtable}
|
|
138
152
|
canCreate={canCreate}
|
|
139
153
|
canDelete={canDelete}
|
|
140
154
|
readonly={relReadonly}
|
|
@@ -152,6 +166,7 @@ export function DynamicRelations({
|
|
|
152
166
|
parentId={parentId}
|
|
153
167
|
filters={filters}
|
|
154
168
|
className={panelClassName}
|
|
169
|
+
lineSubtable={lineSubtable}
|
|
155
170
|
canCreate={canCreate}
|
|
156
171
|
canDelete={canDelete}
|
|
157
172
|
canEdit={canEdit}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Presentational chrome for declarative form layouts (see `form-layout.ts`).
|
|
2
|
+
// Kept apart from the grouping logic so the pure helper stays React-free and the
|
|
3
|
+
// two renderers (`dynamic-form.tsx`, `dialogs/dynamic-record.tsx`) share one
|
|
4
|
+
// look for sections and the wizard progress bar.
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import {
|
|
7
|
+
Collapsible,
|
|
8
|
+
CollapsibleContent,
|
|
9
|
+
CollapsibleTrigger,
|
|
10
|
+
Button,
|
|
11
|
+
} from '@asteby/metacore-ui/primitives'
|
|
12
|
+
import { ChevronDown } from 'lucide-react'
|
|
13
|
+
import type { FieldGroup } from './form-layout'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Section chrome for `mode: "sections"`. Wraps a group's (already gridded)
|
|
17
|
+
* fields in a titled block.
|
|
18
|
+
* - The default/orphan group renders with NO chrome, so a layout-less form (one
|
|
19
|
+
* default group) is visually identical to the legacy flat list.
|
|
20
|
+
* - A section whose `collapsed` is defined (true/false) is rendered COLLAPSIBLE,
|
|
21
|
+
* starting collapsed when `collapsed === true`. A section with no `collapsed`
|
|
22
|
+
* flag renders as a plain, always-open titled block.
|
|
23
|
+
*/
|
|
24
|
+
export function FieldSection({
|
|
25
|
+
group,
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
group: FieldGroup<unknown>
|
|
29
|
+
children: React.ReactNode
|
|
30
|
+
}) {
|
|
31
|
+
const collapsible = group.collapsed !== undefined
|
|
32
|
+
const [open, setOpen] = useState(!group.collapsed)
|
|
33
|
+
|
|
34
|
+
if (group.isDefault) return <>{children}</>
|
|
35
|
+
|
|
36
|
+
const header = (
|
|
37
|
+
<div className="min-w-0 text-left">
|
|
38
|
+
{group.title && (
|
|
39
|
+
<h3 className="text-sm font-semibold leading-none">{group.title}</h3>
|
|
40
|
+
)}
|
|
41
|
+
{group.description && (
|
|
42
|
+
<p className="pt-1 text-sm text-muted-foreground">{group.description}</p>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
if (!collapsible) {
|
|
48
|
+
return (
|
|
49
|
+
<section className="grid gap-3">
|
|
50
|
+
{(group.title || group.description) && header}
|
|
51
|
+
{children}
|
|
52
|
+
</section>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<Collapsible open={open} onOpenChange={setOpen} className="grid gap-3">
|
|
58
|
+
<CollapsibleTrigger asChild>
|
|
59
|
+
<Button
|
|
60
|
+
type="button"
|
|
61
|
+
variant="ghost"
|
|
62
|
+
className="h-auto w-full justify-between px-0 py-1 hover:bg-transparent"
|
|
63
|
+
>
|
|
64
|
+
{header}
|
|
65
|
+
<ChevronDown
|
|
66
|
+
className={
|
|
67
|
+
'h-4 w-4 shrink-0 transition-transform ' + (open ? 'rotate-180' : '')
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
</Button>
|
|
71
|
+
</CollapsibleTrigger>
|
|
72
|
+
<CollapsibleContent className="grid gap-3">{children}</CollapsibleContent>
|
|
73
|
+
</Collapsible>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Progress bar for `mode: "steps"`: one filled segment per completed/current
|
|
79
|
+
* step plus a "Paso i/n · <title>" caption. Mirrors the WizardActionModal look
|
|
80
|
+
* so a model wizard and an action wizard read the same.
|
|
81
|
+
*/
|
|
82
|
+
export function WizardProgress({
|
|
83
|
+
groups,
|
|
84
|
+
stepIndex,
|
|
85
|
+
stepLabel = 'Paso',
|
|
86
|
+
}: {
|
|
87
|
+
groups: FieldGroup<unknown>[]
|
|
88
|
+
stepIndex: number
|
|
89
|
+
stepLabel?: string
|
|
90
|
+
}) {
|
|
91
|
+
const current = groups[stepIndex]
|
|
92
|
+
return (
|
|
93
|
+
<div className="pt-2">
|
|
94
|
+
<div className="flex items-center gap-1.5" role="list" aria-label="progress">
|
|
95
|
+
{groups.map((g, i) => (
|
|
96
|
+
<div
|
|
97
|
+
key={g.key}
|
|
98
|
+
role="listitem"
|
|
99
|
+
aria-current={i === stepIndex ? 'step' : undefined}
|
|
100
|
+
className="h-1.5 flex-1 rounded-full"
|
|
101
|
+
style={{
|
|
102
|
+
backgroundColor:
|
|
103
|
+
i <= stepIndex ? 'hsl(var(--primary))' : 'hsl(var(--muted))',
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
<p className="pt-2 text-sm text-muted-foreground">
|
|
109
|
+
{stepLabel} {stepIndex + 1}/{groups.length}
|
|
110
|
+
{current?.title ? ` · ${current.title}` : ''}
|
|
111
|
+
</p>
|
|
112
|
+
{current?.description && (
|
|
113
|
+
<p className="pt-1 text-sm text-muted-foreground">{current.description}</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Declarative form layout (kernel PR #230): a model's create/edit form can
|
|
2
|
+
// declare `form_layout` to group its fields into named sections, rendered either
|
|
3
|
+
// as stacked (optionally collapsible) SECTIONS or as a multi-step WIZARD.
|
|
4
|
+
//
|
|
5
|
+
// Wire shape served on the table/modal metadata:
|
|
6
|
+
// form_layout: {
|
|
7
|
+
// mode: "sections" | "steps", // default "sections"
|
|
8
|
+
// sections: [{ key, title?, description?, collapsed? }]
|
|
9
|
+
// }
|
|
10
|
+
// and, per field/column: `section: "<key>"` referencing a section key.
|
|
11
|
+
//
|
|
12
|
+
// This module owns the PURE grouping logic (no React), so the two renderers
|
|
13
|
+
// (`dynamic-form.tsx`, `dialogs/dynamic-record.tsx`) share one tested
|
|
14
|
+
// implementation and the modal-render machinery never has to be booted under
|
|
15
|
+
// happy-dom just to assert the grouping (same approach as PR #673).
|
|
16
|
+
|
|
17
|
+
/** One declared section of a model form. `title`/`description` arrive already
|
|
18
|
+
* localized from the kernel and are rendered verbatim. */
|
|
19
|
+
export interface FormSection {
|
|
20
|
+
key: string
|
|
21
|
+
title?: string
|
|
22
|
+
description?: string
|
|
23
|
+
/** Sections mode only: render this section collapsed on first paint. */
|
|
24
|
+
collapsed?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Model-level layout directive. `mode` defaults to `"sections"`. */
|
|
28
|
+
export interface FormLayout {
|
|
29
|
+
mode?: 'sections' | 'steps'
|
|
30
|
+
sections?: FormSection[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A resolved group of fields ready to render: a section plus the (already
|
|
34
|
+
* visibility-filtered) fields that belong to it. */
|
|
35
|
+
export interface FieldGroup<F> {
|
|
36
|
+
/** Section key, or `__default__` for the orphan group. */
|
|
37
|
+
key: string
|
|
38
|
+
title?: string
|
|
39
|
+
description?: string
|
|
40
|
+
collapsed?: boolean
|
|
41
|
+
/** True for the synthetic group holding section-less / unknown-section fields. */
|
|
42
|
+
isDefault: boolean
|
|
43
|
+
fields: F[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The synthetic key of the orphan group (fields with no / unknown `section`). */
|
|
47
|
+
export const DEFAULT_SECTION_KEY = '__default__'
|
|
48
|
+
|
|
49
|
+
/** Reads a field's `section` reference, tolerating a camelCase alias. */
|
|
50
|
+
export function getFieldSection(
|
|
51
|
+
field: { section?: string; Section?: string } | null | undefined,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
if (!field) return undefined
|
|
54
|
+
const s = field.section ?? (field as { Section?: string }).Section
|
|
55
|
+
return typeof s === 'string' && s !== '' ? s : undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Groups already-filtered (visible) fields by their `section`, honoring the
|
|
60
|
+
* order of `layout.sections`.
|
|
61
|
+
*
|
|
62
|
+
* Contract:
|
|
63
|
+
* - No `layout` (or no `sections`) → a single default group carrying every
|
|
64
|
+
* field in its original order. Callers render this exactly like the legacy
|
|
65
|
+
* flat list, so the layout-less path is byte-for-byte the current behaviour.
|
|
66
|
+
* - Fields whose `section` is empty or references an UNKNOWN section key are
|
|
67
|
+
* collected into ONE default group placed FIRST (before any declared
|
|
68
|
+
* section) — the consistent, documented choice: general/uncategorized fields
|
|
69
|
+
* lead, declared sections follow in their authored order.
|
|
70
|
+
* - A declared section with zero visible fields is OMITTED entirely, so a
|
|
71
|
+
* section whose only members are hidden by `visible_when` never renders an
|
|
72
|
+
* empty shell (and never yields an empty wizard step). Because the caller
|
|
73
|
+
* passes the already visibility-filtered list, this falls out for free.
|
|
74
|
+
*/
|
|
75
|
+
export function groupFieldsBySection<F extends { section?: string }>(
|
|
76
|
+
fields: F[],
|
|
77
|
+
layout: FormLayout | undefined,
|
|
78
|
+
): FieldGroup<F>[] {
|
|
79
|
+
if (!layout?.sections?.length) {
|
|
80
|
+
return [{ key: DEFAULT_SECTION_KEY, isDefault: true, fields }]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const known = new Set(layout.sections.map((s) => s.key))
|
|
84
|
+
const bySection = new Map<string, F[]>()
|
|
85
|
+
const orphans: F[] = []
|
|
86
|
+
|
|
87
|
+
for (const f of fields) {
|
|
88
|
+
const sec = getFieldSection(f)
|
|
89
|
+
if (sec && known.has(sec)) {
|
|
90
|
+
const arr = bySection.get(sec)
|
|
91
|
+
if (arr) arr.push(f)
|
|
92
|
+
else bySection.set(sec, [f])
|
|
93
|
+
} else {
|
|
94
|
+
orphans.push(f)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const groups: FieldGroup<F>[] = []
|
|
99
|
+
// Orphan/default group leads (fields with no / unknown section).
|
|
100
|
+
if (orphans.length) {
|
|
101
|
+
groups.push({ key: DEFAULT_SECTION_KEY, isDefault: true, fields: orphans })
|
|
102
|
+
}
|
|
103
|
+
for (const s of layout.sections) {
|
|
104
|
+
const secFields = bySection.get(s.key)
|
|
105
|
+
if (!secFields || secFields.length === 0) continue // hide empty section
|
|
106
|
+
groups.push({
|
|
107
|
+
key: s.key,
|
|
108
|
+
title: s.title,
|
|
109
|
+
description: s.description,
|
|
110
|
+
collapsed: s.collapsed,
|
|
111
|
+
isDefault: false,
|
|
112
|
+
fields: secFields,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
return groups
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -107,6 +107,8 @@ export {
|
|
|
107
107
|
type UseDynamicFiltersResult,
|
|
108
108
|
} from './use-dynamic-filters'
|
|
109
109
|
export * from './dynamic-form'
|
|
110
|
+
export * from './form-layout'
|
|
111
|
+
export * from './form-layout-ui'
|
|
110
112
|
export { FieldGrid, FieldCell, FieldLabel } from './field-grid'
|
|
111
113
|
export {
|
|
112
114
|
ActionModalDispatcher,
|
|
@@ -307,6 +309,8 @@ export {
|
|
|
307
309
|
resolveDependsValue,
|
|
308
310
|
getOptionsConfig,
|
|
309
311
|
resolveOptionsSource,
|
|
312
|
+
getVisibleWhen,
|
|
313
|
+
evaluateVisibleWhen,
|
|
310
314
|
} from './dynamic-form-schema'
|
|
311
315
|
export {
|
|
312
316
|
ActivityValueRenderer,
|