@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.
- package/CHANGELOG.md +47 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +6 -0
- package/dist/dynamic-columns.d.ts +13 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +22 -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 +1 -1
- package/src/__tests__/action-visibility-by-state.test.ts +51 -0
- 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-columns.tsx +21 -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
package/src/dynamic-relation.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|