@asteby/metacore-runtime-react 34.0.2 → 34.1.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 +6 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +19 -2
- package/dist/display-value.d.ts +23 -0
- package/dist/display-value.d.ts.map +1 -1
- package/dist/display-value.js +34 -1
- package/dist/dynamic-columns.d.ts +3 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +36 -6
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +2 -6
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +10 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/metadata-cache.d.ts.map +1 -1
- package/dist/metadata-cache.js +8 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/use-debounced-value.d.ts +8 -0
- package/dist/use-debounced-value.d.ts.map +1 -0
- package/dist/use-debounced-value.js +15 -0
- package/dist/use-dynamic-filters.d.ts.map +1 -1
- package/dist/use-dynamic-filters.js +6 -4
- package/package.json +1 -1
- package/src/__tests__/image-stack.test.tsx +52 -0
- package/src/__tests__/use-debounced-value.test.ts +56 -0
- package/src/dialogs/dynamic-record.tsx +43 -1
- package/src/display-value.tsx +82 -4
- package/src/dynamic-columns.tsx +79 -6
- package/src/dynamic-select-field.tsx +2 -6
- package/src/dynamic-table.tsx +9 -6
- package/src/index.ts +7 -0
- package/src/metadata-cache.ts +8 -1
- package/src/types.ts +2 -0
- package/src/use-debounced-value.ts +17 -0
- package/src/use-dynamic-filters.ts +6 -4
|
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
14
14
|
import { useApi } from './api-context';
|
|
15
15
|
import { DATE_CELL_TYPES } from './dynamic-columns';
|
|
16
16
|
import { getSearchableColumnKeys } from './column-visibility';
|
|
17
|
+
import { useDebouncedValue } from './use-debounced-value';
|
|
17
18
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
|
|
18
19
|
/**
|
|
19
20
|
* Reads the model's filter metadata and returns the live filter state + the
|
|
@@ -26,6 +27,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
26
27
|
const api = useApi();
|
|
27
28
|
const [dynamicFilters, setDynamicFilters] = useState({});
|
|
28
29
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
30
|
+
const debouncedGlobalFilter = useDebouncedValue(globalFilter);
|
|
29
31
|
const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
|
|
30
32
|
// Where a `facet` filter loads its distinct values from. Explicit override
|
|
31
33
|
// wins; otherwise `<endpoint>/facets` (same derivation as the aggregate
|
|
@@ -268,12 +270,12 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
268
270
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
269
271
|
const filterParams = useMemo(() => {
|
|
270
272
|
const params = {};
|
|
271
|
-
if (
|
|
273
|
+
if (debouncedGlobalFilter) {
|
|
272
274
|
if (searchableKeys === null) {
|
|
273
|
-
params.search =
|
|
275
|
+
params.search = debouncedGlobalFilter;
|
|
274
276
|
}
|
|
275
277
|
else if (searchableKeys.length > 0) {
|
|
276
|
-
params.search =
|
|
278
|
+
params.search = debouncedGlobalFilter;
|
|
277
279
|
params.search_columns = searchableKeys.join(',');
|
|
278
280
|
}
|
|
279
281
|
// searchableKeys === [] → no searchable column, skip the search param.
|
|
@@ -300,7 +302,7 @@ export function useDynamicFilters(metadata, opts = {}) {
|
|
|
300
302
|
params[`f_${key}`] = `IN:${values.join(',')}`;
|
|
301
303
|
});
|
|
302
304
|
return params;
|
|
303
|
-
}, [
|
|
305
|
+
}, [debouncedGlobalFilter, searchableKeys, defaultFilters, dynamicFilters]);
|
|
304
306
|
const activeFilterCount = useMemo(() => {
|
|
305
307
|
let n = Object.values(dynamicFilters).filter((v) => v.length > 0).length;
|
|
306
308
|
if (globalFilter)
|
package/package.json
CHANGED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
4
|
+
import { cleanup, render } from '@testing-library/react'
|
|
5
|
+
import { ImageStack } from '../display-value'
|
|
6
|
+
import { ImageCell } from '../dynamic-columns'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
describe('ImageStack', () => {
|
|
11
|
+
it('renders landscape mark with label underneath and no padded plate', () => {
|
|
12
|
+
const { container, getByText } = render(
|
|
13
|
+
<ImageStack src="/brands/bridgestone.png" label="BRIDGESTONE" size="sm" />,
|
|
14
|
+
)
|
|
15
|
+
const img = container.querySelector('img')
|
|
16
|
+
expect(img).toBeTruthy()
|
|
17
|
+
expect(img?.getAttribute('src')).toBe('/brands/bridgestone.png')
|
|
18
|
+
expect(img?.className).toContain('object-contain')
|
|
19
|
+
expect(getByText('BRIDGESTONE')).toBeTruthy()
|
|
20
|
+
// Label comes after the image in DOM order (stack, not side-by-side).
|
|
21
|
+
const root = container.firstElementChild as HTMLElement
|
|
22
|
+
expect(root.className).toContain('flex-col')
|
|
23
|
+
// No muted card / ring wrapping the logo (row height tax).
|
|
24
|
+
expect(root.querySelector('.bg-muted\\/60')).toBeNull()
|
|
25
|
+
expect(root.innerHTML).not.toContain('bg-muted')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('falls back to initials when src is missing', () => {
|
|
29
|
+
const { getByText } = render(<ImageStack label="PIRELLI" size="md" />)
|
|
30
|
+
// Single-token name → first letter only (getInitials).
|
|
31
|
+
expect(getByText('P')).toBeTruthy()
|
|
32
|
+
expect(getByText('PIRELLI')).toBeTruthy()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('ImageCell stack mode', () => {
|
|
37
|
+
const getImageUrl = (p: string) => p
|
|
38
|
+
|
|
39
|
+
it('stacks image above caption when stack=true', () => {
|
|
40
|
+
const { container, getByText } = render(
|
|
41
|
+
<ImageCell
|
|
42
|
+
value="/brands/michelin.png"
|
|
43
|
+
getImageUrl={getImageUrl}
|
|
44
|
+
label="MICHELIN"
|
|
45
|
+
stack
|
|
46
|
+
/>,
|
|
47
|
+
)
|
|
48
|
+
expect(container.querySelector('img')?.className).toContain('object-contain')
|
|
49
|
+
expect(getByText('MICHELIN')).toBeTruthy()
|
|
50
|
+
expect((container.firstElementChild as HTMLElement).className).toContain('flex-col')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
3
|
+
import { renderHook, act } from '@testing-library/react'
|
|
4
|
+
import { SEARCH_DEBOUNCE_MS, useDebouncedValue } from '../use-debounced-value'
|
|
5
|
+
|
|
6
|
+
describe('useDebouncedValue', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.useFakeTimers()
|
|
9
|
+
})
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('lags behind the live value by the default search debounce', () => {
|
|
15
|
+
const { result, rerender } = renderHook(
|
|
16
|
+
({ value }: { value: string }) => useDebouncedValue(value),
|
|
17
|
+
{ initialProps: { value: '' } },
|
|
18
|
+
)
|
|
19
|
+
expect(result.current).toBe('')
|
|
20
|
+
|
|
21
|
+
rerender({ value: 'pi' })
|
|
22
|
+
expect(result.current).toBe('')
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS - 1)
|
|
26
|
+
})
|
|
27
|
+
expect(result.current).toBe('')
|
|
28
|
+
|
|
29
|
+
act(() => {
|
|
30
|
+
vi.advanceTimersByTime(1)
|
|
31
|
+
})
|
|
32
|
+
expect(result.current).toBe('pi')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('resets the timer when the value changes mid-debounce', () => {
|
|
36
|
+
const { result, rerender } = renderHook(
|
|
37
|
+
({ value }: { value: string }) => useDebouncedValue(value, 300),
|
|
38
|
+
{ initialProps: { value: 'a' } },
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
rerender({ value: 'ab' })
|
|
42
|
+
act(() => {
|
|
43
|
+
vi.advanceTimersByTime(200)
|
|
44
|
+
})
|
|
45
|
+
rerender({ value: 'abc' })
|
|
46
|
+
act(() => {
|
|
47
|
+
vi.advanceTimersByTime(200)
|
|
48
|
+
})
|
|
49
|
+
expect(result.current).toBe('a')
|
|
50
|
+
|
|
51
|
+
act(() => {
|
|
52
|
+
vi.advanceTimersByTime(100)
|
|
53
|
+
})
|
|
54
|
+
expect(result.current).toBe('abc')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -71,6 +71,7 @@ import { IconPickerField } from '../icon-picker-field'
|
|
|
71
71
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
72
72
|
import { formatDateCell } from '../dynamic-columns'
|
|
73
73
|
import {
|
|
74
|
+
ImageStack,
|
|
74
75
|
OptionBadge,
|
|
75
76
|
statusColorFor,
|
|
76
77
|
useIsDarkTheme,
|
|
@@ -1243,7 +1244,7 @@ function ReadonlyRelationField({
|
|
|
1243
1244
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
1244
1245
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
1245
1246
|
// endpoint, and renders an OptionLead (thumbnail / icon / color dot) + label.
|
|
1246
|
-
function RelationViewValue({ field, value, record }: { field: FieldDef; value: any; record: any }) {
|
|
1247
|
+
function RelationViewValue({ field, value, record, stack = false }: { field: FieldDef; value: any; record: any; stack?: boolean }) {
|
|
1247
1248
|
const getImageUrl = useContext(ImageUrlContext)
|
|
1248
1249
|
const sib = relationSiblingValue(field, record)
|
|
1249
1250
|
const sibLabel = typeof sib === 'string' ? sib : objectLabel(sib)
|
|
@@ -1278,6 +1279,19 @@ function RelationViewValue({ field, value, record }: { field: FieldDef; value: a
|
|
|
1278
1279
|
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1279
1280
|
}
|
|
1280
1281
|
|
|
1282
|
+
if (stack) {
|
|
1283
|
+
return (
|
|
1284
|
+
<div className="py-1">
|
|
1285
|
+
<ImageStack
|
|
1286
|
+
src={image || undefined}
|
|
1287
|
+
label={label}
|
|
1288
|
+
getImageUrl={getImageUrl}
|
|
1289
|
+
size="lg"
|
|
1290
|
+
/>
|
|
1291
|
+
</div>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1281
1295
|
const lead: Pick<ResolvedOption, 'image' | 'color' | 'icon' | 'label'> = {
|
|
1282
1296
|
image: image ? getImageUrl(image) : null,
|
|
1283
1297
|
color: resolved?.color ?? null,
|
|
@@ -1368,6 +1382,14 @@ export function ViewValue({
|
|
|
1368
1382
|
|
|
1369
1383
|
const value = normalizeNilUuid(rawValue)
|
|
1370
1384
|
|
|
1385
|
+
// Landscape stack on a relation FK (brand marks, product cards).
|
|
1386
|
+
if (
|
|
1387
|
+
renderAs === 'image_stack' &&
|
|
1388
|
+
(isRelationField(field) || (typeof field.key === 'string' && field.key.endsWith('_id')))
|
|
1389
|
+
) {
|
|
1390
|
+
return <RelationViewValue field={field} value={value} record={record} stack />
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1371
1393
|
// Relation (search / dynamic_select / ref / any *_id) → resolved thumbnail +
|
|
1372
1394
|
// label. The *_id catch-all covers plain-typed FK columns not tagged as a
|
|
1373
1395
|
// relation field.
|
|
@@ -1404,6 +1426,26 @@ export function ViewValue({
|
|
|
1404
1426
|
)
|
|
1405
1427
|
}
|
|
1406
1428
|
|
|
1429
|
+
// Landscape stack for image/logo URL columns.
|
|
1430
|
+
if (renderAs === 'image_stack') {
|
|
1431
|
+
const caption =
|
|
1432
|
+
(typeof field.styleConfig?.label_field === 'string' &&
|
|
1433
|
+
record?.[field.styleConfig.label_field]) ||
|
|
1434
|
+
(typeof field.styleConfig?.labelField === 'string' &&
|
|
1435
|
+
record?.[field.styleConfig.labelField]) ||
|
|
1436
|
+
undefined
|
|
1437
|
+
return (
|
|
1438
|
+
<div className="py-1">
|
|
1439
|
+
<ImageStack
|
|
1440
|
+
src={value ? String(value) : undefined}
|
|
1441
|
+
label={caption ? String(caption) : field.label}
|
|
1442
|
+
getImageUrl={getImageUrl}
|
|
1443
|
+
size="lg"
|
|
1444
|
+
/>
|
|
1445
|
+
</div>
|
|
1446
|
+
)
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1407
1449
|
if (field.type === 'image') {
|
|
1408
1450
|
if (isLucideIconName(value)) {
|
|
1409
1451
|
return <IconNameViewValue name={value} />
|
package/src/display-value.tsx
CHANGED
|
@@ -78,24 +78,102 @@ export const RelationThumbnail: React.FC<{
|
|
|
78
78
|
size?: number
|
|
79
79
|
}> = ({ src, alt, getImageUrl, size = 20 }) => (
|
|
80
80
|
<Avatar
|
|
81
|
-
className="shrink-0 rounded-md
|
|
81
|
+
className="shrink-0 rounded-md"
|
|
82
82
|
style={{ width: size, height: size }}
|
|
83
83
|
>
|
|
84
84
|
{/* object-contain: brand logos are typically wide/rectangular, so
|
|
85
85
|
object-cover (fills the square, cropping the sides) was cutting
|
|
86
|
-
them off. contain
|
|
87
|
-
padded neutral background above instead of clipped. */}
|
|
86
|
+
them off. contain shows the whole mark without a padded plate. */}
|
|
88
87
|
<AvatarImage
|
|
89
88
|
src={getImageUrl ? getImageUrl(src) : src}
|
|
90
89
|
alt={alt}
|
|
91
90
|
className="object-contain"
|
|
92
91
|
/>
|
|
93
|
-
<AvatarFallback className="rounded-md bg-
|
|
92
|
+
<AvatarFallback className="rounded-md bg-transparent text-[8px] font-bold text-muted-foreground">
|
|
94
93
|
{getInitials(alt)}
|
|
95
94
|
</AvatarFallback>
|
|
96
95
|
</Avatar>
|
|
97
96
|
)
|
|
98
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Landscape-friendly identity block: wide image ON TOP, label (and optional
|
|
100
|
+
* subtitle) UNDERNEATH. Use for brand logos, product photos and any mark that
|
|
101
|
+
* is wider than it is tall — a square thumb next to text crops logos and
|
|
102
|
+
* wastes the cell. Declared via `display: "image_stack"` on a column (image
|
|
103
|
+
* URL or FK relation with a logo/photo sibling).
|
|
104
|
+
*
|
|
105
|
+
* No padded card / muted plate behind the mark — that ate table row height.
|
|
106
|
+
* The image is sized with max-width/max-height and `object-contain` only.
|
|
107
|
+
*
|
|
108
|
+
* Sizes (CSS, not Tailwind arbitrary — host safelists vary):
|
|
109
|
+
* sm — compact table (~88×32)
|
|
110
|
+
* md — default table (~112×40)
|
|
111
|
+
* lg — detail/modal (~180×72)
|
|
112
|
+
*/
|
|
113
|
+
export const ImageStack: React.FC<{
|
|
114
|
+
src?: string | null
|
|
115
|
+
label?: string | null
|
|
116
|
+
subtitle?: string | null
|
|
117
|
+
getImageUrl?: (path: string) => string
|
|
118
|
+
size?: 'sm' | 'md' | 'lg'
|
|
119
|
+
className?: string
|
|
120
|
+
}> = ({ src, label, subtitle, getImageUrl, size = 'md', className }) => {
|
|
121
|
+
const dims =
|
|
122
|
+
size === 'lg'
|
|
123
|
+
? { w: 180, h: 72, text: 'text-sm' }
|
|
124
|
+
: size === 'sm'
|
|
125
|
+
? { w: 88, h: 32, text: 'text-[11px]' }
|
|
126
|
+
: { w: 112, h: 40, text: 'text-xs' }
|
|
127
|
+
const alt = label || ''
|
|
128
|
+
const resolved = src
|
|
129
|
+
? getImageUrl
|
|
130
|
+
? getImageUrl(src)
|
|
131
|
+
: src
|
|
132
|
+
: undefined
|
|
133
|
+
const [broken, setBroken] = React.useState(false)
|
|
134
|
+
React.useEffect(() => {
|
|
135
|
+
setBroken(false)
|
|
136
|
+
}, [resolved])
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<span
|
|
140
|
+
className={`inline-flex max-w-[220px] flex-col items-start gap-0.5 ${className ?? ''}`}
|
|
141
|
+
title={subtitle ? `${alt} · ${subtitle}` : alt || undefined}
|
|
142
|
+
>
|
|
143
|
+
{resolved && !broken ? (
|
|
144
|
+
<img
|
|
145
|
+
src={resolved}
|
|
146
|
+
alt={alt}
|
|
147
|
+
className="block object-contain"
|
|
148
|
+
style={{ maxWidth: dims.w, maxHeight: dims.h, width: 'auto', height: 'auto' }}
|
|
149
|
+
onError={() => setBroken(true)}
|
|
150
|
+
/>
|
|
151
|
+
) : (
|
|
152
|
+
<span
|
|
153
|
+
className="flex items-center text-[10px] font-semibold tracking-wide text-muted-foreground"
|
|
154
|
+
style={{ minHeight: Math.round(dims.h * 0.55) }}
|
|
155
|
+
>
|
|
156
|
+
{getInitials(alt || '?')}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
{(label || subtitle) && (
|
|
160
|
+
<span className={`flex w-full min-w-0 flex-col items-start leading-tight ${dims.text}`}>
|
|
161
|
+
{label ? (
|
|
162
|
+
<span className="w-full truncate text-left font-medium text-foreground/90">
|
|
163
|
+
{label}
|
|
164
|
+
</span>
|
|
165
|
+
) : null}
|
|
166
|
+
{subtitle ? (
|
|
167
|
+
<span className="w-full truncate text-left text-[0.7em] text-muted-foreground">
|
|
168
|
+
{subtitle}
|
|
169
|
+
</span>
|
|
170
|
+
) : null}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</span>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
99
177
|
export interface DisplayOption {
|
|
100
178
|
value: string
|
|
101
179
|
label: string
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
import { Progress } from './dialogs/_primitives'
|
|
45
45
|
import { humanizeToken } from './dynamic-columns-helpers'
|
|
46
46
|
import { objectLabel } from './dynamic-relation-helpers'
|
|
47
|
-
import {
|
|
47
|
+
import { ImageStack,
|
|
48
48
|
OptionBadge,
|
|
49
49
|
RelationThumbnail,
|
|
50
50
|
statusColorFor,
|
|
@@ -494,7 +494,7 @@ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string =>
|
|
|
494
494
|
export const resolveRelationImage = (col: ColumnDefinition, row: any): string => {
|
|
495
495
|
const sibling = getNestedValue(row, relationKeyFor(col))
|
|
496
496
|
if (sibling && typeof sibling === 'object') {
|
|
497
|
-
const img = sibling.image ?? sibling.avatar ?? sibling.photo
|
|
497
|
+
const img = sibling.image ?? sibling.avatar ?? sibling.photo ?? sibling.logo ?? sibling.thumbnail
|
|
498
498
|
if (img !== undefined && img !== null && img !== '') return String(img)
|
|
499
499
|
}
|
|
500
500
|
return ''
|
|
@@ -608,11 +608,24 @@ const RelationCell: React.FC<{
|
|
|
608
608
|
col: ColumnDefinition
|
|
609
609
|
row: any
|
|
610
610
|
getImageUrl?: (path: string) => string
|
|
611
|
-
|
|
611
|
+
/** Landscape stack: image above, text below (display: image_stack). */
|
|
612
|
+
stack?: boolean
|
|
613
|
+
}> = ({ col, row, getImageUrl, stack = false }) => {
|
|
612
614
|
const display = resolveRelationLabel(col, row)
|
|
613
615
|
if (!display) return <EmptyCell />
|
|
614
616
|
const image = resolveRelationImage(col, row)
|
|
615
617
|
const subtitle = resolveRelationSubtitle(col, row)
|
|
618
|
+
if (stack) {
|
|
619
|
+
return (
|
|
620
|
+
<ImageStack
|
|
621
|
+
src={image || undefined}
|
|
622
|
+
label={display}
|
|
623
|
+
subtitle={subtitle || undefined}
|
|
624
|
+
getImageUrl={getImageUrl}
|
|
625
|
+
size="md"
|
|
626
|
+
/>
|
|
627
|
+
)
|
|
628
|
+
}
|
|
616
629
|
// FLAT reference cell: no tinted capsule around the pair. A reference is
|
|
617
630
|
// data, not a status — the pill treatment (and its per-label tint) made a
|
|
618
631
|
// products/warehouses listing read as a wall of badges. What identifies the
|
|
@@ -751,15 +764,29 @@ const AvatarCell: React.FC<{
|
|
|
751
764
|
export const ImageCell: React.FC<{
|
|
752
765
|
value: unknown
|
|
753
766
|
getImageUrl: (path: string) => string
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
767
|
+
/** Optional caption under the image (display: image_stack). */
|
|
768
|
+
label?: string
|
|
769
|
+
stack?: boolean
|
|
770
|
+
}> = ({ value, getImageUrl, label, stack = false }) => {
|
|
771
|
+
if (!value && !label) return <span className="text-muted-foreground">-</span>
|
|
772
|
+
if (value && isLucideIconName(value)) {
|
|
757
773
|
return (
|
|
758
774
|
<div className="h-10 w-10 flex items-center justify-center rounded bg-muted">
|
|
759
775
|
<DynamicIcon name={value} className="h-5 w-5" />
|
|
760
776
|
</div>
|
|
761
777
|
)
|
|
762
778
|
}
|
|
779
|
+
if (stack) {
|
|
780
|
+
return (
|
|
781
|
+
<ImageStack
|
|
782
|
+
src={value ? String(value) : undefined}
|
|
783
|
+
label={label}
|
|
784
|
+
getImageUrl={getImageUrl}
|
|
785
|
+
size="md"
|
|
786
|
+
/>
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
if (!value) return <span className="text-muted-foreground">-</span>
|
|
763
790
|
return (
|
|
764
791
|
<div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
|
|
765
792
|
<img
|
|
@@ -916,6 +943,37 @@ export function makeDefaultGetDynamicColumns(
|
|
|
916
943
|
return <ReferenceCell col={col} row={row.original} />
|
|
917
944
|
}
|
|
918
945
|
|
|
946
|
+
// Landscape stack: wide image ON TOP, label UNDERNEATH.
|
|
947
|
+
if (renderAs === 'image_stack') {
|
|
948
|
+
const looksRelation =
|
|
949
|
+
!!col.ref ||
|
|
950
|
+
(typeof col.key === 'string' &&
|
|
951
|
+
col.key.endsWith('_id') &&
|
|
952
|
+
resolveRelationLabel(col, row.original) != null)
|
|
953
|
+
if (looksRelation) {
|
|
954
|
+
return (
|
|
955
|
+
<RelationCell
|
|
956
|
+
col={col}
|
|
957
|
+
row={row.original}
|
|
958
|
+
getImageUrl={getImageUrl}
|
|
959
|
+
stack
|
|
960
|
+
/>
|
|
961
|
+
)
|
|
962
|
+
}
|
|
963
|
+
const labelField = styleCfg(col, 'label_field', 'labelField')
|
|
964
|
+
const caption = labelField
|
|
965
|
+
? String(getNestedValue(row.original, labelField) ?? '')
|
|
966
|
+
: undefined
|
|
967
|
+
return (
|
|
968
|
+
<ImageCell
|
|
969
|
+
value={value}
|
|
970
|
+
getImageUrl={getImageUrl}
|
|
971
|
+
label={caption || undefined}
|
|
972
|
+
stack
|
|
973
|
+
/>
|
|
974
|
+
)
|
|
975
|
+
}
|
|
976
|
+
|
|
919
977
|
// Resolved FK relation chip. Triggers on an explicit
|
|
920
978
|
// `cellStyle: 'relation'` or on any column carrying a `ref`
|
|
921
979
|
// (a belongs_to FK) that isn't being rendered as an
|
|
@@ -1274,6 +1332,21 @@ export function makeDefaultGetDynamicColumns(
|
|
|
1274
1332
|
)
|
|
1275
1333
|
}
|
|
1276
1334
|
|
|
1335
|
+
case 'image_stack': {
|
|
1336
|
+
const imageValue =
|
|
1337
|
+
value ||
|
|
1338
|
+
(Array.isArray(row.original.media)
|
|
1339
|
+
? row.original.media.find((m: any) => m.type === 'image')?.url
|
|
1340
|
+
: null)
|
|
1341
|
+
return (
|
|
1342
|
+
<ImageCell
|
|
1343
|
+
value={imageValue}
|
|
1344
|
+
getImageUrl={getImageUrl}
|
|
1345
|
+
stack
|
|
1346
|
+
/>
|
|
1347
|
+
)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1277
1350
|
case 'image': {
|
|
1278
1351
|
const imageValue =
|
|
1279
1352
|
value ||
|
|
@@ -40,6 +40,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react'
|
|
|
40
40
|
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
41
41
|
import { BarcodeScanner } from './barcode-scanner'
|
|
42
42
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
43
|
+
import { useDebouncedValue } from './use-debounced-value'
|
|
43
44
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
44
45
|
import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
|
|
45
46
|
import type { ActionFieldDef } from './types'
|
|
@@ -140,12 +141,7 @@ export function OptionLead({
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
function useDebounced<T>(value: T, ms: number): T {
|
|
143
|
-
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
const t = setTimeout(() => setDebounced(value), ms)
|
|
146
|
-
return () => clearTimeout(t)
|
|
147
|
-
}, [value, ms])
|
|
148
|
-
return debounced
|
|
144
|
+
return useDebouncedValue(value, ms)
|
|
149
145
|
}
|
|
150
146
|
|
|
151
147
|
export interface DynamicSelectFieldProps {
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -72,6 +72,7 @@ import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
|
72
72
|
import { OptionsContext } from './options-context'
|
|
73
73
|
import type { TableMetadata, ApiResponse } from './types'
|
|
74
74
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
75
|
+
import { useDebouncedValue } from './use-debounced-value'
|
|
75
76
|
import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-context'
|
|
76
77
|
import { useDynamicRowActions } from './dynamic-row-actions'
|
|
77
78
|
import { ExportDialog } from './dialogs/export'
|
|
@@ -320,6 +321,8 @@ export function DynamicTable({
|
|
|
320
321
|
pageSize: storedPageSizeRef.current ?? 10,
|
|
321
322
|
})
|
|
322
323
|
const [globalFilter, setGlobalFilter] = useState('')
|
|
324
|
+
// Debounce search → URL + fetch so each keystroke does not thrash the server.
|
|
325
|
+
const debouncedGlobalFilter = useDebouncedValue(globalFilter)
|
|
323
326
|
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
|
|
324
327
|
|
|
325
328
|
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
|
|
@@ -474,7 +477,7 @@ export function DynamicTable({
|
|
|
474
477
|
params.set('sortBy', sorting[0].id)
|
|
475
478
|
params.set('order', sorting[0].desc ? 'desc' : 'asc')
|
|
476
479
|
}
|
|
477
|
-
if (
|
|
480
|
+
if (debouncedGlobalFilter) params.set('search', debouncedGlobalFilter)
|
|
478
481
|
Object.entries(dynamicFilters).forEach(([key, values]) => {
|
|
479
482
|
if (values.length === 0) return
|
|
480
483
|
if (defaultFilters && key in defaultFilters) return
|
|
@@ -495,7 +498,7 @@ export function DynamicTable({
|
|
|
495
498
|
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
|
|
496
499
|
lastSelfSearch.current = search ? `?${search}` : ''
|
|
497
500
|
window.history.replaceState(null, '', newUrl)
|
|
498
|
-
}, [enableUrlSync, urlSynced, pagination, sorting,
|
|
501
|
+
}, [enableUrlSync, urlSynced, pagination, sorting, debouncedGlobalFilter, dynamicFilters, defaultFilters])
|
|
499
502
|
|
|
500
503
|
// The host router can rewrite the query string WITHOUT remounting the
|
|
501
504
|
// table — e.g. sidebar sibling entries deep-link different `f_` filters
|
|
@@ -670,11 +673,11 @@ export function DynamicTable({
|
|
|
670
673
|
params.sortBy = sorting[0].id
|
|
671
674
|
params.order = sorting[0].desc ? 'desc' : 'asc'
|
|
672
675
|
}
|
|
673
|
-
if (
|
|
676
|
+
if (debouncedGlobalFilter) {
|
|
674
677
|
if (searchableKeys === null) {
|
|
675
|
-
params.search =
|
|
678
|
+
params.search = debouncedGlobalFilter
|
|
676
679
|
} else if (searchableKeys.length > 0) {
|
|
677
|
-
params.search =
|
|
680
|
+
params.search = debouncedGlobalFilter
|
|
678
681
|
params.search_columns = searchableKeys.join(',')
|
|
679
682
|
}
|
|
680
683
|
// searchableKeys === [] → drop the search request entirely
|
|
@@ -700,7 +703,7 @@ export function DynamicTable({
|
|
|
700
703
|
params['f_created_at'] = `${startDate}_${endDate}`
|
|
701
704
|
}
|
|
702
705
|
return params
|
|
703
|
-
}, [sorting,
|
|
706
|
+
}, [sorting, debouncedGlobalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
|
|
704
707
|
|
|
705
708
|
const hasActiveFilters = useMemo(() => {
|
|
706
709
|
if (globalFilter) return true
|
package/src/index.ts
CHANGED
|
@@ -119,6 +119,7 @@ export {
|
|
|
119
119
|
type UseDynamicFiltersOptions,
|
|
120
120
|
type UseDynamicFiltersResult,
|
|
121
121
|
} from './use-dynamic-filters'
|
|
122
|
+
export { useDebouncedValue, SEARCH_DEBOUNCE_MS } from './use-debounced-value'
|
|
122
123
|
export * from './dynamic-form'
|
|
123
124
|
export {
|
|
124
125
|
FilePickButton,
|
|
@@ -265,6 +266,12 @@ export {
|
|
|
265
266
|
type UrlKind,
|
|
266
267
|
type LinkifyOptions,
|
|
267
268
|
} from './rich-url'
|
|
269
|
+
export {
|
|
270
|
+
ImageStack,
|
|
271
|
+
OptionBadge,
|
|
272
|
+
statusColorFor,
|
|
273
|
+
useIsDarkTheme,
|
|
274
|
+
} from './display-value'
|
|
268
275
|
export {
|
|
269
276
|
CollectionCell,
|
|
270
277
|
formatScalar,
|
package/src/metadata-cache.ts
CHANGED
|
@@ -178,7 +178,14 @@ export const useMetadataCache = create<MetadataCacheState>()(
|
|
|
178
178
|
}),
|
|
179
179
|
{
|
|
180
180
|
name: 'metacore-metadata-cache',
|
|
181
|
-
|
|
181
|
+
// Bump when display hints (e.g. image_stack) must wipe stale cache.
|
|
182
|
+
version: 4,
|
|
183
|
+
migrate: () => ({
|
|
184
|
+
cache: {},
|
|
185
|
+
modalCache: {},
|
|
186
|
+
metadataVersion: '',
|
|
187
|
+
prefetched: false,
|
|
188
|
+
}),
|
|
182
189
|
partialize: (state) => ({
|
|
183
190
|
cache: state.cache,
|
|
184
191
|
modalCache: state.modalCache,
|
package/src/types.ts
CHANGED
|
@@ -252,6 +252,8 @@ export interface ColumnDefinition {
|
|
|
252
252
|
| 'phone'
|
|
253
253
|
| 'media-gallery'
|
|
254
254
|
| 'image'
|
|
255
|
+
// Landscape stack: wide image on top, label underneath (logos/photos).
|
|
256
|
+
| 'image_stack'
|
|
255
257
|
// Declarative pro cell renderers (resolved via `cellStyle ?? type`).
|
|
256
258
|
| 'url'
|
|
257
259
|
| 'link'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Default delay for free-text search → server/URL (table, kanban, relation). */
|
|
4
|
+
export const SEARCH_DEBOUNCE_MS = 350
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns `value` delayed by `ms`. The input stays live; only the returned
|
|
8
|
+
* value lags — use it for fetches and URL sync so typing does not thrash.
|
|
9
|
+
*/
|
|
10
|
+
export function useDebouncedValue<T>(value: T, ms: number = SEARCH_DEBOUNCE_MS): T {
|
|
11
|
+
const [debounced, setDebounced] = useState(value)
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const t = setTimeout(() => setDebounced(value), ms)
|
|
14
|
+
return () => clearTimeout(t)
|
|
15
|
+
}, [value, ms])
|
|
16
|
+
return debounced
|
|
17
|
+
}
|
|
@@ -14,6 +14,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
|
14
14
|
import { useApi } from './api-context'
|
|
15
15
|
import { DATE_CELL_TYPES } from './dynamic-columns'
|
|
16
16
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
17
|
+
import { useDebouncedValue } from './use-debounced-value'
|
|
17
18
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
18
19
|
import type { ColumnFilterConfig, FilterOption } from './dynamic-columns-shim'
|
|
19
20
|
import type { TableMetadata } from './types'
|
|
@@ -82,6 +83,7 @@ export function useDynamicFilters(
|
|
|
82
83
|
|
|
83
84
|
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
84
85
|
const [globalFilter, setGlobalFilter] = useState('')
|
|
86
|
+
const debouncedGlobalFilter = useDebouncedValue(globalFilter)
|
|
85
87
|
const [filterOptionsMap, setFilterOptionsMap] = useState<
|
|
86
88
|
Map<string, FilterOption[]>
|
|
87
89
|
>(new Map())
|
|
@@ -343,11 +345,11 @@ export function useDynamicFilters(
|
|
|
343
345
|
// DynamicTable.buildFilterParams (IN:/RANGE:/GTE:/LTE:/ILIKE:/plain).
|
|
344
346
|
const filterParams = useMemo(() => {
|
|
345
347
|
const params: Record<string, any> = {}
|
|
346
|
-
if (
|
|
348
|
+
if (debouncedGlobalFilter) {
|
|
347
349
|
if (searchableKeys === null) {
|
|
348
|
-
params.search =
|
|
350
|
+
params.search = debouncedGlobalFilter
|
|
349
351
|
} else if (searchableKeys.length > 0) {
|
|
350
|
-
params.search =
|
|
352
|
+
params.search = debouncedGlobalFilter
|
|
351
353
|
params.search_columns = searchableKeys.join(',')
|
|
352
354
|
}
|
|
353
355
|
// searchableKeys === [] → no searchable column, skip the search param.
|
|
@@ -371,7 +373,7 @@ export function useDynamicFilters(
|
|
|
371
373
|
else params[`f_${key}`] = `IN:${values.join(',')}`
|
|
372
374
|
})
|
|
373
375
|
return params
|
|
374
|
-
}, [
|
|
376
|
+
}, [debouncedGlobalFilter, searchableKeys, defaultFilters, dynamicFilters])
|
|
375
377
|
|
|
376
378
|
const activeFilterCount = useMemo(() => {
|
|
377
379
|
let n = Object.values(dynamicFilters).filter((v) => v.length > 0).length
|