@asteby/metacore-runtime-react 8.0.0 → 9.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.
@@ -0,0 +1,497 @@
1
+ // DynamicRelation — primitivo metadata-driven que renderiza el lado N de una
2
+ // relación 1:N o N:N entre modelos. Cubre dos kinds:
3
+ // - "one_to_many": lista inline editable que cuelga del registro padre.
4
+ // - "many_to_many": multi-select sobre la tabla destino con sync a la pivot.
5
+ // La RFC completa vive en `packages/runtime-react/docs/relations.md`.
6
+ import { useCallback, useEffect, useMemo, useState } from 'react'
7
+ import {
8
+ Button,
9
+ Skeleton,
10
+ AlertDialog,
11
+ AlertDialogAction,
12
+ AlertDialogCancel,
13
+ AlertDialogContent,
14
+ AlertDialogDescription,
15
+ AlertDialogFooter,
16
+ AlertDialogHeader,
17
+ AlertDialogTitle,
18
+ Dialog,
19
+ DialogContent,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ MultiSelect,
23
+ } from '@asteby/metacore-ui/primitives'
24
+ import { Plus, Trash2, Pencil } from 'lucide-react'
25
+ import { useApi } from './api-context'
26
+ import { useMetadataCache } from './metadata-cache'
27
+ import { DynamicForm } from './dynamic-form'
28
+ import type { ApiResponse, TableMetadata } from './types'
29
+ import {
30
+ buildCreatePayload,
31
+ buildPivotAttachPayload,
32
+ buildPivotRowIndex,
33
+ buildRelationFilterParams,
34
+ deriveRelationFormFields,
35
+ diffSelection,
36
+ extractSelectedTargetIds,
37
+ pickOptionLabel,
38
+ relationRowKey,
39
+ type DynamicRelationKind,
40
+ } from './dynamic-relation-helpers'
41
+
42
+ export type { DynamicRelationKind } from './dynamic-relation-helpers'
43
+ export {
44
+ buildCreatePayload,
45
+ buildPivotAttachPayload,
46
+ buildPivotRowIndex,
47
+ buildRelationFilterParams,
48
+ deriveRelationFormFields,
49
+ diffSelection,
50
+ extractSelectedTargetIds,
51
+ pickOptionLabel,
52
+ relationRowKey,
53
+ } from './dynamic-relation-helpers'
54
+
55
+ export interface DynamicRelationStrings {
56
+ title: string
57
+ emptyState: string
58
+ addLabel: string
59
+ editLabel: string
60
+ removeLabel: string
61
+ confirmRemoveTitle: string
62
+ confirmRemoveDescription: string
63
+ cancelLabel: string
64
+ saveLabel: string
65
+ selectPlaceholder: string
66
+ selectSearchPlaceholder: string
67
+ selectEmpty: string
68
+ }
69
+
70
+ const DEFAULT_STRINGS: DynamicRelationStrings = {
71
+ title: '',
72
+ emptyState: 'No hay registros relacionados.',
73
+ addLabel: 'Agregar',
74
+ editLabel: 'Editar',
75
+ removeLabel: 'Quitar',
76
+ confirmRemoveTitle: '¿Quitar el registro?',
77
+ confirmRemoveDescription: 'Esta acción no se puede deshacer.',
78
+ cancelLabel: 'Cancelar',
79
+ saveLabel: 'Guardar',
80
+ selectPlaceholder: 'Seleccionar…',
81
+ selectSearchPlaceholder: 'Buscar…',
82
+ selectEmpty: 'Sin resultados.',
83
+ }
84
+
85
+ interface CommonProps {
86
+ /** id del registro padre. */
87
+ parentId: string | number
88
+ /** Hidden columns; el FK siempre se oculta automáticamente. */
89
+ hiddenColumns?: string[]
90
+ /** Permisos visibles. Default true. */
91
+ canCreate?: boolean
92
+ canDelete?: boolean
93
+ canEdit?: boolean
94
+ /** Strings traducibles. */
95
+ strings?: Partial<DynamicRelationStrings>
96
+ /** Wrapper className. */
97
+ className?: string
98
+ /** Callback opcional cuando la selección o la lista cambia. */
99
+ onChange?: () => void
100
+ }
101
+
102
+ export interface DynamicRelationOneToManyProps extends CommonProps {
103
+ kind: 'one_to_many'
104
+ /** Modelo hijo (lado N) cuyas filas se listan filtradas por `foreignKey == parentId`. */
105
+ model: string
106
+ /** Foreign key del lado N que apunta al padre. */
107
+ foreignKey: string
108
+ /** Endpoint override; default `/data/${model}`. */
109
+ endpoint?: string
110
+ }
111
+
112
+ export interface DynamicRelationManyToManyProps extends CommonProps {
113
+ kind: 'many_to_many'
114
+ /** Tabla pivote (`through`). FK al padre vive acá como `foreignKey`. */
115
+ through: string
116
+ /** Tabla destino (`references`) sobre la que se hace multi-select. */
117
+ references: string
118
+ /** FK del pivot al padre. */
119
+ foreignKey: string
120
+ /** FK del pivot a la tabla destino (default `${references}_id`). */
121
+ referencesKey?: string
122
+ /** Override del endpoint del pivot; default `/data/${through}`. */
123
+ pivotEndpoint?: string
124
+ /** Override del endpoint del target; default `/data/${references}`. */
125
+ referencesEndpoint?: string
126
+ /**
127
+ * Columna del target que se usa como label en el multi-select. Si no se
128
+ * pasa, se infiere de la metadata (primer columna no-id, no-hidden).
129
+ */
130
+ displayKey?: string
131
+ }
132
+
133
+ export type DynamicRelationProps =
134
+ | DynamicRelationOneToManyProps
135
+ | DynamicRelationManyToManyProps
136
+
137
+ export function DynamicRelation(props: DynamicRelationProps) {
138
+ if (props.kind === 'many_to_many') {
139
+ return <ManyToManyRelation {...props} />
140
+ }
141
+ return <OneToManyRelation {...props} />
142
+ }
143
+
144
+ function OneToManyRelation({
145
+ kind,
146
+ model,
147
+ foreignKey,
148
+ parentId,
149
+ endpoint,
150
+ hiddenColumns = [],
151
+ canCreate = true,
152
+ canDelete = true,
153
+ canEdit = true,
154
+ strings,
155
+ className,
156
+ onChange,
157
+ }: DynamicRelationOneToManyProps) {
158
+ const api = useApi()
159
+ const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
160
+ const cachedMeta = getMetadata(model)
161
+ const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
162
+
163
+ const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
164
+ const [rows, setRows] = useState<any[]>([])
165
+ const [loading, setLoading] = useState(true)
166
+ const [formOpen, setFormOpen] = useState(false)
167
+ const [editingRow, setEditingRow] = useState<any | null>(null)
168
+ const [rowToDelete, setRowToDelete] = useState<any | null>(null)
169
+ const [submitting, setSubmitting] = useState(false)
170
+
171
+ const dataEndpoint = endpoint || `/data/${model}`
172
+
173
+ const fetchAll = useCallback(async () => {
174
+ setLoading(true)
175
+ try {
176
+ const params = buildRelationFilterParams(foreignKey, parentId)
177
+ const [metaRes, dataRes] = await Promise.all([
178
+ metadata ? Promise.resolve(null) : api.get(`/metadata/table/${model}`),
179
+ api.get(dataEndpoint, { params }),
180
+ ])
181
+ if (metaRes && (metaRes as any).data?.success) {
182
+ const fresh = (metaRes as { data: ApiResponse<TableMetadata> }).data.data
183
+ setMetadata(fresh)
184
+ cacheMetadata(model, fresh)
185
+ }
186
+ const list = (dataRes as { data: ApiResponse<any[]> }).data
187
+ if (list.success) setRows(list.data || [])
188
+ } catch (err) {
189
+ console.error('DynamicRelation fetch error', err)
190
+ } finally {
191
+ setLoading(false)
192
+ }
193
+ }, [api, dataEndpoint, foreignKey, parentId, metadata, model, cacheMetadata])
194
+
195
+ useEffect(() => { fetchAll() }, [fetchAll])
196
+
197
+ const formFields = useMemo(
198
+ () => deriveRelationFormFields(metadata, foreignKey),
199
+ [metadata, foreignKey],
200
+ )
201
+
202
+ const visibleColumns = useMemo(() => {
203
+ if (!metadata?.columns) return []
204
+ const hidden = new Set([foreignKey, ...hiddenColumns])
205
+ return metadata.columns.filter(c => !hidden.has(c.key) && !c.hidden)
206
+ }, [metadata, foreignKey, hiddenColumns])
207
+
208
+ const handleSubmit = useCallback(async (values: Record<string, any>) => {
209
+ setSubmitting(true)
210
+ try {
211
+ if (editingRow) {
212
+ const res = await api.put(`${dataEndpoint}/${editingRow.id}`, values)
213
+ if (!(res as any).data?.success) throw new Error('update failed')
214
+ } else {
215
+ const payload = buildCreatePayload(foreignKey, parentId, values)
216
+ const res = await api.post(dataEndpoint, payload)
217
+ if (!(res as any).data?.success) throw new Error('create failed')
218
+ }
219
+ setFormOpen(false)
220
+ setEditingRow(null)
221
+ await fetchAll()
222
+ onChange?.()
223
+ } catch (err) {
224
+ console.error('DynamicRelation submit error', err)
225
+ } finally {
226
+ setSubmitting(false)
227
+ }
228
+ }, [api, dataEndpoint, editingRow, fetchAll, foreignKey, onChange, parentId])
229
+
230
+ const handleDelete = useCallback(async () => {
231
+ if (!rowToDelete) return
232
+ setSubmitting(true)
233
+ try {
234
+ const res = await api.delete(`${dataEndpoint}/${rowToDelete.id}`)
235
+ if (!(res as any).data?.success) throw new Error('delete failed')
236
+ setRowToDelete(null)
237
+ await fetchAll()
238
+ onChange?.()
239
+ } catch (err) {
240
+ console.error('DynamicRelation delete error', err)
241
+ } finally {
242
+ setSubmitting(false)
243
+ }
244
+ }, [api, dataEndpoint, fetchAll, onChange, rowToDelete])
245
+
246
+ return (
247
+ <div className={className} data-relation-kind={kind} data-relation-model={model}>
248
+ {(labels.title || canCreate) && (
249
+ <div className="flex items-center justify-between pb-3">
250
+ {labels.title ? <h3 className="text-sm font-medium">{labels.title}</h3> : <span />}
251
+ {canCreate && (
252
+ <Button
253
+ size="sm"
254
+ variant="outline"
255
+ onClick={() => { setEditingRow(null); setFormOpen(true) }}
256
+ >
257
+ <Plus className="h-4 w-4 mr-1" />
258
+ {labels.addLabel}
259
+ </Button>
260
+ )}
261
+ </div>
262
+ )}
263
+
264
+ {loading ? (
265
+ <div className="space-y-2">
266
+ {Array.from({ length: 3 }).map((_, i) => (
267
+ <Skeleton key={`rel-skeleton-${i}`} className="h-10 w-full" />
268
+ ))}
269
+ </div>
270
+ ) : rows.length === 0 ? (
271
+ <div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
272
+ {labels.emptyState}
273
+ </div>
274
+ ) : (
275
+ <div className="border rounded-md divide-y bg-card">
276
+ {rows.map((row, idx) => (
277
+ <div
278
+ key={relationRowKey(row, idx, foreignKey)}
279
+ className="flex items-center justify-between gap-3 px-3 py-2"
280
+ >
281
+ <div className="flex-1 grid grid-cols-[repeat(auto-fit,minmax(0,1fr))] gap-2 text-sm">
282
+ {visibleColumns.map(col => (
283
+ <span key={col.key} className="truncate" title={String(row[col.key] ?? '')}>
284
+ {formatCell(row[col.key])}
285
+ </span>
286
+ ))}
287
+ </div>
288
+ <div className="flex items-center gap-1 shrink-0">
289
+ {canEdit && (
290
+ <Button
291
+ size="sm"
292
+ variant="ghost"
293
+ onClick={() => { setEditingRow(row); setFormOpen(true) }}
294
+ aria-label={labels.editLabel}
295
+ >
296
+ <Pencil className="h-4 w-4" />
297
+ </Button>
298
+ )}
299
+ {canDelete && (
300
+ <Button
301
+ size="sm"
302
+ variant="ghost"
303
+ onClick={() => setRowToDelete(row)}
304
+ aria-label={labels.removeLabel}
305
+ >
306
+ <Trash2 className="h-4 w-4" />
307
+ </Button>
308
+ )}
309
+ </div>
310
+ </div>
311
+ ))}
312
+ </div>
313
+ )}
314
+
315
+ <Dialog open={formOpen} onOpenChange={(open: boolean) => { setFormOpen(open); if (!open) setEditingRow(null) }}>
316
+ <DialogContent>
317
+ <DialogHeader>
318
+ <DialogTitle>{editingRow ? labels.editLabel : labels.addLabel}</DialogTitle>
319
+ </DialogHeader>
320
+ <DynamicForm
321
+ fields={formFields}
322
+ initialValues={editingRow || undefined}
323
+ onSubmit={handleSubmit}
324
+ onCancel={() => { setFormOpen(false); setEditingRow(null) }}
325
+ submitLabel={labels.saveLabel}
326
+ cancelLabel={labels.cancelLabel}
327
+ disabled={submitting}
328
+ />
329
+ </DialogContent>
330
+ </Dialog>
331
+
332
+ <AlertDialog open={!!rowToDelete} onOpenChange={(open: boolean) => !open && setRowToDelete(null)}>
333
+ <AlertDialogContent>
334
+ <AlertDialogHeader>
335
+ <AlertDialogTitle>{labels.confirmRemoveTitle}</AlertDialogTitle>
336
+ <AlertDialogDescription>{labels.confirmRemoveDescription}</AlertDialogDescription>
337
+ </AlertDialogHeader>
338
+ <AlertDialogFooter>
339
+ <AlertDialogCancel disabled={submitting}>{labels.cancelLabel}</AlertDialogCancel>
340
+ <AlertDialogAction
341
+ onClick={(e: React.MouseEvent) => { e.preventDefault(); handleDelete() }}
342
+ className="bg-red-600 hover:bg-red-700"
343
+ disabled={submitting}
344
+ >
345
+ {labels.removeLabel}
346
+ </AlertDialogAction>
347
+ </AlertDialogFooter>
348
+ </AlertDialogContent>
349
+ </AlertDialog>
350
+ </div>
351
+ )
352
+ }
353
+
354
+ function formatCell(value: unknown): string {
355
+ if (value === null || value === undefined) return '—'
356
+ if (typeof value === 'boolean') return value ? '✓' : '—'
357
+ if (typeof value === 'object') return JSON.stringify(value)
358
+ return String(value)
359
+ }
360
+
361
+ function ManyToManyRelation({
362
+ kind,
363
+ through,
364
+ references,
365
+ foreignKey,
366
+ referencesKey,
367
+ parentId,
368
+ pivotEndpoint,
369
+ referencesEndpoint,
370
+ displayKey,
371
+ canCreate = true,
372
+ canDelete = true,
373
+ strings,
374
+ className,
375
+ onChange,
376
+ }: DynamicRelationManyToManyProps) {
377
+ const api = useApi()
378
+ const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache()
379
+ const labels = { ...DEFAULT_STRINGS, ...(strings || {}) }
380
+
381
+ const refKey = referencesKey || `${references}_id`
382
+ const pivotPath = pivotEndpoint || `/data/${through}`
383
+ const targetPath = referencesEndpoint || `/data/${references}`
384
+
385
+ const cachedTargetMeta = getMetadata(references)
386
+ const [targetMeta, setTargetMeta] = useState<TableMetadata | null>(cachedTargetMeta || null)
387
+ const [targetRows, setTargetRows] = useState<any[]>([])
388
+ const [pivotRows, setPivotRows] = useState<any[]>([])
389
+ const [loading, setLoading] = useState(true)
390
+ const [syncing, setSyncing] = useState(false)
391
+
392
+ const fetchAll = useCallback(async () => {
393
+ setLoading(true)
394
+ try {
395
+ const params = buildRelationFilterParams(foreignKey, parentId)
396
+ const [metaRes, pivotRes, targetRes] = await Promise.all([
397
+ targetMeta ? Promise.resolve(null) : api.get(`/metadata/table/${references}`),
398
+ api.get(pivotPath, { params }),
399
+ api.get(targetPath),
400
+ ])
401
+ if (metaRes && (metaRes as any).data?.success) {
402
+ const fresh = (metaRes as { data: ApiResponse<TableMetadata> }).data.data
403
+ setTargetMeta(fresh)
404
+ cacheMetadata(references, fresh)
405
+ }
406
+ const pivotList = (pivotRes as { data: ApiResponse<any[]> }).data
407
+ if (pivotList.success) setPivotRows(pivotList.data || [])
408
+ const targetList = (targetRes as { data: ApiResponse<any[]> }).data
409
+ if (targetList.success) setTargetRows(targetList.data || [])
410
+ } catch (err) {
411
+ console.error('DynamicRelation m2m fetch error', err)
412
+ } finally {
413
+ setLoading(false)
414
+ }
415
+ }, [api, pivotPath, targetPath, foreignKey, parentId, references, targetMeta, cacheMetadata])
416
+
417
+ useEffect(() => { fetchAll() }, [fetchAll])
418
+
419
+ const options = useMemo(() => {
420
+ return targetRows
421
+ .filter(r => r && r.id !== undefined && r.id !== null && r.id !== '')
422
+ .map(r => ({
423
+ value: String(r.id),
424
+ label: pickOptionLabel(r, displayKey, targetMeta?.columns),
425
+ }))
426
+ }, [targetRows, displayKey, targetMeta])
427
+
428
+ const selectedIds = useMemo(
429
+ () => extractSelectedTargetIds(pivotRows, refKey),
430
+ [pivotRows, refKey],
431
+ )
432
+
433
+ const pivotIndex = useMemo(
434
+ () => buildPivotRowIndex(pivotRows, refKey),
435
+ [pivotRows, refKey],
436
+ )
437
+
438
+ const handleChange = useCallback(async (next: string[]) => {
439
+ if (syncing) return
440
+ const { toAdd, toRemove } = diffSelection(selectedIds, next)
441
+ if (toAdd.length === 0 && toRemove.length === 0) return
442
+ if (toAdd.length > 0 && !canCreate) return
443
+ if (toRemove.length > 0 && !canDelete) return
444
+ setSyncing(true)
445
+ try {
446
+ for (const targetId of toAdd) {
447
+ const payload = buildPivotAttachPayload(foreignKey, parentId, refKey, targetId)
448
+ const res = await api.post(pivotPath, payload)
449
+ if (!(res as any).data?.success) throw new Error('attach failed')
450
+ }
451
+ for (const targetId of toRemove) {
452
+ const pivotId = pivotIndex.get(targetId)
453
+ if (pivotId === undefined) continue
454
+ const res = await api.delete(`${pivotPath}/${pivotId}`)
455
+ if (!(res as any).data?.success) throw new Error('detach failed')
456
+ }
457
+ await fetchAll()
458
+ onChange?.()
459
+ } catch (err) {
460
+ console.error('DynamicRelation m2m sync error', err)
461
+ } finally {
462
+ setSyncing(false)
463
+ }
464
+ }, [api, canCreate, canDelete, fetchAll, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
465
+
466
+ return (
467
+ <div
468
+ className={className}
469
+ data-relation-kind={kind}
470
+ data-relation-through={through}
471
+ data-relation-references={references}
472
+ >
473
+ {labels.title && (
474
+ <div className="pb-3">
475
+ <h3 className="text-sm font-medium">{labels.title}</h3>
476
+ </div>
477
+ )}
478
+
479
+ {loading ? (
480
+ <Skeleton className="h-10 w-full" />
481
+ ) : options.length === 0 ? (
482
+ <div className="text-center text-sm text-muted-foreground py-8 border rounded-md bg-muted/30">
483
+ {labels.emptyState}
484
+ </div>
485
+ ) : (
486
+ <MultiSelect
487
+ options={options}
488
+ selected={selectedIds}
489
+ onChange={handleChange}
490
+ placeholder={labels.selectPlaceholder}
491
+ searchPlaceholder={labels.selectSearchPlaceholder}
492
+ emptyMessage={labels.selectEmpty}
493
+ />
494
+ )}
495
+ </div>
496
+ )
497
+ }
package/src/index.ts CHANGED
@@ -39,6 +39,16 @@ export {
39
39
  type DynamicCRUDPageStrings,
40
40
  type DynamicCRUDPageClasses,
41
41
  } from './dynamic-crud-page'
42
+ export {
43
+ DynamicRelation,
44
+ type DynamicRelationProps,
45
+ type DynamicRelationStrings,
46
+ type DynamicRelationKind,
47
+ buildRelationFilterParams,
48
+ buildCreatePayload,
49
+ deriveRelationFormFields,
50
+ relationRowKey,
51
+ } from './dynamic-relation'
42
52
  export {
43
53
  registerModelExtension,
44
54
  getModelExtension,
package/src/types.ts CHANGED
@@ -53,6 +53,28 @@ export interface ActionCondition {
53
53
  value: string | string[]
54
54
  }
55
55
 
56
+ // Mirrors `ValidationRule` from packages/sdk/src/generated/manifest.ts. Kept
57
+ // inline here so runtime-react does not import generated kernel types directly
58
+ // — apps and addons author ActionFieldDef literals.
59
+ export interface FieldValidation {
60
+ regex?: string
61
+ min?: number
62
+ max?: number
63
+ custom?: string
64
+ }
65
+
66
+ // Widget hints for the form renderer. Subset that DynamicForm knows how to
67
+ // render today; unknown values fall back to the `type`-based default.
68
+ export type FieldWidget =
69
+ | 'text'
70
+ | 'textarea'
71
+ | 'richtext'
72
+ | 'color'
73
+ | 'number'
74
+ | 'date'
75
+ | 'select'
76
+ | 'switch'
77
+
56
78
  export interface ActionFieldDef {
57
79
  key: string
58
80
  label: string
@@ -62,6 +84,8 @@ export interface ActionFieldDef {
62
84
  defaultValue?: any
63
85
  placeholder?: string
64
86
  searchEndpoint?: string
87
+ validation?: FieldValidation
88
+ widget?: FieldWidget | string
65
89
  }
66
90
 
67
91
  export interface ActionDefinition {
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
7
+ },
8
+ })