@asteby/metacore-runtime-react 11.0.0 → 13.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 (51) hide show
  1. package/CHANGELOG.md +74 -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-crud-page.d.ts.map +1 -1
  17. package/dist/dynamic-crud-page.js +6 -2
  18. package/dist/dynamic-form-schema.d.ts +9 -0
  19. package/dist/dynamic-form-schema.d.ts.map +1 -1
  20. package/dist/dynamic-form-schema.js +22 -0
  21. package/dist/dynamic-form.d.ts +1 -0
  22. package/dist/dynamic-form.d.ts.map +1 -1
  23. package/dist/dynamic-form.js +12 -1
  24. package/dist/dynamic-line-items.d.ts +9 -0
  25. package/dist/dynamic-line-items.d.ts.map +1 -0
  26. package/dist/dynamic-line-items.js +64 -0
  27. package/dist/dynamic-table.d.ts.map +1 -1
  28. package/dist/dynamic-table.js +7 -1
  29. package/dist/index.d.ts +4 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/model-action-toolbar.d.ts +27 -0
  33. package/dist/model-action-toolbar.d.ts.map +1 -0
  34. package/dist/model-action-toolbar.js +88 -0
  35. package/dist/types.d.ts +18 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +6 -6
  38. package/src/__tests__/dynamic-form.test.ts +56 -1
  39. package/src/action-modal-dispatcher.tsx +22 -2
  40. package/src/dialogs/create-record-dialog.tsx +46 -0
  41. package/src/dialogs/dynamic-record.tsx +111 -15
  42. package/src/dialogs/types.ts +119 -0
  43. package/src/dialogs/view-record-dialog.tsx +37 -0
  44. package/src/dynamic-crud-page.tsx +11 -1
  45. package/src/dynamic-form-schema.ts +25 -0
  46. package/src/dynamic-form.tsx +12 -1
  47. package/src/dynamic-line-items.tsx +221 -0
  48. package/src/dynamic-table.tsx +7 -1
  49. package/src/index.ts +16 -0
  50. package/src/model-action-toolbar.tsx +154 -0
  51. package/src/types.ts +18 -0
@@ -3,6 +3,7 @@
3
3
  // starter. Host-owned infra that was referenced by alias (axios client,
4
4
  // branch store) now flows through <ApiProvider> from runtime-react.
5
5
  import { createContext, useContext, useEffect, useRef, useState } from 'react'
6
+ import type { ModelSchema } from './types'
6
7
  import {
7
8
  Dialog,
8
9
  DialogContent,
@@ -59,11 +60,14 @@ interface FieldDef {
59
60
  filterBy?: string
60
61
  }
61
62
 
63
+ // Permissive shape: the wire payload may omit some fields (e.g. `title` is
64
+ // optional on legacy backends). Keep field types loose so a host-supplied
65
+ // `ModelSchema` (see ./types.ts) is structurally assignable here.
62
66
  interface ModalMetadata {
63
- title: string
64
- createTitle: string
65
- editTitle: string
66
- fields: FieldDef[]
67
+ title?: string
68
+ createTitle?: string
69
+ editTitle?: string
70
+ fields?: FieldDef[]
67
71
  }
68
72
 
69
73
  export interface DynamicRecordDialogProps {
@@ -74,6 +78,38 @@ export interface DynamicRecordDialogProps {
74
78
  recordId?: string | null
75
79
  endpoint?: string
76
80
  onSaved?: () => void
81
+ /**
82
+ * Optional override invoked instead of the default `POST` when the dialog
83
+ * is in `create` mode. Hosts may use this to route writes through custom
84
+ * mutations (optimistic updates, audit hooks, etc.). The dialog still
85
+ * closes and fires `onSaved` on success.
86
+ */
87
+ onCreate?: (data: Record<string, any>) => Promise<{ id?: string | number } | void>
88
+ /**
89
+ * Optional override invoked instead of the default `PUT` when the dialog
90
+ * is in `edit` mode. Receives the record id and the form payload.
91
+ */
92
+ onUpdate?: (recordId: string, data: Record<string, any>) => Promise<{ id?: string | number } | void>
93
+ /**
94
+ * Optional default values seeded into the form on `create`. Ignored when
95
+ * `mode` is `'edit'` or `'view'` (those fetch from the record endpoint).
96
+ */
97
+ defaults?: Record<string, any>
98
+ /**
99
+ * Optional pre-fetched metadata. When provided the dialog skips the
100
+ * `/metadata/modal/:model` request and uses this shape directly.
101
+ */
102
+ schema?: ModelSchema
103
+ /**
104
+ * Optional handler shown as a "Delete" action in `view` mode. The dialog
105
+ * awaits the promise and closes on success. Omit to hide the action.
106
+ */
107
+ onDelete?: () => Promise<void>
108
+ /**
109
+ * Optional handler shown as an "Edit" action in `view` mode. Omit to hide
110
+ * the action.
111
+ */
112
+ onEdit?: () => void
77
113
  }
78
114
 
79
115
  function resolvePath(obj: any, path: string): any {
@@ -136,15 +172,25 @@ export function DynamicRecordDialog({
136
172
  recordId,
137
173
  endpoint,
138
174
  onSaved,
175
+ onCreate,
176
+ onUpdate,
177
+ defaults,
178
+ schema,
179
+ onDelete,
180
+ onEdit,
139
181
  }: DynamicRecordDialogProps) {
140
182
  const api = useApi()
141
- const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(null)
183
+ const [modalMeta, setModalMeta] = useState<ModalMetadata | null>(
184
+ schema ? (schema as ModalMetadata) : null,
185
+ )
142
186
  const [record, setRecord] = useState<any | null>(null)
143
187
  const [formValues, setFormValues] = useState<Record<string, any>>({})
144
188
  const [loading, setLoading] = useState(false)
145
189
  const [saving, setSaving] = useState(false)
190
+ const [deleting, setDeleting] = useState(false)
146
191
 
147
192
  const isCreate = mode === 'create'
193
+ const isView = mode === 'view'
148
194
  const isEditable = mode === 'create' || mode === 'edit'
149
195
  const config = MODE_CONFIG[mode]
150
196
 
@@ -157,16 +203,21 @@ export function DynamicRecordDialog({
157
203
  const load = async () => {
158
204
  setLoading(true)
159
205
  try {
160
- const metaRes = await api.get(`/metadata/modal/${model}`)
161
- if (cancelled) return
162
-
163
- const meta: ModalMetadata = metaRes.data?.data ?? metaRes.data
206
+ let meta: ModalMetadata | null = schema ? (schema as ModalMetadata) : null
207
+ if (!meta) {
208
+ const metaRes = await api.get(`/metadata/modal/${model}`)
209
+ if (cancelled) return
210
+ meta = metaRes.data?.data ?? metaRes.data
211
+ }
164
212
  setModalMeta(meta)
165
213
 
166
214
  if (isCreate) {
167
215
  const initial: Record<string, any> = {}
168
- for (const field of meta.fields ?? []) {
169
- initial[field.key] = field.defaultValue ?? ''
216
+ for (const field of meta?.fields ?? []) {
217
+ initial[field.key] =
218
+ (defaults && Object.prototype.hasOwnProperty.call(defaults, field.key)
219
+ ? defaults[field.key]
220
+ : field.defaultValue) ?? ''
170
221
  }
171
222
  setFormValues(initial)
172
223
  } else {
@@ -181,7 +232,7 @@ export function DynamicRecordDialog({
181
232
  setRecord(rec)
182
233
 
183
234
  const initial: Record<string, any> = {}
184
- for (const field of meta.fields ?? []) {
235
+ for (const field of meta?.fields ?? []) {
185
236
  initial[field.key] = resolvePath(rec, field.key) ?? field.defaultValue ?? ''
186
237
  }
187
238
  setFormValues(initial)
@@ -196,7 +247,7 @@ export function DynamicRecordDialog({
196
247
 
197
248
  load()
198
249
  return () => { cancelled = true }
199
- }, [open, recordId, model, endpoint, isCreate])
250
+ }, [open, recordId, model, endpoint, isCreate, schema, defaults])
200
251
 
201
252
  useEffect(() => {
202
253
  if (!open) {
@@ -211,7 +262,7 @@ export function DynamicRecordDialog({
211
262
  if (!modalMeta) return
212
263
 
213
264
  if (isEditable) {
214
- for (const field of modalMeta.fields) {
265
+ for (const field of modalMeta.fields ?? []) {
215
266
  if (field.required && !formValues[field.key] && formValues[field.key] !== 0 && formValues[field.key] !== false) {
216
267
  toast.error(`El campo "${field.label}" es obligatorio`)
217
268
  return
@@ -221,6 +272,22 @@ export function DynamicRecordDialog({
221
272
 
222
273
  setSaving(true)
223
274
  try {
275
+ if (isCreate && onCreate) {
276
+ await onCreate(formValues)
277
+ toast.success('Registro creado correctamente')
278
+ onSaved?.()
279
+ onOpenChange(false)
280
+ return
281
+ }
282
+
283
+ if (!isCreate && recordId && onUpdate) {
284
+ await onUpdate(String(recordId), formValues)
285
+ toast.success('Guardado correctamente')
286
+ onSaved?.()
287
+ onOpenChange(false)
288
+ return
289
+ }
290
+
224
291
  let res
225
292
  if (isCreate) {
226
293
  const createEndpoint = endpoint || `/dynamic/${model}`
@@ -246,6 +313,20 @@ export function DynamicRecordDialog({
246
313
  }
247
314
  }
248
315
 
316
+ const handleDelete = async () => {
317
+ if (!onDelete) return
318
+ setDeleting(true)
319
+ try {
320
+ await onDelete()
321
+ onOpenChange(false)
322
+ } catch (err: any) {
323
+ console.error('[DynamicRecordDialog] delete error:', err)
324
+ toast.error(err?.response?.data?.message || err?.message || 'Error al eliminar')
325
+ } finally {
326
+ setDeleting(false)
327
+ }
328
+ }
329
+
249
330
  const title = modalMeta ? config.getTitle(modalMeta) : ''
250
331
 
251
332
  const visibleFields = modalMeta?.fields?.filter(f => {
@@ -311,9 +392,24 @@ export function DynamicRecordDialog({
311
392
  </div>
312
393
 
313
394
  <DialogFooter className="p-4 border-t shrink-0">
314
- <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
395
+ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || deleting}>
315
396
  {config.cancelLabel}
316
397
  </Button>
398
+ {isView && onDelete && (
399
+ <Button
400
+ variant="destructive"
401
+ onClick={handleDelete}
402
+ disabled={deleting || loading}
403
+ >
404
+ {deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
405
+ {deleting ? 'Eliminando...' : 'Eliminar'}
406
+ </Button>
407
+ )}
408
+ {isView && onEdit && (
409
+ <Button onClick={onEdit} disabled={deleting || loading}>
410
+ Editar
411
+ </Button>
412
+ )}
317
413
  {isEditable && (
318
414
  <Button
319
415
  type="submit"
@@ -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
+ }
@@ -36,6 +36,7 @@ import { DynamicRecordDialog } from './dialogs/dynamic-record'
36
36
  import { ExportDialog } from './dialogs/export'
37
37
  import { ImportDialog } from './dialogs/import'
38
38
  import { getModelExtension } from './model-extension-registry'
39
+ import { ModelActionToolbar } from './model-action-toolbar'
39
40
  import type { TableMetadata } from './types'
40
41
 
41
42
  export interface DynamicCRUDPageStrings {
@@ -153,7 +154,10 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
153
154
  }, [title])
154
155
 
155
156
  const enableCRUD = metadata?.enableCRUDActions ?? false
156
- const effectiveHideCreate = hideCreate || ext?.hideCreate
157
+ // A "create"-placement action ships a custom create experience — it
158
+ // replaces the generic create button (the ModelActionToolbar renders it).
159
+ const hasCreateAction = metadata?.actions?.some((a) => a.placement === 'create') ?? false
160
+ const effectiveHideCreate = hideCreate || ext?.hideCreate || hasCreateAction
157
161
  const effectiveHideExport = hideExport || ext?.hideExport
158
162
  const effectiveHideImport = hideImport || ext?.hideImport
159
163
  // Refresh defaults to hidden in the page header — <DynamicTable> ships
@@ -222,6 +226,12 @@ export function DynamicCRUDPage(props: DynamicCRUDPageProps) {
222
226
  )}
223
227
  {ext?.toolbarExtras && <ext.toolbarExtras model={model} onRefresh={handleRefresh} />}
224
228
  {toolbarExtras}
229
+ <ModelActionToolbar
230
+ model={model}
231
+ endpoint={dataEndpoint}
232
+ actions={metadata?.actions}
233
+ onChange={handleRefresh}
234
+ />
225
235
  {showCreate && (
226
236
  <button
227
237
  type='button'
@@ -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