@asteby/metacore-runtime-react 8.0.0 → 9.1.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 (41) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/column-visibility.d.ts +22 -0
  3. package/dist/column-visibility.d.ts.map +1 -0
  4. package/dist/column-visibility.js +40 -0
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +4 -1
  7. package/dist/dynamic-form-schema.d.ts +7 -0
  8. package/dist/dynamic-form-schema.d.ts.map +1 -0
  9. package/dist/dynamic-form-schema.js +68 -0
  10. package/dist/dynamic-form.d.ts +2 -0
  11. package/dist/dynamic-form.d.ts.map +1 -1
  12. package/dist/dynamic-form.js +28 -9
  13. package/dist/dynamic-relation-helpers.d.ts +77 -0
  14. package/dist/dynamic-relation-helpers.d.ts.map +1 -0
  15. package/dist/dynamic-relation-helpers.js +186 -0
  16. package/dist/dynamic-relation.d.ts +64 -0
  17. package/dist/dynamic-relation.d.ts.map +1 -0
  18. package/dist/dynamic-relation.js +226 -0
  19. package/dist/dynamic-table.d.ts.map +1 -1
  20. package/dist/dynamic-table.js +17 -3
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/types.d.ts +33 -0
  25. package/dist/types.d.ts.map +1 -1
  26. package/docs/relations.md +290 -0
  27. package/package.json +9 -3
  28. package/src/__tests__/column-visibility.test.ts +116 -0
  29. package/src/__tests__/dynamic-form.test.ts +104 -0
  30. package/src/__tests__/dynamic-relation.test.ts +293 -0
  31. package/src/column-visibility.ts +43 -0
  32. package/src/dynamic-columns.tsx +4 -1
  33. package/src/dynamic-form-schema.ts +66 -0
  34. package/src/dynamic-form.tsx +34 -9
  35. package/src/dynamic-relation-helpers.ts +226 -0
  36. package/src/dynamic-relation.tsx +497 -0
  37. package/src/dynamic-table.tsx +20 -2
  38. package/src/index.ts +14 -0
  39. package/src/types.ts +49 -0
  40. package/tsconfig.json +2 -1
  41. 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
+ }
@@ -69,6 +69,7 @@ import { defaultGetDynamicColumns } from './dynamic-columns'
69
69
  import { OptionsContext } from './options-context'
70
70
  import { ActionModalDispatcher } from './action-modal-dispatcher'
71
71
  import type { TableMetadata, ApiResponse, ActionMetadata } from './types'
72
+ import { getSearchableColumnKeys } from './column-visibility'
72
73
  import { DynamicRecordDialog } from './dialogs/dynamic-record'
73
74
  import { ExportDialog } from './dialogs/export'
74
75
  import { ImportDialog } from './dialogs/import'
@@ -324,13 +325,30 @@ export function DynamicTable({
324
325
  initMetadataAndOptions()
325
326
  }, [model]) // eslint-disable-line react-hooks/exhaustive-deps
326
327
 
328
+ // Derived from `metadata.columns[].searchable`. `null` means the kernel
329
+ // didn't emit the flag for any column → preserve legacy "search every
330
+ // column" behaviour by not narrowing the request. An empty array means
331
+ // every column was explicitly opted out → skip sending `search` at all.
332
+ const searchableKeys = useMemo(
333
+ () => (metadata ? getSearchableColumnKeys(metadata) : null),
334
+ [metadata],
335
+ )
336
+
327
337
  const buildFilterParams = useCallback(() => {
328
338
  const params: Record<string, any> = {}
329
339
  if (sorting.length > 0) {
330
340
  params.sortBy = sorting[0].id
331
341
  params.order = sorting[0].desc ? 'desc' : 'asc'
332
342
  }
333
- if (globalFilter) params.search = globalFilter
343
+ if (globalFilter) {
344
+ if (searchableKeys === null) {
345
+ params.search = globalFilter
346
+ } else if (searchableKeys.length > 0) {
347
+ params.search = globalFilter
348
+ params.search_columns = searchableKeys.join(',')
349
+ }
350
+ // searchableKeys === [] → drop the search request entirely
351
+ }
334
352
  columnFilters.forEach((filter: { id: string; value: unknown }) => { params[`f_${filter.id}`] = filter.value })
335
353
  if (defaultFilters) Object.entries(defaultFilters).forEach(([key, value]) => { params[`f_${key}`] = value })
336
354
  Object.entries(dynamicFilters).forEach(([key, values]) => {
@@ -352,7 +370,7 @@ export function DynamicTable({
352
370
  params['f_created_at'] = `${startDate}_${endDate}`
353
371
  }
354
372
  return params
355
- }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange])
373
+ }, [sorting, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
356
374
 
357
375
  const hasActiveFilters = useMemo(() => {
358
376
  if (globalFilter) return true
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,
@@ -46,3 +56,7 @@ export {
46
56
  type ModelExtension,
47
57
  type ModelExtensionProps,
48
58
  } from './model-extension-registry'
59
+ export {
60
+ isColumnVisibleInTable,
61
+ getSearchableColumnKeys,
62
+ } from './column-visibility'
package/src/types.ts CHANGED
@@ -26,6 +26,18 @@ export interface FilterDefinition {
26
26
  searchEndpoint?: string
27
27
  }
28
28
 
29
+ /**
30
+ * Where a column is rendered. Mirrors `manifest.ColumnDef.Visibility` in the
31
+ * kernel:
32
+ * - `''` / `'all'` — visible everywhere (default).
33
+ * - `'table'` — only the list/index page.
34
+ * - `'modal'` — only the create/edit modal.
35
+ * - `'list'` — only API list payloads (omitted from UI).
36
+ * Hosts may extend the union with their own scopes; the SDK only acts on the
37
+ * canonical values above.
38
+ */
39
+ export type ColumnVisibility = 'all' | 'table' | 'modal' | 'list' | (string & {})
40
+
29
41
  export interface ColumnDefinition {
30
42
  key: string
31
43
  label: string
@@ -33,6 +45,19 @@ export interface ColumnDefinition {
33
45
  sortable: boolean
34
46
  filterable: boolean
35
47
  hidden?: boolean
48
+ /**
49
+ * Scopes where this column is rendered. When `'modal'` (or `'list'`) the
50
+ * column is hidden from the table even if `hidden` is unset. Empty/`'all'`/
51
+ * `'table'` keep the column visible. See `column-visibility.ts`.
52
+ */
53
+ visibility?: ColumnVisibility
54
+ /**
55
+ * Opts the column into the model's full-text/contains search. Independent
56
+ * of `filterable` (which drives column-level filter chips). When at least
57
+ * one column declares `searchable`, the SDK narrows the global search to
58
+ * those columns; otherwise legacy "search every column" behaviour applies.
59
+ */
60
+ searchable?: boolean
36
61
  styleConfig?: Record<string, any>
37
62
  tooltip?: string
38
63
  description?: string
@@ -53,6 +78,28 @@ export interface ActionCondition {
53
78
  value: string | string[]
54
79
  }
55
80
 
81
+ // Mirrors `ValidationRule` from packages/sdk/src/generated/manifest.ts. Kept
82
+ // inline here so runtime-react does not import generated kernel types directly
83
+ // — apps and addons author ActionFieldDef literals.
84
+ export interface FieldValidation {
85
+ regex?: string
86
+ min?: number
87
+ max?: number
88
+ custom?: string
89
+ }
90
+
91
+ // Widget hints for the form renderer. Subset that DynamicForm knows how to
92
+ // render today; unknown values fall back to the `type`-based default.
93
+ export type FieldWidget =
94
+ | 'text'
95
+ | 'textarea'
96
+ | 'richtext'
97
+ | 'color'
98
+ | 'number'
99
+ | 'date'
100
+ | 'select'
101
+ | 'switch'
102
+
56
103
  export interface ActionFieldDef {
57
104
  key: string
58
105
  label: string
@@ -62,6 +109,8 @@ export interface ActionFieldDef {
62
109
  defaultValue?: any
63
110
  placeholder?: string
64
111
  searchEndpoint?: string
112
+ validation?: FieldValidation
113
+ widget?: FieldWidget | string
65
114
  }
66
115
 
67
116
  export interface ActionDefinition {
package/tsconfig.json CHANGED
@@ -12,5 +12,6 @@
12
12
  "outDir": "./dist",
13
13
  "rootDir": "./src"
14
14
  },
15
- "include": ["src/**/*"]
15
+ "include": ["src/**/*"],
16
+ "exclude": ["src/**/*.test.ts", "src/**/__tests__/**"]
16
17
  }
@@ -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
+ })