@asteby/metacore-runtime-react 34.0.1 → 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 +12 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +19 -2
- package/dist/dialogs/import.d.ts.map +1 -1
- package/dist/dialogs/import.js +6 -10
- 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/file-pick-button.d.ts +21 -0
- package/dist/file-pick-button.d.ts.map +1 -0
- package/dist/file-pick-button.js +39 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -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/upload-field.d.ts.map +1 -1
- package/dist/upload-field.js +27 -32
- 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/dialogs/import.tsx +9 -15
- 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/file-pick-button.tsx +125 -0
- package/src/index.ts +11 -0
- package/src/metadata-cache.ts +8 -1
- package/src/types.ts +2 -0
- package/src/upload-field.tsx +41 -71
- package/src/use-debounced-value.ts +17 -0
- package/src/use-dynamic-filters.ts +6 -4
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
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// FilePickButton — locale-aware file picker. Native <input type="file"> always
|
|
2
|
+
// paints the browser/OS chrome ("Choose File" / "No file chosen") in the
|
|
3
|
+
// *browser* language, not the app language — so Spanish UIs on an English
|
|
4
|
+
// Chrome show English. Hide the native control and drive it from a Button
|
|
5
|
+
// whose labels come from i18n (common.upload.*).
|
|
6
|
+
import { useCallback, useId, useRef } from 'react'
|
|
7
|
+
import { useTranslation } from 'react-i18next'
|
|
8
|
+
import { Button } from '@asteby/metacore-ui/primitives'
|
|
9
|
+
import { Loader2, Paperclip, X } from 'lucide-react'
|
|
10
|
+
|
|
11
|
+
export interface FilePickButtonProps {
|
|
12
|
+
/** Called with the picked File, or null when cleared. */
|
|
13
|
+
onFile: (file: File | null) => void
|
|
14
|
+
accept?: string
|
|
15
|
+
/** HTML capture attribute (e.g. "environment" for rear camera). */
|
|
16
|
+
capture?: boolean | 'user' | 'environment'
|
|
17
|
+
disabled?: boolean
|
|
18
|
+
loading?: boolean
|
|
19
|
+
/** When true, shows replace + clear affordances. */
|
|
20
|
+
hasFile?: boolean
|
|
21
|
+
/** Optional display name next to the button when hasFile. */
|
|
22
|
+
fileName?: string
|
|
23
|
+
className?: string
|
|
24
|
+
/** Override the empty-state pick label (still falls back to i18n). */
|
|
25
|
+
pickLabel?: string
|
|
26
|
+
replaceLabel?: string
|
|
27
|
+
clearLabel?: string
|
|
28
|
+
id?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function FilePickButton({
|
|
32
|
+
onFile,
|
|
33
|
+
accept,
|
|
34
|
+
capture,
|
|
35
|
+
disabled,
|
|
36
|
+
loading,
|
|
37
|
+
hasFile,
|
|
38
|
+
fileName,
|
|
39
|
+
className,
|
|
40
|
+
pickLabel,
|
|
41
|
+
replaceLabel,
|
|
42
|
+
clearLabel,
|
|
43
|
+
id: idProp,
|
|
44
|
+
}: FilePickButtonProps) {
|
|
45
|
+
const { t } = useTranslation()
|
|
46
|
+
const autoId = useId()
|
|
47
|
+
const id = idProp || autoId
|
|
48
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
49
|
+
const busy = Boolean(disabled || loading)
|
|
50
|
+
|
|
51
|
+
const pick = useCallback(() => {
|
|
52
|
+
if (busy) return
|
|
53
|
+
inputRef.current?.click()
|
|
54
|
+
}, [busy])
|
|
55
|
+
|
|
56
|
+
const onChange = useCallback(
|
|
57
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
58
|
+
const file = e.target.files?.[0] ?? null
|
|
59
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
60
|
+
if (file) onFile(file)
|
|
61
|
+
},
|
|
62
|
+
[onFile],
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const onClear = useCallback(() => {
|
|
66
|
+
if (busy) return
|
|
67
|
+
onFile(null)
|
|
68
|
+
}, [busy, onFile])
|
|
69
|
+
|
|
70
|
+
const emptyLabel =
|
|
71
|
+
pickLabel || t('common.upload.choose', { defaultValue: 'Choose file' })
|
|
72
|
+
const filledLabel =
|
|
73
|
+
replaceLabel || t('common.upload.replace', { defaultValue: 'Replace' })
|
|
74
|
+
const removeLabel =
|
|
75
|
+
clearLabel || t('common.upload.remove', { defaultValue: 'Remove file' })
|
|
76
|
+
const emptyHint = t('common.upload.none', { defaultValue: 'No file chosen' })
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className={className ? `grid gap-1.5 ${className}` : 'grid gap-1.5'} data-widget="file-pick">
|
|
80
|
+
<input
|
|
81
|
+
ref={inputRef}
|
|
82
|
+
id={id}
|
|
83
|
+
type="file"
|
|
84
|
+
accept={accept}
|
|
85
|
+
{...(capture !== undefined ? { capture } : {})}
|
|
86
|
+
className="sr-only"
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
tabIndex={-1}
|
|
89
|
+
aria-hidden="true"
|
|
90
|
+
/>
|
|
91
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
92
|
+
<Button type="button" variant="outline" size="sm" onClick={pick} disabled={busy}>
|
|
93
|
+
{loading ? (
|
|
94
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
95
|
+
) : (
|
|
96
|
+
<Paperclip className="mr-2 h-4 w-4" />
|
|
97
|
+
)}
|
|
98
|
+
{hasFile ? filledLabel : emptyLabel}
|
|
99
|
+
</Button>
|
|
100
|
+
{hasFile ? (
|
|
101
|
+
<div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
|
102
|
+
{fileName ? (
|
|
103
|
+
<span className="truncate" title={fileName}>
|
|
104
|
+
{fileName}
|
|
105
|
+
</span>
|
|
106
|
+
) : null}
|
|
107
|
+
<Button
|
|
108
|
+
type="button"
|
|
109
|
+
variant="ghost"
|
|
110
|
+
size="sm"
|
|
111
|
+
className="h-6 w-6 p-0"
|
|
112
|
+
onClick={onClear}
|
|
113
|
+
disabled={busy}
|
|
114
|
+
aria-label={removeLabel}
|
|
115
|
+
>
|
|
116
|
+
<X className="h-3.5 w-3.5" />
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
) : (
|
|
120
|
+
<span className="text-xs text-muted-foreground">{emptyHint}</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
)
|
|
125
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -119,7 +119,12 @@ 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'
|
|
124
|
+
export {
|
|
125
|
+
FilePickButton,
|
|
126
|
+
type FilePickButtonProps,
|
|
127
|
+
} from './file-pick-button'
|
|
123
128
|
export * from './barcode-scanner'
|
|
124
129
|
export * from './form-layout'
|
|
125
130
|
export * from './form-layout-ui'
|
|
@@ -261,6 +266,12 @@ export {
|
|
|
261
266
|
type UrlKind,
|
|
262
267
|
type LinkifyOptions,
|
|
263
268
|
} from './rich-url'
|
|
269
|
+
export {
|
|
270
|
+
ImageStack,
|
|
271
|
+
OptionBadge,
|
|
272
|
+
statusColorFor,
|
|
273
|
+
useIsDarkTheme,
|
|
274
|
+
} from './display-value'
|
|
264
275
|
export {
|
|
265
276
|
CollectionCell,
|
|
266
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'
|
package/src/upload-field.tsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// UploadField — the `upload` widget renderer shared by DynamicForm's
|
|
2
2
|
// FieldRenderer and the action-modal-dispatcher's renderField so the two stay
|
|
3
|
-
// in lockstep. Renders a
|
|
4
|
-
// POSTs the picked file to the host upload endpoint as
|
|
5
|
-
// stores the returned file url/path as the field value.
|
|
3
|
+
// in lockstep. Renders a locale-aware FilePickButton that proxies a hidden
|
|
4
|
+
// <input type=file>, POSTs the picked file to the host upload endpoint as
|
|
5
|
+
// multipart/form-data, and stores the returned file url/path as the field value.
|
|
6
6
|
//
|
|
7
7
|
// Endpoint assumption: `POST /uploads` (multipart) returning
|
|
8
8
|
// { success: true, data: { file_url?, url?, path?, file_path? } }
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
// `field.searchEndpoint` (reused as the upload endpoint escape hatch) — kept
|
|
11
11
|
// generic so this carries no host-specific route. Honors field.accept /
|
|
12
12
|
// field.maxSize and forwards field.storagePath as `storage_path`.
|
|
13
|
-
import { useCallback,
|
|
14
|
-
import {
|
|
15
|
-
import { Loader2, Paperclip, X } from 'lucide-react'
|
|
16
|
-
import { useApi } from './api-context'
|
|
13
|
+
import { useCallback, useState } from 'react'
|
|
14
|
+
import { useTranslation } from 'react-i18next'
|
|
17
15
|
import { getUploadConfig } from './dynamic-form-schema'
|
|
16
|
+
import { FilePickButton } from './file-pick-button'
|
|
17
|
+
import { useApi } from './api-context'
|
|
18
18
|
import type { ActionFieldDef } from './types'
|
|
19
19
|
|
|
20
20
|
export interface UploadFieldProps {
|
|
@@ -52,29 +52,31 @@ export function uploadedDisplayName(value: unknown): string {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function UploadField({ field, value, onChange }: UploadFieldProps) {
|
|
55
|
+
const { t } = useTranslation()
|
|
55
56
|
const api = useApi()
|
|
56
|
-
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
57
57
|
const [uploading, setUploading] = useState(false)
|
|
58
58
|
const [error, setError] = useState<string | null>(null)
|
|
59
59
|
|
|
60
60
|
const { accept, maxSize, storagePath } = getUploadConfig(field)
|
|
61
61
|
const endpoint = field.searchEndpoint || DEFAULT_UPLOAD_ENDPOINT
|
|
62
|
-
|
|
63
|
-
const handlePick = useCallback(() => {
|
|
64
|
-
if (uploading) return
|
|
65
|
-
inputRef.current?.click()
|
|
66
|
-
}, [uploading])
|
|
62
|
+
const hasValue = typeof value === 'string' && value !== ''
|
|
67
63
|
|
|
68
64
|
const handleFile = useCallback(
|
|
69
|
-
async (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
async (file: File | null) => {
|
|
66
|
+
if (!file) {
|
|
67
|
+
setError(null)
|
|
68
|
+
onChange('')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
74
71
|
setError(null)
|
|
75
72
|
if (maxSize && file.size > maxSize) {
|
|
76
73
|
const mb = (maxSize / (1024 * 1024)).toFixed(1)
|
|
77
|
-
setError(
|
|
74
|
+
setError(
|
|
75
|
+
t('common.upload.too_large', {
|
|
76
|
+
mb,
|
|
77
|
+
defaultValue: `File too large (max {{mb}} MB).`,
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
78
80
|
return
|
|
79
81
|
}
|
|
80
82
|
const form = new FormData()
|
|
@@ -94,77 +96,45 @@ export function UploadField({ field, value, onChange }: UploadFieldProps) {
|
|
|
94
96
|
})
|
|
95
97
|
const body = (res as { data?: any })?.data
|
|
96
98
|
if (body && body.success === false) {
|
|
97
|
-
setError(
|
|
99
|
+
setError(
|
|
100
|
+
body.message ||
|
|
101
|
+
t('common.upload.failed', { defaultValue: 'Could not upload the file.' }),
|
|
102
|
+
)
|
|
98
103
|
return
|
|
99
104
|
}
|
|
100
105
|
const stored = extractUploadedValue(body)
|
|
101
106
|
if (!stored) {
|
|
102
|
-
setError(
|
|
107
|
+
setError(
|
|
108
|
+
t('common.upload.invalid', {
|
|
109
|
+
defaultValue: 'Invalid upload response.',
|
|
110
|
+
}),
|
|
111
|
+
)
|
|
103
112
|
return
|
|
104
113
|
}
|
|
105
114
|
onChange(stored)
|
|
106
115
|
} catch (err: any) {
|
|
107
|
-
setError(
|
|
116
|
+
setError(
|
|
117
|
+
err?.response?.data?.message ||
|
|
118
|
+
t('common.upload.failed', { defaultValue: 'Could not upload the file.' }),
|
|
119
|
+
)
|
|
108
120
|
} finally {
|
|
109
121
|
setUploading(false)
|
|
110
122
|
}
|
|
111
123
|
},
|
|
112
|
-
[api, endpoint, maxSize, storagePath, onChange],
|
|
124
|
+
[api, endpoint, maxSize, storagePath, onChange, t],
|
|
113
125
|
)
|
|
114
126
|
|
|
115
|
-
const handleClear = useCallback(() => {
|
|
116
|
-
if (uploading) return
|
|
117
|
-
setError(null)
|
|
118
|
-
onChange('')
|
|
119
|
-
}, [uploading, onChange])
|
|
120
|
-
|
|
121
|
-
const hasValue = typeof value === 'string' && value !== ''
|
|
122
|
-
|
|
123
127
|
return (
|
|
124
128
|
<div className="grid gap-1.5" data-widget="upload">
|
|
125
|
-
<
|
|
126
|
-
ref={inputRef}
|
|
129
|
+
<FilePickButton
|
|
127
130
|
id={field.key}
|
|
128
|
-
type="file"
|
|
129
131
|
accept={accept}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
132
|
+
loading={uploading}
|
|
133
|
+
hasFile={hasValue}
|
|
134
|
+
fileName={hasValue ? uploadedDisplayName(value) : undefined}
|
|
135
|
+
onFile={(f) => void handleFile(f)}
|
|
136
|
+
pickLabel={field.placeholder || undefined}
|
|
134
137
|
/>
|
|
135
|
-
<div className="flex items-center gap-2">
|
|
136
|
-
<Button
|
|
137
|
-
type="button"
|
|
138
|
-
variant="outline"
|
|
139
|
-
size="sm"
|
|
140
|
-
onClick={handlePick}
|
|
141
|
-
disabled={uploading}
|
|
142
|
-
>
|
|
143
|
-
{uploading ? (
|
|
144
|
-
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
145
|
-
) : (
|
|
146
|
-
<Paperclip className="mr-2 h-4 w-4" />
|
|
147
|
-
)}
|
|
148
|
-
{hasValue ? 'Reemplazar' : field.placeholder || 'Subir archivo'}
|
|
149
|
-
</Button>
|
|
150
|
-
{hasValue && !uploading && (
|
|
151
|
-
<div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
|
152
|
-
<span className="truncate" title={String(value)}>
|
|
153
|
-
{uploadedDisplayName(value)}
|
|
154
|
-
</span>
|
|
155
|
-
<Button
|
|
156
|
-
type="button"
|
|
157
|
-
variant="ghost"
|
|
158
|
-
size="sm"
|
|
159
|
-
className="h-6 w-6 p-0"
|
|
160
|
-
onClick={handleClear}
|
|
161
|
-
aria-label="Quitar archivo"
|
|
162
|
-
>
|
|
163
|
-
<X className="h-3.5 w-3.5" />
|
|
164
|
-
</Button>
|
|
165
|
-
</div>
|
|
166
|
-
)}
|
|
167
|
-
</div>
|
|
168
138
|
{error && (
|
|
169
139
|
<span className="text-sm text-destructive" role="alert">
|
|
170
140
|
{error}
|