@asteby/metacore-runtime-react 6.0.0 → 6.4.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,531 @@
1
+ // Default `getDynamicColumns` factory used by hosts that don't need a custom
2
+ // renderer. Supports every cell type produced by kernel/dynamic metadata:
3
+ // badge (static + endpoint-loaded options), avatar, phone, date, boolean,
4
+ // relation-badge-list, media-gallery, image, plus a generic text fallback.
5
+ //
6
+ // The implementation was previously duplicated across multiple host apps
7
+ // (~550 LOC each, drifting). It now lives here so a single fix propagates
8
+ // to every host. Hosts inject app-specific URL helpers via the `helpers`
9
+ // argument so the SDK stays free of environment-bound code.
10
+
11
+ import * as React from 'react'
12
+ import { ColumnDef } from '@tanstack/react-table'
13
+ import { format } from 'date-fns'
14
+ import { es, enUS } from 'date-fns/locale'
15
+ import * as icons from 'lucide-react'
16
+ import { MoreHorizontal } from 'lucide-react'
17
+ import {
18
+ Avatar,
19
+ AvatarFallback,
20
+ AvatarImage,
21
+ Badge,
22
+ Button,
23
+ Checkbox,
24
+ DropdownMenu,
25
+ DropdownMenuContent,
26
+ DropdownMenuItem,
27
+ DropdownMenuTrigger,
28
+ } from '@asteby/metacore-ui'
29
+ import {
30
+ DataTableColumnHeader,
31
+ FilterableColumnHeader,
32
+ type ColumnFilterMeta,
33
+ } from '@asteby/metacore-ui/data-table'
34
+ import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
35
+ import { OptionsContext } from './options-context'
36
+ import { DynamicIcon } from './dynamic-icon'
37
+ import type { TableMetadata, ColumnDefinition } from './types'
38
+ import type {
39
+ ColumnFilterConfig,
40
+ GetDynamicColumns,
41
+ } from './dynamic-columns-shim'
42
+
43
+ /** Host-supplied helpers consumed by avatar/image cell renderers. */
44
+ export interface DynamicColumnsHelpers {
45
+ /**
46
+ * Resolves a relative or absolute media path into a renderable URL. Hosts
47
+ * typically prepend their CDN/storage base. If omitted, paths are passed
48
+ * through verbatim.
49
+ */
50
+ getImageUrl?: (path: string) => string
51
+ /**
52
+ * API origin used to build avatar URLs when the row carries a bare filename
53
+ * instead of an absolute URL or sibling `.avatar` field. Usually
54
+ * `import.meta.env.VITE_API_URL.replace('/api', '')`.
55
+ */
56
+ apiBaseUrl?: string
57
+ }
58
+
59
+ const defaultGetImageUrl = (path: string) => path
60
+
61
+ const getNestedValue = (obj: any, path: string) =>
62
+ path.split('.').reduce((acc, part) => acc && acc[part], obj)
63
+
64
+ const lowerFirst = (value?: string) => {
65
+ if (!value) return value
66
+ return value.charAt(0).toLowerCase() + value.slice(1)
67
+ }
68
+
69
+ const getPathVariants = (path?: string) => {
70
+ if (!path) return []
71
+ const normalized = path
72
+ .split('.')
73
+ .map((segment) => lowerFirst(segment) || segment)
74
+ .join('.')
75
+ return Array.from(new Set([path, normalized])).filter(Boolean)
76
+ }
77
+
78
+ const getValueFromPathVariants = (obj: any, path?: string) => {
79
+ if (!path) return undefined
80
+ for (const candidate of getPathVariants(path)) {
81
+ const value = getNestedValue(obj, candidate as string)
82
+ if (value !== undefined && value !== null) return value
83
+ }
84
+ return undefined
85
+ }
86
+
87
+ const useIsDarkTheme = () => {
88
+ const [isDark, setIsDark] = React.useState(() =>
89
+ typeof document !== 'undefined' &&
90
+ document.documentElement.classList.contains('dark')
91
+ )
92
+ React.useEffect(() => {
93
+ if (typeof document === 'undefined') return
94
+ const sync = () =>
95
+ setIsDark(document.documentElement.classList.contains('dark'))
96
+ sync()
97
+ const observer = new MutationObserver(sync)
98
+ observer.observe(document.documentElement, {
99
+ attributes: true,
100
+ attributeFilter: ['class'],
101
+ })
102
+ return () => observer.disconnect()
103
+ }, [])
104
+ return isDark
105
+ }
106
+
107
+ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
108
+ if (!Array.isArray(items) || items.length === 0) {
109
+ return <span className="text-muted-foreground">-</span>
110
+ }
111
+ return (
112
+ <div className="flex flex-wrap gap-1">
113
+ {items.map((item: any, idx: number) => {
114
+ const relationTarget = col.relationPath
115
+ ? getValueFromPathVariants(item, col.relationPath) ?? item
116
+ : item
117
+ const displaySource = relationTarget ?? item
118
+ let displayValue =
119
+ col.displayField !== undefined && col.displayField !== null
120
+ ? getValueFromPathVariants(displaySource, col.displayField)
121
+ : displaySource
122
+ if (displayValue === undefined || displayValue === null) {
123
+ displayValue = displaySource
124
+ }
125
+ const label =
126
+ displayValue !== undefined && displayValue !== null
127
+ ? String(displayValue)
128
+ : '-'
129
+ let iconValue: string | undefined
130
+ if (col.iconField) {
131
+ const rawIcon = getValueFromPathVariants(displaySource, col.iconField)
132
+ if (rawIcon !== undefined && rawIcon !== null) {
133
+ iconValue = String(rawIcon)
134
+ }
135
+ }
136
+ return (
137
+ <Badge
138
+ key={`${col.key}-${idx}`}
139
+ variant="outline"
140
+ className="flex items-center gap-1"
141
+ >
142
+ {iconValue && (
143
+ <DynamicIcon name={iconValue} className="h-3 w-3" />
144
+ )}
145
+ <span>{label}</span>
146
+ </Badge>
147
+ )
148
+ })}
149
+ </div>
150
+ )
151
+ }
152
+
153
+ interface OptionBadgeProps {
154
+ option: { value: string; label: string; icon?: string; color?: string }
155
+ fallback: string
156
+ }
157
+
158
+ const OptionBadge: React.FC<OptionBadgeProps> = ({ option }) => {
159
+ const isDark = useIsDarkTheme()
160
+ const colorStyles = option.color ? generateBadgeStyles(option.color, { isDark }) : {}
161
+ return (
162
+ <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
163
+ {option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />}
164
+ <span>{option.label}</span>
165
+ </Badge>
166
+ )
167
+ }
168
+
169
+ const BadgeWithEndpointOptions: React.FC<{ endpoint: string; value: any }> = ({ endpoint, value }) => {
170
+ const { optionsMap } = React.useContext(OptionsContext)
171
+ const options = optionsMap.get(endpoint) || []
172
+ const option = options.find((opt: any) => opt.value === value)
173
+ if (option) return <OptionBadge option={option} fallback={String(value)} />
174
+ return <Badge variant="outline">{String(value)}</Badge>
175
+ }
176
+
177
+ /**
178
+ * Builds the canonical column factory used by `<DynamicTable>` when the host
179
+ * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
180
+ * URL resolution.
181
+ */
182
+ export function makeDefaultGetDynamicColumns(
183
+ helpers: DynamicColumnsHelpers = {},
184
+ ): GetDynamicColumns {
185
+ const getImageUrl = helpers.getImageUrl ?? defaultGetImageUrl
186
+ const apiBaseUrl = helpers.apiBaseUrl ?? ''
187
+
188
+ return function defaultGetDynamicColumns(
189
+ metadata: TableMetadata,
190
+ onAction?: (action: string, row: any) => void,
191
+ t?: (key: string, options?: any) => string,
192
+ currentLanguage?: string,
193
+ filterConfigs?: Map<string, ColumnFilterConfig>,
194
+ ): ColumnDef<any>[] {
195
+ const dateLocale = currentLanguage === 'en' ? enUS : es
196
+ const columns: ColumnDef<any>[] = [
197
+ {
198
+ id: 'select',
199
+ header: ({ table }) => (
200
+ <Checkbox
201
+ checked={
202
+ table.getIsAllPageRowsSelected() ||
203
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
204
+ }
205
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
206
+ aria-label="Select all"
207
+ className="translate-y-[2px]"
208
+ />
209
+ ),
210
+ cell: ({ row }) => (
211
+ <Checkbox
212
+ checked={row.getIsSelected()}
213
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
214
+ aria-label="Select row"
215
+ className="translate-y-[2px]"
216
+ />
217
+ ),
218
+ enableSorting: false,
219
+ enableHiding: false,
220
+ },
221
+ ]
222
+
223
+ metadata.columns.forEach((col) => {
224
+ if (col.hidden) return
225
+
226
+ const translatedLabel = col.label
227
+ const filterConfig = filterConfigs?.get(col.key)
228
+
229
+ const columnMeta: Record<string, unknown> = {
230
+ label: translatedLabel,
231
+ }
232
+ if (filterConfig) {
233
+ const fm: ColumnFilterMeta = {
234
+ filterable: true,
235
+ filterType: filterConfig.filterType as ColumnFilterMeta['filterType'],
236
+ filterKey: filterConfig.filterKey,
237
+ filterOptions: filterConfig.options,
238
+ filterLoading: filterConfig.loading,
239
+ filterSearchEndpoint: filterConfig.searchEndpoint,
240
+ selectedValues: filterConfig.selectedValues,
241
+ onFilterChange: filterConfig.onFilterChange,
242
+ }
243
+ Object.assign(columnMeta, fm)
244
+ }
245
+
246
+ columns.push({
247
+ accessorKey: col.key,
248
+ id: col.key,
249
+ meta: columnMeta,
250
+ header: ({ column }) =>
251
+ filterConfig ? (
252
+ <FilterableColumnHeader column={column} title={translatedLabel} />
253
+ ) : (
254
+ <DataTableColumnHeader column={column} title={translatedLabel} />
255
+ ),
256
+ cell: ({ row }) => {
257
+ const value = getNestedValue(row.original, col.key)
258
+ // Kernel emits the renderer flag as `type`; older hosts used
259
+ // `cellStyle`. Accept both so a single backend works across
260
+ // SDK versions.
261
+ const renderAs = col.cellStyle ?? col.type
262
+
263
+ // Endpoint-loaded badge options (preloaded into OptionsContext)
264
+ if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) {
265
+ if (!value) return <span className="text-muted-foreground">-</span>
266
+ return <BadgeWithEndpointOptions endpoint={col.searchEndpoint} value={value} />
267
+ }
268
+
269
+ // Static badge options — map value → label/icon/color
270
+ if (renderAs === 'badge' && col.options && col.options.length > 0) {
271
+ if (!value && value !== 0) return <span className="text-muted-foreground">-</span>
272
+ const option = col.options.find((o) => o.value === String(value))
273
+ if (option) return <OptionBadge option={option} fallback={String(value)} />
274
+ return <Badge variant="outline">{String(value)}</Badge>
275
+ }
276
+
277
+ if (renderAs === 'relation-badge-list') {
278
+ return renderRelationBadges(value, col)
279
+ }
280
+
281
+ switch (col.type) {
282
+ case 'date': {
283
+ if (!value) return <span className="text-muted-foreground">-</span>
284
+ try {
285
+ const date = new Date(value)
286
+ if (isNaN(date.getTime()) || date.getFullYear() <= 1) {
287
+ return <span className="text-muted-foreground">-</span>
288
+ }
289
+ return (
290
+ <div className="flex items-center gap-1.5 text-muted-foreground">
291
+ <icons.Calendar className="h-3.5 w-3.5 opacity-70" />
292
+ <span className="text-sm font-medium">
293
+ {format(date, 'PPP', { locale: dateLocale })}
294
+ </span>
295
+ </div>
296
+ )
297
+ } catch {
298
+ return <span>{String(value)}</span>
299
+ }
300
+ }
301
+
302
+ case 'search':
303
+ case 'avatar': {
304
+ const namePath = col.tooltip || col.key
305
+ const name = getNestedValue(row.original, namePath) || 'N/A'
306
+ const desc = getNestedValue(row.original, col.description || '')
307
+
308
+ let avatarSrc: string | undefined
309
+ if (col.key.includes('.')) {
310
+ const parentPath = col.key.split('.').slice(0, -1).join('.')
311
+ const avatarPath = `${parentPath}.avatar`
312
+ const possibleAvatar = getNestedValue(row.original, avatarPath)
313
+ if (possibleAvatar) avatarSrc = String(possibleAvatar)
314
+ } else if (
315
+ value &&
316
+ (String(value).startsWith('http') || String(value).startsWith('https'))
317
+ ) {
318
+ avatarSrc = String(value)
319
+ } else if (value) {
320
+ avatarSrc = `${apiBaseUrl}${col.basePath || ''}${value}`
321
+ }
322
+
323
+ return (
324
+ <div className="flex items-center gap-3 min-w-0">
325
+ <Avatar className="h-8 w-8 rounded-lg ring-1 ring-border/50">
326
+ <AvatarImage
327
+ src={getImageUrl(avatarSrc || '')}
328
+ alt={String(name)}
329
+ className="object-cover"
330
+ />
331
+ <AvatarFallback className="text-[10px] font-bold bg-primary/5 text-primary rounded-lg">
332
+ {String(name)
333
+ .split(' ')
334
+ .map((n: string) => n[0])
335
+ .slice(0, 2)
336
+ .join('')
337
+ .toUpperCase()}
338
+ </AvatarFallback>
339
+ </Avatar>
340
+ <div className="flex flex-col min-w-0 overflow-hidden">
341
+ <span className="font-medium text-sm truncate leading-none mb-0.5 text-foreground/90">
342
+ {String(name)}
343
+ </span>
344
+ {desc && (
345
+ <span className="text-[11px] text-muted-foreground truncate leading-none">
346
+ {String(desc)}
347
+ </span>
348
+ )}
349
+ </div>
350
+ </div>
351
+ )
352
+ }
353
+
354
+ case 'relation-badge-list':
355
+ return renderRelationBadges(value, col)
356
+
357
+ case 'phone': {
358
+ if (!value) return <span className="text-muted-foreground">-</span>
359
+ return <span className="font-medium text-sm">{String(value)}</span>
360
+ }
361
+
362
+ case 'boolean':
363
+ return value ? <Badge>Sí</Badge> : <Badge variant="secondary">No</Badge>
364
+
365
+ case 'media-gallery': {
366
+ if (!value || (Array.isArray(value) && value.length === 0)) {
367
+ return <span className="text-muted-foreground">-</span>
368
+ }
369
+ const mediaItems = Array.isArray(value) ? value : []
370
+ const visibleItems = mediaItems.slice(0, 3)
371
+ const remaining = mediaItems.length - 3
372
+ return (
373
+ <div className="flex -space-x-2 overflow-hidden">
374
+ {visibleItems.map((item: any, i: number) => {
375
+ const src = item.url
376
+ if (item.type === 'image') {
377
+ return (
378
+ <Avatar
379
+ key={i}
380
+ className="inline-block h-8 w-8 rounded-full ring-2 ring-background"
381
+ >
382
+ <AvatarImage src={src} className="object-cover" />
383
+ <AvatarFallback>{item.type?.[0]}</AvatarFallback>
384
+ </Avatar>
385
+ )
386
+ }
387
+ return (
388
+ <div
389
+ key={i}
390
+ className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-muted ring-2 ring-background"
391
+ >
392
+ <DynamicIcon
393
+ name={
394
+ item.type === 'video'
395
+ ? 'Video'
396
+ : item.type === 'audio'
397
+ ? 'AudioLines'
398
+ : 'FileText'
399
+ }
400
+ className="h-4 w-4"
401
+ />
402
+ </div>
403
+ )
404
+ })}
405
+ {remaining > 0 && (
406
+ <div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-medium ring-2 ring-background">
407
+ +{remaining}
408
+ </div>
409
+ )}
410
+ </div>
411
+ )
412
+ }
413
+
414
+ case 'image': {
415
+ const imageValue =
416
+ value ||
417
+ (Array.isArray(row.original.media)
418
+ ? row.original.media.find((m: any) => m.type === 'image')?.url
419
+ : null)
420
+ if (!imageValue) return <span className="text-muted-foreground">-</span>
421
+ return (
422
+ <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
423
+ <img
424
+ src={getImageUrl(String(imageValue))}
425
+ alt="Thumbnail"
426
+ className="h-full w-full object-contain"
427
+ onError={(e) => {
428
+ ;(e.currentTarget as HTMLImageElement).style.display = 'none'
429
+ }}
430
+ />
431
+ </div>
432
+ )
433
+ }
434
+
435
+ default: {
436
+ if (typeof value === 'object' && value !== null) {
437
+ return (
438
+ <span className="text-muted-foreground text-xs">
439
+ {JSON.stringify(value)}
440
+ </span>
441
+ )
442
+ }
443
+ if (
444
+ col.key === 'description' ||
445
+ col.key === 'features' ||
446
+ col.key.includes('description')
447
+ ) {
448
+ return (
449
+ <div className="max-w-[350px]" title={String(value)}>
450
+ <span className="truncate font-medium block">
451
+ {value !== null && value !== undefined ? String(value) : '-'}
452
+ </span>
453
+ </div>
454
+ )
455
+ }
456
+ return (
457
+ <span className="truncate font-medium">
458
+ {value !== null && value !== undefined ? String(value) : '-'}
459
+ </span>
460
+ )
461
+ }
462
+ }
463
+ },
464
+ enableSorting: col.sortable,
465
+ enableHiding: true,
466
+ })
467
+ })
468
+
469
+ if (metadata.hasActions && metadata.actions.length > 0) {
470
+ columns.push({
471
+ id: 'actions',
472
+ header: () => <div className="text-right">{t ? t('common.actions') : 'Acciones'}</div>,
473
+ size: 80,
474
+ maxSize: 80,
475
+ meta: {},
476
+ cell: ({ row }) => (
477
+ <div className="flex items-center justify-end">
478
+ <DropdownMenu>
479
+ <DropdownMenuTrigger asChild>
480
+ <Button variant="ghost" className="h-8 w-8 p-0">
481
+ <span className="sr-only">Abrir menú</span>
482
+ <MoreHorizontal className="h-4 w-4" />
483
+ </Button>
484
+ </DropdownMenuTrigger>
485
+ <DropdownMenuContent align="end">
486
+ {metadata.actions
487
+ .filter((action) => {
488
+ if (!action.condition) return true
489
+ const { field, operator, value } = action.condition
490
+ const rowValue = String((row.original as any)[field] ?? '')
491
+ const values = Array.isArray(value) ? value : [value]
492
+ switch (operator) {
493
+ case 'eq':
494
+ return rowValue === values[0]
495
+ case 'neq':
496
+ return rowValue !== values[0]
497
+ case 'in':
498
+ return values.includes(rowValue)
499
+ case 'not_in':
500
+ return !values.includes(rowValue)
501
+ default:
502
+ return true
503
+ }
504
+ })
505
+ .map((action) => (
506
+ <DropdownMenuItem
507
+ key={action.key}
508
+ onClick={() => onAction && onAction(action.key, row.original)}
509
+ >
510
+ <DynamicIcon name={action.icon} className="mr-2 h-4 w-4" />
511
+ {action.label}
512
+ </DropdownMenuItem>
513
+ ))}
514
+ </DropdownMenuContent>
515
+ </DropdownMenu>
516
+ </div>
517
+ ),
518
+ })
519
+ }
520
+
521
+ return columns
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Eager-built variant — equivalent to `makeDefaultGetDynamicColumns()`. Use
527
+ * this when the host has no helpers to inject and a stable function reference
528
+ * suffices.
529
+ */
530
+ export const defaultGetDynamicColumns: GetDynamicColumns =
531
+ makeDefaultGetDynamicColumns()