@asteby/metacore-runtime-react 17.0.3 → 18.0.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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 18.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - ce9dd72: `DynamicSelectField` (the searchable FK / option picker) now renders each
8
+ option's leading visual: a photo thumbnail (FK relations with an image), else a
9
+ declared icon, else a colored dot for enum/status options that carry a `color`.
10
+ Previously only image thumbnails showed, so enum selects (state, origin, …) read
11
+ as plain text. Plain options with no image/color/icon stay plain.
12
+ - Updated dependencies [8439e9e]
13
+ - @asteby/metacore-ui@2.5.0
14
+
15
+ ## 17.0.4
16
+
17
+ ### Patch Changes
18
+
19
+ - a745f5c: Relation/option thumbnails: resolved FK relation chips and option badges now
20
+ render a small thumbnail when the backend stamps an `image` on the sibling
21
+ `{ value, label }` object or the option (brand logo, product photo, customer
22
+ avatar), with a graceful initials fallback when the image is missing or fails to
23
+ load. Applies to the table `relation`/`select`/`status`/`badge` cells; the
24
+ searchable picker (`DynamicSelectField`) and the detail-view picker already
25
+ rendered option images. Adds a pure `resolveRelationImage` helper (+ tests).
26
+
3
27
  ## 17.0.3
4
28
 
5
29
  ### Patch Changes
@@ -61,6 +61,14 @@ export declare function formatDateCell(value: unknown, renderAs: string | undefi
61
61
  * - else: the raw value coerced to string ('' when nullish).
62
62
  */
63
63
  export declare const resolveRelationLabel: (col: ColumnDefinition, row: any) => string;
64
+ /**
65
+ * Reads the thumbnail URL a backend serves on a resolved FK sibling, when
66
+ * present. The backend stamps `image` onto the `{ value, label }` relation
67
+ * object when the referenced model carries an image column (brand logo,
68
+ * product photo, customer avatar). Returns '' when there is no sibling image —
69
+ * the chip then renders text-only, exactly as before.
70
+ */
71
+ export declare const resolveRelationImage: (col: ColumnDefinition, row: any) => string;
64
72
  /**
65
73
  * Builds the canonical column factory used by `<DynamicTable>` when the host
66
74
  * does not supply its own. Pass `{ getImageUrl, apiBaseUrl }` to wire avatar
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAgGD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AA0HD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,GACf;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAY5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAiED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAsmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
1
+ {"version":3,"file":"dynamic-columns.d.ts","sourceRoot":"","sources":["../src/dynamic-columns.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,UAAU,CAAA;AAgC9C,OAAO,KAAK,EAAiB,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAE9D,OAAO,KAAK,EAER,iBAAiB,EACpB,MAAM,wBAAwB,CAAA;AAE/B,qEAAqE;AACrE,MAAM,WAAW,qBAAqB;IAClC;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAA;IACtC;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACtB;AAgGD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,0BAA0B,GAAI,QAAQ,GAAG,EAAE,KAAK,GAAG,KAAG,OAMlE,CAAA;AAqKD;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,KAAG,MAGnE,CAAA;AAED,6EAA6E;AAC7E,eAAO,MAAM,eAAe,2DAA4D,CAAA;AAExF;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,OAAO,EACd,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,MAAM,EAAE,MAAM,GACf;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAY5C;AAED;;;;;;;GAOG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAWtE,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,GAAI,KAAK,gBAAgB,EAAE,KAAK,GAAG,KAAG,MAOtE,CAAA;AA0ED;;;;GAIG;AACH,wBAAgB,4BAA4B,CACxC,OAAO,GAAE,qBAA0B,GACpC,iBAAiB,CAsmBnB;AAED;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,EAAE,iBACL,CAAA"}
@@ -169,7 +169,16 @@ const renderRelationBadges = (items, col) => {
169
169
  return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1", children: [iconValue && (_jsx(DynamicIcon, { name: iconValue, className: "h-3 w-3" })), _jsx("span", { children: label })] }, `${col.key}-${idx}`));
170
170
  }) }));
171
171
  };
172
- const OptionBadge = ({ option }) => {
172
+ /**
173
+ * Tiny square thumbnail for a resolved relation/option that carries an `image`
174
+ * (brand logo, product photo, customer/user avatar). Uses the same Avatar
175
+ * primitive as the `avatar`/`creator` cells so a broken/loading image
176
+ * gracefully falls back to the record's initials. Sized small (the box is an
177
+ * inline style so an addon-arbitrary Tailwind class never gets dropped by a
178
+ * consuming app's class scan). Rendered inline alongside a label — never alone.
179
+ */
180
+ const RelationThumbnail = ({ src, alt, getImageUrl, size = 18 }) => (_jsxs(Avatar, { className: "shrink-0 rounded-sm ring-1 ring-border/40", style: { width: size, height: size }, children: [_jsx(AvatarImage, { src: getImageUrl ? getImageUrl(src) : src, alt: alt, className: "object-cover" }), _jsx(AvatarFallback, { className: "rounded-sm bg-primary/10 text-[8px] font-bold text-primary", children: getInitials(alt) })] }));
181
+ const OptionBadge = ({ option, getImageUrl }) => {
173
182
  const isDark = useIsDarkTheme();
174
183
  // Explicit backend color wins; otherwise derive a stable, cohesive color
175
184
  // from the option's value (fallback label) so "dead" gray badges come
@@ -177,14 +186,14 @@ const OptionBadge = ({ option }) => {
177
186
  // tailwind safelist — addon-arbitrary classes aren't in the host scan.
178
187
  const colorSource = option.color || optionColor(option.value || option.label);
179
188
  const colorStyles = generateBadgeStyles(colorSource, { isDark });
180
- return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" }), _jsx("span", { children: option.label })] }));
189
+ return (_jsxs(Badge, { variant: "outline", className: "flex items-center gap-1 border-0", style: colorStyles, children: [option.image ? (_jsx(RelationThumbnail, { src: option.image, alt: option.label, getImageUrl: getImageUrl, size: 16 })) : (option.icon && _jsx(DynamicIcon, { name: option.icon, className: "h-3.5 w-3.5" })), _jsx("span", { children: option.label })] }));
181
190
  };
182
- const BadgeWithEndpointOptions = ({ endpoint, value }) => {
191
+ const BadgeWithEndpointOptions = ({ endpoint, value, getImageUrl }) => {
183
192
  const { optionsMap } = React.useContext(OptionsContext);
184
193
  const options = optionsMap.get(endpoint) || [];
185
194
  const option = options.find((opt) => opt.value === value);
186
195
  if (option)
187
- return _jsx(OptionBadge, { option: option, fallback: String(value) });
196
+ return _jsx(OptionBadge, { option: option, fallback: String(value), getImageUrl: getImageUrl });
188
197
  // No declared option matched → humanize the raw token as a safety net so a
189
198
  // cell never shows `in_progress` verbatim (option.label still wins above).
190
199
  return _jsx(Badge, { variant: "outline", children: humanizeToken(value) });
@@ -247,24 +256,42 @@ export const resolveRelationLabel = (col, row) => {
247
256
  return '';
248
257
  return String(raw);
249
258
  };
259
+ /**
260
+ * Reads the thumbnail URL a backend serves on a resolved FK sibling, when
261
+ * present. The backend stamps `image` onto the `{ value, label }` relation
262
+ * object when the referenced model carries an image column (brand logo,
263
+ * product photo, customer avatar). Returns '' when there is no sibling image —
264
+ * the chip then renders text-only, exactly as before.
265
+ */
266
+ export const resolveRelationImage = (col, row) => {
267
+ const sibling = getNestedValue(row, relationKeyFor(col));
268
+ if (sibling && typeof sibling === 'object') {
269
+ const img = sibling.image ?? sibling.avatar ?? sibling.photo;
270
+ if (img !== undefined && img !== null && img !== '')
271
+ return String(img);
272
+ }
273
+ return '';
274
+ };
250
275
  /**
251
276
  * Renders a resolved FK relation as a clean, truncated chip. Reads the
252
- * backend-resolved sibling `{ value, label }` (see `relationKeyFor`) and shows
253
- * its `label`. Falls back to the raw id when no sibling was resolved, and to an
254
- * empty marker when there is no value at all. Domain-agnostic: works for every
255
- * `belongs_to` column (category, supplier, warehouse, …) without per-addon code.
277
+ * backend-resolved sibling `{ value, label[, image] }` (see `relationKeyFor`)
278
+ * and shows its `label`, prefixed with a small thumbnail when the sibling
279
+ * carries an `image`. Falls back to the raw id when no sibling was resolved, and
280
+ * to an empty marker when there is no value at all. Domain-agnostic: works for
281
+ * every `belongs_to` column (category, supplier, brand, …) without per-addon code.
256
282
  */
257
- const RelationCell = ({ col, row }) => {
283
+ const RelationCell = ({ col, row, getImageUrl }) => {
258
284
  const isDark = useIsDarkTheme();
259
285
  const display = resolveRelationLabel(col, row);
260
286
  if (!display)
261
287
  return _jsx(EmptyCell, {});
288
+ const image = resolveRelationImage(col, row);
262
289
  // Deterministic, SUBTLE color keyed on the resolved label — lighter than
263
290
  // enum badges (soft tint, no heavy fill) so category/brand chips read as
264
291
  // alive yet stay visually distinct from option/status badges. Inline style
265
292
  // (hex-derived) bypasses the host tailwind safelist.
266
293
  const chipStyles = relationChipStyles(display, { isDark });
267
- return (_jsx("span", { className: "inline-flex max-w-[220px] items-center truncate rounded-md px-2 py-0.5 text-sm font-medium", style: chipStyles, title: display, children: _jsx("span", { className: "truncate", children: display }) }));
294
+ return (_jsxs("span", { className: "inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-medium", style: chipStyles, title: display, children: [image && (_jsx(RelationThumbnail, { src: image, alt: display, getImageUrl: getImageUrl, size: 18 })), _jsx("span", { className: "truncate", children: display })] }));
268
295
  };
269
296
  /**
270
297
  * Generic avatar-style cell: round/rounded photo (or initials fallback) +
@@ -334,7 +361,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
334
361
  if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) {
335
362
  if (!value)
336
363
  return _jsx("span", { className: "text-muted-foreground", children: "-" });
337
- return _jsx(BadgeWithEndpointOptions, { endpoint: col.searchEndpoint, value: value });
364
+ return _jsx(BadgeWithEndpointOptions, { endpoint: col.searchEndpoint, value: value, getImageUrl: getImageUrl });
338
365
  }
339
366
  // Static badge options — map value → label/icon/color
340
367
  if (renderAs === 'badge' && col.options && col.options.length > 0) {
@@ -342,7 +369,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
342
369
  return _jsx("span", { className: "text-muted-foreground", children: "-" });
343
370
  const option = col.options.find((o) => o.value === String(value));
344
371
  if (option)
345
- return _jsx(OptionBadge, { option: option, fallback: String(value) });
372
+ return _jsx(OptionBadge, { option: option, fallback: String(value), getImageUrl: getImageUrl });
346
373
  return _jsx(Badge, { variant: "outline", children: humanizeToken(value) });
347
374
  }
348
375
  if (renderAs === 'relation-badge-list') {
@@ -362,7 +389,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
362
389
  const sv = String(value);
363
390
  const option = col.options?.find((o) => o.value === sv);
364
391
  if (option)
365
- return _jsx(OptionBadge, { option: option, fallback: sv });
392
+ return _jsx(OptionBadge, { option: option, fallback: sv, getImageUrl: getImageUrl });
366
393
  const isDark = typeof document !== 'undefined' &&
367
394
  document.documentElement.classList.contains('dark');
368
395
  const styles = generateBadgeStyles(statusColorFor(sv), { isDark });
@@ -377,7 +404,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
377
404
  // `row[<key w/o _id>] = { value, label }` sibling.
378
405
  if (renderAs === 'relation' ||
379
406
  (col.ref && !col.options?.length && renderAs !== 'badge' && renderAs !== 'status')) {
380
- return _jsx(RelationCell, { col: col, row: row.original });
407
+ return _jsx(RelationCell, { col: col, row: row.original, getImageUrl: getImageUrl });
381
408
  }
382
409
  // Option/type column: a `select`-style column ships its
383
410
  // localized `options: [{value,label,color,icon}]` inline and
@@ -391,7 +418,7 @@ export function makeDefaultGetDynamicColumns(helpers = {}) {
391
418
  return _jsx(EmptyCell, {});
392
419
  const option = col.options.find((o) => o.value === String(value));
393
420
  if (option)
394
- return _jsx(OptionBadge, { option: option, fallback: String(value) });
421
+ return _jsx(OptionBadge, { option: option, fallback: String(value), getImageUrl: getImageUrl });
395
422
  return _jsx(Badge, { variant: "outline", children: humanizeToken(value) });
396
423
  }
397
424
  switch (renderAs) {
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAuCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAgD7C,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,uBAAuB,+BA2KrF;AAED,eAAe,kBAAkB,CAAA"}
1
+ {"version":3,"file":"dynamic-select-field.d.ts","sourceRoot":"","sources":["../src/dynamic-select-field.tsx"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AA+F7C,MAAM,WAAW,uBAAuB;IACpC,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,uBAAuB,+BA0KrF;AAED,eAAe,kBAAkB,CAAA"}
@@ -25,6 +25,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
25
25
  import { useEffect, useState } from 'react';
26
26
  import { Button, Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, Popover, PopoverContent, PopoverTrigger, } from '@asteby/metacore-ui/primitives';
27
27
  import { Check, ChevronsUpDown, ImageIcon, Loader2, Plus } from 'lucide-react';
28
+ import { resolveColorCss } from '@asteby/metacore-ui/lib';
29
+ import { DynamicIcon } from './dynamic-icon';
28
30
  import { useOptionsResolver } from './use-options-resolver';
29
31
  import { getFieldRef } from './dynamic-form-schema';
30
32
  /**
@@ -46,6 +48,29 @@ function OptionThumb({ image, size = 20 }) {
46
48
  e.currentTarget.style.visibility = 'hidden';
47
49
  } }));
48
50
  }
51
+ /**
52
+ * Leading visual for an option: a photo thumbnail (FK relations with an image),
53
+ * else a declared icon, else a color dot (enum/status options with a color).
54
+ * Returns null when the option carries none, so plain text options stay plain.
55
+ */
56
+ function OptionLead({ option, size = 20, }) {
57
+ if (!option)
58
+ return null;
59
+ if (option.image)
60
+ return _jsx(OptionThumb, { image: option.image, size: size });
61
+ if (option.icon) {
62
+ return (_jsx("span", { className: "flex shrink-0 items-center justify-center", style: { width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }, "aria-hidden": true, children: _jsx(DynamicIcon, { name: option.icon, className: "size-4" }) }));
63
+ }
64
+ if (option.color) {
65
+ return (_jsx("span", { className: "shrink-0 rounded-full", style: { width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }, "aria-hidden": true }));
66
+ }
67
+ return null;
68
+ }
69
+ /** True when any option (or the selected one) carries a renderable visual. */
70
+ function optionsHaveVisual(options, selected) {
71
+ const has = (o) => !!(o && (o.image || o.color || o.icon));
72
+ return has(selected) || options.some(has);
73
+ }
49
74
  function useDebounced(value, ms) {
50
75
  const [debounced, setDebounced] = useState(value);
51
76
  useEffect(() => {
@@ -87,7 +112,7 @@ export function DynamicSelectField({ field, value, onChange }) {
87
112
  // Only switch the picker into "with thumbnails" mode when the data actually
88
113
  // carries images — a relation whose options have no `image` keeps the plain
89
114
  // text list it had before (no empty placeholder column).
90
- const hasImages = !!selectedOption?.image || options.some((o) => !!o.image);
115
+ const hasVisual = optionsHaveVisual(options, selectedOption);
91
116
  const handlePick = (opt) => {
92
117
  setPicked(opt);
93
118
  onChange(String(opt.id));
@@ -119,12 +144,12 @@ export function DynamicSelectField({ field, value, onChange }) {
119
144
  // to the cell. Without min-w-0 the combobox+button row sizes to its content
120
145
  // (the long empty-state placeholder) and overflows the column, pushing the
121
146
  // "+" off-screen — it only "fit" once a short value was selected.
122
- return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, className: "min-w-0 flex-1 justify-between font-normal", "data-empty": !value, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [hasImages && value ? (_jsx(OptionThumb, { image: selectedOption?.image, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: selectedLabel || field.placeholder || 'Buscar…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
147
+ return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs(Button, { type: "button", variant: "outline", role: "combobox", "aria-expanded": open, id: field.key, className: "min-w-0 flex-1 justify-between font-normal", "data-empty": !value, children: [_jsxs("span", { className: "flex min-w-0 flex-1 items-center gap-2 text-left", children: [hasVisual && value ? (_jsx(OptionLead, { option: selectedOption, size: 20 })) : null, _jsx("span", { className: 'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground'), children: selectedLabel || field.placeholder || 'Buscar…' })] }), _jsx(ChevronsUpDown, { className: "ml-2 size-4 shrink-0 opacity-50" })] }) }), _jsx(PopoverContent, { className: "p-0", align: "start",
123
148
  // Match the trigger width without an arbitrary Tailwind class
124
149
  // (those don't always survive a consuming app's Tailwind scan).
125
150
  style: { width: 'var(--radix-popover-trigger-width)' }, children: _jsxs(Command, { shouldFilter: false, children: [_jsx(CommandInput, { placeholder: field.placeholder || 'Buscar…', value: search, onValueChange: setSearch }), _jsxs(CommandList, { children: [loading && (_jsxs("div", { className: "text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm", children: [_jsx(Loader2, { className: "size-4 animate-spin" }), "Buscando\u2026"] })), !loading && options.length === 0 && (_jsx(CommandEmpty, { children: debounced ? 'Sin resultados' : 'Escribí para buscar…' })), !loading && options.length > 0 && (_jsx(CommandGroup, { className: "max-h-64 overflow-auto", children: options.map((opt) => {
126
151
  const isSel = String(opt.id) === String(value);
127
- return (_jsxs(CommandItem, { value: String(opt.id), onSelect: () => handlePick(opt), children: [_jsx(Check, { className: 'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0') }), hasImages && (_jsx(OptionThumb, { image: opt.image, size: 24 })), _jsxs("div", { className: "ml-2 flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: opt.label }), opt.description && (_jsx("span", { className: "text-muted-foreground truncate text-xs", children: opt.description }))] })] }, String(opt.id)));
152
+ return (_jsxs(CommandItem, { value: String(opt.id), onSelect: () => handlePick(opt), children: [_jsx(Check, { className: 'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0') }), hasVisual && (_jsx(OptionLead, { option: opt, size: 24 })), _jsxs("div", { className: "ml-2 flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: opt.label }), opt.description && (_jsx("span", { className: "text-muted-foreground truncate text-xs", children: opt.description }))] })] }, String(opt.id)));
128
153
  }) }))] })] }) })] }), fieldRef && (_jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: openCreate, title: `Crear ${field.label ?? fieldRef}`, "aria-label": `Crear ${field.label ?? fieldRef}`, children: _jsx(Plus, { className: "size-4" }) }))] }));
129
154
  }
130
155
  export default DynamicSelectField;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "17.0.3",
3
+ "version": "18.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "date-fns": ">=3",
35
35
  "react-day-picker": ">=8",
36
36
  "@asteby/metacore-sdk": "^3.2.0",
37
- "@asteby/metacore-ui": "^2.4.2"
37
+ "@asteby/metacore-ui": "^2.5.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@tanstack/react-router": {
@@ -62,7 +62,7 @@
62
62
  "vitest": "^4.0.0",
63
63
  "zustand": "^5.0.0",
64
64
  "@asteby/metacore-sdk": "3.2.0",
65
- "@asteby/metacore-ui": "2.4.2"
65
+ "@asteby/metacore-ui": "2.5.0"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsc -p tsconfig.json",
@@ -3,7 +3,7 @@
3
3
  // host's render tests); here we lock the value-resolution contract that drives
4
4
  // them so a backend shape change is caught without a DOM.
5
5
  import { describe, it, expect } from 'vitest'
6
- import { relationKeyFor, resolveRelationLabel } from '../dynamic-columns'
6
+ import { relationKeyFor, resolveRelationImage, resolveRelationLabel } from '../dynamic-columns'
7
7
  import type { ColumnDefinition } from '../types'
8
8
 
9
9
  const col = (over: Partial<ColumnDefinition>): ColumnDefinition => ({
@@ -64,3 +64,44 @@ describe('resolveRelationLabel', () => {
64
64
  expect(resolveRelationLabel(col({ ref: 'categories' }), row)).toBe('Sin categoría')
65
65
  })
66
66
  })
67
+
68
+ describe('resolveRelationImage', () => {
69
+ const brandCol = (over: Partial<ColumnDefinition> = {}): ColumnDefinition =>
70
+ col({ key: 'brand_id', label: 'Marca', ref: 'brands', ...over })
71
+
72
+ it('reads the thumbnail the backend stamps on the resolved sibling', () => {
73
+ const row = {
74
+ brand_id: 'uuid-1',
75
+ brand: { value: 'uuid-1', label: 'Michelin', image: 'https://cdn/x/m.png' },
76
+ }
77
+ expect(resolveRelationImage(brandCol(), row)).toBe('https://cdn/x/m.png')
78
+ })
79
+
80
+ it('also accepts avatar/photo aliases on the sibling', () => {
81
+ expect(
82
+ resolveRelationImage(brandCol(), {
83
+ brand: { value: 'u', label: 'X', avatar: 'a.png' },
84
+ }),
85
+ ).toBe('a.png')
86
+ expect(
87
+ resolveRelationImage(brandCol(), {
88
+ brand: { value: 'u', label: 'X', photo: 'p.png' },
89
+ }),
90
+ ).toBe('p.png')
91
+ })
92
+
93
+ it('returns empty string when the sibling carries no image (text-only chip)', () => {
94
+ const row = { brand_id: 'uuid-2', brand: { value: 'uuid-2', label: 'Genérica' } }
95
+ expect(resolveRelationImage(brandCol(), row)).toBe('')
96
+ })
97
+
98
+ it('returns empty string when there is no sibling at all', () => {
99
+ expect(resolveRelationImage(brandCol(), { brand_id: 'uuid-3' })).toBe('')
100
+ expect(resolveRelationImage(brandCol(), {})).toBe('')
101
+ })
102
+
103
+ it('ignores an empty-string image (no broken thumbnail)', () => {
104
+ const row = { brand: { value: 'u', label: 'X', image: '' } }
105
+ expect(resolveRelationImage(brandCol(), row)).toBe('')
106
+ })
107
+ })
@@ -272,12 +272,42 @@ const renderRelationBadges = (items: any, col: ColumnDefinition) => {
272
272
  )
273
273
  }
274
274
 
275
+ /**
276
+ * Tiny square thumbnail for a resolved relation/option that carries an `image`
277
+ * (brand logo, product photo, customer/user avatar). Uses the same Avatar
278
+ * primitive as the `avatar`/`creator` cells so a broken/loading image
279
+ * gracefully falls back to the record's initials. Sized small (the box is an
280
+ * inline style so an addon-arbitrary Tailwind class never gets dropped by a
281
+ * consuming app's class scan). Rendered inline alongside a label — never alone.
282
+ */
283
+ const RelationThumbnail: React.FC<{
284
+ src: string
285
+ alt: string
286
+ getImageUrl?: (path: string) => string
287
+ size?: number
288
+ }> = ({ src, alt, getImageUrl, size = 18 }) => (
289
+ <Avatar
290
+ className="shrink-0 rounded-sm ring-1 ring-border/40"
291
+ style={{ width: size, height: size }}
292
+ >
293
+ <AvatarImage
294
+ src={getImageUrl ? getImageUrl(src) : src}
295
+ alt={alt}
296
+ className="object-cover"
297
+ />
298
+ <AvatarFallback className="rounded-sm bg-primary/10 text-[8px] font-bold text-primary">
299
+ {getInitials(alt)}
300
+ </AvatarFallback>
301
+ </Avatar>
302
+ )
303
+
275
304
  interface OptionBadgeProps {
276
- option: { value: string; label: string; icon?: string; color?: string }
305
+ option: { value: string; label: string; icon?: string; color?: string; image?: string }
277
306
  fallback: string
307
+ getImageUrl?: (path: string) => string
278
308
  }
279
309
 
280
- const OptionBadge: React.FC<OptionBadgeProps> = ({ option }) => {
310
+ const OptionBadge: React.FC<OptionBadgeProps> = ({ option, getImageUrl }) => {
281
311
  const isDark = useIsDarkTheme()
282
312
  // Explicit backend color wins; otherwise derive a stable, cohesive color
283
313
  // from the option's value (fallback label) so "dead" gray badges come
@@ -287,17 +317,30 @@ const OptionBadge: React.FC<OptionBadgeProps> = ({ option }) => {
287
317
  const colorStyles = generateBadgeStyles(colorSource, { isDark })
288
318
  return (
289
319
  <Badge variant="outline" className="flex items-center gap-1 border-0" style={colorStyles}>
290
- {option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />}
320
+ {option.image ? (
321
+ <RelationThumbnail
322
+ src={option.image}
323
+ alt={option.label}
324
+ getImageUrl={getImageUrl}
325
+ size={16}
326
+ />
327
+ ) : (
328
+ option.icon && <DynamicIcon name={option.icon} className="h-3.5 w-3.5" />
329
+ )}
291
330
  <span>{option.label}</span>
292
331
  </Badge>
293
332
  )
294
333
  }
295
334
 
296
- const BadgeWithEndpointOptions: React.FC<{ endpoint: string; value: any }> = ({ endpoint, value }) => {
335
+ const BadgeWithEndpointOptions: React.FC<{
336
+ endpoint: string
337
+ value: any
338
+ getImageUrl?: (path: string) => string
339
+ }> = ({ endpoint, value, getImageUrl }) => {
297
340
  const { optionsMap } = React.useContext(OptionsContext)
298
341
  const options = optionsMap.get(endpoint) || []
299
342
  const option = options.find((opt: any) => opt.value === value)
300
- if (option) return <OptionBadge option={option} fallback={String(value)} />
343
+ if (option) return <OptionBadge option={option} fallback={String(value)} getImageUrl={getImageUrl} />
301
344
  // No declared option matched → humanize the raw token as a safety net so a
302
345
  // cell never shows `in_progress` verbatim (option.label still wins above).
303
346
  return <Badge variant="outline">{humanizeToken(value)}</Badge>
@@ -366,17 +409,39 @@ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string =>
366
409
  return String(raw)
367
410
  }
368
411
 
412
+ /**
413
+ * Reads the thumbnail URL a backend serves on a resolved FK sibling, when
414
+ * present. The backend stamps `image` onto the `{ value, label }` relation
415
+ * object when the referenced model carries an image column (brand logo,
416
+ * product photo, customer avatar). Returns '' when there is no sibling image —
417
+ * the chip then renders text-only, exactly as before.
418
+ */
419
+ export const resolveRelationImage = (col: ColumnDefinition, row: any): string => {
420
+ const sibling = getNestedValue(row, relationKeyFor(col))
421
+ if (sibling && typeof sibling === 'object') {
422
+ const img = sibling.image ?? sibling.avatar ?? sibling.photo
423
+ if (img !== undefined && img !== null && img !== '') return String(img)
424
+ }
425
+ return ''
426
+ }
427
+
369
428
  /**
370
429
  * Renders a resolved FK relation as a clean, truncated chip. Reads the
371
- * backend-resolved sibling `{ value, label }` (see `relationKeyFor`) and shows
372
- * its `label`. Falls back to the raw id when no sibling was resolved, and to an
373
- * empty marker when there is no value at all. Domain-agnostic: works for every
374
- * `belongs_to` column (category, supplier, warehouse, …) without per-addon code.
430
+ * backend-resolved sibling `{ value, label[, image] }` (see `relationKeyFor`)
431
+ * and shows its `label`, prefixed with a small thumbnail when the sibling
432
+ * carries an `image`. Falls back to the raw id when no sibling was resolved, and
433
+ * to an empty marker when there is no value at all. Domain-agnostic: works for
434
+ * every `belongs_to` column (category, supplier, brand, …) without per-addon code.
375
435
  */
376
- const RelationCell: React.FC<{ col: ColumnDefinition; row: any }> = ({ col, row }) => {
436
+ const RelationCell: React.FC<{
437
+ col: ColumnDefinition
438
+ row: any
439
+ getImageUrl?: (path: string) => string
440
+ }> = ({ col, row, getImageUrl }) => {
377
441
  const isDark = useIsDarkTheme()
378
442
  const display = resolveRelationLabel(col, row)
379
443
  if (!display) return <EmptyCell />
444
+ const image = resolveRelationImage(col, row)
380
445
  // Deterministic, SUBTLE color keyed on the resolved label — lighter than
381
446
  // enum badges (soft tint, no heavy fill) so category/brand chips read as
382
447
  // alive yet stay visually distinct from option/status badges. Inline style
@@ -384,10 +449,13 @@ const RelationCell: React.FC<{ col: ColumnDefinition; row: any }> = ({ col, row
384
449
  const chipStyles = relationChipStyles(display, { isDark })
385
450
  return (
386
451
  <span
387
- className="inline-flex max-w-[220px] items-center truncate rounded-md px-2 py-0.5 text-sm font-medium"
452
+ className="inline-flex max-w-[220px] items-center gap-1.5 rounded-md px-2 py-0.5 text-sm font-medium"
388
453
  style={chipStyles}
389
454
  title={display}
390
455
  >
456
+ {image && (
457
+ <RelationThumbnail src={image} alt={display} getImageUrl={getImageUrl} size={18} />
458
+ )}
391
459
  <span className="truncate">{display}</span>
392
460
  </span>
393
461
  )
@@ -523,14 +591,14 @@ export function makeDefaultGetDynamicColumns(
523
591
  // Endpoint-loaded badge options (preloaded into OptionsContext)
524
592
  if (renderAs === 'badge' && col.useOptions && col.searchEndpoint) {
525
593
  if (!value) return <span className="text-muted-foreground">-</span>
526
- return <BadgeWithEndpointOptions endpoint={col.searchEndpoint} value={value} />
594
+ return <BadgeWithEndpointOptions endpoint={col.searchEndpoint} value={value} getImageUrl={getImageUrl} />
527
595
  }
528
596
 
529
597
  // Static badge options — map value → label/icon/color
530
598
  if (renderAs === 'badge' && col.options && col.options.length > 0) {
531
599
  if (!value && value !== 0) return <span className="text-muted-foreground">-</span>
532
600
  const option = col.options.find((o) => o.value === String(value))
533
- if (option) return <OptionBadge option={option} fallback={String(value)} />
601
+ if (option) return <OptionBadge option={option} fallback={String(value)} getImageUrl={getImageUrl} />
534
602
  return <Badge variant="outline">{humanizeToken(value)}</Badge>
535
603
  }
536
604
 
@@ -550,7 +618,7 @@ export function makeDefaultGetDynamicColumns(
550
618
  if (!value && value !== 0) return <EmptyCell />
551
619
  const sv = String(value)
552
620
  const option = col.options?.find((o) => o.value === sv)
553
- if (option) return <OptionBadge option={option} fallback={sv} />
621
+ if (option) return <OptionBadge option={option} fallback={sv} getImageUrl={getImageUrl} />
554
622
  const isDark =
555
623
  typeof document !== 'undefined' &&
556
624
  document.documentElement.classList.contains('dark')
@@ -573,7 +641,7 @@ export function makeDefaultGetDynamicColumns(
573
641
  renderAs === 'relation' ||
574
642
  (col.ref && !col.options?.length && renderAs !== 'badge' && renderAs !== 'status')
575
643
  ) {
576
- return <RelationCell col={col} row={row.original} />
644
+ return <RelationCell col={col} row={row.original} getImageUrl={getImageUrl} />
577
645
  }
578
646
 
579
647
  // Option/type column: a `select`-style column ships its
@@ -588,7 +656,7 @@ export function makeDefaultGetDynamicColumns(
588
656
  ) {
589
657
  if (!value && value !== 0) return <EmptyCell />
590
658
  const option = col.options.find((o) => o.value === String(value))
591
- if (option) return <OptionBadge option={option} fallback={String(value)} />
659
+ if (option) return <OptionBadge option={option} fallback={String(value)} getImageUrl={getImageUrl} />
592
660
  return <Badge variant="outline">{humanizeToken(value)}</Badge>
593
661
  }
594
662
 
@@ -35,6 +35,8 @@ import {
35
35
  PopoverTrigger,
36
36
  } from '@asteby/metacore-ui/primitives'
37
37
  import { Check, ChevronsUpDown, ImageIcon, Loader2, Plus } from 'lucide-react'
38
+ import { resolveColorCss } from '@asteby/metacore-ui/lib'
39
+ import { DynamicIcon } from './dynamic-icon'
38
40
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
39
41
  import { getFieldRef } from './dynamic-form-schema'
40
42
  import type { ActionFieldDef } from './types'
@@ -76,6 +78,53 @@ function OptionThumb({ image, size = 20 }: { image?: string | null; size?: numbe
76
78
  )
77
79
  }
78
80
 
81
+ /**
82
+ * Leading visual for an option: a photo thumbnail (FK relations with an image),
83
+ * else a declared icon, else a color dot (enum/status options with a color).
84
+ * Returns null when the option carries none, so plain text options stay plain.
85
+ */
86
+ function OptionLead({
87
+ option,
88
+ size = 20,
89
+ }: {
90
+ option?: Pick<ResolvedOption, 'image' | 'color' | 'icon'> | null
91
+ size?: number
92
+ }) {
93
+ if (!option) return null
94
+ if (option.image) return <OptionThumb image={option.image} size={size} />
95
+ if (option.icon) {
96
+ return (
97
+ <span
98
+ className="flex shrink-0 items-center justify-center"
99
+ style={{ width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }}
100
+ aria-hidden
101
+ >
102
+ <DynamicIcon name={option.icon} className="size-4" />
103
+ </span>
104
+ )
105
+ }
106
+ if (option.color) {
107
+ return (
108
+ <span
109
+ className="shrink-0 rounded-full"
110
+ style={{ width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }}
111
+ aria-hidden
112
+ />
113
+ )
114
+ }
115
+ return null
116
+ }
117
+
118
+ /** True when any option (or the selected one) carries a renderable visual. */
119
+ function optionsHaveVisual(
120
+ options: ReadonlyArray<Pick<ResolvedOption, 'image' | 'color' | 'icon'>>,
121
+ selected?: Pick<ResolvedOption, 'image' | 'color' | 'icon'> | null,
122
+ ): boolean {
123
+ const has = (o?: Pick<ResolvedOption, 'image' | 'color' | 'icon'> | null) =>
124
+ !!(o && (o.image || o.color || o.icon))
125
+ return has(selected) || options.some(has)
126
+ }
127
+
79
128
  function useDebounced<T>(value: T, ms: number): T {
80
129
  const [debounced, setDebounced] = useState(value)
81
130
  useEffect(() => {
@@ -130,8 +179,7 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
130
179
  // Only switch the picker into "with thumbnails" mode when the data actually
131
180
  // carries images — a relation whose options have no `image` keeps the plain
132
181
  // text list it had before (no empty placeholder column).
133
- const hasImages =
134
- !!selectedOption?.image || options.some((o) => !!o.image)
182
+ const hasVisual = optionsHaveVisual(options, selectedOption)
135
183
 
136
184
  const handlePick = (opt: ResolvedOption) => {
137
185
  setPicked(opt)
@@ -181,8 +229,8 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
181
229
  data-empty={!value}
182
230
  >
183
231
  <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
184
- {hasImages && value ? (
185
- <OptionThumb image={selectedOption?.image} size={20} />
232
+ {hasVisual && value ? (
233
+ <OptionLead option={selectedOption} size={20} />
186
234
  ) : null}
187
235
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
188
236
  {selectedLabel || field.placeholder || 'Buscar…'}
@@ -227,8 +275,8 @@ export function DynamicSelectField({ field, value, onChange }: DynamicSelectFiel
227
275
  onSelect={() => handlePick(opt)}
228
276
  >
229
277
  <Check className={'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
230
- {hasImages && (
231
- <OptionThumb image={opt.image} size={24} />
278
+ {hasVisual && (
279
+ <OptionLead option={opt} size={24} />
232
280
  )}
233
281
  <div className="ml-2 flex min-w-0 flex-col">
234
282
  <span className="truncate">{opt.label}</span>