@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.
- package/CHANGELOG.md +74 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +21 -1
- package/dist/dialogs/create-record-dialog.d.ts +3 -0
- package/dist/dialogs/create-record-dialog.d.ts.map +1 -0
- package/dist/dialogs/create-record-dialog.js +20 -0
- package/dist/dialogs/dynamic-record.d.ts +38 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +50 -12
- package/dist/dialogs/types.d.ts +115 -0
- package/dist/dialogs/types.d.ts.map +1 -0
- package/dist/dialogs/types.js +15 -0
- package/dist/dialogs/view-record-dialog.d.ts +3 -0
- package/dist/dialogs/view-record-dialog.d.ts.map +1 -0
- package/dist/dialogs/view-record-dialog.js +15 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +6 -2
- package/dist/dynamic-form-schema.d.ts +9 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +22 -0
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +12 -1
- package/dist/dynamic-line-items.d.ts +9 -0
- package/dist/dynamic-line-items.d.ts.map +1 -0
- package/dist/dynamic-line-items.js +64 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +7 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/model-action-toolbar.d.ts +27 -0
- package/dist/model-action-toolbar.d.ts.map +1 -0
- package/dist/model-action-toolbar.js +88 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/dynamic-form.test.ts +56 -1
- package/src/action-modal-dispatcher.tsx +22 -2
- package/src/dialogs/create-record-dialog.tsx +46 -0
- package/src/dialogs/dynamic-record.tsx +111 -15
- package/src/dialogs/types.ts +119 -0
- package/src/dialogs/view-record-dialog.tsx +37 -0
- package/src/dynamic-crud-page.tsx +11 -1
- package/src/dynamic-form-schema.ts +25 -0
- package/src/dynamic-form.tsx +12 -1
- package/src/dynamic-line-items.tsx +221 -0
- package/src/dynamic-table.tsx +7 -1
- package/src/index.ts +16 -0
- package/src/model-action-toolbar.tsx +154 -0
- 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
|
|
64
|
-
createTitle
|
|
65
|
-
editTitle
|
|
66
|
-
fields
|
|
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>(
|
|
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
|
-
|
|
161
|
-
if (
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
169
|
-
initial[field.key] =
|
|
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
|
|
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
|
-
|
|
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'
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -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
|