@asteby/metacore-runtime-react 13.5.1 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  3. package/dist/action-modal-dispatcher.js +6 -0
  4. package/dist/dynamic-columns.d.ts +13 -0
  5. package/dist/dynamic-columns.d.ts.map +1 -1
  6. package/dist/dynamic-columns.js +22 -0
  7. package/dist/dynamic-form-schema.d.ts +10 -0
  8. package/dist/dynamic-form-schema.d.ts.map +1 -1
  9. package/dist/dynamic-form-schema.js +21 -0
  10. package/dist/dynamic-form.d.ts +1 -0
  11. package/dist/dynamic-form.d.ts.map +1 -1
  12. package/dist/dynamic-form.js +7 -0
  13. package/dist/dynamic-relation-helpers.d.ts +1 -1
  14. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  15. package/dist/dynamic-relation-helpers.js +17 -2
  16. package/dist/dynamic-relation.d.ts +8 -0
  17. package/dist/dynamic-relation.d.ts.map +1 -1
  18. package/dist/dynamic-relation.js +26 -12
  19. package/dist/dynamic-relations.d.ts +51 -0
  20. package/dist/dynamic-relations.d.ts.map +1 -0
  21. package/dist/dynamic-relations.js +76 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/types.d.ts +57 -1
  26. package/dist/types.d.ts.map +1 -1
  27. package/dist/upload-field.d.ts +15 -0
  28. package/dist/upload-field.d.ts.map +1 -0
  29. package/dist/upload-field.js +109 -0
  30. package/package.json +1 -1
  31. package/src/__tests__/action-visibility-by-state.test.ts +51 -0
  32. package/src/__tests__/dynamic-relation.test.ts +28 -0
  33. package/src/__tests__/dynamic-relations.test.ts +60 -0
  34. package/src/__tests__/upload-field.test.ts +74 -0
  35. package/src/action-modal-dispatcher.tsx +6 -0
  36. package/src/dynamic-columns.tsx +21 -0
  37. package/src/dynamic-form-schema.ts +27 -0
  38. package/src/dynamic-form.tsx +7 -0
  39. package/src/dynamic-relation-helpers.ts +15 -1
  40. package/src/dynamic-relation.tsx +35 -10
  41. package/src/dynamic-relations.tsx +160 -0
  42. package/src/index.ts +6 -0
  43. package/src/types.ts +58 -0
  44. package/src/upload-field.tsx +168 -0
@@ -86,6 +86,14 @@ const DEFAULT_STRINGS: DynamicRelationStrings = {
86
86
  interface CommonProps {
87
87
  /** id del registro padre. */
88
88
  parentId: string | number
89
+ /**
90
+ * Filtros estáticos extra (igualdad) aplicados ADEMÁS del foreign-key.
91
+ * Caso polimórfico: una tabla de hijos compartida (attachments,
92
+ * addresses) scopeada por `foreign_key=owner_id` Y `owner_model=Customer`.
93
+ * Cada entrada se thread-ea como `f_<col>=eq:<val>` junto al FK en la query
94
+ * de la lista hija. Aditivo: sin filters el comportamiento es idéntico.
95
+ */
96
+ filters?: Record<string, string>
89
97
  /** Hidden columns; el FK siempre se oculta automáticamente. */
90
98
  hiddenColumns?: string[]
91
99
  /** Permisos visibles. Default true. */
@@ -147,6 +155,7 @@ function OneToManyRelation({
147
155
  model,
148
156
  foreignKey,
149
157
  parentId,
158
+ filters,
150
159
  endpoint,
151
160
  hiddenColumns = [],
152
161
  canCreate = true,
@@ -170,11 +179,15 @@ function OneToManyRelation({
170
179
  const [submitting, setSubmitting] = useState(false)
171
180
 
172
181
  const dataEndpoint = endpoint || `/data/${model}`
182
+ // Stable dependency key for the filters object (callers usually pass a fresh
183
+ // literal each render). Keeps fetchAll from re-firing on identity churn while
184
+ // still reacting to real scope changes.
185
+ const filtersKey = useMemo(() => (filters ? JSON.stringify(filters) : ''), [filters])
173
186
 
174
187
  const fetchAll = useCallback(async () => {
175
188
  setLoading(true)
176
189
  try {
177
- const params = buildRelationFilterParams(foreignKey, parentId)
190
+ const params = buildRelationFilterParams(foreignKey, parentId, filters)
178
191
  const [metaRes, dataRes] = await Promise.all([
179
192
  metadata ? Promise.resolve(null) : api.get(`/metadata/table/${model}`),
180
193
  api.get(dataEndpoint, { params }),
@@ -191,7 +204,8 @@ function OneToManyRelation({
191
204
  } finally {
192
205
  setLoading(false)
193
206
  }
194
- }, [api, dataEndpoint, foreignKey, parentId, metadata, model, cacheMetadata])
207
+ // eslint-disable-next-line react-hooks/exhaustive-deps
208
+ }, [api, dataEndpoint, foreignKey, parentId, filtersKey, metadata, model, cacheMetadata])
195
209
 
196
210
  useEffect(() => { fetchAll() }, [fetchAll])
197
211
 
@@ -202,9 +216,11 @@ function OneToManyRelation({
202
216
 
203
217
  const visibleColumns = useMemo(() => {
204
218
  if (!metadata?.columns) return []
205
- const hidden = new Set([foreignKey, ...hiddenColumns])
219
+ // Hide the FK and every scope column — they're fixed for this parent and
220
+ // would just render the same value on every row.
221
+ const hidden = new Set([foreignKey, ...Object.keys(filters || {}), ...hiddenColumns])
206
222
  return metadata.columns.filter(c => !hidden.has(c.key) && !c.hidden)
207
- }, [metadata, foreignKey, hiddenColumns])
223
+ }, [metadata, foreignKey, filtersKey, hiddenColumns])
208
224
 
209
225
  const handleSubmit = useCallback(async (values: Record<string, any>) => {
210
226
  setSubmitting(true)
@@ -213,7 +229,10 @@ function OneToManyRelation({
213
229
  const res = await api.put(`${dataEndpoint}/${editingRow.id}`, values)
214
230
  if (!(res as any).data?.success) throw new Error('update failed')
215
231
  } else {
216
- const payload = buildCreatePayload(foreignKey, parentId, values)
232
+ // Scope columns (polymorphic discriminators like owner_model)
233
+ // are fixed for this relation, so a newly created child must
234
+ // carry them too — otherwise it would not match the list filter.
235
+ const payload = { ...(filters || {}), ...buildCreatePayload(foreignKey, parentId, values) }
217
236
  const res = await api.post(dataEndpoint, payload)
218
237
  if (!(res as any).data?.success) throw new Error('create failed')
219
238
  }
@@ -226,7 +245,8 @@ function OneToManyRelation({
226
245
  } finally {
227
246
  setSubmitting(false)
228
247
  }
229
- }, [api, dataEndpoint, editingRow, fetchAll, foreignKey, onChange, parentId])
248
+ // eslint-disable-next-line react-hooks/exhaustive-deps
249
+ }, [api, dataEndpoint, editingRow, fetchAll, foreignKey, filtersKey, onChange, parentId])
230
250
 
231
251
  const handleDelete = useCallback(async () => {
232
252
  if (!rowToDelete) return
@@ -366,6 +386,7 @@ function ManyToManyRelation({
366
386
  foreignKey,
367
387
  referencesKey,
368
388
  parentId,
389
+ filters,
369
390
  pivotEndpoint,
370
391
  referencesEndpoint,
371
392
  displayKey,
@@ -405,10 +426,12 @@ function ManyToManyRelation({
405
426
  enabled: useResolver,
406
427
  })
407
428
 
429
+ const filtersKey = useMemo(() => (filters ? JSON.stringify(filters) : ''), [filters])
430
+
408
431
  const fetchPivotAndMeta = useCallback(async () => {
409
432
  setLoading(true)
410
433
  try {
411
- const params = buildRelationFilterParams(foreignKey, parentId)
434
+ const params = buildRelationFilterParams(foreignKey, parentId, filters)
412
435
  const tasks: Promise<unknown>[] = [
413
436
  api.get(pivotPath, { params }),
414
437
  ]
@@ -437,7 +460,8 @@ function ManyToManyRelation({
437
460
  } finally {
438
461
  setLoading(false)
439
462
  }
440
- }, [api, pivotPath, foreignKey, parentId, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath])
463
+ // eslint-disable-next-line react-hooks/exhaustive-deps
464
+ }, [api, pivotPath, foreignKey, parentId, filtersKey, references, targetMeta, cacheMetadata, useResolver, legacyTargetPath])
441
465
 
442
466
  useEffect(() => { fetchPivotAndMeta() }, [fetchPivotAndMeta])
443
467
 
@@ -475,7 +499,7 @@ function ManyToManyRelation({
475
499
  setSyncing(true)
476
500
  try {
477
501
  for (const targetId of toAdd) {
478
- const payload = buildPivotAttachPayload(foreignKey, parentId, refKey, targetId)
502
+ const payload = buildPivotAttachPayload(foreignKey, parentId, refKey, targetId, filters || undefined)
479
503
  const res = await api.post(pivotPath, payload)
480
504
  if (!(res as any).data?.success) throw new Error('attach failed')
481
505
  }
@@ -496,7 +520,8 @@ function ManyToManyRelation({
496
520
  } finally {
497
521
  setSyncing(false)
498
522
  }
499
- }, [api, canCreate, canDelete, fetchPivotAndMeta, useResolver, resolved, foreignKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
523
+ // eslint-disable-next-line react-hooks/exhaustive-deps
524
+ }, [api, canCreate, canDelete, fetchPivotAndMeta, useResolver, resolved, foreignKey, filtersKey, onChange, parentId, pivotIndex, pivotPath, refKey, selectedIds, syncing])
500
525
 
501
526
  return (
502
527
  <div
@@ -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
+ }