Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions packages/plugin-detail/src/DetailSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, unknown>;
return obj.id ?? obj._id ?? obj.value ?? '';
}
return value;
}

export interface VirtualScrollOptions {
/** Enable virtual scrolling for large field sets */
enabled?: boolean;
Expand All @@ -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;
}
Expand All @@ -86,6 +111,7 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
objectName,
isEditing = false,
onFieldChange,
dataSource,
virtualScroll,
}) => {
const [isCollapsed, setIsCollapsed] = React.useState(section.defaultCollapsed ?? false);
Expand Down Expand Up @@ -267,6 +293,28 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
/>
);
}
// 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 (
<RefWidget
field={enrichedField as any}
value={extractLookupId(value)}
onChange={(v: any) => onFieldChange?.(field.name, v)}
dataSource={dataSource}
/>
);
}
const isDate = editType === 'date' || editType === 'datetime';
const inputType = editType === 'number' ? 'number' : isDate ? 'date' : 'text';
// <input type="date"> needs a YYYY-MM-DD string; raw ISO
Expand All @@ -282,7 +330,12 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 (
<input
type={inputType}
Expand Down
6 changes: 6 additions & 0 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
))
)}
Expand All @@ -1247,6 +1248,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
))
)}
Expand All @@ -1261,6 +1263,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
)}
{/* Comments in details tab */}
Expand Down Expand Up @@ -1409,6 +1412,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
))}
</div>
Expand All @@ -1426,6 +1430,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
))}
</div>
Expand All @@ -1443,6 +1448,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
objectName={schema.objectName}
isEditing={isInlineEditing}
onFieldChange={handleInlineFieldChange}
dataSource={dataSource}
/>
)}

Expand Down
4 changes: 4 additions & 0 deletions packages/plugin-detail/src/SectionGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SectionGroupProps> = ({
Expand All @@ -36,6 +38,7 @@ export const SectionGroup: React.FC<SectionGroupProps> = ({
objectName,
isEditing = false,
onFieldChange,
dataSource,
}) => {
const collapsible = group.collapsible ?? true;
const [isCollapsed, setIsCollapsed] = React.useState(group.defaultCollapsed ?? false);
Expand All @@ -51,6 +54,7 @@ export const SectionGroup: React.FC<SectionGroupProps> = ({
objectName={objectName}
isEditing={isEditing}
onFieldChange={onFieldChange}
dataSource={dataSource}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <input> 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(
<DetailSection
section={section}
data={data}
objectSchema={objectSchema}
isEditing
/>,
);
expect(screen.queryByText(/\[object Object\]/)).toBeNull();
expect(screen.queryByDisplayValue(/\[object Object\]/)).toBeNull();
});

it('renders an editable text input for plain text fields', () => {
render(
<DetailSection
section={section}
data={data}
objectSchema={objectSchema}
isEditing
/>,
);
// The plain text field keeps its string value in an editable input.
expect(screen.getByDisplayValue('Hello')).toBeInTheDocument();
});
});
Loading