@asteby/metacore-runtime-react 28.4.1 → 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.
@@ -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,
@@ -47,6 +49,14 @@ export interface DynamicFormProps {
47
49
  submitLabel?: string
48
50
  cancelLabel?: string
49
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
50
60
  }
51
61
 
52
62
  export function DynamicForm({
@@ -57,6 +67,7 @@ export function DynamicForm({
57
67
  submitLabel = 'Guardar',
58
68
  cancelLabel = 'Cancelar',
59
69
  disabled = false,
70
+ formLayout,
60
71
  }: DynamicFormProps) {
61
72
  const [values, setValues] = useState<Record<string, any>>({})
62
73
  const [errors, setErrors] = useState<Record<string, string>>({})
@@ -97,6 +108,23 @@ export function DynamicForm({
97
108
  return false
98
109
  }, [visibleFields, values])
99
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])
127
+
100
128
  useEffect(() => {
101
129
  const defaults: Record<string, any> = {}
102
130
  for (const f of editableFields) {
@@ -131,25 +159,93 @@ export function DynamicForm({
131
159
  try { await onSubmit(result.data as Record<string, any>) } finally { setSubmitting(false) }
132
160
  }
133
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 ───────────────────────────────────────────────
134
237
  // Layout: scalar header fields flow through a responsive 2-column grid;
135
238
  // line-items grids (and textareas) span the full width so the row table /
136
239
  // memo gets room. Mirrors the pro look of the federated journal modal but
137
- // 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).
138
242
  return (
139
243
  <form onSubmit={handleSubmit} className="grid gap-4">
140
- <div className="grid gap-4 sm:grid-cols-2">
141
- {visibleFields.map((field) => (
142
- <FieldRow
143
- key={field.key}
144
- field={field}
145
- value={values[field.key]}
146
- onChange={(v: any) => update(field.key, v)}
147
- values={values}
148
- error={errors[field.key]}
149
- initialValues={initialValues}
150
- />
151
- ))}
152
- </div>
244
+ {groups.map((group) => (
245
+ <FieldSection key={group.key} group={group}>
246
+ {renderGrid(group.fields)}
247
+ </FieldSection>
248
+ ))}
153
249
  <div className="flex justify-end gap-2 pt-2">
154
250
  {onCancel && (
155
251
  <Button type="button" variant="outline" onClick={onCancel} disabled={submitting || disabled}>
@@ -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,
package/src/types.ts CHANGED
@@ -306,6 +306,12 @@ export interface ColumnDefinition {
306
306
  visible_when?: VisibleWhen
307
307
  /** camelCase alias for `visible_when`. */
308
308
  visibleWhen?: VisibleWhen
309
+ /**
310
+ * Form-layout membership: the key of the `form_layout` section this column's
311
+ * modal field belongs to (kernel PR #230). Absent → the default group. See
312
+ * `groupFieldsBySection`.
313
+ */
314
+ section?: string
309
315
  }
310
316
 
311
317
  /**
@@ -529,6 +535,12 @@ export interface ActionFieldDef {
529
535
  visible_when?: VisibleWhen
530
536
  /** camelCase alias for `visible_when`. */
531
537
  visibleWhen?: VisibleWhen
538
+ /**
539
+ * Form-layout membership: the key of the `form_layout` section this field
540
+ * belongs to (kernel PR #230). Absent → the field lands in the default
541
+ * group. See `groupFieldsBySection`.
542
+ */
543
+ section?: string
532
544
  }
533
545
 
534
546
  /**