@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
@@ -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
@@ -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
- const schema = useMemo(() => buildZodSchema(editableFields), [editableFields])
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 editableFields) {
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
- }, [editableFields, values])
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
- <div className="grid gap-4 sm:grid-cols-2">
128
- {editableFields.map((field) => (
129
- <FieldRow
130
- key={field.key}
131
- field={field}
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}>
@@ -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
- // isColumnVisibleInLineSubtable additionally drops `hidden`/table-scoped
270
- // columns AND the audit/system noise (created_by, timestamps, org_id)
271
- // that's redundant under a parent record — see column-visibility.ts.
272
- return metadata.columns.filter(
273
- c => !hidden.has(c.key) && isColumnVisibleInLineSubtable(c),
274
- )
275
- }, [metadata, foreignKey, filtersKey, hiddenColumns])
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
- <div className={className} data-dynamic-relations="">
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,