From 4911660c6d34885dcaf9da3730c55693068754c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sat, 27 Jun 2026 19:24:00 +0800 Subject: [PATCH] fix(plugin-detail): render reference fields via lookup picker in inline edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When toggling "编辑字段" in the record detail drawer, reference fields (lookup / master_detail / tree / user / owner, or any field with reference_to) fell through to a generic . Server returns these values $expand-ed as record objects ({_id, name}), so they rendered the literal "[object Object]". Route reference fields to the real LookupField/UserField picker, feeding the id extracted from the (possibly expanded) value; coerce any stray object in the generic-input fallback via coerceToSafeValue. Thread dataSource through DetailView -> SectionGroup -> DetailSection so the picker can resolve related-record names while editing. Adds DetailSection.inlineEdit.test.tsx regression coverage. --- packages/plugin-detail/src/DetailSection.tsx | 57 ++++++++++++++- packages/plugin-detail/src/DetailView.tsx | 6 ++ packages/plugin-detail/src/SectionGroup.tsx | 4 ++ .../DetailSection.inlineEdit.test.tsx | 70 +++++++++++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 8fc6b8f28..cc5742df7 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -25,7 +25,7 @@ import { } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff } from 'lucide-react'; import { SchemaRenderer } from '@object-ui/react'; -import { getCellRenderer, resolveCellRendererType, SelectField, BooleanField } from '@object-ui/fields'; +import { getCellRenderer, resolveCellRendererType, SelectField, BooleanField, LookupField, UserField, coerceToSafeValue } from '@object-ui/fields'; import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types'; import { applyDetailAutoLayout } from './autoLayout'; import { useDetailTranslation } from './useDetailTranslation'; @@ -53,6 +53,29 @@ export function getResponsiveSpanClass(span: number | undefined, columns: number return ''; } +/** + * Field types that carry a `reference_to` for relational metadata but are NOT + * edited via the lookup picker (they have their own dedicated inputs/renderers). + * Used so the inline-edit branch doesn't hijack them into a record picker. + */ +const TEXTUAL_REF_FALLBACK_TYPES = new Set(['formula', 'summary', 'rollup', 'auto_number']); + +/** + * Extract the id a reference widget expects from a value that may already be + * an `$expand`-ed record object (`{ id, name, ... }`), an array of those, or a + * bare id. Mirrors the display logic in `LookupCellRenderer` so edit-mode and + * read-mode agree on which id a relationship points at. + */ +function extractLookupId(value: unknown): unknown { + if (value == null) return value; + if (Array.isArray(value)) return value.map(extractLookupId); + if (typeof value === 'object') { + const obj = value as Record; + return obj.id ?? obj._id ?? obj.value ?? ''; + } + return value; +} + export interface VirtualScrollOptions { /** Enable virtual scrolling for large field sets */ enabled?: boolean; @@ -74,6 +97,8 @@ export interface DetailSectionProps { isEditing?: boolean; /** Callback when a field value changes during inline editing */ onFieldChange?: (field: string, value: any) => void; + /** DataSource used by reference (lookup/master_detail/user) widgets during inline editing */ + dataSource?: any; /** Virtual scrolling configuration for sections with many fields */ virtualScroll?: VirtualScrollOptions; } @@ -86,6 +111,7 @@ export const DetailSection: React.FC = ({ objectName, isEditing = false, onFieldChange, + dataSource, virtualScroll, }) => { const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false); @@ -267,6 +293,28 @@ export const DetailSection: React.FC = ({ /> ); } + // Reference fields (lookup / master_detail / tree / user / owner) + // store an id but may arrive `$expand`-ed as a record object. A + // plain text input would stringify that to "[object Object]", so + // render the real picker and feed it the id extracted from the + // (possibly expanded) value. + const isUserRef = editType === 'user' || editType === 'owner'; + const isLookupRef = + editType === 'lookup' || + editType === 'master_detail' || + editType === 'tree' || + (!!enrichedField.reference_to && !TEXTUAL_REF_FALLBACK_TYPES.has(editType as string)); + if (isUserRef || isLookupRef) { + const RefWidget = isUserRef ? UserField : LookupField; + return ( + onFieldChange?.(field.name, v)} + dataSource={dataSource} + /> + ); + } const isDate = editType === 'date' || editType === 'datetime'; const inputType = editType === 'number' ? 'number' : isDate ? 'date' : 'text'; // needs a YYYY-MM-DD string; raw ISO @@ -282,7 +330,12 @@ export const DetailSection: React.FC = ({ const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA'); })() - : String(value); + // Coerce objects (e.g. an unexpanded reference that slipped + // through type detection) to a readable label rather than + // leaking "[object Object]" into the input. + : typeof value === 'object' + ? String(coerceToSafeValue(value) ?? '') + : String(value); return ( = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> )) )} @@ -1247,6 +1248,7 @@ export const DetailView: React.FC = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> )) )} @@ -1261,6 +1263,7 @@ export const DetailView: React.FC = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> )} {/* Comments in details tab */} @@ -1409,6 +1412,7 @@ export const DetailView: React.FC = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> ))} @@ -1426,6 +1430,7 @@ export const DetailView: React.FC = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> ))} @@ -1443,6 +1448,7 @@ export const DetailView: React.FC = ({ objectName={schema.objectName} isEditing={isInlineEditing} onFieldChange={handleInlineFieldChange} + dataSource={dataSource} /> )} diff --git a/packages/plugin-detail/src/SectionGroup.tsx b/packages/plugin-detail/src/SectionGroup.tsx index be47b6ec8..6ffd6eac3 100644 --- a/packages/plugin-detail/src/SectionGroup.tsx +++ b/packages/plugin-detail/src/SectionGroup.tsx @@ -26,6 +26,8 @@ export interface SectionGroupProps { objectName?: string; isEditing?: boolean; onFieldChange?: (field: string, value: any) => void; + /** DataSource used by reference widgets during inline editing */ + dataSource?: any; } export const SectionGroup: React.FC = ({ @@ -36,6 +38,7 @@ export const SectionGroup: React.FC = ({ objectName, isEditing = false, onFieldChange, + dataSource, }) => { const collapsible = group.collapsible ?? true; const [isCollapsed, setIsCollapsed] = React.useState(group.defaultCollapsed ?? false); @@ -51,6 +54,7 @@ export const SectionGroup: React.FC = ({ objectName={objectName} isEditing={isEditing} onFieldChange={onFieldChange} + dataSource={dataSource} /> ))} diff --git a/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx new file mode 100644 index 000000000..35942705a --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx @@ -0,0 +1,70 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DetailSection } from '../DetailSection'; +import type { DetailViewSection } from '@object-ui/types'; + +/** + * Regression: in inline-edit mode a reference field (lookup / master_detail / + * user) whose value arrived `$expand`-ed as a record object used to fall + * through to a generic and render `String({...})` → "[object Object]". + * It must now render the lookup picker (no raw object leak) and never surface + * the "[object Object]" string in any editable input. + */ +describe('DetailSection inline-edit reference fields', () => { + const objectSchema = { + fields: { + project: { type: 'master_detail', reference_to: 'projects' }, + factory: { type: 'lookup', reference_to: 'factories' }, + title: { type: 'text' }, + }, + }; + + const section: DetailViewSection = { + fields: [ + { name: 'project', label: '所属项目' }, + { name: 'factory', label: '制作工厂' }, + { name: 'title', label: '标题' }, + ], + } as DetailViewSection; + + // Values as the server returns them with $expand: nested record objects. + const data = { + project: { _id: 'p1', name: 'Apollo' }, + factory: { id: 'f9', name: 'Shenzhen Plant' }, + title: 'Hello', + }; + + it('never renders "[object Object]" for expanded reference values in edit mode', () => { + render( + , + ); + expect(screen.queryByText(/\[object Object\]/)).toBeNull(); + expect(screen.queryByDisplayValue(/\[object Object\]/)).toBeNull(); + }); + + it('renders an editable text input for plain text fields', () => { + render( + , + ); + // The plain text field keeps its string value in an editable input. + expect(screen.getByDisplayValue('Hello')).toBeInTheDocument(); + }); +});