@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 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;CAC3B;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAC3B,KAAK,EACL,SAAa,EACb,MAAM,EACN,CAAC,EACD,UAAU,GACb,EAAE,mBAAmB,qBAoGrB"}
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"}
@@ -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): preview first N joined, "+N" overflow.
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
- const inline = entries
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 ? `${inline} +${overflow}` : inline;
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":"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"}
1
+ {"version":3,"file":"dynamic-record.d.ts","sourceRoot":"","sources":["../../src/dialogs/dynamic-record.tsx"],"names":[],"mappings":"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"}
@@ -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
- // plain objects become a key→value list (keys humanized), primitive arrays a
611
- // comma-joined line, and anything deeper a pretty-printed JSON block. Empty
612
- // structures render the same "—" marker as null scalars.
613
- function StructuredViewValue({ value }) {
614
- if (Array.isArray(value)) {
615
- if (value.length === 0) {
616
- return _jsx("p", { className: "text-sm py-1 text-muted-foreground", children: "\u2014" });
617
- }
618
- if (value.every(v => v === null || typeof v !== 'object')) {
619
- return _jsx("p", { className: "text-sm py-1", children: value.map(v => String(v ?? '—')).join(', ') });
620
- }
621
- return (_jsx("pre", { className: "text-xs whitespace-pre-wrap rounded-md bg-muted/40 p-3 overflow-x-auto", children: JSON.stringify(value, null, 2) }));
622
- }
623
- const entries = Object.entries(value).filter(([, v]) => v !== null && v !== undefined && v !== '');
624
- if (entries.length === 0) {
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("dl", { className: "text-sm py-1 space-y-0.5", children: entries.map(([k, v]) => (_jsxs("div", { className: "flex gap-2", children: [_jsxs("dt", { className: "text-muted-foreground shrink-0", children: [humanizeToken(k), ":"] }), _jsx("dd", { className: "break-words", children: typeof v === 'object' ? JSON.stringify(v) : String(v) })] }, k))) }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "18.21.0",
3
+ "version": "18.23.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
+ })
@@ -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): preview first N joined, "+N" overflow.
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
- const inline = entries
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 ? `${inline} +${overflow}` : inline
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 <StructuredViewValue value={value} />
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
- // plain objects become a key→value list (keys humanized), primitive arrays a
1104
- // comma-joined line, and anything deeper a pretty-printed JSON block. Empty
1105
- // structures render the same "—" marker as null scalars.
1106
- function StructuredViewValue({ value }: { value: any }) {
1107
- if (Array.isArray(value)) {
1108
- if (value.length === 0) {
1109
- return <p className="text-sm py-1 text-muted-foreground">—</p>
1110
- }
1111
- if (value.every(v => v === null || typeof v !== 'object')) {
1112
- return <p className="text-sm py-1">{value.map(v => String(v ?? '—')).join(', ')}</p>
1113
- }
1114
- return (
1115
- <pre className="text-xs whitespace-pre-wrap rounded-md bg-muted/40 p-3 overflow-x-auto">
1116
- {JSON.stringify(value, null, 2)}
1117
- </pre>
1118
- )
1119
- }
1120
- const entries = Object.entries(value).filter(
1121
- ([, v]) => v !== null && v !== undefined && v !== '',
1122
- )
1123
- if (entries.length === 0) {
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
- <dl className="text-sm py-1 space-y-0.5">
1128
- {entries.map(([k, v]) => (
1129
- <div key={k} className="flex gap-2">
1130
- <dt className="text-muted-foreground shrink-0">{humanizeToken(k)}:</dt>
1131
- <dd className="break-words">
1132
- {typeof v === 'object' ? JSON.stringify(v) : String(v)}
1133
- </dd>
1134
- </div>
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 <DynamicSelectField field={field as ActionFieldDef} value={value} onChange={onChange} />
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) {