@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  3. package/dist/dialogs/dynamic-record.js +19 -2
  4. package/dist/display-value.d.ts +23 -0
  5. package/dist/display-value.d.ts.map +1 -1
  6. package/dist/display-value.js +34 -1
  7. package/dist/dynamic-columns.d.ts +3 -0
  8. package/dist/dynamic-columns.d.ts.map +1 -1
  9. package/dist/dynamic-columns.js +36 -6
  10. package/dist/dynamic-select-field.d.ts.map +1 -1
  11. package/dist/dynamic-select-field.js +2 -6
  12. package/dist/dynamic-table.d.ts.map +1 -1
  13. package/dist/dynamic-table.js +10 -7
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -0
  17. package/dist/metadata-cache.d.ts.map +1 -1
  18. package/dist/metadata-cache.js +8 -1
  19. package/dist/types.d.ts +1 -1
  20. package/dist/types.d.ts.map +1 -1
  21. package/dist/use-debounced-value.d.ts +8 -0
  22. package/dist/use-debounced-value.d.ts.map +1 -0
  23. package/dist/use-debounced-value.js +15 -0
  24. package/dist/use-dynamic-filters.d.ts.map +1 -1
  25. package/dist/use-dynamic-filters.js +6 -4
  26. package/package.json +1 -1
  27. package/src/__tests__/image-stack.test.tsx +52 -0
  28. package/src/__tests__/use-debounced-value.test.ts +56 -0
  29. package/src/dialogs/dynamic-record.tsx +43 -1
  30. package/src/display-value.tsx +82 -4
  31. package/src/dynamic-columns.tsx +79 -6
  32. package/src/dynamic-select-field.tsx +2 -6
  33. package/src/dynamic-table.tsx +9 -6
  34. package/src/index.ts +7 -0
  35. package/src/metadata-cache.ts +8 -1
  36. package/src/types.ts +2 -0
  37. package/src/use-debounced-value.ts +17 -0
  38. 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 (globalFilter) {
273
+ if (debouncedGlobalFilter) {
272
274
  if (searchableKeys === null) {
273
- params.search = globalFilter;
275
+ params.search = debouncedGlobalFilter;
274
276
  }
275
277
  else if (searchableKeys.length > 0) {
276
- params.search = globalFilter;
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
- }, [globalFilter, searchableKeys, defaultFilters, dynamicFilters]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "34.0.2",
3
+ "version": "34.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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} />
@@ -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 bg-muted/50 p-[3px] ring-1 ring-border/40"
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 lets the whole mark show, letterboxed by the
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-primary/10 text-[8px] font-bold text-primary">
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
@@ -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
- }> = ({ col, row, getImageUrl }) => {
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
- }> = ({ value, getImageUrl }) => {
755
- if (!value) return <span className="text-muted-foreground">-</span>
756
- if (isLucideIconName(value)) {
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
- const [debounced, setDebounced] = useState(value)
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 {
@@ -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 (globalFilter) params.set('search', globalFilter)
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, globalFilter, dynamicFilters, defaultFilters])
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 (globalFilter) {
676
+ if (debouncedGlobalFilter) {
674
677
  if (searchableKeys === null) {
675
- params.search = globalFilter
678
+ params.search = debouncedGlobalFilter
676
679
  } else if (searchableKeys.length > 0) {
677
- params.search = globalFilter
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, globalFilter, columnFilters, defaultFilters, dynamicFilters, dateRange, searchableKeys])
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,
@@ -178,7 +178,14 @@ export const useMetadataCache = create<MetadataCacheState>()(
178
178
  }),
179
179
  {
180
180
  name: 'metacore-metadata-cache',
181
- version: 3,
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 (globalFilter) {
348
+ if (debouncedGlobalFilter) {
347
349
  if (searchableKeys === null) {
348
- params.search = globalFilter
350
+ params.search = debouncedGlobalFilter
349
351
  } else if (searchableKeys.length > 0) {
350
- params.search = globalFilter
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
- }, [globalFilter, searchableKeys, defaultFilters, dynamicFilters])
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