@asteby/metacore-runtime-react 34.0.2 → 35.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +5 -84
  2. package/dist/action-modal-dispatcher.d.ts +4 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +102 -23
  5. package/dist/addon-loader.d.ts +1 -1
  6. package/dist/addon-loader.d.ts.map +1 -1
  7. package/dist/addon-loader.js +8 -1
  8. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  9. package/dist/dialogs/dynamic-record.js +107 -52
  10. package/dist/display-value.d.ts +23 -0
  11. package/dist/display-value.d.ts.map +1 -1
  12. package/dist/display-value.js +34 -1
  13. package/dist/dynamic-columns.d.ts +3 -0
  14. package/dist/dynamic-columns.d.ts.map +1 -1
  15. package/dist/dynamic-columns.js +51 -6
  16. package/dist/dynamic-select-field.d.ts.map +1 -1
  17. package/dist/dynamic-select-field.js +8 -9
  18. package/dist/dynamic-table.d.ts.map +1 -1
  19. package/dist/dynamic-table.js +10 -7
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/metadata-cache.d.ts.map +1 -1
  24. package/dist/metadata-cache.js +8 -1
  25. package/dist/types.d.ts +1 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/use-debounced-value.d.ts +8 -0
  28. package/dist/use-debounced-value.d.ts.map +1 -0
  29. package/dist/use-debounced-value.js +15 -0
  30. package/dist/use-dynamic-filters.d.ts.map +1 -1
  31. package/dist/use-dynamic-filters.js +6 -4
  32. package/dist/use-print-document.d.ts +10 -1
  33. package/dist/use-print-document.d.ts.map +1 -1
  34. package/dist/use-print-document.js +28 -1
  35. package/package.json +1 -1
  36. package/src/__tests__/filename-from-disposition.test.ts +30 -0
  37. package/src/__tests__/image-stack.test.tsx +52 -0
  38. package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
  39. package/src/__tests__/record-detail-display.test.tsx +30 -0
  40. package/src/__tests__/use-debounced-value.test.ts +56 -0
  41. package/src/action-modal-dispatcher.tsx +110 -22
  42. package/src/addon-loader.tsx +7 -1
  43. package/src/dialogs/dynamic-record.tsx +167 -51
  44. package/src/display-value.tsx +82 -4
  45. package/src/dynamic-columns.tsx +94 -5
  46. package/src/dynamic-select-field.tsx +9 -9
  47. package/src/dynamic-table.tsx +9 -6
  48. package/src/index.ts +7 -0
  49. package/src/metadata-cache.ts +8 -1
  50. package/src/types.ts +2 -0
  51. package/src/use-debounced-value.ts +17 -0
  52. package/src/use-dynamic-filters.ts +6 -4
  53. package/src/use-print-document.ts +38 -2
@@ -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
@@ -47,6 +47,7 @@ import { objectLabel } from './dynamic-relation-helpers'
47
47
  import {
48
48
  OptionBadge,
49
49
  RelationThumbnail,
50
+ ImageStack,
50
51
  statusColorFor,
51
52
  useIsDarkTheme,
52
53
  } from './display-value'
@@ -494,7 +495,7 @@ export const resolveRelationLabel = (col: ColumnDefinition, row: any): string =>
494
495
  export const resolveRelationImage = (col: ColumnDefinition, row: any): string => {
495
496
  const sibling = getNestedValue(row, relationKeyFor(col))
496
497
  if (sibling && typeof sibling === 'object') {
497
- const img = sibling.image ?? sibling.avatar ?? sibling.photo
498
+ const img = sibling.image ?? sibling.avatar ?? sibling.photo ?? sibling.logo ?? sibling.thumbnail
498
499
  if (img !== undefined && img !== null && img !== '') return String(img)
499
500
  }
500
501
  return ''
@@ -603,16 +604,33 @@ export const resolveRelationSubtitle = (col: ColumnDefinition, row: any): string
603
604
  * carries an `image`. Falls back to the raw id when no sibling was resolved, and
604
605
  * to an empty marker when there is no value at all. Domain-agnostic: works for
605
606
  * every `belongs_to` column (category, supplier, brand, …) without per-addon code.
607
+ *
608
+ * When `stack` is true (column `display: "image_stack"`), the landscape mark
609
+ * sits ON TOP of the label — wide logos fit without cropping and the cell
610
+ * stays readable in dense tables.
606
611
  */
607
612
  const RelationCell: React.FC<{
608
613
  col: ColumnDefinition
609
614
  row: any
610
615
  getImageUrl?: (path: string) => string
611
- }> = ({ col, row, getImageUrl }) => {
616
+ /** Landscape stack: image above, text below (display: image_stack). */
617
+ stack?: boolean
618
+ }> = ({ col, row, getImageUrl, stack = false }) => {
612
619
  const display = resolveRelationLabel(col, row)
613
620
  if (!display) return <EmptyCell />
614
621
  const image = resolveRelationImage(col, row)
615
622
  const subtitle = resolveRelationSubtitle(col, row)
623
+ if (stack) {
624
+ return (
625
+ <ImageStack
626
+ src={image || undefined}
627
+ label={display}
628
+ subtitle={subtitle || undefined}
629
+ getImageUrl={getImageUrl}
630
+ size="md"
631
+ />
632
+ )
633
+ }
616
634
  // FLAT reference cell: no tinted capsule around the pair. A reference is
617
635
  // data, not a status — the pill treatment (and its per-label tint) made a
618
636
  // products/warehouses listing read as a wall of badges. What identifies the
@@ -751,15 +769,29 @@ const AvatarCell: React.FC<{
751
769
  export const ImageCell: React.FC<{
752
770
  value: unknown
753
771
  getImageUrl: (path: string) => string
754
- }> = ({ value, getImageUrl }) => {
755
- if (!value) return <span className="text-muted-foreground">-</span>
756
- if (isLucideIconName(value)) {
772
+ /** Optional caption under the image (display: image_stack). */
773
+ label?: string
774
+ stack?: boolean
775
+ }> = ({ value, getImageUrl, label, stack = false }) => {
776
+ if (!value && !label) return <span className="text-muted-foreground">-</span>
777
+ if (value && isLucideIconName(value)) {
757
778
  return (
758
779
  <div className="h-10 w-10 flex items-center justify-center rounded bg-muted">
759
780
  <DynamicIcon name={value} className="h-5 w-5" />
760
781
  </div>
761
782
  )
762
783
  }
784
+ if (stack) {
785
+ return (
786
+ <ImageStack
787
+ src={value ? String(value) : undefined}
788
+ label={label}
789
+ getImageUrl={getImageUrl}
790
+ size="md"
791
+ />
792
+ )
793
+ }
794
+ if (!value) return <span className="text-muted-foreground">-</span>
763
795
  return (
764
796
  <div className="h-10 w-10 relative rounded overflow-hidden bg-muted flex items-center justify-center">
765
797
  <img
@@ -916,6 +948,45 @@ export function makeDefaultGetDynamicColumns(
916
948
  return <ReferenceCell col={col} row={row.original} />
917
949
  }
918
950
 
951
+ // Landscape stack: wide image ON TOP, label UNDERNEATH.
952
+ // Declared via `display: "image_stack"` on an image column
953
+ // or on a belongs_to FK whose sibling carries a logo/photo
954
+ // (brand marks, product cards). Fits logos that are wider
955
+ // than tall without cropping into a square thumb.
956
+ if (renderAs === 'image_stack') {
957
+ // FK relation (brand_id → brands) OR any column that
958
+ // already resolved a sibling with an image — stack it.
959
+ // Don't require `col.ref` alone: enrichment sometimes
960
+ // leaves type=text while cellStyle carries image_stack.
961
+ const looksRelation =
962
+ !!col.ref ||
963
+ (typeof col.key === 'string' &&
964
+ col.key.endsWith('_id') &&
965
+ resolveRelationLabel(col, row.original) != null)
966
+ if (looksRelation) {
967
+ return (
968
+ <RelationCell
969
+ col={col}
970
+ row={row.original}
971
+ getImageUrl={getImageUrl}
972
+ stack
973
+ />
974
+ )
975
+ }
976
+ const labelField = styleCfg(col, 'label_field', 'labelField')
977
+ const caption = labelField
978
+ ? String(getNestedValue(row.original, labelField) ?? '')
979
+ : undefined
980
+ return (
981
+ <ImageCell
982
+ value={value}
983
+ getImageUrl={getImageUrl}
984
+ label={caption || undefined}
985
+ stack
986
+ />
987
+ )
988
+ }
989
+
919
990
  // Resolved FK relation chip. Triggers on an explicit
920
991
  // `cellStyle: 'relation'` or on any column carrying a `ref`
921
992
  // (a belongs_to FK) that isn't being rendered as an
@@ -1283,6 +1354,24 @@ export function makeDefaultGetDynamicColumns(
1283
1354
  return <ImageCell value={imageValue} getImageUrl={getImageUrl} />
1284
1355
  }
1285
1356
 
1357
+ case 'image_stack': {
1358
+ // Defensive: normally handled above before the
1359
+ // switch; kept so a late `type: image_stack` without
1360
+ // cellStyle still stacks.
1361
+ const labelField = styleCfg(col, 'label_field', 'labelField')
1362
+ const caption = labelField
1363
+ ? String(getNestedValue(row.original, labelField) ?? '')
1364
+ : undefined
1365
+ return (
1366
+ <ImageCell
1367
+ value={value}
1368
+ getImageUrl={getImageUrl}
1369
+ label={caption || undefined}
1370
+ stack
1371
+ />
1372
+ )
1373
+ }
1374
+
1286
1375
  default: {
1287
1376
  if (typeof value === 'object' && value !== null) {
1288
1377
  return (
@@ -22,6 +22,7 @@
22
22
  // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
23
  // case — start empty and never hit this.
24
24
  import { useEffect, useRef, useState } from 'react'
25
+ import { useTranslation } from 'react-i18next'
25
26
  import {
26
27
  Badge,
27
28
  Button,
@@ -40,6 +41,7 @@ import { Check, ChevronsUpDown, Loader2, Plus, ScanLine } from 'lucide-react'
40
41
  import { resolveColorCss } from '@asteby/metacore-ui/lib'
41
42
  import { BarcodeScanner } from './barcode-scanner'
42
43
  import { DynamicIcon, isLucideIconName } from './dynamic-icon'
44
+ import { useDebouncedValue } from './use-debounced-value'
43
45
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
44
46
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
45
47
  import type { ActionFieldDef } from './types'
@@ -140,12 +142,7 @@ export function OptionLead({
140
142
  }
141
143
 
142
144
  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
145
+ return useDebouncedValue(value, ms)
149
146
  }
150
147
 
151
148
  export interface DynamicSelectFieldProps {
@@ -209,6 +206,9 @@ export function DynamicSelectField({
209
206
  descriptionAsBadge = false,
210
207
  hideCreate = false,
211
208
  }: DynamicSelectFieldProps) {
209
+ const { t } = useTranslation()
210
+ const ph = (fallback: string) =>
211
+ field.placeholder ? t(field.placeholder, { defaultValue: field.placeholder }) : fallback
212
212
  const [open, setOpen] = useState(false)
213
213
  const [search, setSearch] = useState('')
214
214
  const [scanOpen, setScanOpen] = useState(false)
@@ -357,7 +357,7 @@ export function DynamicSelectField({
357
357
  <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
358
358
  {/* Never flash the raw id: until the eager fetch resolves the
359
359
  option, show a loading hint instead of String(value). */}
360
- {selectedOption?.label ?? (loading ? 'Cargando…' : field.placeholder || '—')}
360
+ {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
361
361
  </span>
362
362
  </span>
363
363
  </Button>
@@ -390,7 +390,7 @@ export function DynamicSelectField({
390
390
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
391
391
  {blockedByDependency
392
392
  ? (dependsHint || DEFAULT_DEPENDS_HINT)
393
- : selectedLabel || field.placeholder || 'Buscar…'}
393
+ : selectedLabel || ph('Buscar…')}
394
394
  </span>
395
395
  {descriptionAsBadge && selectedOption?.description ? (
396
396
  <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
@@ -410,7 +410,7 @@ export function DynamicSelectField({
410
410
  >
411
411
  <Command shouldFilter={false}>
412
412
  <CommandInput
413
- placeholder={field.placeholder || 'Buscar…'}
413
+ placeholder={ph('Buscar…')}
414
414
  value={search}
415
415
  onValueChange={setSearch}
416
416
  />
@@ -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
@@ -33,10 +33,36 @@ export interface PrintDocumentArgs {
33
33
  * open → open the PDF in a new tab (user prints from the viewer).
34
34
  */
35
35
  mode?: 'print' | 'download' | 'open'
36
- /** Filename for the download mode (defaults to "<key>.pdf"). */
36
+ /**
37
+ * Hint filename for download mode. Prefer leaving this unset: the server
38
+ * expands `{{record.*}}` into Content-Disposition. A raw template string
39
+ * (e.g. "cfdi-{{record.number}}.pdf") must NOT be used as a.download —
40
+ * that is how downloads end up literally named with mustache braces.
41
+ */
37
42
  filename?: string
38
43
  }
39
44
 
45
+ /** Parse filename from Content-Disposition (RFC 5987 / quoted). */
46
+ export function filenameFromContentDisposition(header: string | undefined | null): string | undefined {
47
+ if (!header) return undefined
48
+ const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
49
+ if (star?.[1]) {
50
+ try {
51
+ return decodeURIComponent(star[1].trim().replace(/^"|"$/g, ''))
52
+ } catch {
53
+ return star[1].trim().replace(/^"|"$/g, '')
54
+ }
55
+ }
56
+ const plain = /filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i.exec(header)
57
+ const raw = (plain?.[1] ?? plain?.[2] ?? '').trim()
58
+ return raw || undefined
59
+ }
60
+
61
+ /** True when a caller passed an unexpanded mustache template as filename. */
62
+ export function looksLikeFilenameTemplate(name: string | undefined): boolean {
63
+ return !!name && /\{\{/.test(name)
64
+ }
65
+
40
66
  /**
41
67
  * Returns a `printDocument(args)` callback. Resolves once the PDF has been
42
68
  * fetched and the browser action (print/download/open) has been kicked off;
@@ -65,9 +91,19 @@ export function usePrintDocument() {
65
91
  const cleanup = () => setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
66
92
 
67
93
  if (mode === 'download') {
94
+ const headers = (res as { headers?: Record<string, string> }).headers || {}
95
+ const fromHeader =
96
+ filenameFromContentDisposition(
97
+ headers['content-disposition'] || headers['Content-Disposition'],
98
+ ) || undefined
99
+ // Prefer server-expanded name; never use a raw {{record.*}} template.
100
+ const downloadName =
101
+ fromHeader ||
102
+ (!looksLikeFilenameTemplate(filename) ? filename : undefined) ||
103
+ `${key}.pdf`
68
104
  const a = document.createElement('a')
69
105
  a.href = blobUrl
70
- a.download = filename || `${key}.pdf`
106
+ a.download = downloadName
71
107
  document.body.appendChild(a)
72
108
  a.click()
73
109
  a.remove()