@asteby/metacore-runtime-react 10.0.0 → 12.0.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 (45) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +21 -1
  4. package/dist/dialogs/create-record-dialog.d.ts +3 -0
  5. package/dist/dialogs/create-record-dialog.d.ts.map +1 -0
  6. package/dist/dialogs/create-record-dialog.js +20 -0
  7. package/dist/dialogs/dynamic-record.d.ts +38 -1
  8. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  9. package/dist/dialogs/dynamic-record.js +50 -12
  10. package/dist/dialogs/types.d.ts +115 -0
  11. package/dist/dialogs/types.d.ts.map +1 -0
  12. package/dist/dialogs/types.js +15 -0
  13. package/dist/dialogs/view-record-dialog.d.ts +3 -0
  14. package/dist/dialogs/view-record-dialog.d.ts.map +1 -0
  15. package/dist/dialogs/view-record-dialog.js +15 -0
  16. package/dist/dynamic-form-schema.d.ts +9 -0
  17. package/dist/dynamic-form-schema.d.ts.map +1 -1
  18. package/dist/dynamic-form-schema.js +22 -0
  19. package/dist/dynamic-form.d.ts +1 -0
  20. package/dist/dynamic-form.d.ts.map +1 -1
  21. package/dist/dynamic-form.js +12 -1
  22. package/dist/dynamic-line-items.d.ts +9 -0
  23. package/dist/dynamic-line-items.d.ts.map +1 -0
  24. package/dist/dynamic-line-items.js +64 -0
  25. package/dist/index.d.ts +3 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +2 -0
  28. package/dist/slot.d.ts.map +1 -1
  29. package/dist/slot.js +2 -0
  30. package/dist/types.d.ts +10 -0
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +6 -6
  33. package/src/__tests__/dynamic-form.test.ts +56 -1
  34. package/src/__tests__/slot.test.ts +70 -0
  35. package/src/action-modal-dispatcher.tsx +22 -2
  36. package/src/dialogs/create-record-dialog.tsx +46 -0
  37. package/src/dialogs/dynamic-record.tsx +111 -15
  38. package/src/dialogs/types.ts +119 -0
  39. package/src/dialogs/view-record-dialog.tsx +37 -0
  40. package/src/dynamic-form-schema.ts +25 -0
  41. package/src/dynamic-form.tsx +12 -1
  42. package/src/dynamic-line-items.tsx +221 -0
  43. package/src/index.ts +10 -0
  44. package/src/slot.tsx +2 -0
  45. package/src/types.ts +10 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared types for record-oriented dialogs in `@asteby/metacore-runtime-react`.
3
+ *
4
+ * These dialogs are intentionally generic: any addon that drives a metadata-
5
+ * backed CRUD surface can render them by passing a `modelKey`. The dialogs
6
+ * resolve field shape, labels, and validation hints from the metadata server
7
+ * (default: `/metadata/modal/:modelKey`) via the configured `useApi()`
8
+ * transport. Hosts may override transport behaviour by supplying explicit
9
+ * `endpoint` overrides and/or `onCreate` / `onDelete` callbacks.
10
+ *
11
+ * Wave 2.5 cleanup — replaces the product-specific dialogs that used to live
12
+ * in `ops/frontend/src/components/dynamic/` for any record kind that does not
13
+ * need pricing rules, media galleries, or category-driven custom fields.
14
+ */
15
+
16
+ /**
17
+ * Identifier for a model registered with the metadata service. The wire format
18
+ * is a plain string (e.g. `"products"`, `"organizations"`, `"users"`). The
19
+ * dialogs do not interpret this value; it is forwarded to the API transport.
20
+ */
21
+ export type ModelKey = string
22
+
23
+ /**
24
+ * Optional schema hint, accepted only as a passthrough for hosts that want to
25
+ * supply pre-fetched metadata instead of waiting for the dialog to call
26
+ * `/metadata/modal/:modelKey`. The runtime intentionally keeps the shape loose
27
+ * because metadata is owned by each backend addon.
28
+ */
29
+ export interface ModelSchema {
30
+ title?: string
31
+ createTitle?: string
32
+ editTitle?: string
33
+ fields?: Array<{
34
+ key: string
35
+ label: string
36
+ type: string
37
+ required?: boolean
38
+ defaultValue?: unknown
39
+ placeholder?: string
40
+ readonly?: boolean
41
+ hidden?: boolean
42
+ // additional metadata may be present; the dialogs treat it as opaque
43
+ [extra: string]: unknown
44
+ }>
45
+ }
46
+
47
+ /**
48
+ * Result shape returned by `onCreate` callbacks. Hosts may return `void` if
49
+ * the resulting record id is not needed downstream.
50
+ */
51
+ export type CreateResult = { id: string } | void
52
+
53
+ /**
54
+ * Base props shared by every record dialog.
55
+ */
56
+ export interface RecordDialogProps {
57
+ /** Model key forwarded to the metadata service and transport calls. */
58
+ modelKey: ModelKey
59
+ /** Controlled open state. */
60
+ open: boolean
61
+ /** Open-state controller. */
62
+ onOpenChange: (open: boolean) => void
63
+ /**
64
+ * Optional custom endpoint base. When provided, the dialog uses
65
+ * `${endpoint}` (for create/list) and `${endpoint}/${recordId}` (for
66
+ * fetch/update/delete). When omitted, the dialog uses
67
+ * `/dynamic/${modelKey}` / `/dynamic/${modelKey}/${recordId}`.
68
+ */
69
+ endpoint?: string
70
+ /**
71
+ * Optional pre-fetched schema. When omitted, the dialog fetches metadata
72
+ * from `/metadata/modal/${modelKey}` via the configured API transport.
73
+ */
74
+ schema?: ModelSchema
75
+ }
76
+
77
+ /**
78
+ * Props for the create / edit dialog.
79
+ *
80
+ * When `recordId` is provided the dialog operates in edit mode; otherwise it
81
+ * starts in create mode. Supplying `onCreate` overrides the default transport
82
+ * call so that hosts may route writes through custom mutations (optimistic
83
+ * updates, audit hooks, etc.). The default behaviour POSTs/PUTs through the
84
+ * configured `useApi()` transport.
85
+ */
86
+ export interface CreateRecordDialogProps extends RecordDialogProps {
87
+ /** When set, the dialog operates as an editor for this record id. */
88
+ recordId?: string | null
89
+ /**
90
+ * Optional override invoked instead of the default POST. The dialog still
91
+ * closes and calls `onSaved` on success. Hosts that need to support both
92
+ * create and edit through callbacks should also pass `onUpdate`.
93
+ */
94
+ onCreate?: (data: Record<string, unknown>) => Promise<CreateResult>
95
+ /**
96
+ * Optional override invoked instead of the default PUT when `recordId`
97
+ * is provided.
98
+ */
99
+ onUpdate?: (recordId: string, data: Record<string, unknown>) => Promise<CreateResult>
100
+ /** Default values seeded into the form on create. */
101
+ defaults?: Record<string, unknown>
102
+ /** Notification when a create or update succeeds. */
103
+ onSaved?: () => void
104
+ }
105
+
106
+ /**
107
+ * Props for the read-only viewer dialog.
108
+ */
109
+ export interface ViewRecordDialogProps extends RecordDialogProps {
110
+ /** Identifier of the record to display. */
111
+ recordId: string
112
+ /** Optional handler triggered by the "Edit" affordance. */
113
+ onEdit?: () => void
114
+ /**
115
+ * Optional handler triggered by the "Delete" affordance. When omitted the
116
+ * delete button is hidden. The dialog awaits the promise before closing.
117
+ */
118
+ onDelete?: () => Promise<void>
119
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * ViewRecordDialog — read-only record viewer with optional edit/delete
3
+ * affordances.
4
+ *
5
+ * Thin wrapper around `DynamicRecordDialog` (mode = `'view'`) that exposes a
6
+ * narrower, intent-specific API matching the Wave 2.5 cleanup spec.
7
+ * The "Edit" and "Delete" footer buttons are only rendered when the host
8
+ * supplies `onEdit` / `onDelete`, so the dialog gracefully degrades to a
9
+ * pure viewer when those affordances are not needed.
10
+ */
11
+ import { DynamicRecordDialog } from './dynamic-record'
12
+ import type { ViewRecordDialogProps } from './types'
13
+
14
+ export function ViewRecordDialog({
15
+ modelKey,
16
+ open,
17
+ onOpenChange,
18
+ recordId,
19
+ endpoint,
20
+ schema,
21
+ onEdit,
22
+ onDelete,
23
+ }: ViewRecordDialogProps) {
24
+ return (
25
+ <DynamicRecordDialog
26
+ open={open}
27
+ onOpenChange={onOpenChange}
28
+ mode="view"
29
+ model={modelKey}
30
+ recordId={recordId}
31
+ endpoint={endpoint}
32
+ schema={schema}
33
+ onEdit={onEdit}
34
+ onDelete={onDelete}
35
+ />
36
+ )
37
+ }
@@ -47,7 +47,32 @@ export function buildZodSchema(fields: ActionFieldDef[]) {
47
47
  return z.object(shape)
48
48
  }
49
49
 
50
+ /**
51
+ * Returns the line-items columns of a repeatable-group field, tolerating both
52
+ * the camelCase `itemFields` (the authored SDK shape) and the raw snake_case
53
+ * `item_fields` that the kernel serves in action metadata. Empty when the
54
+ * field is not a line-items group.
55
+ */
56
+ export function getItemFields(field: ActionFieldDef): ActionFieldDef[] {
57
+ const raw = field.itemFields ?? (field as { item_fields?: ActionFieldDef[] }).item_fields
58
+ return Array.isArray(raw) ? raw : []
59
+ }
60
+
61
+ /** A field is a repeatable line-items group when it declares item columns. */
62
+ export function isLineItemsField(field: ActionFieldDef): boolean {
63
+ return getItemFields(field).length > 0
64
+ }
65
+
50
66
  function fieldToZod(field: ActionFieldDef): ZodTypeAny {
67
+ // Repeatable line-items group → array of row objects, each row built from
68
+ // the item field columns. Required keeps at least one row.
69
+ const itemFields = getItemFields(field)
70
+ if (itemFields.length > 0) {
71
+ const row = buildZodSchema(itemFields)
72
+ const arr = z.array(row)
73
+ return field.required ? arr.min(1, `${field.label} requiere al menos un renglón`) : arr
74
+ }
75
+
51
76
  const v = field.validation ?? ({} as FieldValidation)
52
77
  const isNumeric = field.type === 'number'
53
78
  const isBool = field.type === 'boolean'
@@ -15,10 +15,12 @@ import {
15
15
  SelectValue,
16
16
  } from '@asteby/metacore-ui/primitives'
17
17
  import type { ActionFieldDef } from './types'
18
- import { buildZodSchema, resolveWidget } from './dynamic-form-schema'
18
+ import { buildZodSchema, resolveWidget, isLineItemsField } from './dynamic-form-schema'
19
19
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
20
+ import { DynamicLineItems } from './dynamic-line-items'
20
21
 
21
22
  export { buildZodSchema, resolveWidget }
23
+ export { DynamicLineItems } from './dynamic-line-items'
22
24
 
23
25
  export interface DynamicFormProps {
24
26
  fields: ActionFieldDef[]
@@ -48,6 +50,10 @@ export function DynamicForm({
48
50
  useEffect(() => {
49
51
  const defaults: Record<string, any> = {}
50
52
  for (const f of fields) {
53
+ if (isLineItemsField(f)) {
54
+ defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? []
55
+ continue
56
+ }
51
57
  defaults[f.key] = initialValues?.[f.key] ?? f.defaultValue ?? (f.type === 'boolean' ? false : '')
52
58
  }
53
59
  setValues(defaults)
@@ -111,6 +117,11 @@ interface FieldRendererProps {
111
117
  }
112
118
 
113
119
  function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
120
+ // Repeatable line-items group → render the row grid. Its value is an array
121
+ // of row objects rather than a scalar.
122
+ if (isLineItemsField(field)) {
123
+ return <DynamicLineItems field={field} value={value} onChange={onChange} />
124
+ }
114
125
  const widget = resolveWidget(field)
115
126
  // Ref-driven select: hook into useOptionsResolver so the canonical
116
127
  // /api/options/<ref>?field=id endpoint feeds the dropdown. This is
@@ -0,0 +1,221 @@
1
+ // DynamicLineItems — renders a repeatable line-items group: a table/grid of
2
+ // rows where each column is one of the field's `itemFields` (the v3
3
+ // `item_fields`). Powers declarative multi-line action modals (e.g. the item
4
+ // rows of a "Recibir mercancía" modal, or the debit/credit lines of a journal
5
+ // entry) without needing a custom federated modal.
6
+ //
7
+ // The value is an array of row objects keyed by the item field keys. Add/remove
8
+ // row controls mutate the array; each cell is a widget resolved via
9
+ // `resolveWidget`, matching the flat-field renderer in dynamic-form.tsx.
10
+ import {
11
+ Input,
12
+ Textarea,
13
+ Switch,
14
+ Button,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ } from '@asteby/metacore-ui/primitives'
21
+ import { Plus, Trash2 } from 'lucide-react'
22
+ import type { ActionFieldDef } from './types'
23
+ import { resolveWidget, getItemFields } from './dynamic-form-schema'
24
+ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
25
+
26
+ export interface DynamicLineItemsProps {
27
+ field: ActionFieldDef
28
+ value: any[] | undefined
29
+ onChange: (rows: any[]) => void
30
+ disabled?: boolean
31
+ }
32
+
33
+ function emptyRow(itemFields: ActionFieldDef[]): Record<string, any> {
34
+ const row: Record<string, any> = {}
35
+ for (const f of itemFields) {
36
+ row[f.key] = f.defaultValue ?? (f.type === 'boolean' ? false : '')
37
+ }
38
+ return row
39
+ }
40
+
41
+ export function DynamicLineItems({ field, value, onChange, disabled = false }: DynamicLineItemsProps) {
42
+ const itemFields = getItemFields(field)
43
+ const rows: any[] = Array.isArray(value) ? value : []
44
+
45
+ const addRow = () => onChange([...rows, emptyRow(itemFields)])
46
+ const removeRow = (idx: number) => onChange(rows.filter((_, i) => i !== idx))
47
+ const updateCell = (idx: number, key: string, cellValue: any) =>
48
+ onChange(rows.map((r, i) => (i === idx ? { ...r, [key]: cellValue } : r)))
49
+
50
+ return (
51
+ <div className="grid gap-2" data-widget="line_items">
52
+ <div className="overflow-x-auto rounded-md border">
53
+ <table className="w-full text-sm">
54
+ <thead className="bg-muted/50">
55
+ <tr>
56
+ {itemFields.map((col) => (
57
+ <th key={col.key} className="px-3 py-2 text-left font-medium">
58
+ {col.label}
59
+ {col.required && <span className="text-red-500 ml-1">*</span>}
60
+ </th>
61
+ ))}
62
+ <th className="w-12 px-3 py-2" aria-label="acciones" />
63
+ </tr>
64
+ </thead>
65
+ <tbody>
66
+ {rows.length === 0 && (
67
+ <tr>
68
+ <td
69
+ colSpan={itemFields.length + 1}
70
+ className="px-3 py-4 text-center text-muted-foreground"
71
+ >
72
+ Sin renglones
73
+ </td>
74
+ </tr>
75
+ )}
76
+ {rows.map((row, idx) => (
77
+ <tr key={idx} className="border-t align-top">
78
+ {itemFields.map((col) => (
79
+ <td key={col.key} className="px-2 py-1.5">
80
+ <CellRenderer
81
+ field={col}
82
+ value={row?.[col.key]}
83
+ onChange={(v: any) => updateCell(idx, col.key, v)}
84
+ disabled={disabled}
85
+ />
86
+ </td>
87
+ ))}
88
+ <td className="px-2 py-1.5 text-center">
89
+ <Button
90
+ type="button"
91
+ variant="ghost"
92
+ size="icon"
93
+ onClick={() => removeRow(idx)}
94
+ disabled={disabled}
95
+ aria-label="Eliminar renglón"
96
+ >
97
+ <Trash2 className="h-4 w-4 text-red-500" />
98
+ </Button>
99
+ </td>
100
+ </tr>
101
+ ))}
102
+ </tbody>
103
+ </table>
104
+ </div>
105
+ <div>
106
+ <Button type="button" variant="outline" size="sm" onClick={addRow} disabled={disabled}>
107
+ <Plus className="mr-1 h-4 w-4" />
108
+ Agregar renglón
109
+ </Button>
110
+ </div>
111
+ </div>
112
+ )
113
+ }
114
+
115
+ interface CellRendererProps {
116
+ field: ActionFieldDef
117
+ value: any
118
+ onChange: (v: any) => void
119
+ disabled?: boolean
120
+ }
121
+
122
+ // Per-cell widget. Mirrors the flat FieldRenderer in dynamic-form.tsx but
123
+ // without the per-field Label (the column header is the label) and sized for a
124
+ // table cell. Nested line-items inside a row are not supported (a row column is
125
+ // a scalar widget).
126
+ function CellRenderer({ field, value, onChange, disabled }: CellRendererProps) {
127
+ const widget = resolveWidget(field)
128
+ if (widget === 'select' && field.ref) {
129
+ return <RefCell field={field} value={value} onChange={onChange} disabled={disabled} />
130
+ }
131
+ switch (widget) {
132
+ case 'textarea':
133
+ case 'richtext':
134
+ return (
135
+ <Textarea
136
+ value={value || ''}
137
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
138
+ placeholder={field.placeholder}
139
+ disabled={disabled}
140
+ rows={2}
141
+ />
142
+ )
143
+ case 'color':
144
+ return (
145
+ <Input
146
+ type="color"
147
+ value={value || '#000000'}
148
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
149
+ disabled={disabled}
150
+ />
151
+ )
152
+ case 'select':
153
+ return (
154
+ <Select value={value || ''} onValueChange={onChange} disabled={disabled}>
155
+ <SelectTrigger>
156
+ <SelectValue placeholder={field.placeholder || 'Seleccionar...'} />
157
+ </SelectTrigger>
158
+ <SelectContent>
159
+ {field.options?.map((opt) => (
160
+ <SelectItem key={opt.value} value={opt.value}>
161
+ {opt.label}
162
+ </SelectItem>
163
+ ))}
164
+ </SelectContent>
165
+ </Select>
166
+ )
167
+ case 'switch':
168
+ return <Switch checked={!!value} onCheckedChange={onChange} disabled={disabled} />
169
+ case 'number':
170
+ return (
171
+ <Input
172
+ type="number"
173
+ value={value ?? ''}
174
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.valueAsNumber || '')}
175
+ placeholder={field.placeholder}
176
+ disabled={disabled}
177
+ />
178
+ )
179
+ case 'date':
180
+ return (
181
+ <Input
182
+ type="date"
183
+ value={value || ''}
184
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
185
+ disabled={disabled}
186
+ />
187
+ )
188
+ default:
189
+ return (
190
+ <Input
191
+ type={field.type === 'email' ? 'email' : field.type === 'url' ? 'url' : 'text'}
192
+ value={value || ''}
193
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
194
+ placeholder={field.placeholder}
195
+ disabled={disabled}
196
+ />
197
+ )
198
+ }
199
+ }
200
+
201
+ function RefCell({ field, value, onChange, disabled }: CellRendererProps) {
202
+ const { options, loading } = useOptionsResolver({
203
+ modelKey: '',
204
+ fieldKey: 'id',
205
+ ref: field.ref,
206
+ })
207
+ return (
208
+ <Select value={value || ''} onValueChange={onChange} disabled={disabled || loading}>
209
+ <SelectTrigger>
210
+ <SelectValue placeholder={loading ? 'Cargando…' : field.placeholder || 'Seleccionar...'} />
211
+ </SelectTrigger>
212
+ <SelectContent>
213
+ {options.map((opt: ResolvedOption) => (
214
+ <SelectItem key={String(opt.id)} value={String(opt.id)}>
215
+ {opt.label}
216
+ </SelectItem>
217
+ ))}
218
+ </SelectContent>
219
+ </Select>
220
+ )
221
+ }
package/src/index.ts CHANGED
@@ -59,6 +59,16 @@ export {
59
59
  type DynamicColumnsHelpers,
60
60
  } from './dynamic-columns'
61
61
  export { DynamicRecordDialog } from './dialogs/dynamic-record'
62
+ export { CreateRecordDialog } from './dialogs/create-record-dialog'
63
+ export { ViewRecordDialog } from './dialogs/view-record-dialog'
64
+ export type {
65
+ ModelKey,
66
+ ModelSchema,
67
+ CreateResult,
68
+ RecordDialogProps,
69
+ CreateRecordDialogProps,
70
+ ViewRecordDialogProps,
71
+ } from './dialogs/types'
62
72
  export { ExportDialog } from './dialogs/export'
63
73
  export { ImportDialog } from './dialogs/import'
64
74
  export {
package/src/slot.tsx CHANGED
@@ -22,6 +22,8 @@ class SlotRegistryImpl {
22
22
  const entry: SlotEntry = { id: slotId, component, priority: opts?.priority ?? 0, source: opts?.source }
23
23
  const list = this.slots.get(slotId) ?? []
24
24
  list.push(entry)
25
+ // Higher priority renders first — canonical across SDK and runtime-react.
26
+ // See docs/slot-priority.md.
25
27
  list.sort((a, b) => b.priority - a.priority)
26
28
  this.slots.set(slotId, list)
27
29
  this.emit()
package/src/types.ts CHANGED
@@ -135,6 +135,16 @@ export interface ActionFieldDef {
135
135
  * `useOptionsResolver` against `/api/options/<ref>?field=id`.
136
136
  */
137
137
  ref?: string
138
+ /**
139
+ * Columns of a repeatable line-items group. Mirrors the kernel v3
140
+ * `ActionField.item_fields` (json `item_fields`). Present on a field
141
+ * with `type: "array"` — the multi-row container (e.g. the item rows
142
+ * of a "Recibir mercancía" modal, or the debit/credit lines of a
143
+ * journal entry). Each entry is itself an ActionFieldDef describing
144
+ * one column's cell widget. The field value is an array of objects
145
+ * keyed by these item field keys. Rendered by `DynamicLineItems`.
146
+ */
147
+ itemFields?: ActionFieldDef[]
138
148
  }
139
149
 
140
150
  export interface ActionDefinition {