@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.
- package/CHANGELOG.md +66 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +206 -18
- package/dist/dynamic-form-schema.d.ts +9 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +29 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +42 -11
- package/dist/types.d.ts +9 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/dynamic-form.test.ts +28 -0
- package/src/dynamic-columns.tsx +374 -39
- package/src/dynamic-form-schema.ts +29 -0
- package/src/dynamic-select-field.tsx +77 -16
- package/src/types.ts +35 -1
|
@@ -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)', () => {
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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,
|
|
4
|
-
// relation-badge-list, media-gallery, image, plus
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
<
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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`).
|