@asteby/metacore-runtime-react 18.21.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 +42 -0
- package/dist/collection-cell.d.ts +16 -1
- package/dist/collection-cell.d.ts.map +1 -1
- package/dist/collection-cell.js +24 -4
- package/dist/dialogs/dynamic-record.d.ts +22 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +82 -22
- package/package.json +1 -1
- package/src/__tests__/collection-cell.test.tsx +94 -0
- package/src/__tests__/edit-field-polish.test.tsx +170 -0
- package/src/__tests__/structured-view-value.test.tsx +99 -0
- package/src/collection-cell.tsx +40 -3
- package/src/dialogs/dynamic-record.tsx +144 -37
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
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
|
+
|
|
29
|
+
## 18.22.0
|
|
30
|
+
|
|
31
|
+
### Minor Changes
|
|
32
|
+
|
|
33
|
+
- 24cced0: The read-only record detail view now renders jsonb line-items with the same pro
|
|
34
|
+
rendering as the table instead of raw `JSON.stringify`. `CollectionCell` gains a
|
|
35
|
+
`variant?: 'badge' | 'inline'` prop (default `'badge'` = unchanged behaviour);
|
|
36
|
+
`'inline'` renders the mini-table / pair-list / scalar-list directly, with no
|
|
37
|
+
badge or popover, for the full-width detail dialog. The detail view's
|
|
38
|
+
`StructuredViewValue` delegates to `<CollectionCell variant="inline" …>`,
|
|
39
|
+
threading the field's `item_fields` schema plus locale + translator: an
|
|
40
|
+
`item_fields` schema drives localized headers + resolved ref labels (the
|
|
41
|
+
injected `{ value, label }` sibling — product name instead of the raw uuid),
|
|
42
|
+
and without a schema it falls back to a localized mini-table / pair list. The
|
|
43
|
+
"—" empty marker is preserved.
|
|
44
|
+
|
|
3
45
|
## 18.21.0
|
|
4
46
|
|
|
5
47
|
### Minor Changes
|
|
@@ -57,10 +57,25 @@ export interface CollectionCellProps {
|
|
|
57
57
|
* behaviour is unchanged.
|
|
58
58
|
*/
|
|
59
59
|
itemFields?: ItemField[];
|
|
60
|
+
/**
|
|
61
|
+
* Presentation mode.
|
|
62
|
+
* - `'badge'` (default): the compact count/preview badge that opens a
|
|
63
|
+
* popover with the mini-table / pair-list. Used in dense table cells.
|
|
64
|
+
* - `'inline'`: render the mini-table / pair-list DIRECTLY, with no badge
|
|
65
|
+
* or popover. Used by the read-only record detail view, which has full
|
|
66
|
+
* width and shows one field per row. All schema/locale logic is shared.
|
|
67
|
+
*/
|
|
68
|
+
variant?: 'badge' | 'inline';
|
|
60
69
|
}
|
|
61
70
|
/**
|
|
62
71
|
* Generic renderer for jsonb / array / object cell values. Brand-neutral,
|
|
63
72
|
* compact, dark-mode friendly, locale-aware. Never throws on unexpected shapes.
|
|
73
|
+
*
|
|
74
|
+
* `variant` selects the surface: the default `'badge'` shows a compact trigger
|
|
75
|
+
* + popover (dense table cells); `'inline'` renders the mini-table / pair-list
|
|
76
|
+
* directly for the full-width record detail view. Both paths share the
|
|
77
|
+
* itemFields schema (localized headers + resolved ref labels) and the
|
|
78
|
+
* locale-aware generic fallback.
|
|
64
79
|
*/
|
|
65
|
-
export declare function CollectionCell({ value, maxInline, locale, t, itemFields, }: CollectionCellProps): React.JSX.Element;
|
|
80
|
+
export declare function CollectionCell({ value, maxInline, locale, t, itemFields, variant, }: CollectionCellProps): React.JSX.Element;
|
|
66
81
|
//# sourceMappingURL=collection-cell.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"collection-cell.d.ts","sourceRoot":"","sources":["../src/collection-cell.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAoB9B,sFAAsF;AACtF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,CAAA;AAE9D;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACtB,qEAAqE;IACrE,GAAG,EAAE,MAAM,CAAA;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAA;CACf;AAmGD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACvB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAWR;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACtB,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAcR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYnD;AAiMD,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,OAAO,CAAA;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,CAAC,CAAC,EAAE,SAAS,CAAA;IACb;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"collection-cell.d.ts","sourceRoot":"","sources":["../src/collection-cell.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAoB9B,sFAAsF;AACtF,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,MAAM,CAAA;AAE9D;;;;;;;GAOG;AACH,MAAM,WAAW,SAAS;IACtB,qEAAqE;IACrE,GAAG,EAAE,MAAM,CAAA;IACX,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8EAA8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAA;CACf;AAmGD;;;;;;GAMG;AACH,wBAAgB,WAAW,CACvB,GAAG,EAAE,MAAM,EACX,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAWR;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACtB,KAAK,EAAE,MAAM,EACb,MAAM,CAAC,EAAE,MAAM,EACf,CAAC,CAAC,EAAE,SAAS,GACd,MAAM,CAcR;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAYnD;AAiMD,MAAM,WAAW,mBAAmB;IAChC,KAAK,EAAE,OAAO,CAAA;IACd,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,2EAA2E;IAC3E,CAAC,CAAC,EAAE,SAAS,CAAA;IACb;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAA;CAC/B;AAED;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,SAAa,EACb,MAAM,EACN,CAAC,EACD,UAAU,EACV,OAAiB,GACpB,EAAE,mBAAmB,qBAyHrB"}
|
package/dist/collection-cell.js
CHANGED
|
@@ -222,9 +222,16 @@ function PopoverShell({ label, title, children, icon = true, }) {
|
|
|
222
222
|
/**
|
|
223
223
|
* Generic renderer for jsonb / array / object cell values. Brand-neutral,
|
|
224
224
|
* compact, dark-mode friendly, locale-aware. Never throws on unexpected shapes.
|
|
225
|
+
*
|
|
226
|
+
* `variant` selects the surface: the default `'badge'` shows a compact trigger
|
|
227
|
+
* + popover (dense table cells); `'inline'` renders the mini-table / pair-list
|
|
228
|
+
* directly for the full-width record detail view. Both paths share the
|
|
229
|
+
* itemFields schema (localized headers + resolved ref labels) and the
|
|
230
|
+
* locale-aware generic fallback.
|
|
225
231
|
*/
|
|
226
|
-
export function CollectionCell({ value, maxInline = 3, locale, t, itemFields, }) {
|
|
232
|
+
export function CollectionCell({ value, maxInline = 3, locale, t, itemFields, variant = 'badge', }) {
|
|
227
233
|
const parsed = parseValue(value);
|
|
234
|
+
const inline = variant === 'inline';
|
|
228
235
|
// Empty-ish → muted dash.
|
|
229
236
|
if (parsed === null ||
|
|
230
237
|
parsed === undefined ||
|
|
@@ -243,6 +250,11 @@ export function CollectionCell({ value, maxInline = 3, locale, t, itemFields, })
|
|
|
243
250
|
const allObjects = parsed.every((item) => isPlainObject(item));
|
|
244
251
|
if (allObjects) {
|
|
245
252
|
const rows = parsed;
|
|
253
|
+
// Inline mode (detail view): render the mini-table directly, no
|
|
254
|
+
// badge/popover. The same schema-driven path applies.
|
|
255
|
+
if (inline) {
|
|
256
|
+
return (_jsx(MiniTable, { rows: rows, locale: locale, t: t, itemFields: itemFields }));
|
|
257
|
+
}
|
|
246
258
|
const count = rows.length;
|
|
247
259
|
const label = countLabel(count, locale, t);
|
|
248
260
|
const hasSchema = !!(itemFields && itemFields.length > 0);
|
|
@@ -262,7 +274,11 @@ export function CollectionCell({ value, maxInline = 3, locale, t, itemFields, })
|
|
|
262
274
|
.join(' | ');
|
|
263
275
|
return (_jsx(PopoverShell, { label: label, title: title, children: _jsx(MiniTable, { rows: rows, locale: locale, t: t, itemFields: itemFields }) }));
|
|
264
276
|
}
|
|
265
|
-
// Array of scalars (or mixed)
|
|
277
|
+
// Array of scalars (or mixed). Inline mode renders the full list; badge
|
|
278
|
+
// mode previews the first N joined with a "+N" overflow trigger.
|
|
279
|
+
if (inline) {
|
|
280
|
+
return _jsx(ScalarList, { values: parsed });
|
|
281
|
+
}
|
|
266
282
|
const preview = parsed.slice(0, maxInline).map(formatScalar).join(', ');
|
|
267
283
|
const overflow = parsed.length - maxInline;
|
|
268
284
|
const label = overflow > 0 ? `${preview} +${overflow}` : preview;
|
|
@@ -271,12 +287,16 @@ export function CollectionCell({ value, maxInline = 3, locale, t, itemFields, })
|
|
|
271
287
|
}
|
|
272
288
|
// PLAIN OBJECT -----------------------------------------------------------
|
|
273
289
|
const entries = Object.entries(parsed);
|
|
274
|
-
|
|
290
|
+
// Inline mode renders the full key→value pair list directly.
|
|
291
|
+
if (inline) {
|
|
292
|
+
return _jsx(PairList, { entries: entries, locale: locale, t: t });
|
|
293
|
+
}
|
|
294
|
+
const previewPairs = entries
|
|
275
295
|
.slice(0, maxInline)
|
|
276
296
|
.map(([k, v]) => `${prettifyKey(k, locale, t)}: ${formatScalar(v)}`)
|
|
277
297
|
.join(', ');
|
|
278
298
|
const overflow = entries.length - maxInline;
|
|
279
|
-
const label = overflow > 0 ? `${
|
|
299
|
+
const label = overflow > 0 ? `${previewPairs} +${overflow}` : previewPairs;
|
|
280
300
|
const title = entries
|
|
281
301
|
.map(([k, v]) => `${prettifyKey(k, locale, t)}: ${formatScalar(v)}`)
|
|
282
302
|
.join(', ');
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ModelSchema } from './types';
|
|
2
|
+
import { type ResolvedOption } from '../use-options-resolver';
|
|
3
|
+
import { type ItemField } from '../collection-cell';
|
|
2
4
|
import { type GetImageUrl } from '../image-url-context';
|
|
3
5
|
export type { GetImageUrl };
|
|
4
6
|
export interface FieldOption {
|
|
@@ -58,6 +60,17 @@ export interface FieldDef {
|
|
|
58
60
|
* over the org fallback.
|
|
59
61
|
*/
|
|
60
62
|
styleConfig?: Record<string, any>;
|
|
63
|
+
/**
|
|
64
|
+
* Declared schema for a jsonb line-items field (kernel v3 `item_fields`).
|
|
65
|
+
* The backend serves this on modal/detail fields the same way it does on
|
|
66
|
+
* table columns. When present the read-only detail view renders the
|
|
67
|
+
* `CollectionCell` mini-table with these (already-localized) headers in
|
|
68
|
+
* order and resolves `ref` columns to the backend-injected sibling label.
|
|
69
|
+
* Tolerates the snake_case `item_fields` the kernel serves.
|
|
70
|
+
*/
|
|
71
|
+
itemFields?: ItemField[];
|
|
72
|
+
/** snake_case alias served by the kernel for `itemFields`. */
|
|
73
|
+
item_fields?: ItemField[];
|
|
61
74
|
}
|
|
62
75
|
export interface DynamicRecordDialogProps {
|
|
63
76
|
open: boolean;
|
|
@@ -146,6 +159,8 @@ export interface DynamicRecordDialogProps {
|
|
|
146
159
|
*/
|
|
147
160
|
onChange?: () => void;
|
|
148
161
|
}
|
|
162
|
+
export declare function isLineItemsField(field: FieldDef, value: any): boolean;
|
|
163
|
+
export declare function fkSeedOption(field: FieldDef, value: any, record: any): ResolvedOption | null;
|
|
149
164
|
export declare function isMoneyField(field: FieldDef, value: any): boolean;
|
|
150
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;
|
|
151
166
|
export declare function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }: {
|
|
@@ -159,4 +174,11 @@ export declare function ViewValue({ field, value: rawValue, record, getImageUrl:
|
|
|
159
174
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
160
175
|
currency?: string;
|
|
161
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;
|
|
162
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":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAA;AAuC1C,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,yBAAyB,CAAA;AAMjF,OAAO,EAAkB,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAEnE,OAAO,EAAqC,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAA;AAK1F,YAAY,EAAE,WAAW,EAAE,CAAA;AAE3B,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,KAAK,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,QAAQ;IACrB,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,MAAM,CAAA;IACpH,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,OAAO,CAAC,EAAE,WAAW,EAAE,CAAA;IACvB,YAAY,CAAC,EAAE,GAAG,CAAA;IAClB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;;;OAQG;IACH,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAA;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACjC;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,SAAS,EAAE,CAAA;IACxB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,SAAS,EAAE,CAAA;CAC5B;AAiCD,MAAM,WAAW,wBAAwB;IACrC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACxB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;2DAEuD;IACvD,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IAClF;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAAK,OAAO,CAAC;QAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC,CAAA;IACpG;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC9B;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;IACnB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,IAAI,CAAA;IAC3B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAA;IAC1C;;;;OAIG;IACH,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACxB;AAyDD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CASrE;AASD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,GAAG,cAAc,GAAG,IAAI,CAa5F;AA6FD,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,GAAG,GAAG,OAAO,CAUjE;AAED,wBAAgB,mBAAmB,CAAC,EAChC,IAAI,EACJ,YAAY,EACZ,IAAI,EACJ,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,OAAO,EACP,QAAQ,EACR,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,QAAQ,EACR,MAAM,EACN,cAAc,EACd,aAAa,EACb,WAA8B,EAC9B,QAAQ,EACR,QAAQ,EACR,QAAQ,GACX,EAAE,wBAAwB,+BAuY1B;AAgGD,wBAAgB,SAAS,CAAC,EACtB,KAAK,EACL,KAAK,EAAE,QAAQ,EACf,MAAM,EACN,WAAW,EAAE,eAAe,EAC5B,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,GACzB,EAAE;IACC,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,MAAM,EAAE,GAAG,CAAA;IACX,mFAAmF;IACnF,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAA;CACpB,+BAqLA;AA2DD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE;IAC1D,KAAK,EAAE,QAAQ,CAAA;IACf,KAAK,EAAE,GAAG,CAAA;IACV,QAAQ,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC5B,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAA;CACf,+BA6KA"}
|
|
@@ -27,6 +27,7 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid';
|
|
|
27
27
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon';
|
|
28
28
|
import { humanizeToken } from '../dynamic-columns-helpers';
|
|
29
29
|
import { formatDateCell } from '../dynamic-columns';
|
|
30
|
+
import { CollectionCell } from '../collection-cell';
|
|
30
31
|
import { ImageUrlContext, identityImageUrl } from '../image-url-context';
|
|
31
32
|
import { TimeZoneContext, CurrencyContext } from '../org-runtime-context';
|
|
32
33
|
// localizedModelName resolves the (possibly addon-i18n) model name: prefer the
|
|
@@ -84,6 +85,51 @@ function relationSiblingValue(field, record) {
|
|
|
84
85
|
}
|
|
85
86
|
return undefined;
|
|
86
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
|
+
}
|
|
87
133
|
// servedOption matches a field's served option list (enum/select with
|
|
88
134
|
// {value,label,color,icon,image}) against the current value.
|
|
89
135
|
function servedOption(field, value) {
|
|
@@ -453,7 +499,7 @@ function LoadingSkeleton() {
|
|
|
453
499
|
}
|
|
454
500
|
function FieldRow({ field, record, value, mode, onChange }) {
|
|
455
501
|
const isReadonly = field.readonly || mode === 'view';
|
|
456
|
-
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 }))] }));
|
|
457
503
|
}
|
|
458
504
|
// RelationViewValue — read-only FK lead. Resolves the relation's label + image
|
|
459
505
|
// from (1) the sibling object the table served, then (2) the canonical options
|
|
@@ -496,7 +542,7 @@ function RelationViewValue({ field, value, record }) {
|
|
|
496
542
|
return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx(OptionLead, { option: lead, size: 24 }), _jsx("span", { className: "text-sm", children: label ?? '—' })] }));
|
|
497
543
|
}
|
|
498
544
|
export function ViewValue({ field, value: rawValue, record, getImageUrl: getImageUrlProp, timeZone: timeZoneProp, currency: currencyProp, }) {
|
|
499
|
-
const { i18n } = useTranslation();
|
|
545
|
+
const { t, i18n } = useTranslation();
|
|
500
546
|
const ctxImageUrl = useContext(ImageUrlContext);
|
|
501
547
|
const ctxTimeZone = useContext(TimeZoneContext);
|
|
502
548
|
const ctxCurrency = useContext(CurrencyContext);
|
|
@@ -592,7 +638,7 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
|
|
|
592
638
|
// to surface — render readable key/value pairs instead of falling through to
|
|
593
639
|
// String(value) ("[object Object]").
|
|
594
640
|
if (value !== null && typeof value === 'object') {
|
|
595
|
-
return _jsx(StructuredViewValue, { value: value });
|
|
641
|
+
return (_jsx(StructuredViewValue, { value: value, field: field, locale: i18n.language, t: t }));
|
|
596
642
|
}
|
|
597
643
|
const display = formatDisplayValue(value, field);
|
|
598
644
|
if (field.type === 'textarea') {
|
|
@@ -606,27 +652,36 @@ export function ViewValue({ field, value: rawValue, record, getImageUrl: getImag
|
|
|
606
652
|
function IconNameViewValue({ name }) {
|
|
607
653
|
return (_jsxs("div", { className: "flex items-center gap-2 py-1", children: [_jsx("div", { className: "h-8 w-8 flex items-center justify-center rounded bg-muted", children: _jsx(DynamicIcon, { name: name, className: "h-4 w-4" }) }), _jsx("span", { className: "text-sm text-muted-foreground", children: name })] }));
|
|
608
654
|
}
|
|
609
|
-
// StructuredViewValue renders a jsonb object/array that has no resolvable label
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
655
|
+
// StructuredViewValue renders a jsonb object/array that has no resolvable label.
|
|
656
|
+
// It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
|
|
657
|
+
// view gets the SAME pro rendering as the table: a declared `item_fields` schema
|
|
658
|
+
// drives localized headers + resolved ref labels (the injected `{value,label}`
|
|
659
|
+
// sibling) for line-items; without a schema it falls back to a localized
|
|
660
|
+
// key→value pair list / mini-table — never raw `JSON.stringify`. Empty arrays /
|
|
661
|
+
// empty objects keep the "—" marker (CollectionCell renders a muted dash, which
|
|
662
|
+
// we normalize to the em-dash the detail view uses elsewhere).
|
|
663
|
+
function StructuredViewValue({ value, field, locale, t, }) {
|
|
664
|
+
const isEmpty = value === null ||
|
|
665
|
+
value === undefined ||
|
|
666
|
+
value === '' ||
|
|
667
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
668
|
+
(typeof value === 'object' &&
|
|
669
|
+
!Array.isArray(value) &&
|
|
670
|
+
Object.keys(value).length === 0);
|
|
671
|
+
if (isEmpty) {
|
|
625
672
|
return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
|
|
626
673
|
}
|
|
627
|
-
return (_jsx("
|
|
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 }) }));
|
|
628
675
|
}
|
|
629
|
-
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
|
+
}
|
|
630
685
|
if (field.type === 'boolean') {
|
|
631
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' })] }));
|
|
632
687
|
}
|
|
@@ -643,7 +698,12 @@ function EditField({ field, value, onChange }) {
|
|
|
643
698
|
// option thumbnails and the inline-create "+" — against /api/options/<ref>.
|
|
644
699
|
// Static inline `options` are handled by the enum <Select> branch below.
|
|
645
700
|
if ((getFieldRef(field) || field.widget === 'dynamic_select') && !field.options?.length) {
|
|
646
|
-
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) }));
|
|
647
707
|
}
|
|
648
708
|
if (field.type === 'search' && field.searchEndpoint) {
|
|
649
709
|
return _jsx(SearchField, { field: field, value: value, onChange: onChange });
|
package/package.json
CHANGED
|
@@ -329,3 +329,97 @@ describe('CollectionCell with itemFields schema', () => {
|
|
|
329
329
|
expect(title).toContain('Cantidad: 2')
|
|
330
330
|
})
|
|
331
331
|
})
|
|
332
|
+
|
|
333
|
+
describe('CollectionCell variant="inline" (detail view)', () => {
|
|
334
|
+
const itemFields = [
|
|
335
|
+
{ key: 'product_id', label: 'Producto', ref: 'Product' },
|
|
336
|
+
{ key: 'quantity', label: 'Cantidad' },
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
it('renders the mini-table DIRECTLY with no popover trigger / badge', () => {
|
|
340
|
+
render(
|
|
341
|
+
<CollectionCell
|
|
342
|
+
variant="inline"
|
|
343
|
+
value={[{ product_id: 'a', quantity: 2 }]}
|
|
344
|
+
/>
|
|
345
|
+
)
|
|
346
|
+
// The table is in the DOM immediately — no click needed.
|
|
347
|
+
expect(screen.getByRole('table')).toBeTruthy()
|
|
348
|
+
// No count-badge trigger ("1 item") is rendered in inline mode.
|
|
349
|
+
expect(screen.queryByText('1 item')).toBeNull()
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('uses localized schema headers + resolved ref labels (no raw uuid/JSON)', () => {
|
|
353
|
+
const { container } = render(
|
|
354
|
+
<CollectionCell
|
|
355
|
+
variant="inline"
|
|
356
|
+
locale="es"
|
|
357
|
+
itemFields={itemFields}
|
|
358
|
+
value={[
|
|
359
|
+
{
|
|
360
|
+
product_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
361
|
+
product: { value: 'x', label: 'Test' },
|
|
362
|
+
quantity: 10,
|
|
363
|
+
},
|
|
364
|
+
]}
|
|
365
|
+
/>
|
|
366
|
+
)
|
|
367
|
+
// "Producto | Cantidad / Test | 10"
|
|
368
|
+
expect(screen.getByRole('columnheader', { name: 'Producto' })).toBeTruthy()
|
|
369
|
+
expect(screen.getByRole('columnheader', { name: 'Cantidad' })).toBeTruthy()
|
|
370
|
+
expect(screen.getByRole('cell', { name: 'Test' })).toBeTruthy()
|
|
371
|
+
expect(screen.getByRole('cell', { name: '10' })).toBeTruthy()
|
|
372
|
+
// The raw uuid must not leak, and there is no JSON.stringify <pre> block.
|
|
373
|
+
expect(screen.queryByText('550e8400…')).toBeNull()
|
|
374
|
+
expect(container.querySelector('pre')).toBeNull()
|
|
375
|
+
expect(container.textContent).not.toContain('550e8400-e29b')
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
it('falls back to a generic localized mini-table when no itemFields (no raw JSON)', () => {
|
|
379
|
+
const { container } = render(
|
|
380
|
+
<CollectionCell
|
|
381
|
+
variant="inline"
|
|
382
|
+
locale="es"
|
|
383
|
+
value={[{ product_id: 'abc', quantity: 2 }]}
|
|
384
|
+
/>
|
|
385
|
+
)
|
|
386
|
+
// Generic dict path still drives the header; no JSON dump.
|
|
387
|
+
expect(screen.getByRole('columnheader', { name: 'Producto' })).toBeTruthy()
|
|
388
|
+
expect(screen.getByRole('cell', { name: '2' })).toBeTruthy()
|
|
389
|
+
expect(container.querySelector('pre')).toBeNull()
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
it('renders a plain object as an inline localized pair list (no popover)', () => {
|
|
393
|
+
render(
|
|
394
|
+
<CollectionCell
|
|
395
|
+
variant="inline"
|
|
396
|
+
locale="es"
|
|
397
|
+
value={{ price: 10, quantity: 20 }}
|
|
398
|
+
/>
|
|
399
|
+
)
|
|
400
|
+
// Pair list is rendered directly; the badge "+N" preview is not used.
|
|
401
|
+
expect(screen.getByText('Precio:')).toBeTruthy()
|
|
402
|
+
expect(screen.getByText('Cantidad:')).toBeTruthy()
|
|
403
|
+
expect(screen.queryByRole('table')).toBeNull()
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('renders a scalar array as an inline list directly', () => {
|
|
407
|
+
render(
|
|
408
|
+
<CollectionCell variant="inline" value={['a', 'b', 'c', 'd', 'e']} />
|
|
409
|
+
)
|
|
410
|
+
// Full list, no "+2" overflow badge.
|
|
411
|
+
expect(screen.getByText('e')).toBeTruthy()
|
|
412
|
+
expect(screen.queryByText('a, b, c +2')).toBeNull()
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('keeps the muted dash for empty / null values', () => {
|
|
416
|
+
const { container: empty } = render(
|
|
417
|
+
<CollectionCell variant="inline" value={[]} />
|
|
418
|
+
)
|
|
419
|
+
expect(empty.textContent).toBe('-')
|
|
420
|
+
const { container: nul } = render(
|
|
421
|
+
<CollectionCell variant="inline" value={null} />
|
|
422
|
+
)
|
|
423
|
+
expect(nul.textContent).toBe('-')
|
|
424
|
+
})
|
|
425
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Detail-view wiring: the read-only "Información detallada del registro" dialog
|
|
4
|
+
// renders jsonb line-items through `ViewValue` → `StructuredViewValue` →
|
|
5
|
+
// `CollectionCell variant="inline"`. This locks in the regression fix: the
|
|
6
|
+
// detail view no longer dumps raw `JSON.stringify`, and an `item_fields` schema
|
|
7
|
+
// drives localized headers + resolved ref labels (the injected `{value,label}`
|
|
8
|
+
// sibling) — "Producto | Cantidad / Test | 10".
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
|
|
12
|
+
// Identity translator + a Spanish locale, matching the host dialog.
|
|
13
|
+
vi.mock('react-i18next', () => ({
|
|
14
|
+
useTranslation: () => ({ t: (k: string) => k, i18n: { language: 'es' } }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
import { ViewValue } from '../dialogs/dynamic-record'
|
|
18
|
+
|
|
19
|
+
afterEach(cleanup)
|
|
20
|
+
|
|
21
|
+
describe('detail-view jsonb line-items (ViewValue → inline CollectionCell)', () => {
|
|
22
|
+
const itemFields = [
|
|
23
|
+
{ key: 'product_id', label: 'Producto', ref: 'Product' },
|
|
24
|
+
{ key: 'quantity', label: 'Cantidad' },
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
it('renders the schema mini-table with resolved ref labels, not raw JSON', () => {
|
|
28
|
+
const { container } = render(
|
|
29
|
+
<ViewValue
|
|
30
|
+
field={{ key: 'items', label: 'Items', type: 'json', itemFields }}
|
|
31
|
+
value={[
|
|
32
|
+
{
|
|
33
|
+
product_id: '550e8400-e29b-41d4-a716-446655440000',
|
|
34
|
+
product: { value: 'x', label: 'Test' },
|
|
35
|
+
quantity: 10,
|
|
36
|
+
},
|
|
37
|
+
]}
|
|
38
|
+
record={{}}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
// Localized headers from the schema (verbatim) + resolved ref value.
|
|
42
|
+
expect(screen.getByRole('columnheader', { name: 'Producto' })).toBeTruthy()
|
|
43
|
+
expect(screen.getByRole('columnheader', { name: 'Cantidad' })).toBeTruthy()
|
|
44
|
+
expect(screen.getByRole('cell', { name: 'Test' })).toBeTruthy()
|
|
45
|
+
expect(screen.getByRole('cell', { name: '10' })).toBeTruthy()
|
|
46
|
+
// No raw JSON dump, no leaked uuid.
|
|
47
|
+
expect(container.querySelector('pre')).toBeNull()
|
|
48
|
+
expect(container.textContent).not.toContain('550e8400-e29b')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('tolerates the snake_case item_fields alias', () => {
|
|
52
|
+
render(
|
|
53
|
+
<ViewValue
|
|
54
|
+
field={{ key: 'items', label: 'Items', type: 'json', item_fields: itemFields }}
|
|
55
|
+
value={[
|
|
56
|
+
{ product_id: 'a', product: { value: 'a', label: 'Aceite' }, quantity: 3 },
|
|
57
|
+
]}
|
|
58
|
+
record={{}}
|
|
59
|
+
/>
|
|
60
|
+
)
|
|
61
|
+
expect(screen.getByRole('cell', { name: 'Aceite' })).toBeTruthy()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('falls back to a generic localized mini-table when no schema (no raw JSON)', () => {
|
|
65
|
+
const { container } = render(
|
|
66
|
+
<ViewValue
|
|
67
|
+
field={{ key: 'items', label: 'Items', type: 'json' }}
|
|
68
|
+
value={[{ product_id: 'abc', quantity: 2 }]}
|
|
69
|
+
record={{}}
|
|
70
|
+
/>
|
|
71
|
+
)
|
|
72
|
+
// product_id → "Producto" via the built-in es dictionary; no <pre>.
|
|
73
|
+
expect(screen.getByRole('columnheader', { name: 'Producto' })).toBeTruthy()
|
|
74
|
+
expect(container.querySelector('pre')).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('renders a plain jsonb object as a localized pair list (not [object Object])', () => {
|
|
78
|
+
const { container } = render(
|
|
79
|
+
<ViewValue
|
|
80
|
+
field={{ key: 'fiscal_data', label: 'Fiscal', type: 'json' }}
|
|
81
|
+
value={{ price: 10, quantity: 20 }}
|
|
82
|
+
record={{}}
|
|
83
|
+
/>
|
|
84
|
+
)
|
|
85
|
+
expect(screen.getByText('Precio:')).toBeTruthy()
|
|
86
|
+
expect(container.textContent).not.toContain('[object Object]')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the "—" empty marker for an empty array', () => {
|
|
90
|
+
const { container } = render(
|
|
91
|
+
<ViewValue
|
|
92
|
+
field={{ key: 'items', label: 'Items', type: 'json' }}
|
|
93
|
+
value={[]}
|
|
94
|
+
record={{}}
|
|
95
|
+
/>
|
|
96
|
+
)
|
|
97
|
+
expect(container.textContent).toContain('—')
|
|
98
|
+
})
|
|
99
|
+
})
|
package/src/collection-cell.tsx
CHANGED
|
@@ -421,11 +421,26 @@ export interface CollectionCellProps {
|
|
|
421
421
|
* behaviour is unchanged.
|
|
422
422
|
*/
|
|
423
423
|
itemFields?: ItemField[]
|
|
424
|
+
/**
|
|
425
|
+
* Presentation mode.
|
|
426
|
+
* - `'badge'` (default): the compact count/preview badge that opens a
|
|
427
|
+
* popover with the mini-table / pair-list. Used in dense table cells.
|
|
428
|
+
* - `'inline'`: render the mini-table / pair-list DIRECTLY, with no badge
|
|
429
|
+
* or popover. Used by the read-only record detail view, which has full
|
|
430
|
+
* width and shows one field per row. All schema/locale logic is shared.
|
|
431
|
+
*/
|
|
432
|
+
variant?: 'badge' | 'inline'
|
|
424
433
|
}
|
|
425
434
|
|
|
426
435
|
/**
|
|
427
436
|
* Generic renderer for jsonb / array / object cell values. Brand-neutral,
|
|
428
437
|
* compact, dark-mode friendly, locale-aware. Never throws on unexpected shapes.
|
|
438
|
+
*
|
|
439
|
+
* `variant` selects the surface: the default `'badge'` shows a compact trigger
|
|
440
|
+
* + popover (dense table cells); `'inline'` renders the mini-table / pair-list
|
|
441
|
+
* directly for the full-width record detail view. Both paths share the
|
|
442
|
+
* itemFields schema (localized headers + resolved ref labels) and the
|
|
443
|
+
* locale-aware generic fallback.
|
|
429
444
|
*/
|
|
430
445
|
export function CollectionCell({
|
|
431
446
|
value,
|
|
@@ -433,8 +448,10 @@ export function CollectionCell({
|
|
|
433
448
|
locale,
|
|
434
449
|
t,
|
|
435
450
|
itemFields,
|
|
451
|
+
variant = 'badge',
|
|
436
452
|
}: CollectionCellProps) {
|
|
437
453
|
const parsed = parseValue(value)
|
|
454
|
+
const inline = variant === 'inline'
|
|
438
455
|
|
|
439
456
|
// Empty-ish → muted dash.
|
|
440
457
|
if (
|
|
@@ -465,6 +482,18 @@ export function CollectionCell({
|
|
|
465
482
|
const allObjects = parsed.every((item) => isPlainObject(item))
|
|
466
483
|
if (allObjects) {
|
|
467
484
|
const rows = parsed as Record<string, unknown>[]
|
|
485
|
+
// Inline mode (detail view): render the mini-table directly, no
|
|
486
|
+
// badge/popover. The same schema-driven path applies.
|
|
487
|
+
if (inline) {
|
|
488
|
+
return (
|
|
489
|
+
<MiniTable
|
|
490
|
+
rows={rows}
|
|
491
|
+
locale={locale}
|
|
492
|
+
t={t}
|
|
493
|
+
itemFields={itemFields}
|
|
494
|
+
/>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
468
497
|
const count = rows.length
|
|
469
498
|
const label = countLabel(count, locale, t)
|
|
470
499
|
const hasSchema = !!(itemFields && itemFields.length > 0)
|
|
@@ -504,7 +533,11 @@ export function CollectionCell({
|
|
|
504
533
|
)
|
|
505
534
|
}
|
|
506
535
|
|
|
507
|
-
// Array of scalars (or mixed)
|
|
536
|
+
// Array of scalars (or mixed). Inline mode renders the full list; badge
|
|
537
|
+
// mode previews the first N joined with a "+N" overflow trigger.
|
|
538
|
+
if (inline) {
|
|
539
|
+
return <ScalarList values={parsed} />
|
|
540
|
+
}
|
|
508
541
|
const preview = parsed.slice(0, maxInline).map(formatScalar).join(', ')
|
|
509
542
|
const overflow = parsed.length - maxInline
|
|
510
543
|
const label =
|
|
@@ -519,12 +552,16 @@ export function CollectionCell({
|
|
|
519
552
|
|
|
520
553
|
// PLAIN OBJECT -----------------------------------------------------------
|
|
521
554
|
const entries = Object.entries(parsed)
|
|
522
|
-
|
|
555
|
+
// Inline mode renders the full key→value pair list directly.
|
|
556
|
+
if (inline) {
|
|
557
|
+
return <PairList entries={entries} locale={locale} t={t} />
|
|
558
|
+
}
|
|
559
|
+
const previewPairs = entries
|
|
523
560
|
.slice(0, maxInline)
|
|
524
561
|
.map(([k, v]) => `${prettifyKey(k, locale, t)}: ${formatScalar(v)}`)
|
|
525
562
|
.join(', ')
|
|
526
563
|
const overflow = entries.length - maxInline
|
|
527
|
-
const label = overflow > 0 ? `${
|
|
564
|
+
const label = overflow > 0 ? `${previewPairs} +${overflow}` : previewPairs
|
|
528
565
|
const title = entries
|
|
529
566
|
.map(([k, v]) => `${prettifyKey(k, locale, t)}: ${formatScalar(v)}`)
|
|
530
567
|
.join(', ')
|
|
@@ -56,6 +56,7 @@ import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
|
|
|
56
56
|
import { DynamicIcon, isLucideIconName } from '../dynamic-icon'
|
|
57
57
|
import { humanizeToken } from '../dynamic-columns-helpers'
|
|
58
58
|
import { formatDateCell } from '../dynamic-columns'
|
|
59
|
+
import { CollectionCell, type ItemField } from '../collection-cell'
|
|
59
60
|
import type { ActionFieldDef, RelationMeta } from '../types'
|
|
60
61
|
import { ImageUrlContext, identityImageUrl, type GetImageUrl } from '../image-url-context'
|
|
61
62
|
import { TimeZoneContext, CurrencyContext } from '../org-runtime-context'
|
|
@@ -122,6 +123,17 @@ export interface FieldDef {
|
|
|
122
123
|
* over the org fallback.
|
|
123
124
|
*/
|
|
124
125
|
styleConfig?: Record<string, any>
|
|
126
|
+
/**
|
|
127
|
+
* Declared schema for a jsonb line-items field (kernel v3 `item_fields`).
|
|
128
|
+
* The backend serves this on modal/detail fields the same way it does on
|
|
129
|
+
* table columns. When present the read-only detail view renders the
|
|
130
|
+
* `CollectionCell` mini-table with these (already-localized) headers in
|
|
131
|
+
* order and resolves `ref` columns to the backend-injected sibling label.
|
|
132
|
+
* Tolerates the snake_case `item_fields` the kernel serves.
|
|
133
|
+
*/
|
|
134
|
+
itemFields?: ItemField[]
|
|
135
|
+
/** snake_case alias served by the kernel for `itemFields`. */
|
|
136
|
+
item_fields?: ItemField[]
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
// Permissive shape: the wire payload may omit some fields (e.g. `title` is
|
|
@@ -282,6 +294,51 @@ function relationSiblingValue(field: FieldDef, record: any): any {
|
|
|
282
294
|
return undefined
|
|
283
295
|
}
|
|
284
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
|
+
|
|
285
342
|
// servedOption matches a field's served option list (enum/select with
|
|
286
343
|
// {value,label,color,icon,image}) against the current value.
|
|
287
344
|
function servedOption(field: FieldDef, value: any): FieldOption | undefined {
|
|
@@ -834,7 +891,7 @@ function FieldRow({ field, record, value, mode, onChange }: FieldRowProps) {
|
|
|
834
891
|
{isReadonly ? (
|
|
835
892
|
<ViewValue field={field} value={value} record={record} />
|
|
836
893
|
) : (
|
|
837
|
-
<EditField field={field} value={value} onChange={onChange} />
|
|
894
|
+
<EditField field={field} value={value} onChange={onChange} record={record} />
|
|
838
895
|
)}
|
|
839
896
|
</div>
|
|
840
897
|
)
|
|
@@ -910,7 +967,7 @@ export function ViewValue({
|
|
|
910
967
|
/** Optional override; when omitted falls back to the nearest provider. */
|
|
911
968
|
currency?: string
|
|
912
969
|
}) {
|
|
913
|
-
const { i18n } = useTranslation()
|
|
970
|
+
const { t, i18n } = useTranslation()
|
|
914
971
|
const ctxImageUrl = useContext(ImageUrlContext)
|
|
915
972
|
const ctxTimeZone = useContext(TimeZoneContext)
|
|
916
973
|
const ctxCurrency = useContext(CurrencyContext)
|
|
@@ -1069,7 +1126,14 @@ export function ViewValue({
|
|
|
1069
1126
|
// to surface — render readable key/value pairs instead of falling through to
|
|
1070
1127
|
// String(value) ("[object Object]").
|
|
1071
1128
|
if (value !== null && typeof value === 'object') {
|
|
1072
|
-
return
|
|
1129
|
+
return (
|
|
1130
|
+
<StructuredViewValue
|
|
1131
|
+
value={value}
|
|
1132
|
+
field={field}
|
|
1133
|
+
locale={i18n.language}
|
|
1134
|
+
t={t}
|
|
1135
|
+
/>
|
|
1136
|
+
)
|
|
1073
1137
|
}
|
|
1074
1138
|
|
|
1075
1139
|
const display = formatDisplayValue(value, field)
|
|
@@ -1099,49 +1163,81 @@ function IconNameViewValue({ name }: { name: string }) {
|
|
|
1099
1163
|
)
|
|
1100
1164
|
}
|
|
1101
1165
|
|
|
1102
|
-
// StructuredViewValue renders a jsonb object/array that has no resolvable label
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
//
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1166
|
+
// StructuredViewValue renders a jsonb object/array that has no resolvable label.
|
|
1167
|
+
// It delegates to the shared `CollectionCell` in `'inline'` mode so the detail
|
|
1168
|
+
// view gets the SAME pro rendering as the table: a declared `item_fields` schema
|
|
1169
|
+
// drives localized headers + resolved ref labels (the injected `{value,label}`
|
|
1170
|
+
// sibling) for line-items; without a schema it falls back to a localized
|
|
1171
|
+
// key→value pair list / mini-table — never raw `JSON.stringify`. Empty arrays /
|
|
1172
|
+
// empty objects keep the "—" marker (CollectionCell renders a muted dash, which
|
|
1173
|
+
// we normalize to the em-dash the detail view uses elsewhere).
|
|
1174
|
+
function StructuredViewValue({
|
|
1175
|
+
value,
|
|
1176
|
+
field,
|
|
1177
|
+
locale,
|
|
1178
|
+
t,
|
|
1179
|
+
}: {
|
|
1180
|
+
value: any
|
|
1181
|
+
field?: FieldDef
|
|
1182
|
+
locale?: string
|
|
1183
|
+
t?: (key: string, options?: any) => string
|
|
1184
|
+
}) {
|
|
1185
|
+
const isEmpty =
|
|
1186
|
+
value === null ||
|
|
1187
|
+
value === undefined ||
|
|
1188
|
+
value === '' ||
|
|
1189
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
1190
|
+
(typeof value === 'object' &&
|
|
1191
|
+
!Array.isArray(value) &&
|
|
1192
|
+
Object.keys(value).length === 0)
|
|
1193
|
+
if (isEmpty) {
|
|
1124
1194
|
return <p className="text-sm py-1 text-muted-foreground">—</p>
|
|
1125
1195
|
}
|
|
1126
1196
|
return (
|
|
1127
|
-
<
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
</dl>
|
|
1197
|
+
<div className="text-sm py-1">
|
|
1198
|
+
<CollectionCell
|
|
1199
|
+
value={value}
|
|
1200
|
+
itemFields={field?.itemFields ?? field?.item_fields}
|
|
1201
|
+
variant="inline"
|
|
1202
|
+
locale={locale}
|
|
1203
|
+
t={t}
|
|
1204
|
+
/>
|
|
1205
|
+
</div>
|
|
1137
1206
|
)
|
|
1138
1207
|
}
|
|
1139
1208
|
|
|
1140
|
-
function EditField({ field, value, onChange }: {
|
|
1209
|
+
export function EditField({ field, value, onChange, record }: {
|
|
1141
1210
|
field: FieldDef
|
|
1142
1211
|
value: any
|
|
1143
1212
|
onChange: (val: any) => void
|
|
1213
|
+
/** The full record being edited — supplies FK relation siblings + line-items. */
|
|
1214
|
+
record?: any
|
|
1144
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
|
+
|
|
1145
1241
|
if (field.type === 'boolean') {
|
|
1146
1242
|
return (
|
|
1147
1243
|
<div className="flex items-center gap-2 py-1">
|
|
@@ -1178,7 +1274,18 @@ function EditField({ field, value, onChange }: {
|
|
|
1178
1274
|
// option thumbnails and the inline-create "+" — against /api/options/<ref>.
|
|
1179
1275
|
// Static inline `options` are handled by the enum <Select> branch below.
|
|
1180
1276
|
if ((getFieldRef(field as ActionFieldDef) || field.widget === 'dynamic_select') && !field.options?.length) {
|
|
1181
|
-
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
|
+
)
|
|
1182
1289
|
}
|
|
1183
1290
|
|
|
1184
1291
|
if (field.type === 'search' && field.searchEndpoint) {
|