@asteby/metacore-runtime-react 18.22.0 → 18.23.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 +26 -0
- package/dist/dialogs/dynamic-record.d.ts +10 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +61 -3
- package/package.json +3 -3
- package/src/__tests__/edit-field-polish.test.tsx +170 -0
- package/src/dialogs/dynamic-record.tsx +86 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 18.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- dc5e552: Polish the generic record EDIT dialog (`DynamicRecordDialog` mode='edit'),
|
|
8
|
+
fixing two prod issues that affected every module using it (transfers, orders,
|
|
9
|
+
customers):
|
|
10
|
+
- **jsonb line-items render read-only instead of "[object Object]".** A field
|
|
11
|
+
that is a jsonb line-items column (declares `item_fields`, or its value is an
|
|
12
|
+
array/plain object) is no longer rendered as a broken text input. The edit
|
|
13
|
+
form now renders it read-only with the same inline `CollectionCell` table the
|
|
14
|
+
detail view uses — localized headers + resolved ref labels — plus a
|
|
15
|
+
translatable "Solo lectura" hint (`datatable.readOnly`). These are
|
|
16
|
+
action-built documents; field-by-field array editing stays out of scope.
|
|
17
|
+
- **FK selects show the related record's name, not the raw uuid.** A
|
|
18
|
+
`dynamic_select` / `ref` field in edit mode now seeds its trigger from the
|
|
19
|
+
backend-injected relation sibling (`source_warehouse_id` →
|
|
20
|
+
`source_warehouse: { value, label }`, the key without `_id`) via the existing
|
|
21
|
+
`DynamicSelectField` `seedOption` prop, so an existing selection displays the
|
|
22
|
+
label immediately without waiting for a lookup. Falls back to the raw value
|
|
23
|
+
(today's behaviour) when no sibling is present; creating/changing the
|
|
24
|
+
selection is unchanged.
|
|
25
|
+
|
|
26
|
+
`EditField`, `isLineItemsField`, and `fkSeedOption` are exported from the
|
|
27
|
+
dialogs module.
|
|
28
|
+
|
|
3
29
|
## 18.22.0
|
|
4
30
|
|
|
5
31
|
### Minor Changes
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelSchema } from './types';
|
|
2
|
+
import { type ResolvedOption } from '../use-options-resolver';
|
|
2
3
|
import { type ItemField } from '../collection-cell';
|
|
3
4
|
import { type GetImageUrl } from '../image-url-context';
|
|
4
5
|
export type { GetImageUrl };
|
|
@@ -158,6 +159,8 @@ export interface DynamicRecordDialogProps {
|
|
|
158
159
|
*/
|
|
159
160
|
onChange?: () => void;
|
|
160
161
|
}
|
|
162
|
+
export declare function isLineItemsField(field: FieldDef, value: any): boolean;
|
|
163
|
+
export declare function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null;
|
|
161
164
|
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
162
165
|
export declare function DynamicRecordDialog({ open, onOpenChange, mode, model, recordId, endpoint, onSaved, onCreate, onUpdate, defaults, schema, onDelete, onEdit, onOpenFullPage, initialRecord, getImageUrl, timeZone, currency, onChange, }: DynamicRecordDialogProps): import("react").JSX.Element;
|
|
163
166
|
export declare function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }: {
|
|
@@ -171,4 +174,11 @@ export declare function ViewValue({ field, value: rawValue, record, getImageUrl:
|
|
|
171
174
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
172
175
|
currency?: string;
|
|
173
176
|
}): import("react").JSX.Element;
|
|
177
|
+
export declare function EditField({ field, value, onChange, record }: {
|
|
178
|
+
field: FieldDef;
|
|
179
|
+
value: any;
|
|
180
|
+
onChange: (val: any) => void;
|
|
181
|
+
/** The full record being edited — supplies FK relation siblings + line-items. */
|
|
182
|
+
record?: any;
|
|
183
|
+
}): import("react").JSX.Element;
|
|
174
184
|
//# sourceMappingURL=dynamic-record.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
|
|
@@ -85,6 +85,51 @@ function relationSiblingValue(field, record) {
|
|
|
85
85
|
}
|
|
86
86
|
return undefined;
|
|
87
87
|
}
|
|
88
|
+
// fieldItemFields reads the declared jsonb line-items schema off a field,
|
|
89
|
+
// tolerating the snake_case `item_fields` alias the kernel serves.
|
|
90
|
+
function fieldItemFields(field) {
|
|
91
|
+
return field.itemFields ?? field.item_fields;
|
|
92
|
+
}
|
|
93
|
+
// isLineItemsField — a jsonb line-items column (e.g. Transfer.items): it either
|
|
94
|
+
// declares an `item_fields` schema, or its value is a structured array/object.
|
|
95
|
+
// These are action-built documents; field-by-field editing of the array is out
|
|
96
|
+
// of scope, so the edit dialog renders them read-only (the inline table) rather
|
|
97
|
+
// than an input that would stringify to "[object Object]". Scalars and known
|
|
98
|
+
// editable widgets (media/upload, color, dates) are NOT line-items.
|
|
99
|
+
export function isLineItemsField(field, value) {
|
|
100
|
+
if (field.type === 'image' || field.widget === 'upload')
|
|
101
|
+
return false;
|
|
102
|
+
if (fieldItemFields(field)?.length)
|
|
103
|
+
return true;
|
|
104
|
+
if (Array.isArray(value))
|
|
105
|
+
return true;
|
|
106
|
+
return (value !== null &&
|
|
107
|
+
typeof value === 'object' &&
|
|
108
|
+
!(value instanceof Date));
|
|
109
|
+
}
|
|
110
|
+
// fkSeedOption builds the pre-resolved option for a FK select's CURRENT value
|
|
111
|
+
// from the relation sibling the backend injected alongside the column
|
|
112
|
+
// (`source_warehouse_id` → `source_warehouse = {value,label,image?}`, the key
|
|
113
|
+
// without `_id`; same convention as the jsonb refs). Lets the edit picker show
|
|
114
|
+
// the related record's NAME instead of the raw uuid without waiting for a
|
|
115
|
+
// network lookup. Returns null when there is no usable sibling (the picker then
|
|
116
|
+
// falls back to its existing typeahead/lookup behaviour).
|
|
117
|
+
export function fkSeedOption(field, value, record) {
|
|
118
|
+
if (value === undefined || value === null || value === '')
|
|
119
|
+
return null;
|
|
120
|
+
const sib = relationSiblingValue(field, record);
|
|
121
|
+
const label = typeof sib === 'string' ? sib : objectLabel(sib);
|
|
122
|
+
if (!label)
|
|
123
|
+
return null;
|
|
124
|
+
const id = String(value);
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
value: id,
|
|
128
|
+
label,
|
|
129
|
+
name: label,
|
|
130
|
+
image: pickImage(sib) ?? null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
88
133
|
// servedOption matches a field's served option list (enum/select with
|
|
89
134
|
// {value,label,color,icon,image}) against the current value.
|
|
90
135
|
function servedOption(field, value) {
|
|
@@ -454,7 +499,7 @@ function LoadingSkeleton() {
|
|
|
454
499
|
}
|
|
455
500
|
function FieldRow({ field, record, value, mode, onChange }) {
|
|
456
501
|
const isReadonly = field.readonly || mode === 'view';
|
|
457
|
-
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), isReadonly ? (_jsx(ViewValue, { field: field, value: value, record: record })) : (_jsx(EditField, { field: field, value: value, onChange: onChange }))] }));
|
|
502
|
+
return (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsxs(Label, { className: "text-xs font-medium text-muted-foreground uppercase tracking-wide", children: [field.label, field.required && mode !== 'view' && (_jsx("span", { className: "text-destructive ml-0.5", children: "*" }))] }), isReadonly ? (_jsx(ViewValue, { field: field, value: value, record: record })) : (_jsx(EditField, { field: field, value: value, onChange: onChange, record: record }))] }));
|
|
458
503
|
}
|
|
459
504
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
460
505
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
@@ -628,7 +673,15 @@ function StructuredViewValue({ value, field, locale, t, }) {
|
|
|
628
673
|
}
|
|
629
674
|
return (_jsx("div", { className: "text-sm py-1", children: _jsx(CollectionCell, { value: value, itemFields: field?.itemFields ?? field?.item_fields, variant: "inline", locale: locale, t: t }) }));
|
|
630
675
|
}
|
|
631
|
-
function EditField({ field, value, onChange }) {
|
|
676
|
+
export function EditField({ field, value, onChange, record }) {
|
|
677
|
+
const { t, i18n } = useTranslation();
|
|
678
|
+
// Jsonb line-items columns (e.g. Transfer.items) are action-built documents:
|
|
679
|
+
// editing the array field-by-field is out of scope. Render them READ-ONLY
|
|
680
|
+
// with the same inline table the detail view uses — a localized, ref-resolved
|
|
681
|
+
// mini-table — instead of an input that stringifies to "[object Object]".
|
|
682
|
+
if (isLineItemsField(field, value)) {
|
|
683
|
+
return (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "rounded-md border bg-muted/30 p-2", children: _jsx(CollectionCell, { value: value, itemFields: fieldItemFields(field), variant: "inline", locale: i18n.language, t: t }) }), _jsx("p", { className: "text-[11px] text-muted-foreground", children: t('datatable.readOnly', { defaultValue: 'Solo lectura' }) })] }));
|
|
684
|
+
}
|
|
632
685
|
if (field.type === 'boolean') {
|
|
633
686
|
return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(Switch, { checked: !!value, onCheckedChange: onChange }), _jsx("span", { className: "text-sm text-muted-foreground", children: value ? 'Sí' : 'No' })] }));
|
|
634
687
|
}
|
|
@@ -645,7 +698,12 @@ function EditField({ field, value, onChange }) {
|
|
|
645
698
|
// option thumbnails and the inline-create "+" — against /api/options/<ref>.
|
|
646
699
|
// Static inline `options` are handled by the enum <Select> branch below.
|
|
647
700
|
if ((getFieldRef(field) || field.widget === 'dynamic_select') && !field.options?.length) {
|
|
648
|
-
return _jsx(DynamicSelectField, { field: field, value: value, onChange: onChange
|
|
701
|
+
return (_jsx(DynamicSelectField, { field: field, value: value, onChange: onChange,
|
|
702
|
+
// Seed the trigger with the related record's NAME (from the
|
|
703
|
+
// backend-injected FK sibling, key without `_id`) so an existing
|
|
704
|
+
// selection shows the label, not the raw uuid — without waiting
|
|
705
|
+
// for the popover to open and fetch a page.
|
|
706
|
+
seedOption: fkSeedOption(field, value, record) }));
|
|
649
707
|
}
|
|
650
708
|
if (field.type === 'search' && field.searchEndpoint) {
|
|
651
709
|
return _jsx(SearchField, { field: field, value: value, onChange: onChange });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "18.
|
|
3
|
+
"version": "18.23.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
"typescript": "^6.0.0",
|
|
65
65
|
"vitest": "^4.0.0",
|
|
66
66
|
"zustand": "^5.0.0",
|
|
67
|
-
"@asteby/metacore-
|
|
68
|
-
"@asteby/metacore-
|
|
67
|
+
"@asteby/metacore-sdk": "3.2.0",
|
|
68
|
+
"@asteby/metacore-ui": "2.5.2"
|
|
69
69
|
},
|
|
70
70
|
"scripts": {
|
|
71
71
|
"build": "tsc -p tsconfig.json",
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Record EDIT dialog polish (mode='edit'), affecting every module that uses the
|
|
4
|
+
// generic dialog (transfers, orders, customers):
|
|
5
|
+
// PART 1 — a jsonb line-items field renders READ-ONLY as the inline table
|
|
6
|
+
// (the detail-view renderer), never an input that stringifies to
|
|
7
|
+
// "[object Object]".
|
|
8
|
+
// PART 2 — a FK select seeds its trigger from the backend-injected relation
|
|
9
|
+
// sibling (`source_warehouse_id` → `source_warehouse: {value,label}`)
|
|
10
|
+
// so an existing selection shows the related record's NAME, not the
|
|
11
|
+
// raw uuid.
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
14
|
+
|
|
15
|
+
// Identity translator (so `defaultValue` surfaces) + Spanish locale.
|
|
16
|
+
vi.mock('react-i18next', () => ({
|
|
17
|
+
useTranslation: () => ({
|
|
18
|
+
t: (k: string, o?: any) => o?.defaultValue ?? k,
|
|
19
|
+
i18n: { language: 'es' },
|
|
20
|
+
}),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
import { EditField, isLineItemsField, fkSeedOption } from '../dialogs/dynamic-record'
|
|
24
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
25
|
+
|
|
26
|
+
afterEach(cleanup)
|
|
27
|
+
|
|
28
|
+
const noopApi = { get: vi.fn(async () => ({ data: { data: [] } })) } as unknown as ApiClient
|
|
29
|
+
|
|
30
|
+
describe('isLineItemsField', () => {
|
|
31
|
+
it('matches a field with a declared item_fields schema', () => {
|
|
32
|
+
expect(
|
|
33
|
+
isLineItemsField(
|
|
34
|
+
{ key: 'items', label: 'Items', type: 'json', itemFields: [{ key: 'q', label: 'Q' }] },
|
|
35
|
+
null,
|
|
36
|
+
),
|
|
37
|
+
).toBe(true)
|
|
38
|
+
// snake_case alias
|
|
39
|
+
expect(
|
|
40
|
+
isLineItemsField(
|
|
41
|
+
{ key: 'items', label: 'Items', type: 'json', item_fields: [{ key: 'q', label: 'Q' }] },
|
|
42
|
+
null,
|
|
43
|
+
),
|
|
44
|
+
).toBe(true)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('matches any array / plain-object value (would stringify to [object Object])', () => {
|
|
48
|
+
expect(isLineItemsField({ key: 'items', label: 'Items', type: 'json' }, [{ a: 1 }])).toBe(true)
|
|
49
|
+
expect(isLineItemsField({ key: 'data', label: 'Data', type: 'json' }, { a: 1 })).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does NOT match scalars or dedicated editable widgets', () => {
|
|
53
|
+
expect(isLineItemsField({ key: 'name', label: 'Name', type: 'text' }, 'hi')).toBe(false)
|
|
54
|
+
expect(isLineItemsField({ key: 'n', label: 'N', type: 'number' }, 5)).toBe(false)
|
|
55
|
+
expect(isLineItemsField({ key: 'photo', label: 'Photo', type: 'image' }, { url: 'x' })).toBe(false)
|
|
56
|
+
expect(isLineItemsField({ key: 'f', label: 'F', type: 'text', widget: 'upload' }, {})).toBe(false)
|
|
57
|
+
expect(isLineItemsField({ key: 'when', label: 'When', type: 'date' }, new Date())).toBe(false)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('fkSeedOption', () => {
|
|
62
|
+
const field = { key: 'source_warehouse_id', label: 'Origen', type: 'text', ref: 'warehouses' }
|
|
63
|
+
|
|
64
|
+
it('builds a seed option from the injected {value,label} sibling', () => {
|
|
65
|
+
const seed = fkSeedOption(field, 'wh-1', {
|
|
66
|
+
source_warehouse_id: 'wh-1',
|
|
67
|
+
source_warehouse: { value: 'wh-1', label: 'Test2', image: 'logo.png' },
|
|
68
|
+
})
|
|
69
|
+
expect(seed).toEqual({ id: 'wh-1', value: 'wh-1', label: 'Test2', name: 'Test2', image: 'logo.png' })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null when there is no usable sibling', () => {
|
|
73
|
+
expect(fkSeedOption(field, 'wh-1', { source_warehouse_id: 'wh-1' })).toBeNull()
|
|
74
|
+
expect(fkSeedOption(field, '', {})).toBeNull()
|
|
75
|
+
expect(fkSeedOption(field, null, { source_warehouse: { label: 'x' } })).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('EditField — PART 1: jsonb line-items read-only', () => {
|
|
80
|
+
it('renders the inline table (read-only), not an input or [object Object]', () => {
|
|
81
|
+
const onChange = vi.fn()
|
|
82
|
+
const { container } = render(
|
|
83
|
+
<ApiProvider client={noopApi}>
|
|
84
|
+
<EditField
|
|
85
|
+
field={{
|
|
86
|
+
key: 'items',
|
|
87
|
+
label: 'Items',
|
|
88
|
+
type: 'json',
|
|
89
|
+
itemFields: [
|
|
90
|
+
{ key: 'product_id', label: 'Producto', ref: 'Product' },
|
|
91
|
+
{ key: 'quantity', label: 'Cantidad' },
|
|
92
|
+
],
|
|
93
|
+
}}
|
|
94
|
+
value={[
|
|
95
|
+
{
|
|
96
|
+
product_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
97
|
+
product: { value: 'x', label: 'Test' },
|
|
98
|
+
quantity: 10,
|
|
99
|
+
},
|
|
100
|
+
]}
|
|
101
|
+
onChange={onChange}
|
|
102
|
+
record={{}}
|
|
103
|
+
/>
|
|
104
|
+
</ApiProvider>,
|
|
105
|
+
)
|
|
106
|
+
// Localized headers + resolved ref label from the inline CollectionCell.
|
|
107
|
+
expect(screen.getByRole('columnheader', { name: 'Producto' })).toBeTruthy()
|
|
108
|
+
expect(screen.getByRole('cell', { name: 'Test' })).toBeTruthy()
|
|
109
|
+
expect(screen.getByRole('cell', { name: '10' })).toBeTruthy()
|
|
110
|
+
// No editable input, no [object Object], no leaked uuid, read-only hint.
|
|
111
|
+
expect(container.querySelector('input')).toBeNull()
|
|
112
|
+
expect(container.querySelector('textarea')).toBeNull()
|
|
113
|
+
expect(container.textContent).not.toContain('[object Object]')
|
|
114
|
+
expect(container.textContent).not.toContain('550e8400-e29b')
|
|
115
|
+
expect(screen.getByText('Solo lectura')).toBeTruthy()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('renders a bare jsonb object read-only (no schema) instead of [object Object]', () => {
|
|
119
|
+
const { container } = render(
|
|
120
|
+
<ApiProvider client={noopApi}>
|
|
121
|
+
<EditField
|
|
122
|
+
field={{ key: 'fiscal_data', label: 'Fiscal', type: 'json' }}
|
|
123
|
+
value={{ price: 10, quantity: 20 }}
|
|
124
|
+
onChange={vi.fn()}
|
|
125
|
+
record={{}}
|
|
126
|
+
/>
|
|
127
|
+
</ApiProvider>,
|
|
128
|
+
)
|
|
129
|
+
expect(container.querySelector('input')).toBeNull()
|
|
130
|
+
expect(container.textContent).not.toContain('[object Object]')
|
|
131
|
+
expect(screen.getByText('Precio:')).toBeTruthy()
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('EditField — PART 2: FK select shows the resolved label', () => {
|
|
136
|
+
it('seeds the trigger with the sibling label, not the raw uuid', () => {
|
|
137
|
+
render(
|
|
138
|
+
<ApiProvider client={noopApi}>
|
|
139
|
+
<EditField
|
|
140
|
+
field={{ key: 'source_warehouse_id', label: 'Origen', type: 'text', ref: 'warehouses' }}
|
|
141
|
+
value={'550e8400-e29b-41d4-a716-446655440000'}
|
|
142
|
+
onChange={vi.fn()}
|
|
143
|
+
record={{
|
|
144
|
+
source_warehouse_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
145
|
+
source_warehouse: { value: '550e8400-e29b-41d4-a716-446655440000', label: 'Test2' },
|
|
146
|
+
}}
|
|
147
|
+
/>
|
|
148
|
+
</ApiProvider>,
|
|
149
|
+
)
|
|
150
|
+
// The combobox trigger shows the resolved label.
|
|
151
|
+
expect(screen.getByText('Test2')).toBeTruthy()
|
|
152
|
+
// The raw uuid is not shown.
|
|
153
|
+
expect(screen.queryByText('550e8400-e29b-41d4-a716-446655440000')).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('keeps the raw value as fallback when no sibling was injected', () => {
|
|
157
|
+
render(
|
|
158
|
+
<ApiProvider client={noopApi}>
|
|
159
|
+
<EditField
|
|
160
|
+
field={{ key: 'source_warehouse_id', label: 'Origen', type: 'text', ref: 'warehouses' }}
|
|
161
|
+
value={'wh-xyz'}
|
|
162
|
+
onChange={vi.fn()}
|
|
163
|
+
record={{ source_warehouse_id: 'wh-xyz' }}
|
|
164
|
+
/>
|
|
165
|
+
</ApiProvider>,
|
|
166
|
+
)
|
|
167
|
+
// No sibling → existing behaviour: the raw value is shown on the trigger.
|
|
168
|
+
expect(screen.getByText('wh-xyz')).toBeTruthy()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
@@ -294,6 +294,51 @@ function relationSiblingValue(field: FieldDef, record: any): any {
|
|
|
294
294
|
return undefined
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
// fieldItemFields reads the declared jsonb line-items schema off a field,
|
|
298
|
+
// tolerating the snake_case `item_fields` alias the kernel serves.
|
|
299
|
+
function fieldItemFields(field: FieldDef): ItemField[] | undefined {
|
|
300
|
+
return field.itemFields ?? field.item_fields
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// isLineItemsField — a jsonb line-items column (e.g. Transfer.items): it either
|
|
304
|
+
// declares an `item_fields` schema, or its value is a structured array/object.
|
|
305
|
+
// These are action-built documents; field-by-field editing of the array is out
|
|
306
|
+
// of scope, so the edit dialog renders them read-only (the inline table) rather
|
|
307
|
+
// than an input that would stringify to "[object Object]". Scalars and known
|
|
308
|
+
// editable widgets (media/upload, color, dates) are NOT line-items.
|
|
309
|
+
export function isLineItemsField(field: FieldDef, value: any): boolean {
|
|
310
|
+
if (field.type === 'image' || field.widget === 'upload') return false
|
|
311
|
+
if (fieldItemFields(field)?.length) return true
|
|
312
|
+
if (Array.isArray(value)) return true
|
|
313
|
+
return (
|
|
314
|
+
value !== null &&
|
|
315
|
+
typeof value === 'object' &&
|
|
316
|
+
!(value instanceof Date)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// fkSeedOption builds the pre-resolved option for a FK select's CURRENT value
|
|
321
|
+
// from the relation sibling the backend injected alongside the column
|
|
322
|
+
// (`source_warehouse_id` → `source_warehouse = {value,label,image?}`, the key
|
|
323
|
+
// without `_id`; same convention as the jsonb refs). Lets the edit picker show
|
|
324
|
+
// the related record's NAME instead of the raw uuid without waiting for a
|
|
325
|
+
// network lookup. Returns null when there is no usable sibling (the picker then
|
|
326
|
+
// falls back to its existing typeahead/lookup behaviour).
|
|
327
|
+
export function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null {
|
|
328
|
+
if (value === undefined || value === null || value === '') return null
|
|
329
|
+
const sib = relationSiblingValue(field, record)
|
|
330
|
+
const label = typeof sib === 'string' ? sib : objectLabel(sib)
|
|
331
|
+
if (!label) return null
|
|
332
|
+
const id = String(value)
|
|
333
|
+
return {
|
|
334
|
+
id,
|
|
335
|
+
value: id,
|
|
336
|
+
label,
|
|
337
|
+
name: label,
|
|
338
|
+
image: pickImage(sib) ?? null,
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
297
342
|
// servedOption matches a field's served option list (enum/select with
|
|
298
343
|
// {value,label,color,icon,image}) against the current value.
|
|
299
344
|
function servedOption(field: FieldDef, value: any): FieldOption | undefined {
|
|
@@ -846,7 +891,7 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
846
891
|
{isReadonly ? (
|
|
847
892
|
<ViewValue field={field} value={value} record={record} />
|
|
848
893
|
) : (
|
|
849
|
-
<EditField field={field} value={value} onChange={onChange} />
|
|
894
|
+
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
850
895
|
)}
|
|
851
896
|
</div>
|
|
852
897
|
)
|
|
@@ -1161,11 +1206,38 @@ function StructuredViewValue({
|
|
|
1161
1206
|
)
|
|
1162
1207
|
}
|
|
1163
1208
|
|
|
1164
|
-
function EditField({ field, value, onChange }: {
|
|
1209
|
+
export function EditField({ field, value, onChange, record }: {
|
|
1165
1210
|
field: FieldDef
|
|
1166
1211
|
value: any
|
|
1167
1212
|
onChange: (val: any) => void
|
|
1213
|
+
/** The full record being edited — supplies FK relation siblings + line-items. */
|
|
1214
|
+
record?: any
|
|
1168
1215
|
}) {
|
|
1216
|
+
const { t, i18n } = useTranslation()
|
|
1217
|
+
|
|
1218
|
+
// Jsonb line-items columns (e.g. Transfer.items) are action-built documents:
|
|
1219
|
+
// editing the array field-by-field is out of scope. Render them READ-ONLY
|
|
1220
|
+
// with the same inline table the detail view uses — a localized, ref-resolved
|
|
1221
|
+
// mini-table — instead of an input that stringifies to "[object Object]".
|
|
1222
|
+
if (isLineItemsField(field, value)) {
|
|
1223
|
+
return (
|
|
1224
|
+
<div className="space-y-1">
|
|
1225
|
+
<div className="rounded-md border bg-muted/30 p-2">
|
|
1226
|
+
<CollectionCell
|
|
1227
|
+
value={value}
|
|
1228
|
+
itemFields={fieldItemFields(field)}
|
|
1229
|
+
variant="inline"
|
|
1230
|
+
locale={i18n.language}
|
|
1231
|
+
t={t}
|
|
1232
|
+
/>
|
|
1233
|
+
</div>
|
|
1234
|
+
<p className="text-[11px] text-muted-foreground">
|
|
1235
|
+
{t('datatable.readOnly', { defaultValue: 'Solo lectura' })}
|
|
1236
|
+
</p>
|
|
1237
|
+
</div>
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1169
1241
|
if (field.type === 'boolean') {
|
|
1170
1242
|
return (
|
|
1171
1243
|
<div className="flex items-center gap-2 py-1">
|
|
@@ -1202,7 +1274,18 @@ function EditField({ field, value, onChange }: {
|
|
|
1202
1274
|
// option thumbnails and the inline-create "+" — against /api/options/<ref>.
|
|
1203
1275
|
// Static inline `options` are handled by the enum <Select> branch below.
|
|
1204
1276
|
if ((getFieldRef(field as ActionFieldDef) || field.widget === 'dynamic_select') && !field.options?.length) {
|
|
1205
|
-
return
|
|
1277
|
+
return (
|
|
1278
|
+
<DynamicSelectField
|
|
1279
|
+
field={field as ActionFieldDef}
|
|
1280
|
+
value={value}
|
|
1281
|
+
onChange={onChange}
|
|
1282
|
+
// Seed the trigger with the related record's NAME (from the
|
|
1283
|
+
// backend-injected FK sibling, key without `_id`) so an existing
|
|
1284
|
+
// selection shows the label, not the raw uuid — without waiting
|
|
1285
|
+
// for the popover to open and fetch a page.
|
|
1286
|
+
seedOption={fkSeedOption(field, value, record)}
|
|
1287
|
+
/>
|
|
1288
|
+
)
|
|
1206
1289
|
}
|
|
1207
1290
|
|
|
1208
1291
|
if (field.type === 'search' && field.searchEndpoint) {
|