Skip to content

Commit 48be214

Browse files
authored
fix(plugin-detail): render reference fields via lookup picker in inline edit (#2024)
1 parent 1b26453 commit 48be214

4 files changed

Lines changed: 135 additions & 2 deletions

File tree

packages/plugin-detail/src/DetailSection.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
} from '@object-ui/components';
2626
import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff } from 'lucide-react';
2727
import { SchemaRenderer } from '@object-ui/react';
28-
import { getCellRenderer, resolveCellRendererType, SelectField, BooleanField } from '@object-ui/fields';
28+
import { getCellRenderer, resolveCellRendererType, SelectField, BooleanField, LookupField, UserField, coerceToSafeValue } from '@object-ui/fields';
2929
import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types';
3030
import { applyDetailAutoLayout } from './autoLayout';
3131
import { useDetailTranslation } from './useDetailTranslation';
@@ -53,6 +53,29 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number
5353
return '';
5454
}
5555

56+
/**
57+
* Field types that carry a `reference_to` for relational metadata but are NOT
58+
* edited via the lookup picker (they have their own dedicated inputs/renderers).
59+
* Used so the inline-edit branch doesn't hijack them into a record picker.
60+
*/
61+
const TEXTUAL_REF_FALLBACK_TYPES = new Set(['formula', 'summary', 'rollup', 'auto_number']);
62+
63+
/**
64+
* Extract the id a reference widget expects from a value that may already be
65+
* an `$expand`-ed record object (`{ id, name, ... }`), an array of those, or a
66+
* bare id. Mirrors the display logic in `LookupCellRenderer` so edit-mode and
67+
* read-mode agree on which id a relationship points at.
68+
*/
69+
function extractLookupId(value: unknown): unknown {
70+
if (value == null) return value;
71+
if (Array.isArray(value)) return value.map(extractLookupId);
72+
if (typeof value === 'object') {
73+
const obj = value as Record<string, unknown>;
74+
return obj.id ?? obj._id ?? obj.value ?? '';
75+
}
76+
return value;
77+
}
78+
5679
export interface VirtualScrollOptions {
5780
/** Enable virtual scrolling for large field sets */
5881
enabled?: boolean;
@@ -74,6 +97,8 @@ export interface DetailSectionProps {
7497
isEditing?: boolean;
7598
/** Callback when a field value changes during inline editing */
7699
onFieldChange?: (field: string, value: any) => void;
100+
/** DataSource used by reference (lookup/master_detail/user) widgets during inline editing */
101+
dataSource?: any;
77102
/** Virtual scrolling configuration for sections with many fields */
78103
virtualScroll?: VirtualScrollOptions;
79104
}
@@ -86,6 +111,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
86111
objectName,
87112
isEditing = false,
88113
onFieldChange,
114+
dataSource,
89115
virtualScroll,
90116
}) => {
91117
const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
@@ -267,6 +293,28 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
267293
/>
268294
);
269295
}
296+
// Reference fields (lookup / master_detail / tree / user / owner)
297+
// store an id but may arrive `$expand`-ed as a record object. A
298+
// plain text input would stringify that to "[object Object]", so
299+
// render the real picker and feed it the id extracted from the
300+
// (possibly expanded) value.
301+
const isUserRef = editType === 'user' || editType === 'owner';
302+
const isLookupRef =
303+
editType === 'lookup' ||
304+
editType === 'master_detail' ||
305+
editType === 'tree' ||
306+
(!!enrichedField.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(editType as string));
307+
if (isUserRef || isLookupRef) {
308+
const RefWidget = isUserRef ? UserField : LookupField;
309+
return (
310+
<RefWidget
311+
field={enrichedField as any}
312+
value={extractLookupId(value)}
313+
onChange={(v: any) => onFieldChange?.(field.name, v)}
314+
dataSource={dataSource}
315+
/>
316+
);
317+
}
270318
const isDate = editType === 'date' || editType === 'datetime';
271319
const inputType = editType === 'number' ? 'number' : isDate ? 'date' : 'text';
272320
// <input type="date"> needs a YYYY-MM-DD string; raw ISO
@@ -282,7 +330,12 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
282330
const d = new Date(s);
283331
return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA');
284332
})()
285-
: String(value);
333+
// Coerce objects (e.g. an unexpanded reference that slipped
334+
// through type detection) to a readable label rather than
335+
// leaking "[object Object]" into the input.
336+
: typeof value === 'object'
337+
? String(coerceToSafeValue(value) ?? '')
338+
: String(value);
286339
return (
287340
<input
288341
type={inputType}

packages/plugin-detail/src/DetailView.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
12341234
objectName={schema.objectName}
12351235
isEditing={isInlineEditing}
12361236
onFieldChange={handleInlineFieldChange}
1237+
dataSource={dataSource}
12371238
/>
12381239
))
12391240
)}
@@ -1247,6 +1248,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
12471248
objectName={schema.objectName}
12481249
isEditing={isInlineEditing}
12491250
onFieldChange={handleInlineFieldChange}
1251+
dataSource={dataSource}
12501252
/>
12511253
))
12521254
)}
@@ -1261,6 +1263,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
12611263
objectName={schema.objectName}
12621264
isEditing={isInlineEditing}
12631265
onFieldChange={handleInlineFieldChange}
1266+
dataSource={dataSource}
12641267
/>
12651268
)}
12661269
{/* Comments in details tab */}
@@ -1409,6 +1412,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
14091412
objectName={schema.objectName}
14101413
isEditing={isInlineEditing}
14111414
onFieldChange={handleInlineFieldChange}
1415+
dataSource={dataSource}
14121416
/>
14131417
))}
14141418
</div>
@@ -1426,6 +1430,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
14261430
objectName={schema.objectName}
14271431
isEditing={isInlineEditing}
14281432
onFieldChange={handleInlineFieldChange}
1433+
dataSource={dataSource}
14291434
/>
14301435
))}
14311436
</div>
@@ -1443,6 +1448,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
14431448
objectName={schema.objectName}
14441449
isEditing={isInlineEditing}
14451450
onFieldChange={handleInlineFieldChange}
1451+
dataSource={dataSource}
14461452
/>
14471453
)}
14481454

packages/plugin-detail/src/SectionGroup.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export interface SectionGroupProps {
2626
objectName?: string;
2727
isEditing?: boolean;
2828
onFieldChange?: (field: string, value: any) => void;
29+
/** DataSource used by reference widgets during inline editing */
30+
dataSource?: any;
2931
}
3032

3133
export const SectionGroup: React.FC<SectionGroupProps> = ({
@@ -36,6 +38,7 @@ export const SectionGroup: React.FC<SectionGroupProps> = ({
3638
objectName,
3739
isEditing = false,
3840
onFieldChange,
41+
dataSource,
3942
}) => {
4043
const collapsible = group.collapsible ?? true;
4144
const [isCollapsed, setIsCollapsed] = React.useState(group.defaultCollapsed ?? false);
@@ -51,6 +54,7 @@ export const SectionGroup: React.FC<SectionGroupProps> = ({
5154
objectName={objectName}
5255
isEditing={isEditing}
5356
onFieldChange={onFieldChange}
57+
dataSource={dataSource}
5458
/>
5559
))}
5660
</div>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { render, screen } from '@testing-library/react';
11+
import { DetailSection } from '../DetailSection';
12+
import type { DetailViewSection } from '@object-ui/types';
13+
14+
/**
15+
* Regression: in inline-edit mode a reference field (lookup / master_detail /
16+
* user) whose value arrived `$expand`-ed as a record object used to fall
17+
* through to a generic <input> and render `String({...})` → "[object Object]".
18+
* It must now render the lookup picker (no raw object leak) and never surface
19+
* the "[object Object]" string in any editable input.
20+
*/
21+
describe('DetailSection inline-edit reference fields', () => {
22+
const objectSchema = {
23+
fields: {
24+
project: { type: 'master_detail', reference_to: 'projects' },
25+
factory: { type: 'lookup', reference_to: 'factories' },
26+
title: { type: 'text' },
27+
},
28+
};
29+
30+
const section: DetailViewSection = {
31+
fields: [
32+
{ name: 'project', label: '所属项目' },
33+
{ name: 'factory', label: '制作工厂' },
34+
{ name: 'title', label: '标题' },
35+
],
36+
} as DetailViewSection;
37+
38+
// Values as the server returns them with $expand: nested record objects.
39+
const data = {
40+
project: { _id: 'p1', name: 'Apollo' },
41+
factory: { id: 'f9', name: 'Shenzhen Plant' },
42+
title: 'Hello',
43+
};
44+
45+
it('never renders "[object Object]" for expanded reference values in edit mode', () => {
46+
render(
47+
<DetailSection
48+
section={section}
49+
data={data}
50+
objectSchema={objectSchema}
51+
isEditing
52+
/>,
53+
);
54+
expect(screen.queryByText(/\[object Object\]/)).toBeNull();
55+
expect(screen.queryByDisplayValue(/\[object Object\]/)).toBeNull();
56+
});
57+
58+
it('renders an editable text input for plain text fields', () => {
59+
render(
60+
<DetailSection
61+
section={section}
62+
data={data}
63+
objectSchema={objectSchema}
64+
isEditing
65+
/>,
66+
);
67+
// The plain text field keeps its string value in an editable input.
68+
expect(screen.getByDisplayValue('Hello')).toBeInTheDocument();
69+
});
70+
});

0 commit comments

Comments
 (0)