@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.
- package/CHANGELOG.md +5 -84
- package/dist/action-modal-dispatcher.d.ts +4 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +102 -23
- package/dist/addon-loader.d.ts +1 -1
- package/dist/addon-loader.d.ts.map +1 -1
- package/dist/addon-loader.js +8 -1
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +107 -52
- 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 +51 -6
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +8 -9
- 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/dist/use-print-document.d.ts +10 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +28 -1
- package/package.json +1 -1
- package/src/__tests__/filename-from-disposition.test.ts +30 -0
- package/src/__tests__/image-stack.test.tsx +52 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +37 -0
- package/src/__tests__/record-detail-display.test.tsx +30 -0
- package/src/__tests__/use-debounced-value.test.ts +56 -0
- package/src/action-modal-dispatcher.tsx +110 -22
- package/src/addon-loader.tsx +7 -1
- package/src/dialogs/dynamic-record.tsx +167 -51
- package/src/display-value.tsx +82 -4
- package/src/dynamic-columns.tsx +94 -5
- package/src/dynamic-select-field.tsx +9 -9
- 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
- package/src/use-print-document.ts +38 -2
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
|
@@ -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
|
-
|
|
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
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
-
|
|
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…' :
|
|
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 ||
|
|
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={
|
|
413
|
+
placeholder={ph('Buscar…')}
|
|
414
414
|
value={search}
|
|
415
415
|
onValueChange={setSearch}
|
|
416
416
|
/>
|
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
|
|
@@ -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
|
-
/**
|
|
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 =
|
|
106
|
+
a.download = downloadName
|
|
71
107
|
document.body.appendChild(a)
|
|
72
108
|
a.click()
|
|
73
109
|
a.remove()
|