@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
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAA;IAC5B,cAAc,EAAE,MAAM,EAAE,CAAA;IACxB,cAAc,EAAE,MAAM,CAAA;IACtB,iBAAiB,EAAE,MAAM,CAAA;IACzB,iBAAiB,EAAE,OAAO,CAAA;IAC1B,UAAU,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ActionFieldDef } from './types';
|
|
2
|
+
export interface UploadFieldProps {
|
|
3
|
+
field: ActionFieldDef;
|
|
4
|
+
value: any;
|
|
5
|
+
onChange: (v: any) => void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Pulls the stored file url/path out of an upload response envelope, tolerating
|
|
9
|
+
* the common key shapes a host might return. Pure — exported for tests.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractUploadedValue(payload: any): string;
|
|
12
|
+
/** Short, human display name for an already-stored file value (a url/path). */
|
|
13
|
+
export declare function uploadedDisplayName(value: unknown): string;
|
|
14
|
+
export declare function UploadField({ field, value, onChange }: UploadFieldProps): import("react/jsx-runtime").JSX.Element;
|
|
15
|
+
//# sourceMappingURL=upload-field.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upload-field.d.ts","sourceRoot":"","sources":["../src/upload-field.tsx"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAA;AAE7C,MAAM,WAAW,gBAAgB;IAC7B,KAAK,EAAE,cAAc,CAAA;IACrB,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;CAC7B;AAKD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,GAAG,GAAG,MAAM,CAWzD;AAED,+EAA+E;AAC/E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAK1D;AAED,wBAAgB,WAAW,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,gBAAgB,2CAkHvE"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// UploadField — the `upload` widget renderer shared by DynamicForm's
|
|
3
|
+
// FieldRenderer and the action-modal-dispatcher's renderField so the two stay
|
|
4
|
+
// in lockstep. Renders a themed Button that proxies a hidden <input type=file>,
|
|
5
|
+
// POSTs the picked file to the host upload endpoint as multipart/form-data, and
|
|
6
|
+
// stores the returned file url/path as the field value.
|
|
7
|
+
//
|
|
8
|
+
// Endpoint assumption: `POST /uploads` (multipart) returning
|
|
9
|
+
// { success: true, data: { file_url?, url?, path?, file_path? } }
|
|
10
|
+
// matching the kernel envelope. A field may override the path via
|
|
11
|
+
// `field.searchEndpoint` (reused as the upload endpoint escape hatch) — kept
|
|
12
|
+
// generic so this carries no host-specific route. Honors field.accept /
|
|
13
|
+
// field.maxSize and forwards field.storagePath as `storage_path`.
|
|
14
|
+
import { useCallback, useRef, useState } from 'react';
|
|
15
|
+
import { Button } from '@asteby/metacore-ui/primitives';
|
|
16
|
+
import { Loader2, Paperclip, X } from 'lucide-react';
|
|
17
|
+
import { useApi } from './api-context';
|
|
18
|
+
import { getUploadConfig } from './dynamic-form-schema';
|
|
19
|
+
/** Default host upload endpoint. Overridable per-field via `searchEndpoint`. */
|
|
20
|
+
const DEFAULT_UPLOAD_ENDPOINT = '/uploads';
|
|
21
|
+
/**
|
|
22
|
+
* Pulls the stored file url/path out of an upload response envelope, tolerating
|
|
23
|
+
* the common key shapes a host might return. Pure — exported for tests.
|
|
24
|
+
*/
|
|
25
|
+
export function extractUploadedValue(payload) {
|
|
26
|
+
if (payload === null || payload === undefined)
|
|
27
|
+
return '';
|
|
28
|
+
if (typeof payload === 'string')
|
|
29
|
+
return payload;
|
|
30
|
+
const d = (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) ?? payload;
|
|
31
|
+
if (typeof d === 'string')
|
|
32
|
+
return d;
|
|
33
|
+
if (d && typeof d === 'object') {
|
|
34
|
+
const candidate = d.file_url ?? d.fileUrl ?? d.url ?? d.file_path ?? d.filePath ?? d.path;
|
|
35
|
+
if (typeof candidate === 'string')
|
|
36
|
+
return candidate;
|
|
37
|
+
}
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
/** Short, human display name for an already-stored file value (a url/path). */
|
|
41
|
+
export function uploadedDisplayName(value) {
|
|
42
|
+
if (typeof value !== 'string' || value === '')
|
|
43
|
+
return '';
|
|
44
|
+
const cleaned = value.split('?')[0];
|
|
45
|
+
const parts = cleaned.split('/');
|
|
46
|
+
return parts[parts.length - 1] || cleaned;
|
|
47
|
+
}
|
|
48
|
+
export function UploadField({ field, value, onChange }) {
|
|
49
|
+
const api = useApi();
|
|
50
|
+
const inputRef = useRef(null);
|
|
51
|
+
const [uploading, setUploading] = useState(false);
|
|
52
|
+
const [error, setError] = useState(null);
|
|
53
|
+
const { accept, maxSize, storagePath } = getUploadConfig(field);
|
|
54
|
+
const endpoint = field.searchEndpoint || DEFAULT_UPLOAD_ENDPOINT;
|
|
55
|
+
const handlePick = useCallback(() => {
|
|
56
|
+
if (uploading)
|
|
57
|
+
return;
|
|
58
|
+
inputRef.current?.click();
|
|
59
|
+
}, [uploading]);
|
|
60
|
+
const handleFile = useCallback(async (e) => {
|
|
61
|
+
const file = e.target.files?.[0];
|
|
62
|
+
// Reset the input so picking the same file again re-fires change.
|
|
63
|
+
if (inputRef.current)
|
|
64
|
+
inputRef.current.value = '';
|
|
65
|
+
if (!file)
|
|
66
|
+
return;
|
|
67
|
+
setError(null);
|
|
68
|
+
if (maxSize && file.size > maxSize) {
|
|
69
|
+
const mb = (maxSize / (1024 * 1024)).toFixed(1);
|
|
70
|
+
setError(`Archivo muy grande (máx. ${mb} MB).`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const form = new FormData();
|
|
74
|
+
form.append('file', file);
|
|
75
|
+
if (storagePath)
|
|
76
|
+
form.append('storage_path', storagePath);
|
|
77
|
+
setUploading(true);
|
|
78
|
+
try {
|
|
79
|
+
const res = await api.post(endpoint, form, {
|
|
80
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
81
|
+
});
|
|
82
|
+
const body = res?.data;
|
|
83
|
+
if (body && body.success === false) {
|
|
84
|
+
setError(body.message || 'No se pudo subir el archivo.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const stored = extractUploadedValue(body);
|
|
88
|
+
if (!stored) {
|
|
89
|
+
setError('Respuesta de subida inválida.');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
onChange(stored);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
setError(err?.response?.data?.message || 'No se pudo subir el archivo.');
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
setUploading(false);
|
|
99
|
+
}
|
|
100
|
+
}, [api, endpoint, maxSize, storagePath, onChange]);
|
|
101
|
+
const handleClear = useCallback(() => {
|
|
102
|
+
if (uploading)
|
|
103
|
+
return;
|
|
104
|
+
setError(null);
|
|
105
|
+
onChange('');
|
|
106
|
+
}, [uploading, onChange]);
|
|
107
|
+
const hasValue = typeof value === 'string' && value !== '';
|
|
108
|
+
return (_jsxs("div", { className: "grid gap-1.5", "data-widget": "upload", children: [_jsx("input", { ref: inputRef, id: field.key, type: "file", accept: accept, className: "sr-only", onChange: handleFile, tabIndex: -1, "aria-hidden": "true" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: handlePick, disabled: uploading, children: [uploading ? (_jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" })) : (_jsx(Paperclip, { className: "mr-2 h-4 w-4" })), hasValue ? 'Reemplazar' : field.placeholder || 'Subir archivo'] }), hasValue && !uploading && (_jsxs("div", { className: "flex min-w-0 items-center gap-1 text-sm text-muted-foreground", children: [_jsx("span", { className: "truncate", title: String(value), children: uploadedDisplayName(value) }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-6 w-6 p-0", onClick: handleClear, "aria-label": "Quitar archivo", children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }))] }), error && (_jsx("span", { className: "text-sm text-destructive", role: "alert", children: error }))] }));
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "13.
|
|
3
|
+
"version": "13.6.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"typescript": "^6.0.0",
|
|
62
62
|
"vitest": "^4.0.0",
|
|
63
63
|
"zustand": "^5.0.0",
|
|
64
|
-
"@asteby/metacore-
|
|
65
|
-
"@asteby/metacore-
|
|
64
|
+
"@asteby/metacore-sdk": "3.1.0",
|
|
65
|
+
"@asteby/metacore-ui": "2.1.0"
|
|
66
66
|
},
|
|
67
67
|
"scripts": {
|
|
68
68
|
"build": "tsc -p tsconfig.json",
|
|
@@ -25,6 +25,34 @@ describe('buildRelationFilterParams', () => {
|
|
|
25
25
|
})
|
|
26
26
|
})
|
|
27
27
|
|
|
28
|
+
it('agrega filtros de scope extra como f_<col>=eq:<val> (caso polimórfico)', () => {
|
|
29
|
+
expect(
|
|
30
|
+
buildRelationFilterParams('owner_id', 'cust_1', { owner_model: 'Customer' }),
|
|
31
|
+
).toEqual({
|
|
32
|
+
f_owner_id: 'eq:cust_1',
|
|
33
|
+
f_owner_model: 'eq:Customer',
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('el foreign-key gana sobre un scope redundante con el mismo key', () => {
|
|
38
|
+
expect(
|
|
39
|
+
buildRelationFilterParams('owner_id', 'cust_1', { owner_id: 'evil', tier: 'gold' }),
|
|
40
|
+
).toEqual({
|
|
41
|
+
f_owner_id: 'eq:cust_1',
|
|
42
|
+
f_tier: 'eq:gold',
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('ignora scope null/undefined sin romper', () => {
|
|
47
|
+
expect(
|
|
48
|
+
// @ts-expect-error testing runtime tolerance
|
|
49
|
+
buildRelationFilterParams('owner_id', 'c1', { a: null, b: undefined, c: 'ok' }),
|
|
50
|
+
).toEqual({ f_owner_id: 'eq:c1', f_c: 'eq:ok' })
|
|
51
|
+
expect(buildRelationFilterParams('owner_id', 'c1', null)).toEqual({
|
|
52
|
+
f_owner_id: 'eq:c1',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
28
56
|
it('rechaza foreignKey vacío', () => {
|
|
29
57
|
expect(() => buildRelationFilterParams('', 'x')).toThrow(/foreignKey/)
|
|
30
58
|
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { resolveParentId, buildRelationFilters } from '../dynamic-relations'
|
|
3
|
+
import type { RelationMeta } from '../types'
|
|
4
|
+
|
|
5
|
+
describe('resolveParentId', () => {
|
|
6
|
+
it('lee record.id por defecto', () => {
|
|
7
|
+
expect(resolveParentId({ id: 'c_1' })).toBe('c_1')
|
|
8
|
+
expect(resolveParentId({ id: 7 })).toBe(7)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('respeta un parentIdKey custom', () => {
|
|
12
|
+
expect(resolveParentId({ uuid: 'u_1' }, 'uuid')).toBe('u_1')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('devuelve undefined cuando falta / es vacío / no es escalar', () => {
|
|
16
|
+
expect(resolveParentId(null)).toBeUndefined()
|
|
17
|
+
expect(resolveParentId(undefined)).toBeUndefined()
|
|
18
|
+
expect(resolveParentId({})).toBeUndefined()
|
|
19
|
+
expect(resolveParentId({ id: '' })).toBeUndefined()
|
|
20
|
+
expect(resolveParentId({ id: null })).toBeUndefined()
|
|
21
|
+
expect(resolveParentId({ id: { nested: true } })).toBeUndefined()
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('buildRelationFilters', () => {
|
|
26
|
+
it('mergea scope estático + foreign_key=parentId (caso polimórfico)', () => {
|
|
27
|
+
const rel: Pick<RelationMeta, 'foreign_key' | 'scope'> = {
|
|
28
|
+
foreign_key: 'owner_id',
|
|
29
|
+
scope: { owner_model: 'Customer' },
|
|
30
|
+
}
|
|
31
|
+
expect(buildRelationFilters(rel, 'c_1')).toEqual({
|
|
32
|
+
owner_model: 'Customer',
|
|
33
|
+
owner_id: 'c_1',
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('funciona sin scope (one_to_many simple)', () => {
|
|
38
|
+
expect(buildRelationFilters({ foreign_key: 'customer_id' }, 42)).toEqual({
|
|
39
|
+
customer_id: '42',
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('coerce parentId y valores de scope a string', () => {
|
|
44
|
+
const rel: Pick<RelationMeta, 'foreign_key' | 'scope'> = {
|
|
45
|
+
foreign_key: 'owner_id',
|
|
46
|
+
// @ts-expect-error testing runtime coercion of non-string scope value
|
|
47
|
+
scope: { kind: 5 },
|
|
48
|
+
}
|
|
49
|
+
expect(buildRelationFilters(rel, 9)).toEqual({ kind: '5', owner_id: '9' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('omite entradas de scope null/undefined', () => {
|
|
53
|
+
const rel: Pick<RelationMeta, 'foreign_key' | 'scope'> = {
|
|
54
|
+
foreign_key: 'owner_id',
|
|
55
|
+
// @ts-expect-error testing runtime tolerance
|
|
56
|
+
scope: { a: null, b: undefined, c: 'ok' },
|
|
57
|
+
}
|
|
58
|
+
expect(buildRelationFilters(rel, 'c_1')).toEqual({ c: 'ok', owner_id: 'c_1' })
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { extractUploadedValue, uploadedDisplayName } from '../upload-field'
|
|
3
|
+
import { resolveWidget, getUploadConfig } from '../dynamic-form-schema'
|
|
4
|
+
import type { ActionFieldDef } from '../types'
|
|
5
|
+
|
|
6
|
+
describe('resolveWidget upload', () => {
|
|
7
|
+
it('infiere upload desde type', () => {
|
|
8
|
+
expect(resolveWidget({ key: 'f', label: 'F', type: 'upload' })).toBe('upload')
|
|
9
|
+
})
|
|
10
|
+
it('respeta widget explícito upload sobre cualquier type', () => {
|
|
11
|
+
expect(resolveWidget({ key: 'f', label: 'F', type: 'string', widget: 'upload' })).toBe('upload')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('getUploadConfig', () => {
|
|
16
|
+
it('lee la forma camelCase autorada', () => {
|
|
17
|
+
const field: ActionFieldDef = {
|
|
18
|
+
key: 'logo', label: 'Logo', type: 'upload',
|
|
19
|
+
accept: 'image/*', maxSize: 1024, storagePath: 'brand/',
|
|
20
|
+
}
|
|
21
|
+
expect(getUploadConfig(field)).toEqual({ accept: 'image/*', maxSize: 1024, storagePath: 'brand/' })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('tolera la forma snake_case del kernel', () => {
|
|
25
|
+
const field = {
|
|
26
|
+
key: 'doc', label: 'Doc', type: 'upload',
|
|
27
|
+
accept: '.pdf', max_size: 2048, storage_path: 'docs/',
|
|
28
|
+
} as ActionFieldDef
|
|
29
|
+
expect(getUploadConfig(field)).toEqual({ accept: '.pdf', maxSize: 2048, storagePath: 'docs/' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('descarta maxSize inválido / no positivo y campos vacíos', () => {
|
|
33
|
+
expect(getUploadConfig({ key: 'f', label: 'F', type: 'upload', maxSize: 0 })).toEqual({
|
|
34
|
+
accept: undefined, maxSize: undefined, storagePath: undefined,
|
|
35
|
+
})
|
|
36
|
+
expect(getUploadConfig({ key: 'f', label: 'F', type: 'upload', max_size: -5 } as ActionFieldDef).maxSize).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('extractUploadedValue', () => {
|
|
41
|
+
it('extrae del envelope {success,data:{file_url}}', () => {
|
|
42
|
+
expect(extractUploadedValue({ success: true, data: { file_url: '/u/a.png' } })).toBe('/u/a.png')
|
|
43
|
+
})
|
|
44
|
+
it('soporta url / path / file_path / camelCase', () => {
|
|
45
|
+
expect(extractUploadedValue({ data: { url: '/u/b' } })).toBe('/u/b')
|
|
46
|
+
expect(extractUploadedValue({ data: { path: '/u/c' } })).toBe('/u/c')
|
|
47
|
+
expect(extractUploadedValue({ data: { file_path: '/u/d' } })).toBe('/u/d')
|
|
48
|
+
expect(extractUploadedValue({ data: { fileUrl: '/u/e' } })).toBe('/u/e')
|
|
49
|
+
})
|
|
50
|
+
it('soporta respuesta data como string plano', () => {
|
|
51
|
+
expect(extractUploadedValue({ data: '/u/flat' })).toBe('/u/flat')
|
|
52
|
+
expect(extractUploadedValue('/u/raw')).toBe('/u/raw')
|
|
53
|
+
})
|
|
54
|
+
it('devuelve "" cuando no hay nada usable', () => {
|
|
55
|
+
expect(extractUploadedValue(null)).toBe('')
|
|
56
|
+
expect(extractUploadedValue(undefined)).toBe('')
|
|
57
|
+
expect(extractUploadedValue({ data: { foo: 'bar' } })).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('uploadedDisplayName', () => {
|
|
62
|
+
it('toma el último segmento del path', () => {
|
|
63
|
+
expect(uploadedDisplayName('/uploads/2026/a.png')).toBe('a.png')
|
|
64
|
+
expect(uploadedDisplayName('a.png')).toBe('a.png')
|
|
65
|
+
})
|
|
66
|
+
it('descarta el querystring', () => {
|
|
67
|
+
expect(uploadedDisplayName('/u/a.png?sig=xyz')).toBe('a.png')
|
|
68
|
+
})
|
|
69
|
+
it('devuelve "" para valores no-string o vacíos', () => {
|
|
70
|
+
expect(uploadedDisplayName('')).toBe('')
|
|
71
|
+
expect(uploadedDisplayName(null)).toBe('')
|
|
72
|
+
expect(uploadedDisplayName(42)).toBe('')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -41,6 +41,7 @@ import { DynamicIcon } from './dynamic-icon'
|
|
|
41
41
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
42
42
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
43
43
|
import { DynamicDateField } from './dynamic-date-field'
|
|
44
|
+
import { UploadField } from './upload-field'
|
|
44
45
|
import { isLineItemsField, resolveWidget } from './dynamic-form-schema'
|
|
45
46
|
import type { ActionFieldDef } from './types'
|
|
46
47
|
// Canonical registry lives in @asteby/metacore-sdk
|
|
@@ -322,6 +323,11 @@ function renderField(
|
|
|
322
323
|
if (widget === 'dynamic_select') {
|
|
323
324
|
return <DynamicSelectField field={field} value={value} onChange={onChange} />
|
|
324
325
|
}
|
|
326
|
+
// File upload → themed picker that POSTs the file to the host upload
|
|
327
|
+
// endpoint and stores the returned url/path. Kept in sync with DynamicForm.
|
|
328
|
+
if (widget === 'upload') {
|
|
329
|
+
return <UploadField field={field} value={value} onChange={onChange} />
|
|
330
|
+
}
|
|
325
331
|
switch (widget) {
|
|
326
332
|
case 'textarea':
|
|
327
333
|
return <Textarea id={field.key} value={value || ''} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)} placeholder={field.placeholder} />
|
|
@@ -216,6 +216,33 @@ export function resolveWidget(field: ActionFieldDef): string {
|
|
|
216
216
|
case 'boolean': return 'switch'
|
|
217
217
|
case 'number': return 'number'
|
|
218
218
|
case 'date': return 'date'
|
|
219
|
+
// File upload: POSTs to the host upload endpoint and stores the returned
|
|
220
|
+
// file url/path as the field value. Rendered by `UploadField`.
|
|
221
|
+
case 'upload': return 'upload'
|
|
219
222
|
default: return 'text'
|
|
220
223
|
}
|
|
221
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Normalizes an upload field's config, tolerating both the camelCase authored
|
|
228
|
+
* SDK shape and the snake_case the kernel serves (`max_size`, `storage_path`).
|
|
229
|
+
* Pure — shared by both field renderers and unit tests.
|
|
230
|
+
*/
|
|
231
|
+
export function getUploadConfig(field: ActionFieldDef): {
|
|
232
|
+
accept?: string
|
|
233
|
+
maxSize?: number
|
|
234
|
+
storagePath?: string
|
|
235
|
+
} {
|
|
236
|
+
const accept = field.accept
|
|
237
|
+
const maxSizeRaw = field.maxSize ?? field.max_size
|
|
238
|
+
const maxSize =
|
|
239
|
+
typeof maxSizeRaw === 'number' && Number.isFinite(maxSizeRaw) && maxSizeRaw > 0
|
|
240
|
+
? maxSizeRaw
|
|
241
|
+
: undefined
|
|
242
|
+
const storagePath = field.storagePath ?? field.storage_path
|
|
243
|
+
return {
|
|
244
|
+
accept: accept || undefined,
|
|
245
|
+
maxSize,
|
|
246
|
+
storagePath: storagePath || undefined,
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -25,11 +25,13 @@ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
|
25
25
|
import { DynamicLineItems } from './dynamic-line-items'
|
|
26
26
|
import { DynamicSelectField } from './dynamic-select-field'
|
|
27
27
|
import { DynamicDateField } from './dynamic-date-field'
|
|
28
|
+
import { UploadField } from './upload-field'
|
|
28
29
|
|
|
29
30
|
export { buildZodSchema, resolveWidget }
|
|
30
31
|
export { DynamicLineItems } from './dynamic-line-items'
|
|
31
32
|
export { DynamicSelectField } from './dynamic-select-field'
|
|
32
33
|
export { DynamicDateField } from './dynamic-date-field'
|
|
34
|
+
export { UploadField } from './upload-field'
|
|
33
35
|
|
|
34
36
|
export interface DynamicFormProps {
|
|
35
37
|
fields: ActionFieldDef[]
|
|
@@ -168,6 +170,11 @@ function FieldRenderer({ field, value, onChange }: FieldRendererProps) {
|
|
|
168
170
|
if (widget === 'dynamic_select') {
|
|
169
171
|
return <DynamicSelectField field={field} value={value} onChange={onChange} />
|
|
170
172
|
}
|
|
173
|
+
// File upload → themed picker that POSTs to the host upload endpoint and
|
|
174
|
+
// stores the returned file url/path as the field value.
|
|
175
|
+
if (widget === 'upload') {
|
|
176
|
+
return <UploadField field={field} value={value} onChange={onChange} />
|
|
177
|
+
}
|
|
171
178
|
// Ref-driven select: hook into useOptionsResolver so the canonical
|
|
172
179
|
// /api/options/<ref>?field=id endpoint feeds the dropdown. This is
|
|
173
180
|
// the path the kernel auto-derives for FK columns; legacy callers
|
|
@@ -24,12 +24,26 @@ export interface TargetRowLike {
|
|
|
24
24
|
export function buildRelationFilterParams(
|
|
25
25
|
foreignKey: string,
|
|
26
26
|
parentId: string | number,
|
|
27
|
+
extraFilters?: Record<string, string> | null,
|
|
27
28
|
): Record<string, string> {
|
|
28
29
|
if (!foreignKey) throw new Error('foreignKey requerido')
|
|
29
30
|
if (parentId === undefined || parentId === null || parentId === '') {
|
|
30
31
|
throw new Error('parentId requerido')
|
|
31
32
|
}
|
|
32
|
-
|
|
33
|
+
const params: Record<string, string> = {
|
|
34
|
+
[`f_${foreignKey}`]: `eq:${String(parentId)}`,
|
|
35
|
+
}
|
|
36
|
+
// Additional static-equality scope columns (polymorphic case: the FK plus
|
|
37
|
+
// e.g. owner_model=Customer). Each becomes its own `f_<col>=eq:<val>` param.
|
|
38
|
+
// The foreign-key entry above wins if a caller redundantly repeats it.
|
|
39
|
+
if (extraFilters) {
|
|
40
|
+
for (const [col, val] of Object.entries(extraFilters)) {
|
|
41
|
+
if (!col || col === foreignKey) continue
|
|
42
|
+
if (val === undefined || val === null) continue
|
|
43
|
+
params[`f_${col}`] = `eq:${String(val)}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return params
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
/**
|
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
|