@asteby/metacore-runtime-react 23.2.0 → 23.3.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.
@@ -56,6 +56,13 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
56
56
  import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
57
57
  import { humanizeToken } from '../dynamic-columns-helpers'
58
58
  import { formatDateCell } from '../dynamic-columns'
59
+ import {
60
+ OptionBadge,
61
+ statusColorFor,
62
+ useIsDarkTheme,
63
+ type DisplayOption,
64
+ } from '../display-value'
65
+ import { generateBadgeStyles } from '@asteby/metacore-ui/lib'
59
66
  import { CollectionCell, type ItemField } from '../collection-cell'
60
67
  import type { ActionFieldDef, RelationMeta } from '../types'
61
68
  import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
@@ -442,6 +449,21 @@ export function isMoneyField(field: FieldDef, value: any): boolean {
442
449
  )
443
450
  }
444
451
 
452
+ // filterVisibleFields decides which declared fields render in the form for a
453
+ // given mode. `hidden` fields never render. A `readonly` (server/system-
454
+ // generated) field is EXCLUDED on create — the user can't set a value the
455
+ // server will overwrite — but stays visible on edit/view (rendered disabled).
456
+ export function filterVisibleFields(
457
+ fields: FieldDef[] | undefined,
458
+ mode: 'view' | 'edit' | 'create',
459
+ ): FieldDef[] {
460
+ return (fields ?? []).filter(f => {
461
+ if (f.hidden) return false
462
+ if (mode === 'create' && f.readonly) return false
463
+ return true
464
+ })
465
+ }
466
+
445
467
  export function DynamicRecordDialog({
446
468
  open,
447
469
  onOpenChange,
@@ -557,7 +579,7 @@ export function DynamicRecordDialog({
557
579
  }
558
580
  } catch (err) {
559
581
  console.error('[DynamicRecordDialog] load error:', err)
560
- if (!seed) toast.error('Error al cargar los datos')
582
+ if (!seed) toast.error(t('dynamic.load_error', { defaultValue: 'No se pudieron cargar los datos' }))
561
583
  } finally {
562
584
  if (!cancelled) setLoading(false)
563
585
  }
@@ -662,7 +684,7 @@ export function DynamicRecordDialog({
662
684
  try {
663
685
  if (isCreate && onCreate) {
664
686
  const created = await onCreate(formValues)
665
- toast.success(modalMeta?.messages?.created || 'Registro creado correctamente')
687
+ toast.success(modalMeta?.messages?.created || t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' }))
666
688
  onSaved?.(created ?? undefined)
667
689
  onOpenChange(false)
668
690
  return
@@ -670,7 +692,7 @@ export function DynamicRecordDialog({
670
692
 
671
693
  if (!isCreate && recordId && onUpdate) {
672
694
  const updated = await onUpdate(String(recordId), formValues)
673
- toast.success(modalMeta?.messages?.updated || 'Guardado correctamente')
695
+ toast.success(modalMeta?.messages?.updated || t('dynamic.update_success', { defaultValue: 'Guardado correctamente' }))
674
696
  onSaved?.(updated ?? undefined)
675
697
  onOpenChange(false)
676
698
  return
@@ -693,16 +715,18 @@ export function DynamicRecordDialog({
693
715
  // endpoint returns a raw English string that would leak into the toast.
694
716
  toast.success(
695
717
  modalMeta?.messages?.[isCreate ? 'created' : 'updated']
696
- || (isCreate ? 'Registro creado correctamente' : 'Guardado correctamente'),
718
+ || (isCreate
719
+ ? t('dynamic.create_success', { defaultValue: 'Registro creado correctamente' })
720
+ : t('dynamic.update_success', { defaultValue: 'Guardado correctamente' })),
697
721
  )
698
722
  // Hand the persisted record back so callers can auto-select it.
699
723
  onSaved?.(res.data?.data ?? res.data ?? undefined)
700
724
  onOpenChange(false)
701
725
  } else {
702
- toast.error(res.data?.message || 'Error al guardar')
726
+ toast.error(res.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
703
727
  }
704
728
  } catch (err: any) {
705
- toast.error(err?.response?.data?.message || 'Error al guardar')
729
+ toast.error(err?.response?.data?.message || t('dynamic.save_error', { defaultValue: 'No se pudo guardar' }))
706
730
  } finally {
707
731
  setSaving(false)
708
732
  }
@@ -716,7 +740,7 @@ export function DynamicRecordDialog({
716
740
  onOpenChange(false)
717
741
  } catch (err: any) {
718
742
  console.error('[DynamicRecordDialog] delete error:', err)
719
- toast.error(err?.response?.data?.message || err?.message || 'Error al eliminar')
743
+ toast.error(err?.response?.data?.message || err?.message || t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
720
744
  } finally {
721
745
  setDeleting(false)
722
746
  }
@@ -724,11 +748,7 @@ export function DynamicRecordDialog({
724
748
 
725
749
  const title = modalMeta ? config.getTitle(modalMeta, t) : ''
726
750
 
727
- const visibleFields = modalMeta?.fields?.filter(f => {
728
- if (f.hidden) return false
729
- if (isCreate && f.readonly) return false
730
- return true
731
- }) ?? []
751
+ const visibleFields = filterVisibleFields(modalMeta?.fields, mode)
732
752
 
733
753
  return (
734
754
  <Dialog open={open} onOpenChange={onOpenChange}>
@@ -877,19 +897,27 @@ interface FieldRowProps {
877
897
  }
878
898
 
879
899
  function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
880
- const isReadonly = field.readonly || mode === 'view'
900
+ // A `readonly` field is server/system-generated (e.g. the GitHub addon's
901
+ // `number`/`github_url`, filled by the API after the outbound create). On
902
+ // CREATE it is excluded from the form entirely (see `visibleFields`); on EDIT
903
+ // it stays visible but is NOT editable — rendered as a disabled, muted input
904
+ // so the user sees its value without being able to change it. View mode keeps
905
+ // the rich read-only renderer.
906
+ const isEditReadonly = mode === 'edit' && !!field.readonly
881
907
 
882
908
  return (
883
909
  <div className="flex flex-col gap-1.5">
884
910
  <Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
885
911
  {field.label}
886
- {field.required && mode !== 'view' && (
912
+ {field.required && mode !== 'view' && !isEditReadonly && (
887
913
  <span className="text-destructive ml-0.5">*</span>
888
914
  )}
889
915
  </Label>
890
916
 
891
- {isReadonly ? (
917
+ {mode === 'view' ? (
892
918
  <ViewValue field={field} value={value} record={record} />
919
+ ) : isEditReadonly ? (
920
+ <ReadonlyEditField field={field} value={value} />
893
921
  ) : (
894
922
  <EditField field={field} value={value} onChange={onChange} record={record} />
895
923
  )}
@@ -897,6 +925,24 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
897
925
  )
898
926
  }
899
927
 
928
+ // ReadonlyEditField — the edit-mode rendering of a `readonly` (system-generated)
929
+ // field: a disabled, muted input that shows the current value without allowing
930
+ // edits. Booleans render as a disabled switch to match their editable
931
+ // counterpart; everything else renders the formatted display value in a disabled
932
+ // text input.
933
+ export function ReadonlyEditField({ field, value }: { field: FieldDef; value: any }) {
934
+ if (field.type === 'boolean' || typeof value === 'boolean') {
935
+ return (
936
+ <div className="flex items-center gap-2 py-1">
937
+ <Switch checked={!!value} disabled />
938
+ <span className="text-sm text-muted-foreground">{value ? 'Sí' : 'No'}</span>
939
+ </div>
940
+ )
941
+ }
942
+ const display = formatDisplayValue(value, field)
943
+ return <Input value={display === '—' ? '' : display} disabled readOnly className="text-muted-foreground" />
944
+ }
945
+
900
946
  // RelationViewValue — read-only FK lead. Resolves the relation's label + image
901
947
  // from (1) the sibling object the table served, then (2) the canonical options
902
948
  // endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
@@ -975,8 +1021,22 @@ export function ViewValue({
975
1021
  const timeZone = timeZoneProp ?? ctxTimeZone
976
1022
  const currency = currencyProp ?? ctxCurrency
977
1023
 
1024
+ // Declarative display hint the backend stamps (mirrors the table column's
1025
+ // `cellStyle`). The table renders each cell off `cellStyle ?? type`; the
1026
+ // detail view keys off the SAME resolved renderer so both stay in lock-step
1027
+ // (a `datetime` display on a numeric column, a `url` display on a text
1028
+ // column, a `status`/`badge` pill, …).
1029
+ const renderAs = field.cellStyle ?? field.type
1030
+
978
1031
  // created_by / avatar resolver sibling → name (+ avatar) instead of "—".
979
- if (field.type === 'avatar' || field.key === 'created_by' || field.key === 'created_by_id') {
1032
+ if (
1033
+ field.type === 'avatar' ||
1034
+ renderAs === 'avatar' ||
1035
+ renderAs === 'creator' ||
1036
+ renderAs === 'user' ||
1037
+ field.key === 'created_by' ||
1038
+ field.key === 'created_by_id'
1039
+ ) {
980
1040
  const user = createdBySibling(rawValue, record)
981
1041
  if (user) {
982
1042
  return (
@@ -1055,15 +1115,22 @@ export function ViewValue({
1055
1115
  return <IconNameViewValue name={value} />
1056
1116
  }
1057
1117
 
1058
- if (field.type === 'url' && value) {
1118
+ // URL/link display (matches the table's `url`/`link` cell). Triggers on the
1119
+ // stamped display type — not just the storage `type` — so a text column
1120
+ // carrying `cellStyle:'url'` (e.g. `github_url`) renders as a clickable
1121
+ // external link, opening in a new tab, truncated.
1122
+ if ((renderAs === 'url' || renderAs === 'link') && value) {
1123
+ const urlStr = String(value)
1124
+ const href = /^https?:\/\//i.test(urlStr) ? urlStr : `https://${urlStr}`
1059
1125
  return (
1060
1126
  <a
1061
- href={value}
1127
+ href={href}
1062
1128
  target="_blank"
1063
- rel="noreferrer"
1064
- className="text-sm text-primary hover:underline truncate"
1129
+ rel="noopener noreferrer"
1130
+ className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
1065
1131
  >
1066
- {value}
1132
+ <ExternalLink className="h-3.5 w-3.5 shrink-0" />
1133
+ <span className="truncate max-w-[360px]">{urlStr}</span>
1067
1134
  </a>
1068
1135
  )
1069
1136
  }
@@ -1088,9 +1155,16 @@ export function ViewValue({
1088
1155
 
1089
1156
  // Date/datetime/timestamp → tz-aware format. `date` pins to UTC (calendar
1090
1157
  // day); instants render in the org timezone with a full-precision tooltip.
1091
- if (field.type === 'date' || field.type === 'datetime' || field.type === 'timestamp') {
1092
- const renderAs = field.type === 'date' ? 'date' : field.type
1093
- const formatted = formatDateCell(value, renderAs, es, timeZone)
1158
+ // Keys off the display type (`cellStyle ?? type`) so a numeric/epoch column
1159
+ // stamped `datetime` (e.g. `synced_at`) formats as a date, never raw digits.
1160
+ if (
1161
+ renderAs === 'date' ||
1162
+ renderAs === 'datetime' ||
1163
+ renderAs === 'timestamp' ||
1164
+ renderAs === 'timestamptz'
1165
+ ) {
1166
+ const dateRenderAs = renderAs === 'date' ? 'date' : renderAs
1167
+ const formatted = formatDateCell(value, dateRenderAs, es, timeZone)
1094
1168
  if (formatted) {
1095
1169
  return (
1096
1170
  <p className="text-sm py-1" title={formatted.title}>
@@ -1101,27 +1175,56 @@ export function ViewValue({
1101
1175
  return <p className="text-sm py-1 text-muted-foreground">—</p>
1102
1176
  }
1103
1177
 
1104
- // Enum/option field with served options → colored/iconed badge using the
1105
- // served label (e.g. "Almacenable" instead of "storable").
1178
+ // Enum/option field with served options → the SAME colored/iconed pill the
1179
+ // table renders (shared `OptionBadge`): resolved color, thumbnail/icon and
1180
+ // the localized option label (e.g. "Almacenable" instead of "storable").
1106
1181
  const opt = servedOption(field, value)
1107
1182
  if (opt) {
1108
- const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon'> = {
1109
- image: opt.image ? getImageUrl(opt.image) : null,
1110
- color: opt.color ?? null,
1111
- icon: opt.icon ?? null,
1112
- }
1113
1183
  return (
1114
- <Badge
1115
- variant="secondary"
1116
- className="w-fit flex items-center gap-1"
1117
- style={opt.color && !opt.icon ? { backgroundColor: opt.color, color: '#fff', borderColor: 'transparent' } : undefined}
1118
- >
1119
- <OptionLead option={lead} size={16} />
1120
- {opt.label}
1121
- </Badge>
1184
+ <div className="py-1">
1185
+ <OptionBadge
1186
+ option={{
1187
+ value: String(opt.value ?? value ?? ''),
1188
+ label: opt.label,
1189
+ color: opt.color ?? undefined,
1190
+ icon: opt.icon ?? undefined,
1191
+ image: opt.image ?? undefined,
1192
+ }}
1193
+ getImageUrl={getImageUrl}
1194
+ />
1195
+ </div>
1122
1196
  )
1123
1197
  }
1124
1198
 
1199
+ // Array of scalars / label objects (e.g. github `labels`, tags, a
1200
+ // group-badge list) → a row of pills, mirroring the table's `tags` /
1201
+ // `relation-badge-list` cells. Checked before the structured-object branch
1202
+ // so a flat label array never renders as a mini-table.
1203
+ if (
1204
+ Array.isArray(value) &&
1205
+ (renderAs === 'tags' ||
1206
+ renderAs === 'relation-badge-list' ||
1207
+ value.every((v) => v === null || typeof v !== 'object' || 'label' in v || 'name' in v))
1208
+ ) {
1209
+ return <BadgeListViewValue items={value} getImageUrl={getImageUrl} />
1210
+ }
1211
+
1212
+ // Status / badge / select display with no served option list — a bare enum
1213
+ // token (e.g. a kanban `stage` like "backlog"). Render a colored pill with a
1214
+ // semantic/value-derived color and a localized-or-humanized label, matching
1215
+ // the table's `status`/`badge` cells.
1216
+ if (
1217
+ (renderAs === 'status' ||
1218
+ renderAs === 'badge' ||
1219
+ renderAs === 'select' ||
1220
+ renderAs === 'option') &&
1221
+ value !== null &&
1222
+ value !== undefined &&
1223
+ typeof value !== 'object'
1224
+ ) {
1225
+ return <StatusBadgeViewValue field={field} value={value} t={t} />
1226
+ }
1227
+
1125
1228
  // Structured value (jsonb column, e.g. fiscal_data) with no label/name/title
1126
1229
  // to surface — render readable key/value pairs instead of falling through to
1127
1230
  // String(value) ("[object Object]").
@@ -1163,6 +1266,79 @@ function IconNameViewValue({ name }: { name: string }) {
1163
1266
  )
1164
1267
  }
1165
1268
 
1269
+ // StatusBadgeViewValue — a bare enum/status token (no served option list) as a
1270
+ // colored pill: a semantic/value-derived color (same `statusColorFor` the table
1271
+ // uses) plus a localized-or-humanized label. Mirrors the table's `status`/
1272
+ // `badge` cell so a kanban `stage` ("backlog") reads as a colored, translated
1273
+ // badge instead of the raw token.
1274
+ function StatusBadgeViewValue({
1275
+ field,
1276
+ value,
1277
+ t,
1278
+ }: {
1279
+ field: FieldDef
1280
+ value: any
1281
+ t: (key: string, options?: any) => string
1282
+ }) {
1283
+ const isDark = useIsDarkTheme()
1284
+ const token = String(value)
1285
+ // Prefer an explicit per-option color served on the field (metadata.stages /
1286
+ // options), else derive a semantic color from the token.
1287
+ const declared = field.options?.find((o) => String(o.value) === token)
1288
+ const color = declared?.color || statusColorFor(token)
1289
+ // Localized label: the option's already-localized label wins, then a manifest
1290
+ // i18n key matching the raw token, then a humanized fallback.
1291
+ const label =
1292
+ declared?.label ?? t(token, { defaultValue: humanizeToken(token) })
1293
+ return (
1294
+ <div className="py-1">
1295
+ <Badge
1296
+ variant="outline"
1297
+ className="border-0 flex w-fit items-center gap-1"
1298
+ style={generateBadgeStyles(color, { isDark })}
1299
+ >
1300
+ {label}
1301
+ </Badge>
1302
+ </div>
1303
+ )
1304
+ }
1305
+
1306
+ // BadgeListViewValue — an array of scalars / label objects as a row of pills
1307
+ // (github `labels`, tags, a group-badge list). Objects carrying a `color` render
1308
+ // as colored `OptionBadge`s; plain strings render as neutral secondary pills.
1309
+ function BadgeListViewValue({
1310
+ items,
1311
+ getImageUrl,
1312
+ }: {
1313
+ items: any[]
1314
+ getImageUrl: GetImageUrl
1315
+ }) {
1316
+ if (!items || items.length === 0) {
1317
+ return <p className="text-sm py-1 text-muted-foreground">—</p>
1318
+ }
1319
+ return (
1320
+ <div className="flex flex-wrap gap-1 py-1">
1321
+ {items.map((item, i) => {
1322
+ if (item !== null && typeof item === 'object') {
1323
+ const opt: DisplayOption = {
1324
+ value: String(item.value ?? item.id ?? item.name ?? item.label ?? i),
1325
+ label: String(item.label ?? item.name ?? item.value ?? ''),
1326
+ color: item.color ?? undefined,
1327
+ icon: item.icon ?? undefined,
1328
+ image: item.image ?? item.avatar ?? undefined,
1329
+ }
1330
+ return <OptionBadge key={i} option={opt} getImageUrl={getImageUrl} />
1331
+ }
1332
+ return (
1333
+ <Badge key={i} variant="secondary" className="w-fit">
1334
+ {String(item)}
1335
+ </Badge>
1336
+ )
1337
+ })}
1338
+ </div>
1339
+ )
1340
+ }
1341
+
1166
1342
  // StructuredViewValue renders a jsonb object/array that has no resolvable label.
1167
1343
  // It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
1168
1344
  // view gets the SAME pro rendering as the table: a declared `item_fields` schema
@@ -1392,6 +1568,7 @@ export function EditField({ field, value, onChange, record }: {
1392
1568
  }
1393
1569
 
1394
1570
  function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
1571
+ const { t } = useTranslation()
1395
1572
  const api = useApi()
1396
1573
  const model = useContext(ModelContext)
1397
1574
  const getImageUrl = useContext(ImageUrlContext)
@@ -1411,7 +1588,7 @@ function ImageUploadField({ field: _field, value, onChange }: { field: FieldDef;
1411
1588
  const url = res.data?.data?.url || res.data?.url
1412
1589
  if (url) onChange(url)
1413
1590
  } catch {
1414
- toast.error('Error al subir imagen')
1591
+ toast.error(t('dynamic.image_upload_error', { defaultValue: 'No se pudo subir la imagen' }))
1415
1592
  } finally {
1416
1593
  setUploading(false)
1417
1594
  if (inputRef.current) inputRef.current.value = ''
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Shared display-value primitives.
3
+ *
4
+ * These are the building blocks the dynamic TABLE (`dynamic-columns.tsx`) uses
5
+ * to render "pro" cells — colored option badges, relation thumbnails, semantic
6
+ * status colors, dark-mode detection. They live here (instead of inline in the
7
+ * table) so the read-only DETAIL DIALOG (`dialogs/dynamic-record.tsx`) can reuse
8
+ * the EXACT same rendering. Ecosystem rule: shared primitives, zero copy-paste —
9
+ * table and dialog must not drift.
10
+ */
11
+ import React from 'react'
12
+ import { Avatar, AvatarImage, AvatarFallback, Badge } from '@asteby/metacore-ui'
13
+ import {
14
+ generateBadgeStyles,
15
+ getInitials,
16
+ optionColor,
17
+ } from '@asteby/metacore-ui/lib'
18
+ import { DynamicIcon } from './dynamic-icon'
19
+
20
+ /**
21
+ * `true` when the host document is in dark mode. Observes the `<html>` class so
22
+ * badge colors re-derive on theme toggle. SSR/no-DOM safe (starts light).
23
+ */
24
+ export function useIsDarkTheme(): boolean {
25
+ const [isDark, setIsDark] = React.useState(
26
+ () =>
27
+ typeof document !== 'undefined' &&
28
+ document.documentElement.classList.contains('dark')
29
+ )
30
+ React.useEffect(() => {
31
+ if (typeof document === 'undefined') return
32
+ const sync = () =>
33
+ setIsDark(document.documentElement.classList.contains('dark'))
34
+ sync()
35
+ const observer = new MutationObserver(sync)
36
+ observer.observe(document.documentElement, {
37
+ attributes: true,
38
+ attributeFilter: ['class'],
39
+ })
40
+ return () => observer.disconnect()
41
+ }, [])
42
+ return isDark
43
+ }
44
+
45
+ /**
46
+ * Semantic status → badge color. Used by the `status` cell/value when no
47
+ * explicit `options` color is declared. Generic, value-driven mapping.
48
+ */
49
+ export function statusColorFor(value: string): string {
50
+ const v = value.toLowerCase()
51
+ if (
52
+ ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
53
+ .includes(v)
54
+ )
55
+ return '#22c55e'
56
+ if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
57
+ return '#eab308'
58
+ if (
59
+ ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
60
+ .includes(v)
61
+ )
62
+ return '#ef4444'
63
+ return '#6b7280'
64
+ }
65
+
66
+ /**
67
+ * Tiny square thumbnail for a resolved relation/option that carries an `image`
68
+ * (brand logo, product photo, customer/user avatar). Uses the same Avatar
69
+ * primitive as the `avatar`/`creator` cells so a broken/loading image
70
+ * gracefully falls back to the record's initials. Sized small (inline style so
71
+ * an addon-arbitrary Tailwind class never gets dropped by a consuming app's
72
+ * class scan). Rendered inline alongside a label — never alone.
73
+ */
74
+ export const RelationThumbnail: React.FC<{
75
+ src: string
76
+ alt: string
77
+ getImageUrl?: (path: string) => string
78
+ size?: number
79
+ }> = ({ src, alt, getImageUrl, size = 18 }) => (
80
+ <Avatar
81
+ className="shrink-0 rounded-sm ring-1 ring-border/40"
82
+ style={{ width: size, height: size }}
83
+ >
84
+ <AvatarImage
85
+ src={getImageUrl ? getImageUrl(src) : src}
86
+ alt={alt}
87
+ className="object-cover"
88
+ />
89
+ <AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
90
+ {getInitials(alt)}
91
+ </AvatarFallback>
92
+ </Avatar>
93
+ )
94
+
95
+ export interface DisplayOption {
96
+ value: string
97
+ label: string
98
+ icon?: string
99
+ color?: string
100
+ image?: string
101
+ }
102
+
103
+ /**
104
+ * Colored option badge — the canonical "pro" pill for a select/status/badge
105
+ * value. Explicit backend `color` wins; otherwise a stable, cohesive color is
106
+ * derived from the option value so "dead" gray badges come alive. Inline style
107
+ * (hex-derived) so it works regardless of the host's tailwind safelist —
108
+ * addon-arbitrary classes aren't in the host scan.
109
+ */
110
+ export const OptionBadge: React.FC<{
111
+ option: DisplayOption
112
+ getImageUrl?: (path: string) => string
113
+ /** Accepted for call-site compatibility with the table; unused (option.label wins). */
114
+ fallback?: string
115
+ }> = ({ option, getImageUrl }) => {
116
+ const isDark = useIsDarkTheme()
117
+ const colorSource = option.color || optionColor(option.value || option.label)
118
+ const colorStyles = generateBadgeStyles(colorSource, { isDark })
119
+ return (
120
+ <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
121
+ {option.image ? (
122
+ <RelationThumbnail
123
+ src={option.image}
124
+ alt={option.label}
125
+ getImageUrl={getImageUrl}
126
+ size={16}
127
+ />
128
+ ) : (
129
+ option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
130
+ )}
131
+ <span>{option.label}</span>
132
+ </Badge>
133
+ )
134
+ }
@@ -38,11 +38,16 @@ import {
38
38
  import {
39
39
  generateBadgeStyles,
40
40
  getInitials,
41
- optionColor,
42
41
  relationChipStyles,
43
42
  } from '@asteby/metacore-ui/lib'
44
43
  import { Progress } from './dialogs/_primitives'
45
44
  import { humanizeToken } from './dynamic-columns-helpers'
45
+ import {
46
+ OptionBadge,
47
+ RelationThumbnail,
48
+ statusColorFor,
49
+ useIsDarkTheme,
50
+ } from './display-value'
46
51
  import { OptionsContext } from './options-context'
47
52
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
48
53
  import { CollectionCell } from './collection-cell'
@@ -158,27 +163,6 @@ export const formatAggregateTotal = (
158
163
  )
159
164
  }
160
165
 
161
- /**
162
- * Semantic status → badge color. Used by the `status` cell when no explicit
163
- * `options` color is declared. Generic, value-driven mapping.
164
- */
165
- const statusColorFor = (value: string): string => {
166
- const v = value.toLowerCase()
167
- if (
168
- ['active', 'enabled', 'paid', 'completed', 'done', 'success', 'approved', 'open']
169
- .includes(v)
170
- )
171
- return '#22c55e'
172
- if (['pending', 'draft', 'processing', 'in_progress', 'review', 'waiting'].includes(v))
173
- return '#eab308'
174
- if (
175
- ['inactive', 'disabled', 'cancelled', 'canceled', 'failed', 'rejected', 'error', 'closed']
176
- .includes(v)
177
- )
178
- return '#ef4444'
179
- return '#6b7280'
180
- }
181
-
182
166
  /** Copyable monospaced text cell (code/IDs/hashes). */
183
167
  const CodeCell: React.FC<{ text: string; maxLength?: number }> = ({ text, maxLength }) => {
184
168
  const [copied, setCopied] = React.useState(false)
@@ -293,26 +277,6 @@ const getValueFromPathVariants = (obj: any, path?: string) => {
293
277
  return undefined
294
278
  }
295
279
 
296
- const useIsDarkTheme = () => {
297
- const [isDark, setIsDark] = React.useState(() =>
298
- typeof document !== 'undefined' &&
299
- document.documentElement.classList.contains('dark')
300
- )
301
- React.useEffect(() => {
302
- if (typeof document === 'undefined') return
303
- const sync = () =>
304
- setIsDark(document.documentElement.classList.contains('dark'))
305
- sync()
306
- const observer = new MutationObserver(sync)
307
- observer.observe(document.documentElement, {
308
- attributes: true,
309
- attributeFilter: ['class'],
310
- })
311
- return () => observer.disconnect()
312
- }, [])
313
- return isDark
314
- }
315
-
316
280
  const renderRelationBadges = (items: any, col: ColumnDefinition) => {
317
281
  if (!Array.isArray(items) || items.length === 0) {
318
282
  return <span className="text-muted-foreground">-</span>
@@ -359,66 +323,6 @@ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
359
323
  )
360
324
  }
361
325
 
362
- /**
363
- * Tiny square thumbnail for a resolved relation/option that carries an `image`
364
- * (brand logo, product photo, customer/user avatar). Uses the same Avatar
365
- * primitive as the `avatar`/`creator` cells so a broken/loading image
366
- * gracefully falls back to the record's initials. Sized small (the box is an
367
- * inline style so an addon-arbitrary Tailwind class never gets dropped by a
368
- * consuming app's class scan). Rendered inline alongside a label — never alone.
369
- */
370
- const RelationThumbnail: React.FC<{
371
- src: string
372
- alt: string
373
- getImageUrl?: (path: string) => string
374
- size?: number
375
- }> = ({ src, alt, getImageUrl, size = 18 }) => (
376
- <Avatar
377
- className="shrink-0 rounded-sm ring-1 ring-border/40"
378
- style={{ width: size, height: size }}
379
- >
380
- <AvatarImage
381
- src={getImageUrl ? getImageUrl(src) : src}
382
- alt={alt}
383
- className="object-cover"
384
- />
385
- <AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
386
- {getInitials(alt)}
387
- </AvatarFallback>
388
- </Avatar>
389
- )
390
-
391
- interface OptionBadgeProps {
392
- option: { value: string; label: string; icon?: string; color?: string; image?: string }
393
- fallback: string
394
- getImageUrl?: (path: string) => string
395
- }
396
-
397
- const OptionBadge: React.FC<OptionBadgeProps> = ({ option, getImageUrl }) => {
398
- const isDark = useIsDarkTheme()
399
- // Explicit backend color wins; otherwise derive a stable, cohesive color
400
- // from the option's value (fallback label) so "dead" gray badges come
401
- // alive. Inline style (hex-derived) so it works regardless of the host's
402
- // tailwind safelist — addon-arbitrary classes aren't in the host scan.
403
- const colorSource = option.color || optionColor(option.value || option.label)
404
- const colorStyles = generateBadgeStyles(colorSource, { isDark })
405
- return (
406
- <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
407
- {option.image ? (
408
- <RelationThumbnail
409
- src={option.image}
410
- alt={option.label}
411
- getImageUrl={getImageUrl}
412
- size={16}
413
- />
414
- ) : (
415
- option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
416
- )}
417
- <span>{option.label}</span>
418
- </Badge>
419
- )
420
- }
421
-
422
326
  const BadgeWithEndpointOptions: React.FC<{
423
327
  endpoint: string
424
328
  value: any