@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/dist/types.d.ts
CHANGED
|
@@ -12,6 +12,41 @@ export interface TableMetadata {
|
|
|
12
12
|
canExport?: boolean;
|
|
13
13
|
canImport?: boolean;
|
|
14
14
|
canCreate?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Child relations of this model, served by the kernel (>= v0.41.0). A
|
|
17
|
+
* generic detail page renders one `DynamicRelation` panel per entry via
|
|
18
|
+
* `<DynamicRelations>` to surface, e.g., a Customer's vehicles, addresses
|
|
19
|
+
* and attachments. Absent on hosts/older kernels — purely additive.
|
|
20
|
+
*/
|
|
21
|
+
relations?: RelationMeta[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Describes one child relation of a parent model, mirroring the kernel
|
|
25
|
+
* `RelationMeta` shape (>= v0.41.0). Drives the metadata-driven
|
|
26
|
+
* `<DynamicRelations>` panel list. All keys are snake_case as served by the
|
|
27
|
+
* kernel; the SDK reads them as-is.
|
|
28
|
+
*/
|
|
29
|
+
export interface RelationMeta {
|
|
30
|
+
/** Stable identifier for the relation (used as a React key / data attr). */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Cardinality. The SDK maps this onto `DynamicRelation.kind`. */
|
|
33
|
+
kind: 'one_to_many' | 'many_to_many';
|
|
34
|
+
/**
|
|
35
|
+
* Child model key (the `through` model). For one_to_many this is the model
|
|
36
|
+
* whose rows are listed; for many_to_many it is the pivot table.
|
|
37
|
+
*/
|
|
38
|
+
through: string;
|
|
39
|
+
/** Child column holding the FK back to the parent. */
|
|
40
|
+
foreign_key: string;
|
|
41
|
+
/**
|
|
42
|
+
* Static equality filters applied on top of the foreign-key scope. Used for
|
|
43
|
+
* polymorphic children (e.g. `{ "owner_model": "Customer" }`) so a shared
|
|
44
|
+
* attachments/addresses table is narrowed to this parent's rows. Each entry
|
|
45
|
+
* becomes a `f_<col>=eq:<val>` query param.
|
|
46
|
+
*/
|
|
47
|
+
scope?: Record<string, string>;
|
|
48
|
+
/** Human-readable panel header. */
|
|
49
|
+
label?: string;
|
|
15
50
|
}
|
|
16
51
|
export interface FilterDefinition {
|
|
17
52
|
key: string;
|
|
@@ -100,7 +135,7 @@ export interface FieldValidation {
|
|
|
100
135
|
max?: number;
|
|
101
136
|
custom?: string;
|
|
102
137
|
}
|
|
103
|
-
export type FieldWidget = 'text' | 'textarea' | 'richtext' | 'color' | 'number' | 'date' | 'select' | 'dynamic_select' | 'switch';
|
|
138
|
+
export type FieldWidget = 'text' | 'textarea' | 'richtext' | 'color' | 'number' | 'date' | 'select' | 'dynamic_select' | 'switch' | 'upload';
|
|
104
139
|
export interface ActionFieldDef {
|
|
105
140
|
key: string;
|
|
106
141
|
label: string;
|
|
@@ -146,6 +181,27 @@ export interface ActionFieldDef {
|
|
|
146
181
|
* reconcile. Mirrors kernel v3 `ActionField.balance`.
|
|
147
182
|
*/
|
|
148
183
|
balance?: FieldBalanceRule;
|
|
184
|
+
/**
|
|
185
|
+
* `upload` widget: comma-separated accept list forwarded to the file input
|
|
186
|
+
* `accept` attribute (e.g. `"image/*,.pdf"`). Tolerates the snake_case the
|
|
187
|
+
* kernel may serve. Optional — when absent any file type is allowed.
|
|
188
|
+
*/
|
|
189
|
+
accept?: string;
|
|
190
|
+
/**
|
|
191
|
+
* `upload` widget: maximum file size in bytes. The renderer rejects larger
|
|
192
|
+
* files client-side before POSTing. Tolerates kernel snake_case `max_size`.
|
|
193
|
+
*/
|
|
194
|
+
maxSize?: number;
|
|
195
|
+
/** snake_case alias served by the kernel manifest for `maxSize`. */
|
|
196
|
+
max_size?: number;
|
|
197
|
+
/**
|
|
198
|
+
* `upload` widget: server-side storage bucket/prefix the host writes the
|
|
199
|
+
* file under, forwarded to the upload endpoint as `storage_path`. Tolerates
|
|
200
|
+
* kernel snake_case `storage_path`.
|
|
201
|
+
*/
|
|
202
|
+
storagePath?: string;
|
|
203
|
+
/** snake_case alias served by the kernel manifest for `storagePath`. */
|
|
204
|
+
storage_path?: string;
|
|
149
205
|
}
|
|
150
206
|
/**
|
|
151
207
|
* Declarative reconciliation constraint on a line-items field: the summed value
|
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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { isActionAllowedForRowState } from '../dynamic-columns'
|
|
4
|
+
|
|
5
|
+
describe('isActionAllowedForRowState', () => {
|
|
6
|
+
it('hides the action when row.status is NOT in requiresState', () => {
|
|
7
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
8
|
+
const row = { id: 1, status: 'in_progress' }
|
|
9
|
+
expect(isActionAllowedForRowState(action, row)).toBe(false)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows the action when row.status IS in requiresState', () => {
|
|
13
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
14
|
+
const row = { id: 1, status: 'reception' }
|
|
15
|
+
expect(isActionAllowedForRowState(action, row)).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('shows the action when row.status matches one of several requiresState entries', () => {
|
|
19
|
+
const action = { key: 'finish', requiresState: ['in_progress', 'paused'] }
|
|
20
|
+
expect(isActionAllowedForRowState(action, { status: 'paused' })).toBe(true)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('always shows the action when requiresState is empty', () => {
|
|
24
|
+
const action = { key: 'view', requiresState: [] as string[] }
|
|
25
|
+
expect(isActionAllowedForRowState(action, { status: 'whatever' })).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('always shows the action when requiresState is absent (no regression)', () => {
|
|
29
|
+
const action = { key: 'view' }
|
|
30
|
+
expect(isActionAllowedForRowState(action, { status: 'in_progress' })).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('shows all actions when the row has no status field (no regression)', () => {
|
|
34
|
+
const action = { key: 'start', requiresState: ['reception'] }
|
|
35
|
+
expect(isActionAllowedForRowState(action, { id: 1 })).toBe(true)
|
|
36
|
+
expect(isActionAllowedForRowState(action, { id: 1, status: null })).toBe(true)
|
|
37
|
+
expect(isActionAllowedForRowState(action, { id: 1, status: '' })).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('tolerates the snake_case requires_state served by the backend', () => {
|
|
41
|
+
const action = { key: 'start', requires_state: ['reception'] }
|
|
42
|
+
expect(isActionAllowedForRowState(action, { status: 'reception' })).toBe(true)
|
|
43
|
+
expect(isActionAllowedForRowState(action, { status: 'in_progress' })).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('coerces numeric status / state values via String() comparison', () => {
|
|
47
|
+
const action = { key: 'advance', requiresState: [1, 2] as unknown as string[] }
|
|
48
|
+
expect(isActionAllowedForRowState(action, { status: 2 })).toBe(true)
|
|
49
|
+
expect(isActionAllowedForRowState(action, { status: '3' })).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -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} />
|
package/src/dynamic-columns.tsx
CHANGED
|
@@ -62,6 +62,26 @@ const defaultGetImageUrl = (path: string) => path
|
|
|
62
62
|
const getNestedValue = (obj: any, path: string) =>
|
|
63
63
|
path.split('.').reduce((acc, part) => acc && acc[part], obj)
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* State-machine gate for per-row actions.
|
|
67
|
+
*
|
|
68
|
+
* An action that declares a non-empty `requiresState` (camelCase) / `requires_state`
|
|
69
|
+
* (snake_case, as served by some backends) is only surfaced for rows whose `status`
|
|
70
|
+
* field value is contained in that array. This hides e.g. an "Iniciar trabajo"
|
|
71
|
+
* action (requiresState: ['reception']) on an order already in `in_progress`.
|
|
72
|
+
*
|
|
73
|
+
* Null-safe & non-regressive:
|
|
74
|
+
* - action without requiresState (or empty array) → always shown.
|
|
75
|
+
* - row with no `status` field → all actions shown.
|
|
76
|
+
*/
|
|
77
|
+
export const isActionAllowedForRowState = (action: any, row: any): boolean => {
|
|
78
|
+
const requires: unknown = action?.requiresState ?? action?.requires_state
|
|
79
|
+
if (!Array.isArray(requires) || requires.length === 0) return true
|
|
80
|
+
const status = row?.status
|
|
81
|
+
if (status === undefined || status === null || status === '') return true
|
|
82
|
+
return requires.map(String).includes(String(status))
|
|
83
|
+
}
|
|
84
|
+
|
|
65
85
|
const lowerFirst = (value?: string) => {
|
|
66
86
|
if (!value) return value
|
|
67
87
|
return value.charAt(0).toLowerCase() + value.slice(1)
|
|
@@ -519,6 +539,7 @@ export function makeDefaultGetDynamicColumns(
|
|
|
519
539
|
</DropdownMenuTrigger>
|
|
520
540
|
<DropdownMenuContent align="end">
|
|
521
541
|
{resolvedActions
|
|
542
|
+
.filter((action) => isActionAllowedForRowState(action, row.original))
|
|
522
543
|
.filter((action) => {
|
|
523
544
|
if (!action.condition) return true
|
|
524
545
|
const { field, operator, value } = action.condition
|
|
@@ -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
|
/**
|