@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.
- package/CHANGELOG.md +63 -0
- package/dist/__tests__/dynamic-form.test.d.ts +2 -0
- package/dist/__tests__/dynamic-form.test.d.ts.map +1 -0
- package/dist/__tests__/dynamic-form.test.js +93 -0
- package/dist/__tests__/dynamic-relation.test.d.ts +2 -0
- package/dist/__tests__/dynamic-relation.test.d.ts.map +1 -0
- package/dist/__tests__/dynamic-relation.test.js +228 -0
- package/dist/dynamic-form-schema.d.ts +7 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -0
- package/dist/dynamic-form-schema.js +68 -0
- package/dist/dynamic-form.d.ts +2 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +28 -9
- package/dist/dynamic-relation-helpers.d.ts +77 -0
- package/dist/dynamic-relation-helpers.d.ts.map +1 -0
- package/dist/dynamic-relation-helpers.js +186 -0
- package/dist/dynamic-relation.d.ts +64 -0
- package/dist/dynamic-relation.d.ts.map +1 -0
- package/dist/dynamic-relation.js +226 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/relations.md +290 -0
- package/package.json +8 -3
- package/src/__tests__/dynamic-form.test.ts +104 -0
- package/src/__tests__/dynamic-relation.test.ts +293 -0
- package/src/dynamic-form-schema.ts +66 -0
- package/src/dynamic-form.tsx +34 -9
- package/src/dynamic-relation-helpers.ts +226 -0
- package/src/dynamic-relation.tsx +497 -0
- package/src/index.ts +10 -0
- package/src/types.ts +24 -0
- package/vitest.config.ts +8 -0
|
@@ -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 {
|