@asteby/metacore-runtime-react 17.0.3 → 17.0.4
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,17 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 17.0.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a745f5c: Relation/option thumbnails: resolved FK relation chips and option badges now
|
|
8
|
+
render a small thumbnail when the backend stamps an `image` on the sibling
|
|
9
|
+
`{ value, label }` object or the option (brand logo, product photo, customer
|
|
10
|
+
avatar), with a graceful initials fallback when the image is missing or fails to
|
|
11
|
+
load. Applies to the table `relation`/`select`/`status`/`badge` cells; the
|
|
12
|
+
searchable picker (`DynamicSelectField`) and the detail-view picker already
|
|
13
|
+
rendered option images. Adds a pure `resolveRelationImage` helper (+ tests).
|
|
14
|
+
|
|
3
15
|
## 17.0.3
|
|
4
16
|
|
|
5
17
|
### 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;
|
|
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"}
|
package/dist/dynamic-columns.js
CHANGED
|
@@ -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
|
-
|
|
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`)
|
|
253
|
-
* its `label
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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 (
|
|
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) {
|
package/package.json
CHANGED
|
@@ -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
|
+
})
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -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.
|
|
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<{
|
|
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`)
|
|
372
|
-
* its `label
|
|
373
|
-
*
|
|
374
|
-
*
|
|
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<{
|
|
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
|
|
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
|
|