@asteby/metacore-runtime-react 13.5.2 → 13.6.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 +31 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +6 -0
- package/dist/dynamic-form-schema.d.ts +10 -0
- package/dist/dynamic-form-schema.d.ts.map +1 -1
- package/dist/dynamic-form-schema.js +21 -0
- package/dist/dynamic-form.d.ts +1 -0
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +7 -0
- package/dist/dynamic-relation-helpers.d.ts +1 -1
- package/dist/dynamic-relation-helpers.d.ts.map +1 -1
- package/dist/dynamic-relation-helpers.js +17 -2
- package/dist/dynamic-relation.d.ts +8 -0
- package/dist/dynamic-relation.d.ts.map +1 -1
- package/dist/dynamic-relation.js +26 -12
- package/dist/dynamic-relations.d.ts +51 -0
- package/dist/dynamic-relations.d.ts.map +1 -0
- package/dist/dynamic-relations.js +76 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/types.d.ts +57 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/upload-field.d.ts +15 -0
- package/dist/upload-field.d.ts.map +1 -0
- package/dist/upload-field.js +109 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-relation.test.ts +28 -0
- package/src/__tests__/dynamic-relations.test.ts +60 -0
- package/src/__tests__/upload-field.test.ts +74 -0
- package/src/action-modal-dispatcher.tsx +6 -0
- package/src/dynamic-form-schema.ts +27 -0
- package/src/dynamic-form.tsx +7 -0
- package/src/dynamic-relation-helpers.ts +15 -1
- package/src/dynamic-relation.tsx +35 -10
- package/src/dynamic-relations.tsx +160 -0
- package/src/index.ts +6 -0
- package/src/types.ts +58 -0
- package/src/upload-field.tsx +168 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// DynamicRelations — metadata-driven panel list. Given a parent record and the
|
|
2
|
+
// `TableMetadata.relations[]` the kernel serves (>= v0.41.0), it renders one
|
|
3
|
+
// `<DynamicRelation>` panel per relation. This is what a generic detail page
|
|
4
|
+
// renders to surface "a Customer's vehicles, addresses, attachments" without
|
|
5
|
+
// hand-wiring each child list.
|
|
6
|
+
//
|
|
7
|
+
// For every RelationMeta it:
|
|
8
|
+
// - maps `kind` straight through (one_to_many | many_to_many),
|
|
9
|
+
// - uses `through` as the child/pivot model and `foreign_key` as the FK,
|
|
10
|
+
// - merges the relation's static `scope` (polymorphic discriminators, e.g.
|
|
11
|
+
// { owner_model: "Customer" }) into the panel's `filters` so the child list
|
|
12
|
+
// is scoped by the FK AND every scope column.
|
|
13
|
+
import { useMemo } from 'react'
|
|
14
|
+
import { DynamicRelation, type DynamicRelationStrings } from './dynamic-relation'
|
|
15
|
+
import type { RelationMeta } from './types'
|
|
16
|
+
|
|
17
|
+
export interface DynamicRelationsProps {
|
|
18
|
+
/**
|
|
19
|
+
* The parent record. Its `id` (or `parentIdKey`) seeds every child list's
|
|
20
|
+
* foreign-key filter. Null/undefined → renders nothing (loading guard).
|
|
21
|
+
*/
|
|
22
|
+
record: { id?: string | number; [k: string]: unknown } | null | undefined
|
|
23
|
+
/** The relations to render — typically `metadata.relations`. */
|
|
24
|
+
relations: RelationMeta[] | null | undefined
|
|
25
|
+
/**
|
|
26
|
+
* Which field of `record` holds the parent id. Default `'id'`. Lets a host
|
|
27
|
+
* key relations off a non-`id` primary key.
|
|
28
|
+
*/
|
|
29
|
+
parentIdKey?: string
|
|
30
|
+
/** Wrapper className for the whole stack. */
|
|
31
|
+
className?: string
|
|
32
|
+
/** Per-panel wrapper className. */
|
|
33
|
+
panelClassName?: string
|
|
34
|
+
/**
|
|
35
|
+
* Permisos propagados a cada panel. Default true. A host can lock the whole
|
|
36
|
+
* detail page read-only by passing canCreate/canDelete/canEdit = false.
|
|
37
|
+
*/
|
|
38
|
+
canCreate?: boolean
|
|
39
|
+
canDelete?: boolean
|
|
40
|
+
canEdit?: boolean
|
|
41
|
+
/** Translatable strings forwarded to each DynamicRelation. */
|
|
42
|
+
strings?: Partial<DynamicRelationStrings>
|
|
43
|
+
/** Bubble up when any panel's data changes (create/delete/attach/detach). */
|
|
44
|
+
onChange?: (relation: RelationMeta) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalizes the parent id off the record, tolerating a custom `parentIdKey`.
|
|
49
|
+
* Returns `undefined` when unusable so callers can guard rendering.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveParentId(
|
|
52
|
+
record: { [k: string]: unknown } | null | undefined,
|
|
53
|
+
parentIdKey = 'id',
|
|
54
|
+
): string | number | undefined {
|
|
55
|
+
if (!record) return undefined
|
|
56
|
+
const raw = record[parentIdKey]
|
|
57
|
+
if (raw === undefined || raw === null || raw === '') return undefined
|
|
58
|
+
if (typeof raw === 'number' || typeof raw === 'string') return raw
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Merges a relation's static `scope` with its foreign-key entry into the flat
|
|
64
|
+
* `filters` map `<DynamicRelation>` expects. The FK is included so a panel that
|
|
65
|
+
* only consumes `filters` (rather than the dedicated `foreignKey` prop) stays
|
|
66
|
+
* correctly scoped; `<DynamicRelation>` already de-dups the FK so passing it in
|
|
67
|
+
* both places is safe.
|
|
68
|
+
*/
|
|
69
|
+
export function buildRelationFilters(
|
|
70
|
+
relation: Pick<RelationMeta, 'foreign_key' | 'scope'>,
|
|
71
|
+
parentId: string | number,
|
|
72
|
+
): Record<string, string> {
|
|
73
|
+
const out: Record<string, string> = {}
|
|
74
|
+
if (relation.scope) {
|
|
75
|
+
for (const [k, v] of Object.entries(relation.scope)) {
|
|
76
|
+
if (!k || v === undefined || v === null) continue
|
|
77
|
+
out[k] = String(v)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (relation.foreign_key) out[relation.foreign_key] = String(parentId)
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Stable React key for a relation panel. */
|
|
85
|
+
function relationKey(rel: RelationMeta, idx: number): string {
|
|
86
|
+
return rel.name || `${rel.through}-${rel.foreign_key}-${idx}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function DynamicRelations({
|
|
90
|
+
record,
|
|
91
|
+
relations,
|
|
92
|
+
parentIdKey = 'id',
|
|
93
|
+
className,
|
|
94
|
+
panelClassName,
|
|
95
|
+
canCreate = true,
|
|
96
|
+
canDelete = true,
|
|
97
|
+
canEdit = true,
|
|
98
|
+
strings,
|
|
99
|
+
onChange,
|
|
100
|
+
}: DynamicRelationsProps) {
|
|
101
|
+
const parentId = useMemo(
|
|
102
|
+
() => resolveParentId(record, parentIdKey),
|
|
103
|
+
[record, parentIdKey],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if (parentId === undefined || !relations || relations.length === 0) {
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={className} data-dynamic-relations="">
|
|
112
|
+
{relations.map((rel, idx) => {
|
|
113
|
+
const filters = buildRelationFilters(rel, parentId)
|
|
114
|
+
const panelStrings: Partial<DynamicRelationStrings> = {
|
|
115
|
+
...(strings || {}),
|
|
116
|
+
...(rel.label ? { title: rel.label } : {}),
|
|
117
|
+
}
|
|
118
|
+
if (rel.kind === 'many_to_many') {
|
|
119
|
+
return (
|
|
120
|
+
<DynamicRelation
|
|
121
|
+
key={relationKey(rel, idx)}
|
|
122
|
+
kind="many_to_many"
|
|
123
|
+
through={rel.through}
|
|
124
|
+
// The pivot's reference table is unknown from
|
|
125
|
+
// RelationMeta alone; default to the through model so
|
|
126
|
+
// the panel degrades to the pivot list. Hosts with a
|
|
127
|
+
// declared `references` should render DynamicRelation
|
|
128
|
+
// directly. (one_to_many is the common detail-page case.)
|
|
129
|
+
references={rel.through}
|
|
130
|
+
foreignKey={rel.foreign_key}
|
|
131
|
+
parentId={parentId}
|
|
132
|
+
filters={filters}
|
|
133
|
+
className={panelClassName}
|
|
134
|
+
canCreate={canCreate}
|
|
135
|
+
canDelete={canDelete}
|
|
136
|
+
strings={panelStrings}
|
|
137
|
+
onChange={onChange ? () => onChange(rel) : undefined}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<DynamicRelation
|
|
143
|
+
key={relationKey(rel, idx)}
|
|
144
|
+
kind="one_to_many"
|
|
145
|
+
model={rel.through}
|
|
146
|
+
foreignKey={rel.foreign_key}
|
|
147
|
+
parentId={parentId}
|
|
148
|
+
filters={filters}
|
|
149
|
+
className={panelClassName}
|
|
150
|
+
canCreate={canCreate}
|
|
151
|
+
canDelete={canDelete}
|
|
152
|
+
canEdit={canEdit}
|
|
153
|
+
strings={panelStrings}
|
|
154
|
+
onChange={onChange ? () => onChange(rel) : undefined}
|
|
155
|
+
/>
|
|
156
|
+
)
|
|
157
|
+
})}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
160
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,12 @@ export {
|
|
|
93
93
|
deriveRelationFormFields,
|
|
94
94
|
relationRowKey,
|
|
95
95
|
} from './dynamic-relation'
|
|
96
|
+
export {
|
|
97
|
+
DynamicRelations,
|
|
98
|
+
resolveParentId,
|
|
99
|
+
buildRelationFilters,
|
|
100
|
+
type DynamicRelationsProps,
|
|
101
|
+
} from './dynamic-relations'
|
|
96
102
|
export {
|
|
97
103
|
registerModelExtension,
|
|
98
104
|
getModelExtension,
|
package/src/types.ts
CHANGED
|
@@ -15,6 +15,42 @@ export interface TableMetadata {
|
|
|
15
15
|
canExport?: boolean
|
|
16
16
|
canImport?: boolean
|
|
17
17
|
canCreate?: boolean
|
|
18
|
+
/**
|
|
19
|
+
* Child relations of this model, served by the kernel (>= v0.41.0). A
|
|
20
|
+
* generic detail page renders one `DynamicRelation` panel per entry via
|
|
21
|
+
* `<DynamicRelations>` to surface, e.g., a Customer's vehicles, addresses
|
|
22
|
+
* and attachments. Absent on hosts/older kernels — purely additive.
|
|
23
|
+
*/
|
|
24
|
+
relations?: RelationMeta[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Describes one child relation of a parent model, mirroring the kernel
|
|
29
|
+
* `RelationMeta` shape (>= v0.41.0). Drives the metadata-driven
|
|
30
|
+
* `<DynamicRelations>` panel list. All keys are snake_case as served by the
|
|
31
|
+
* kernel; the SDK reads them as-is.
|
|
32
|
+
*/
|
|
33
|
+
export interface RelationMeta {
|
|
34
|
+
/** Stable identifier for the relation (used as a React key / data attr). */
|
|
35
|
+
name: string
|
|
36
|
+
/** Cardinality. The SDK maps this onto `DynamicRelation.kind`. */
|
|
37
|
+
kind: 'one_to_many' | 'many_to_many'
|
|
38
|
+
/**
|
|
39
|
+
* Child model key (the `through` model). For one_to_many this is the model
|
|
40
|
+
* whose rows are listed; for many_to_many it is the pivot table.
|
|
41
|
+
*/
|
|
42
|
+
through: string
|
|
43
|
+
/** Child column holding the FK back to the parent. */
|
|
44
|
+
foreign_key: string
|
|
45
|
+
/**
|
|
46
|
+
* Static equality filters applied on top of the foreign-key scope. Used for
|
|
47
|
+
* polymorphic children (e.g. `{ "owner_model": "Customer" }`) so a shared
|
|
48
|
+
* attachments/addresses table is narrowed to this parent's rows. Each entry
|
|
49
|
+
* becomes a `f_<col>=eq:<val>` query param.
|
|
50
|
+
*/
|
|
51
|
+
scope?: Record<string, string>
|
|
52
|
+
/** Human-readable panel header. */
|
|
53
|
+
label?: string
|
|
18
54
|
}
|
|
19
55
|
|
|
20
56
|
export interface FilterDefinition {
|
|
@@ -118,6 +154,7 @@ export type FieldWidget =
|
|
|
118
154
|
| 'select'
|
|
119
155
|
| 'dynamic_select'
|
|
120
156
|
| 'switch'
|
|
157
|
+
| 'upload'
|
|
121
158
|
|
|
122
159
|
export interface ActionFieldDef {
|
|
123
160
|
key: string
|
|
@@ -161,6 +198,27 @@ export interface ActionFieldDef {
|
|
|
161
198
|
* reconcile. Mirrors kernel v3 `ActionField.balance`.
|
|
162
199
|
*/
|
|
163
200
|
balance?: FieldBalanceRule
|
|
201
|
+
/**
|
|
202
|
+
* `upload` widget: comma-separated accept list forwarded to the file input
|
|
203
|
+
* `accept` attribute (e.g. `"image/*,.pdf"`). Tolerates the snake_case the
|
|
204
|
+
* kernel may serve. Optional — when absent any file type is allowed.
|
|
205
|
+
*/
|
|
206
|
+
accept?: string
|
|
207
|
+
/**
|
|
208
|
+
* `upload` widget: maximum file size in bytes. The renderer rejects larger
|
|
209
|
+
* files client-side before POSTing. Tolerates kernel snake_case `max_size`.
|
|
210
|
+
*/
|
|
211
|
+
maxSize?: number
|
|
212
|
+
/** snake_case alias served by the kernel manifest for `maxSize`. */
|
|
213
|
+
max_size?: number
|
|
214
|
+
/**
|
|
215
|
+
* `upload` widget: server-side storage bucket/prefix the host writes the
|
|
216
|
+
* file under, forwarded to the upload endpoint as `storage_path`. Tolerates
|
|
217
|
+
* kernel snake_case `storage_path`.
|
|
218
|
+
*/
|
|
219
|
+
storagePath?: string
|
|
220
|
+
/** snake_case alias served by the kernel manifest for `storagePath`. */
|
|
221
|
+
storage_path?: string
|
|
164
222
|
}
|
|
165
223
|
|
|
166
224
|
/**
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// UploadField — the `upload` widget renderer shared by DynamicForm's
|
|
2
|
+
// FieldRenderer and the action-modal-dispatcher's renderField so the two stay
|
|
3
|
+
// in lockstep. Renders a themed Button that proxies a hidden <input type=file>,
|
|
4
|
+
// POSTs the picked file to the host upload endpoint as multipart/form-data, and
|
|
5
|
+
// stores the returned file url/path as the field value.
|
|
6
|
+
//
|
|
7
|
+
// Endpoint assumption: `POST /uploads` (multipart) returning
|
|
8
|
+
// { success: true, data: { file_url?, url?, path?, file_path? } }
|
|
9
|
+
// matching the kernel envelope. A field may override the path via
|
|
10
|
+
// `field.searchEndpoint` (reused as the upload endpoint escape hatch) — kept
|
|
11
|
+
// generic so this carries no host-specific route. Honors field.accept /
|
|
12
|
+
// field.maxSize and forwards field.storagePath as `storage_path`.
|
|
13
|
+
import { useCallback, useRef, useState } from 'react'
|
|
14
|
+
import { Button } from '@asteby/metacore-ui/primitives'
|
|
15
|
+
import { Loader2, Paperclip, X } from 'lucide-react'
|
|
16
|
+
import { useApi } from './api-context'
|
|
17
|
+
import { getUploadConfig } from './dynamic-form-schema'
|
|
18
|
+
import type { ActionFieldDef } from './types'
|
|
19
|
+
|
|
20
|
+
export interface UploadFieldProps {
|
|
21
|
+
field: ActionFieldDef
|
|
22
|
+
value: any
|
|
23
|
+
onChange: (v: any) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Default host upload endpoint. Overridable per-field via `searchEndpoint`. */
|
|
27
|
+
const DEFAULT_UPLOAD_ENDPOINT = '/uploads'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Pulls the stored file url/path out of an upload response envelope, tolerating
|
|
31
|
+
* the common key shapes a host might return. Pure — exported for tests.
|
|
32
|
+
*/
|
|
33
|
+
export function extractUploadedValue(payload: any): string {
|
|
34
|
+
if (payload === null || payload === undefined) return ''
|
|
35
|
+
if (typeof payload === 'string') return payload
|
|
36
|
+
const d = (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) ?? payload
|
|
37
|
+
if (typeof d === 'string') return d
|
|
38
|
+
if (d && typeof d === 'object') {
|
|
39
|
+
const candidate =
|
|
40
|
+
d.file_url ?? d.fileUrl ?? d.url ?? d.file_path ?? d.filePath ?? d.path
|
|
41
|
+
if (typeof candidate === 'string') return candidate
|
|
42
|
+
}
|
|
43
|
+
return ''
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Short, human display name for an already-stored file value (a url/path). */
|
|
47
|
+
export function uploadedDisplayName(value: unknown): string {
|
|
48
|
+
if (typeof value !== 'string' || value === '') return ''
|
|
49
|
+
const cleaned = value.split('?')[0]
|
|
50
|
+
const parts = cleaned.split('/')
|
|
51
|
+
return parts[parts.length - 1] || cleaned
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function UploadField({ field, value, onChange }: UploadFieldProps) {
|
|
55
|
+
const api = useApi()
|
|
56
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
57
|
+
const [uploading, setUploading] = useState(false)
|
|
58
|
+
const [error, setError] = useState<string | null>(null)
|
|
59
|
+
|
|
60
|
+
const { accept, maxSize, storagePath } = getUploadConfig(field)
|
|
61
|
+
const endpoint = field.searchEndpoint || DEFAULT_UPLOAD_ENDPOINT
|
|
62
|
+
|
|
63
|
+
const handlePick = useCallback(() => {
|
|
64
|
+
if (uploading) return
|
|
65
|
+
inputRef.current?.click()
|
|
66
|
+
}, [uploading])
|
|
67
|
+
|
|
68
|
+
const handleFile = useCallback(
|
|
69
|
+
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
70
|
+
const file = e.target.files?.[0]
|
|
71
|
+
// Reset the input so picking the same file again re-fires change.
|
|
72
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
73
|
+
if (!file) return
|
|
74
|
+
setError(null)
|
|
75
|
+
if (maxSize && file.size > maxSize) {
|
|
76
|
+
const mb = (maxSize / (1024 * 1024)).toFixed(1)
|
|
77
|
+
setError(`Archivo muy grande (máx. ${mb} MB).`)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const form = new FormData()
|
|
81
|
+
form.append('file', file)
|
|
82
|
+
if (storagePath) form.append('storage_path', storagePath)
|
|
83
|
+
setUploading(true)
|
|
84
|
+
try {
|
|
85
|
+
const res = await api.post(endpoint, form, {
|
|
86
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
87
|
+
})
|
|
88
|
+
const body = (res as { data?: any })?.data
|
|
89
|
+
if (body && body.success === false) {
|
|
90
|
+
setError(body.message || 'No se pudo subir el archivo.')
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const stored = extractUploadedValue(body)
|
|
94
|
+
if (!stored) {
|
|
95
|
+
setError('Respuesta de subida inválida.')
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
onChange(stored)
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
setError(err?.response?.data?.message || 'No se pudo subir el archivo.')
|
|
101
|
+
} finally {
|
|
102
|
+
setUploading(false)
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
[api, endpoint, maxSize, storagePath, onChange],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const handleClear = useCallback(() => {
|
|
109
|
+
if (uploading) return
|
|
110
|
+
setError(null)
|
|
111
|
+
onChange('')
|
|
112
|
+
}, [uploading, onChange])
|
|
113
|
+
|
|
114
|
+
const hasValue = typeof value === 'string' && value !== ''
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="grid gap-1.5" data-widget="upload">
|
|
118
|
+
<input
|
|
119
|
+
ref={inputRef}
|
|
120
|
+
id={field.key}
|
|
121
|
+
type="file"
|
|
122
|
+
accept={accept}
|
|
123
|
+
className="sr-only"
|
|
124
|
+
onChange={handleFile}
|
|
125
|
+
tabIndex={-1}
|
|
126
|
+
aria-hidden="true"
|
|
127
|
+
/>
|
|
128
|
+
<div className="flex items-center gap-2">
|
|
129
|
+
<Button
|
|
130
|
+
type="button"
|
|
131
|
+
variant="outline"
|
|
132
|
+
size="sm"
|
|
133
|
+
onClick={handlePick}
|
|
134
|
+
disabled={uploading}
|
|
135
|
+
>
|
|
136
|
+
{uploading ? (
|
|
137
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
138
|
+
) : (
|
|
139
|
+
<Paperclip className="mr-2 h-4 w-4" />
|
|
140
|
+
)}
|
|
141
|
+
{hasValue ? 'Reemplazar' : field.placeholder || 'Subir archivo'}
|
|
142
|
+
</Button>
|
|
143
|
+
{hasValue && !uploading && (
|
|
144
|
+
<div className="flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
|
|
145
|
+
<span className="truncate" title={String(value)}>
|
|
146
|
+
{uploadedDisplayName(value)}
|
|
147
|
+
</span>
|
|
148
|
+
<Button
|
|
149
|
+
type="button"
|
|
150
|
+
variant="ghost"
|
|
151
|
+
size="sm"
|
|
152
|
+
className="h-6 w-6 p-0"
|
|
153
|
+
onClick={handleClear}
|
|
154
|
+
aria-label="Quitar archivo"
|
|
155
|
+
>
|
|
156
|
+
<X className="h-3.5 w-3.5" />
|
|
157
|
+
</Button>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
{error && (
|
|
162
|
+
<span className="text-sm text-destructive" role="alert">
|
|
163
|
+
{error}
|
|
164
|
+
</span>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|