@asteby/metacore-runtime-react 13.8.5 → 13.10.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.
@@ -101,6 +101,34 @@ describe('resolveWidget', () => {
101
101
  expect(resolveWidget({ key: 'k', label: 'L', type: 'string' })).toBe('text')
102
102
  expect(resolveWidget({ key: 'k', label: 'L', type: 'email' })).toBe('text')
103
103
  })
104
+
105
+ // S1: a declared FK target makes the field a searchable picker regardless
106
+ // of its SQL column type — wins over the type→text fallback.
107
+ it('un field con ref (FK) resuelve a dynamic_select antes del switch por type', () => {
108
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', ref: 'product' })).toBe('dynamic_select')
109
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'uuid', ref: 'customer' })).toBe('dynamic_select')
110
+ })
111
+
112
+ it('tolera los alias snake_case source/relation como ref', () => {
113
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', source: 'product' })).toBe('dynamic_select')
114
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', relation: 'vendor' })).toBe('dynamic_select')
115
+ })
116
+
117
+ it('un ref en blanco NO fuerza dynamic_select', () => {
118
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', ref: '' })).toBe('text')
119
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', ref: ' ' })).toBe('text')
120
+ })
121
+
122
+ // S2: media-bearing types render the upload widget (file picker), not text.
123
+ it('image/media/file resuelven a upload', () => {
124
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'image' })).toBe('upload')
125
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'media' })).toBe('upload')
126
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'file' })).toBe('upload')
127
+ })
128
+
129
+ it('widget explícito sigue ganando incluso con ref', () => {
130
+ expect(resolveWidget({ key: 'k', label: 'L', type: 'string', ref: 'product', widget: 'textarea' })).toBe('textarea')
131
+ })
104
132
  })
105
133
 
106
134
  describe('line-items (repeatable group)', () => {
@@ -1,7 +1,10 @@
1
1
  // Default `getDynamicColumns` factory used by hosts that don't need a custom
2
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.
3
+ // badge (static + endpoint-loaded options), avatar/search, creator/user,
4
+ // phone, date, boolean, relation-badge-list, media-gallery, image, plus the
5
+ // declarative pro renderers url/link, email, currency, number, percent/
6
+ // progress, status, tags, color, code/truncate-text, and a generic text
7
+ // fallback. The renderer resolves `cellStyle ?? type` for each column.
5
8
  //
6
9
  // The implementation was previously duplicated across multiple host apps
7
10
  // (~550 LOC each, drifting). It now lives here so a single fix propagates
@@ -32,6 +35,7 @@ import {
32
35
  type ColumnFilterMeta,
33
36
  } from '@asteby/metacore-ui/data-table'
34
37
  import { generateBadgeStyles, getInitials } from '@asteby/metacore-ui/lib'
38
+ import { Progress } from './dialogs/_primitives'
35
39
  import { OptionsContext } from './options-context'
36
40
  import { DynamicIcon } from './dynamic-icon'
37
41
  import type { TableMetadata, ColumnDefinition } from './types'
@@ -62,6 +66,95 @@ const defaultGetImageUrl = (path: string) => path
62
66
  const getNestedValue = (obj: any, path: string) =>
63
67
  path.split('.').reduce((acc, part) => acc && acc[part], obj)
64
68
 
69
+ /**
70
+ * Reads a styleConfig key tolerating both snake_case (emitted by the kernel)
71
+ * and camelCase (sometimes produced by compiled models). Returns the first
72
+ * defined match, e.g. `cfg('label_field', 'labelField')`.
73
+ */
74
+ const styleCfg = (
75
+ col: ColumnDefinition,
76
+ ...keys: string[]
77
+ ): any => {
78
+ const cfg = col.styleConfig
79
+ if (!cfg) return undefined
80
+ for (const k of keys) {
81
+ if (cfg[k] !== undefined && cfg[k] !== null) return cfg[k]
82
+ }
83
+ return undefined
84
+ }
85
+
86
+ const EmptyCell = () => <span className="text-muted-foreground">-</span>
87
+
88
+ /** Resolves the active org currency, defaulting to USD when no override. */
89
+ const resolveCurrency = (col: ColumnDefinition): string =>
90
+ styleCfg(col, 'currency') || 'USD'
91
+
92
+ const formatNumber = (
93
+ value: number,
94
+ opts: Intl.NumberFormatOptions,
95
+ locale?: string,
96
+ ) => new Intl.NumberFormat(locale || undefined, opts).format(value)
97
+
98
+ /**
99
+ * Semantic status → badge color. Used by the `status` cell when no explicit
100
+ * `options` color is declared. Generic, value-driven mapping.
101
+ */
102
+ const statusColorFor = (value: string): string => {
103
+ const v = value.toLowerCase()
104
+ if (
105
+ ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
106
+ .includes(v)
107
+ )
108
+ return '#22c55e'
109
+ if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
110
+ return '#eab308'
111
+ if (
112
+ ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
113
+ .includes(v)
114
+ )
115
+ return '#ef4444'
116
+ return '#6b7280'
117
+ }
118
+
119
+ /** Copyable monospaced text cell (code/IDs/hashes). */
120
+ const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLength }) => {
121
+ const [copied, setCopied] = React.useState(false)
122
+ const display =
123
+ maxLength && text.length > maxLength ? `${text.slice(0, maxLength)}…` : text
124
+ const onCopy = () => {
125
+ try {
126
+ navigator.clipboard?.writeText(text)
127
+ setCopied(true)
128
+ setTimeout(() => setCopied(false), 1200)
129
+ } catch {
130
+ /* clipboard unavailable */
131
+ }
132
+ }
133
+ return (
134
+ <div className="group flex items-center gap-1.5">
135
+ <code
136
+ className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/80"
137
+ title={text}
138
+ >
139
+ {display}
140
+ </code>
141
+ <button
142
+ type="button"
143
+ onClick={onCopy}
144
+ className="opacity-0 transition-opacity group-hover:opacity-100 text-muted-foreground hover:text-foreground"
145
+ aria-label="Copiar"
146
+ title="Copiar"
147
+ >
148
+ {copied ? (
149
+ <icons.Check className="h-3.5 w-3.5 text-green-500" />
150
+ ) : (
151
+ <icons.Copy className="h-3.5 w-3.5" />
152
+ )}
153
+ </button>
154
+ </div>
155
+ )
156
+ }
157
+
65
158
  /**
66
159
  * State-machine gate for per-row actions.
67
160
  *
@@ -195,6 +288,42 @@ const BadgeWithEndpointOptions: React.FC<{ endpoint: string; value: any }> = ({
195
288
  return <Badge variant="outline">{String(value)}</Badge>
196
289
  }
197
290
 
291
+ /**
292
+ * Generic avatar-style cell: round/rounded photo (or initials fallback) +
293
+ * primary name + optional subtitle. Backs the `avatar`/`search` columns as
294
+ * well as the `creator`/`user` cellStyles. Paths are parameterised so the same
295
+ * JSX serves every variant.
296
+ */
297
+ const AvatarCell: React.FC<{
298
+ name: string
299
+ desc?: string
300
+ avatarSrc?: string
301
+ getImageUrl: (path: string) => string
302
+ }> = ({ name, desc, avatarSrc, getImageUrl }) => (
303
+ <div className="flex items-center gap-3 min-w-0">
304
+ <Avatar className="h-8 w-8 rounded-lg ring-1 ring-border/50">
305
+ <AvatarImage
306
+ src={avatarSrc ? getImageUrl(avatarSrc) : ''}
307
+ alt={name}
308
+ className="object-cover"
309
+ />
310
+ <AvatarFallback className="text-[10px] font-bold bg-primary/5 text-primary rounded-lg">
311
+ {getInitials(name)}
312
+ </AvatarFallback>
313
+ </Avatar>
314
+ <div className="flex flex-col min-w-0 overflow-hidden">
315
+ <span className="font-medium text-sm truncate leading-none mb-0.5 text-foreground/90">
316
+ {name}
317
+ </span>
318
+ {desc && (
319
+ <span className="text-[11px] text-muted-foreground truncate leading-none">
320
+ {desc}
321
+ </span>
322
+ )}
323
+ </div>
324
+ </div>
325
+ )
326
+
198
327
  /**
199
328
  * Builds the canonical column factory used by `<DynamicTable>` when the host
200
329
  * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
@@ -301,7 +430,30 @@ export function makeDefaultGetDynamicColumns(
301
430
  return renderRelationBadges(value, col)
302
431
  }
303
432
 
304
- switch (col.type) {
433
+ // Generic badge (no options/endpoint) — still pill it.
434
+ if (renderAs === 'badge') {
435
+ if (!value && value !== 0) return <EmptyCell />
436
+ return <Badge variant="outline">{String(value)}</Badge>
437
+ }
438
+
439
+ // Status — semantic color by value, options color wins.
440
+ if (renderAs === 'status') {
441
+ if (!value && value !== 0) return <EmptyCell />
442
+ const sv = String(value)
443
+ const option = col.options?.find((o) => o.value === sv)
444
+ if (option) return <OptionBadge option={option} fallback={sv} />
445
+ const isDark =
446
+ typeof document !== 'undefined' &&
447
+ document.documentElement.classList.contains('dark')
448
+ const styles = generateBadgeStyles(statusColorFor(sv), { isDark })
449
+ return (
450
+ <Badge variant="outline" className="border-0 capitalize" style={styles}>
451
+ {sv}
452
+ </Badge>
453
+ )
454
+ }
455
+
456
+ switch (renderAs) {
305
457
  case 'date': {
306
458
  if (!value) return <span className="text-muted-foreground">-</span>
307
459
  try {
@@ -323,62 +475,245 @@ export function makeDefaultGetDynamicColumns(
323
475
  }
324
476
 
325
477
  case 'search':
326
- case 'avatar': {
327
- const namePath = col.tooltip || col.key
478
+ case 'avatar':
479
+ case 'creator':
480
+ case 'user': {
481
+ // `creator`/`user` resolve the name from an explicit
482
+ // styleConfig.name_field first, then the legacy
483
+ // tooltip/displayField hints, then the column key.
484
+ const namePath =
485
+ styleCfg(col, 'name_field', 'nameField') ||
486
+ col.tooltip ||
487
+ col.displayField ||
488
+ col.key
328
489
  const name = getNestedValue(row.original, namePath) || 'N/A'
329
490
  const desc = getNestedValue(row.original, col.description || '')
330
491
 
492
+ const basePath = styleCfg(col, 'base_path', 'basePath') ?? col.basePath ?? ''
331
493
  let avatarSrc: string | undefined
332
494
  if (col.key.includes('.')) {
495
+ // Look for a sibling `.avatar` or `.photo` field.
333
496
  const parentPath = col.key.split('.').slice(0, -1).join('.')
334
- const avatarPath = `${parentPath}.avatar`
335
- const possibleAvatar = getNestedValue(row.original, avatarPath)
336
- if (possibleAvatar) avatarSrc = String(possibleAvatar)
337
- } else if (
338
- value &&
339
- (String(value).startsWith('http') || String(value).startsWith('https'))
340
- ) {
341
- avatarSrc = String(value)
342
- } else if (value) {
343
- avatarSrc = `${apiBaseUrl}${col.basePath || ''}${value}`
497
+ const sibling =
498
+ getNestedValue(row.original, `${parentPath}.avatar`) ||
499
+ getNestedValue(row.original, `${parentPath}.photo`)
500
+ if (sibling) avatarSrc = String(sibling)
501
+ }
502
+ if (!avatarSrc && value) {
503
+ if (String(value).startsWith('http')) {
504
+ avatarSrc = String(value)
505
+ } else {
506
+ avatarSrc = `${apiBaseUrl}${basePath}${value}`
507
+ }
344
508
  }
345
509
 
346
510
  return (
347
- <div className="flex items-center gap-3 min-w-0">
348
- <Avatar className="h-8 w-8 rounded-lg ring-1 ring-border/50">
349
- <AvatarImage
350
- src={getImageUrl(avatarSrc || '')}
351
- alt={String(name)}
352
- className="object-cover"
353
- />
354
- <AvatarFallback className="text-[10px] font-bold bg-primary/5 text-primary rounded-lg">
355
- {getInitials(String(name))}
356
- </AvatarFallback>
357
- </Avatar>
358
- <div className="flex flex-col min-w-0 overflow-hidden">
359
- <span className="font-medium text-sm truncate leading-none mb-0.5 text-foreground/90">
360
- {String(name)}
361
- </span>
362
- {desc && (
363
- <span className="text-[11px] text-muted-foreground truncate leading-none">
364
- {String(desc)}
365
- </span>
366
- )}
367
- </div>
368
- </div>
511
+ <AvatarCell
512
+ name={String(name)}
513
+ desc={desc ? String(desc) : undefined}
514
+ avatarSrc={avatarSrc}
515
+ getImageUrl={getImageUrl}
516
+ />
369
517
  )
370
518
  }
371
519
 
372
520
  case 'relation-badge-list':
373
521
  return renderRelationBadges(value, col)
374
522
 
523
+ case 'url':
524
+ case 'link': {
525
+ const labelField = styleCfg(col, 'label_field', 'labelField')
526
+ const urlField = styleCfg(col, 'url_field', 'urlField')
527
+ const rawUrl = urlField
528
+ ? getNestedValue(row.original, urlField)
529
+ : value
530
+ if (!rawUrl) return <EmptyCell />
531
+ const urlStr = String(rawUrl)
532
+ const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
533
+ let label: string
534
+ if (labelField) {
535
+ label = String(getNestedValue(row.original, labelField) ?? href)
536
+ } else {
537
+ try {
538
+ label = new URL(href).hostname
539
+ } catch {
540
+ label = urlStr
541
+ }
542
+ }
543
+ const isExternal = !/^https?:\/\/(localhost|127\.)/i.test(href)
544
+ const newTab =
545
+ styleCfg(col, 'new_tab', 'newTab') === true || isExternal
546
+ const iconName = styleCfg(col, 'icon') || 'ExternalLink'
547
+ return (
548
+ <a
549
+ href={href}
550
+ {...(newTab
551
+ ? { target: '_blank', rel: 'noopener noreferrer' }
552
+ : {})}
553
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
554
+ onClick={(e) => e.stopPropagation()}
555
+ >
556
+ <DynamicIcon name={iconName} className="h-3.5 w-3.5 shrink-0" />
557
+ <span className="truncate max-w-[260px]">{label}</span>
558
+ </a>
559
+ )
560
+ }
561
+
562
+ case 'email': {
563
+ if (!value) return <EmptyCell />
564
+ const email = String(value)
565
+ return (
566
+ <a
567
+ href={`mailto:${email}`}
568
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
569
+ onClick={(e) => e.stopPropagation()}
570
+ >
571
+ <icons.Mail className="h-3.5 w-3.5 shrink-0 opacity-70" />
572
+ <span className="truncate max-w-[260px]">{email}</span>
573
+ </a>
574
+ )
575
+ }
576
+
577
+ case 'currency': {
578
+ const num =
579
+ typeof value === 'number' ? value : Number(value)
580
+ if (value === null || value === undefined || isNaN(num))
581
+ return (
582
+ <div className="text-right">
583
+ <EmptyCell />
584
+ </div>
585
+ )
586
+ const decimals = styleCfg(col, 'decimals') ?? 2
587
+ return (
588
+ <span className="block text-right font-medium tabular-nums">
589
+ {formatNumber(
590
+ num,
591
+ {
592
+ style: 'currency',
593
+ currency: resolveCurrency(col),
594
+ minimumFractionDigits: decimals,
595
+ maximumFractionDigits: decimals,
596
+ },
597
+ currentLanguage,
598
+ )}
599
+ </span>
600
+ )
601
+ }
602
+
603
+ case 'number': {
604
+ const num =
605
+ typeof value === 'number' ? value : Number(value)
606
+ if (value === null || value === undefined || isNaN(num))
607
+ return (
608
+ <div className="text-right">
609
+ <EmptyCell />
610
+ </div>
611
+ )
612
+ const decimals = styleCfg(col, 'decimals')
613
+ return (
614
+ <span className="block text-right font-medium tabular-nums">
615
+ {formatNumber(
616
+ num,
617
+ decimals !== undefined
618
+ ? {
619
+ minimumFractionDigits: decimals,
620
+ maximumFractionDigits: decimals,
621
+ }
622
+ : {},
623
+ currentLanguage,
624
+ )}
625
+ </span>
626
+ )
627
+ }
628
+
629
+ case 'percent':
630
+ case 'progress': {
631
+ const num =
632
+ typeof value === 'number' ? value : Number(value)
633
+ if (value === null || value === undefined || isNaN(num))
634
+ return <EmptyCell />
635
+ const pct = Math.max(0, Math.min(100, num))
636
+ return (
637
+ <div className="flex items-center gap-2 min-w-[120px]">
638
+ <Progress value={pct} className="flex-1" />
639
+ <span className="text-xs font-medium tabular-nums text-muted-foreground w-9 text-right">
640
+ {Math.round(pct)}%
641
+ </span>
642
+ </div>
643
+ )
644
+ }
645
+
646
+ case 'tags': {
647
+ const list: string[] = Array.isArray(value)
648
+ ? value.map(String)
649
+ : value
650
+ ? String(value)
651
+ .split(',')
652
+ .map((s) => s.trim())
653
+ .filter(Boolean)
654
+ : []
655
+ if (list.length === 0) return <EmptyCell />
656
+ return (
657
+ <div className="flex flex-wrap gap-1">
658
+ {list.map((tag, i) => (
659
+ <Badge
660
+ key={`${col.key}-${i}`}
661
+ variant="secondary"
662
+ className="px-1.5 py-0 text-[10px]"
663
+ >
664
+ {tag}
665
+ </Badge>
666
+ ))}
667
+ </div>
668
+ )
669
+ }
670
+
671
+ case 'color': {
672
+ if (!value) return <EmptyCell />
673
+ const hex = String(value)
674
+ return (
675
+ <div className="flex items-center gap-2">
676
+ <span
677
+ className="h-4 w-4 rounded border border-border/60 shrink-0"
678
+ style={{ background: hex }}
679
+ />
680
+ <code className="font-mono text-xs text-muted-foreground">
681
+ {hex}
682
+ </code>
683
+ </div>
684
+ )
685
+ }
686
+
687
+ case 'code':
688
+ case 'truncate-text': {
689
+ if (value === null || value === undefined || value === '')
690
+ return <EmptyCell />
691
+ const maxLength = styleCfg(col, 'max_length', 'maxLength')
692
+ return <CodeCell text={String(value)} maxLength={maxLength} />
693
+ }
694
+
375
695
  case 'phone': {
376
696
  if (!value) return <span className="text-muted-foreground">-</span>
377
697
  return <span className="font-medium text-sm">{String(value)}</span>
378
698
  }
379
699
 
380
- case 'boolean':
381
- return value ? <Badge>Sí</Badge> : <Badge variant="secondary">No</Badge>
700
+ case 'boolean': {
701
+ const showText = styleCfg(col, 'show_text', 'showText') !== false
702
+ return (
703
+ <span className="inline-flex items-center gap-1.5">
704
+ {value ? (
705
+ <icons.Check className="h-4 w-4 text-green-500" />
706
+ ) : (
707
+ <icons.Minus className="h-4 w-4 text-muted-foreground" />
708
+ )}
709
+ {showText && (
710
+ <span className="text-sm text-muted-foreground">
711
+ {value ? 'Sí' : 'No'}
712
+ </span>
713
+ )}
714
+ </span>
715
+ )
716
+ }
382
717
 
383
718
  case 'media-gallery': {
384
719
  if (!value || (Array.isArray(value) && value.length === 0)) {
@@ -207,6 +207,12 @@ function fieldToZod(field: ActionFieldDef): ZodTypeAny {
207
207
  // same render as before).
208
208
  export function resolveWidget(field: ActionFieldDef): string {
209
209
  if (field.widget) return field.widget
210
+ // S1: any field that declares an FK target (`ref`, or the snake_case
211
+ // `source`/`relation` the kernel may serve) renders as an async searchable
212
+ // single-select — NOT a raw text input. This wins over the `type` switch so
213
+ // a declared FK column is a picker regardless of its SQL column type
214
+ // (uuid/text/etc), matching the kernel's option-resolution semantics.
215
+ if (fieldHasRef(field)) return 'dynamic_select'
210
216
  switch (field.type) {
211
217
  case 'textarea': return 'textarea'
212
218
  case 'select': return 'select'
@@ -219,10 +225,33 @@ export function resolveWidget(field: ActionFieldDef): string {
219
225
  // File upload: POSTs to the host upload endpoint and stores the returned
220
226
  // file url/path as the field value. Rendered by `UploadField`.
221
227
  case 'upload': return 'upload'
228
+ // S2: media-bearing types resolve to the upload widget so an `image`
229
+ // (logo/photo) or generic `file`/`media` field gets a real file picker
230
+ // instead of a free-text input.
231
+ case 'image': return 'upload'
232
+ case 'media': return 'upload'
233
+ case 'file': return 'upload'
222
234
  default: return 'text'
223
235
  }
224
236
  }
225
237
 
238
+ /**
239
+ * Resolves a field's FK target, tolerating the camelCase `ref` (authored SDK
240
+ * shape) and the snake_case `source` / `relation` aliases the kernel manifest
241
+ * may serve for a belongs_to column. Returns the trimmed model key, or
242
+ * `undefined` when the field declares no relation.
243
+ */
244
+ export function getFieldRef(field: ActionFieldDef): string | undefined {
245
+ const ref = field.ref ?? field.source ?? field.relation
246
+ if (typeof ref === 'string' && ref.trim() !== '') return ref.trim()
247
+ return undefined
248
+ }
249
+
250
+ /** True when a field declares an FK target the SDK can resolve options against. */
251
+ export function fieldHasRef(field: ActionFieldDef): boolean {
252
+ return getFieldRef(field) !== undefined
253
+ }
254
+
226
255
  /**
227
256
  * Normalizes an upload field's config, tolerating both the camelCase authored
228
257
  * SDK shape and the snake_case the kernel serves (`max_size`, `storage_path`).