diff --git a/ROADMAP.md b/ROADMAP.md index 294dd90df..aecb625bc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -873,6 +873,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th - [x] `sharing` schema reconciliation: Supports both ObjectUI `{ visibility, enabled }` and spec `{ type: personal/collaborative, lockedBy }` models. Share button renders when either `enabled: true` or `type` is set. Zod validator updated with `type` and `lockedBy` fields. Bridge normalizes spec format: `type: personal` → `visibility: private`, `type: collaborative` → `visibility: team`, auto-sets `enabled: true`. - [x] `exportOptions` schema reconciliation: Zod validator updated to accept both spec `string[]` format and ObjectUI object format via `z.union()`. ListView normalizes string[] to `{ formats }` at render time. - [x] `pagination.pageSizeOptions` backend integration: Page size selector is now a controlled component that dynamically updates `effectivePageSize`, triggering data re-fetch. `onPageSizeChange` callback fires on selection. Full test coverage for selector rendering, option enumeration, and data reload. +- [x] `$expand` auto-injection: `buildExpandFields()` utility in `@object-ui/core` scans schema fields for `lookup`/`master_detail` types and returns field names for `$expand`. Integrated into **all** data-fetching plugins (ListView, ObjectGrid, ObjectKanban, ObjectCalendar, ObjectGantt, ObjectMap, ObjectTimeline, ObjectGallery, ObjectView, ObjectAgGrid) so the backend (objectql) returns expanded objects instead of raw foreign-key IDs. Supports column-scoped expansion (`ListColumn[]` compatible) and graceful fallback when `$expand` is not supported. Cross-repo: objectql engine expand support required for multi-level nesting. ### P2.7 Platform UI Consistency & Interaction Optimization ✅ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e3114ebd7..7957b46ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export * from './builder/schema-builder.js'; export * from './utils/filter-converter.js'; export * from './utils/normalize-quick-filter.js'; export * from './utils/extract-records.js'; +export * from './utils/expand-fields.js'; export * from './evaluator/index.js'; export * from './actions/index.js'; export * from './query/index.js'; diff --git a/packages/core/src/utils/__tests__/expand-fields.test.ts b/packages/core/src/utils/__tests__/expand-fields.test.ts new file mode 100644 index 000000000..214f9aa57 --- /dev/null +++ b/packages/core/src/utils/__tests__/expand-fields.test.ts @@ -0,0 +1,120 @@ +/** + * 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 { buildExpandFields } from '../expand-fields'; + +describe('buildExpandFields', () => { + const sampleFields = { + name: { type: 'text', label: 'Name' }, + email: { type: 'email', label: 'Email' }, + account: { type: 'lookup', label: 'Account', reference_to: 'accounts' }, + parent: { type: 'master_detail', label: 'Parent', reference_to: 'contacts' }, + status: { type: 'select', label: 'Status' }, + }; + + it('should return lookup and master_detail field names', () => { + const result = buildExpandFields(sampleFields); + expect(result).toEqual(['account', 'parent']); + }); + + it('should return empty array when no lookup/master_detail fields exist', () => { + const fields = { + name: { type: 'text' }, + age: { type: 'number' }, + }; + expect(buildExpandFields(fields)).toEqual([]); + }); + + it('should return empty array for null/undefined schema', () => { + expect(buildExpandFields(null)).toEqual([]); + expect(buildExpandFields(undefined)).toEqual([]); + }); + + it('should return empty array for empty fields object', () => { + expect(buildExpandFields({})).toEqual([]); + }); + + it('should filter by string columns when provided', () => { + const result = buildExpandFields(sampleFields, ['name', 'account']); + expect(result).toEqual(['account']); + }); + + it('should filter by ListColumn objects with field property', () => { + const columns = [ + { field: 'name', label: 'Name' }, + { field: 'parent', label: 'Parent Contact' }, + ]; + const result = buildExpandFields(sampleFields, columns); + expect(result).toEqual(['parent']); + }); + + it('should support columns with name property', () => { + const columns = [ + { name: 'account', label: 'Account' }, + ]; + const result = buildExpandFields(sampleFields, columns); + expect(result).toEqual(['account']); + }); + + it('should support columns with fieldName property', () => { + const columns = [ + { fieldName: 'parent', label: 'Parent' }, + ]; + const result = buildExpandFields(sampleFields, columns); + expect(result).toEqual(['parent']); + }); + + it('should return empty array when columns have no lookup fields', () => { + const result = buildExpandFields(sampleFields, ['name', 'email']); + expect(result).toEqual([]); + }); + + it('should handle mixed string and object columns', () => { + const columns = [ + 'name', + { field: 'account' }, + 'parent', + ]; + const result = buildExpandFields(sampleFields, columns); + expect(result).toEqual(['account', 'parent']); + }); + + it('should return all lookup fields when columns is empty array', () => { + // Empty columns array does not satisfy the length > 0 check, + // so no column restriction is applied → all lookup fields returned + const result = buildExpandFields(sampleFields, []); + expect(result).toEqual(['account', 'parent']); + }); + + it('should handle malformed field definitions gracefully', () => { + const fields = { + name: null, + account: { type: 'lookup' }, + broken: 'not-an-object', + empty: {}, + }; + const result = buildExpandFields(fields as any); + expect(result).toEqual(['account']); + }); + + it('should handle only lookup fields', () => { + const fields = { + ref1: { type: 'lookup', reference_to: 'obj1' }, + ref2: { type: 'lookup', reference_to: 'obj2' }, + }; + expect(buildExpandFields(fields)).toEqual(['ref1', 'ref2']); + }); + + it('should handle only master_detail fields', () => { + const fields = { + detail1: { type: 'master_detail', reference_to: 'obj1' }, + }; + expect(buildExpandFields(fields)).toEqual(['detail1']); + }); +}); diff --git a/packages/core/src/utils/expand-fields.ts b/packages/core/src/utils/expand-fields.ts new file mode 100644 index 000000000..21a8ecc08 --- /dev/null +++ b/packages/core/src/utils/expand-fields.ts @@ -0,0 +1,76 @@ +/** + * ObjectUI — expand-fields utility + * 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. + */ + +/** + * Build an array of field names that should be included in `$expand` + * when fetching data. This scans the given object schema fields + * (and optional column configuration) for `lookup` and `master_detail` + * field types, so the backend (e.g. objectql) returns expanded objects + * instead of raw foreign-key IDs. + * + * @param schemaFields - Object map of field metadata from `getObjectSchema()`, + * e.g. `{ account: { type: 'lookup', reference_to: 'accounts' }, ... }`. + * @param columns - Optional explicit column list. When provided, only + * lookup/master_detail fields that appear in `columns` are expanded. + * Accepts `string[]` or `ListColumn[]` (objects with a `field` property). + * @returns Array of field names to pass as `$expand`. + * + * @example + * ```ts + * const fields = { + * name: { type: 'text' }, + * account: { type: 'lookup', reference_to: 'accounts' }, + * parent: { type: 'master_detail', reference_to: 'contacts' }, + * }; + * buildExpandFields(fields); + * // → ['account', 'parent'] + * + * buildExpandFields(fields, ['name', 'account']); + * // → ['account'] + * ``` + */ +export function buildExpandFields( + schemaFields?: Record | null, + columns?: (string | { field?: string; name?: string; fieldName?: string })[], +): string[] { + if (!schemaFields || typeof schemaFields !== 'object') { + return []; + } + + // Collect all lookup / master_detail field names from the schema + const lookupFieldNames: string[] = []; + for (const [fieldName, fieldDef] of Object.entries(schemaFields)) { + if ( + fieldDef && + typeof fieldDef === 'object' && + (fieldDef.type === 'lookup' || fieldDef.type === 'master_detail') + ) { + lookupFieldNames.push(fieldName); + } + } + + if (lookupFieldNames.length === 0) { + return []; + } + + // When columns are provided, restrict expansion to visible columns only + if (columns && Array.isArray(columns) && columns.length > 0) { + const columnFieldNames = new Set(); + for (const col of columns) { + if (typeof col === 'string') { + columnFieldNames.add(col); + } else if (col && typeof col === 'object') { + const name = col.field ?? col.name ?? col.fieldName; + if (name) columnFieldNames.add(name); + } + } + return lookupFieldNames.filter((f) => columnFieldNames.has(f)); + } + + return lookupFieldNames; +} diff --git a/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx b/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx index 9424ca2ba..26b8332ba 100644 --- a/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx +++ b/packages/plugin-aggrid/src/ObjectAgGridImpl.tsx @@ -24,6 +24,7 @@ import type { FieldMetadata, ObjectSchemaMetadata } from '@object-ui/types'; import type { ObjectAgGridImplProps } from './object-aggrid.types'; import { FIELD_TYPE_TO_FILTER_TYPE } from './object-aggrid.types'; import { createFieldCellRenderer, createFieldCellEditor } from './field-renderers'; +import { buildExpandFields } from '@object-ui/core'; /** * ObjectAgGridImpl - Metadata-driven AG Grid implementation @@ -112,6 +113,12 @@ export default function ObjectAgGridImpl({ queryParams.$orderby = sort; } + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectSchema?.fields); + if (expand.length > 0) { + queryParams.$expand = expand; + } + const result = await dataSource.find(objectName, queryParams); setRowData(result.data || []); callbacks?.onDataLoaded?.(result.data || []); diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 3022be3ec..1568c772c 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -28,7 +28,7 @@ import { CalendarView, type CalendarEvent } from './CalendarView'; import { usePullToRefresh } from '@object-ui/mobile'; import { useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; -import { extractRecords } from '@object-ui/core'; +import { extractRecords, buildExpandFields } from '@object-ui/core'; export interface CalendarSchema { type: 'calendar'; @@ -215,9 +215,12 @@ export const ObjectCalendar: React.FC = ({ if (dataConfig?.provider === 'object') { const objectName = dataConfig.object; + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectSchema?.fields); const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + ...(expand.length > 0 ? { $expand: expand } : {}), }); let items: any[] = extractRecords(result); @@ -242,7 +245,7 @@ export const ObjectCalendar: React.FC = ({ fetchData(); return () => { isMounted = false; }; - }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey]); + }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey, objectSchema]); // Fetch object schema for field metadata useEffect(() => { diff --git a/packages/plugin-gantt/src/ObjectGantt.tsx b/packages/plugin-gantt/src/ObjectGantt.tsx index e752f6a16..3e0f93de9 100644 --- a/packages/plugin-gantt/src/ObjectGantt.tsx +++ b/packages/plugin-gantt/src/ObjectGantt.tsx @@ -27,7 +27,7 @@ import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@objec import { GanttConfigSchema } from '@objectstack/spec/ui'; import { useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; -import { extractRecords } from '@object-ui/core'; +import { extractRecords, buildExpandFields } from '@object-ui/core'; import { GanttView, type GanttTask } from './GanttView'; export interface ObjectGanttProps { @@ -174,9 +174,12 @@ export const ObjectGantt: React.FC = ({ if (dataConfig?.provider === 'object') { const objectName = dataConfig.object; + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectSchema?.fields); const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + ...(expand.length > 0 ? { $expand: expand } : {}), }); let items: any[] = extractRecords(result); setData(items); @@ -193,7 +196,7 @@ export const ObjectGantt: React.FC = ({ }; fetchData(); - }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]); + }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]); // Fetch object schema for field metadata useEffect(() => { diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 47c0367aa..5598bf8c5 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -30,7 +30,7 @@ import { Popover, PopoverContent, PopoverTrigger, } from '@object-ui/components'; import { usePullToRefresh } from '@object-ui/mobile'; -import { evaluatePlainCondition } from '@object-ui/core'; +import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core'; import { ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react'; import { useRowColor } from './useRowColor'; import { useGroupedData } from './useGroupedData'; @@ -308,6 +308,12 @@ export const ObjectGrid: React.FC = ({ params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`; } + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(resolvedSchema?.fields, schemaColumns ?? schemaFields); + if (expand.length > 0) { + params.$expand = expand; + } + const result = await dataSource.find(objectName, params); if (cancelled) return; setData(result.data || []); diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index 2313c8321..b4b61fb56 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState, useMemo } from 'react'; import type { DataSource } from '@object-ui/types'; import { useDataScope, useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; -import { extractRecords } from '@object-ui/core'; +import { extractRecords, buildExpandFields } from '@object-ui/core'; import { KanbanRenderer } from './index'; import { KanbanSchema } from './types'; @@ -61,9 +61,12 @@ export const ObjectKanban: React.FC = ({ if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return; if (isMounted) setLoading(true); try { + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectDef?.fields); const results = await dataSource.find(schema.objectName, { options: { $top: 100 }, - $filter: schema.filter + $filter: schema.filter, + ...(expand.length > 0 ? { $expand: expand } : {}), }); // Handle { value: [] } OData shape or { data: [] } shape or direct array @@ -88,7 +91,7 @@ export const ObjectKanban: React.FC = ({ fetchData(); } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data, objectDef]); // Determine which data to use: props.data -> bound -> inline -> fetched const rawData = (props as any).data || boundData || schema.data || fetchedData; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index dada2751f..287cab708 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -19,7 +19,7 @@ import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react'; import { useDensityMode } from '@object-ui/react'; import type { ListViewSchema } from '@object-ui/types'; import { usePullToRefresh } from '@object-ui/mobile'; -import { evaluatePlainCondition, normalizeQuickFilter, normalizeQuickFilters } from '@object-ui/core'; +import { evaluatePlainCondition, normalizeQuickFilter, normalizeQuickFilters, buildExpandFields } from '@object-ui/core'; import { useObjectTranslation } from '@object-ui/i18n'; export interface ListViewProps { @@ -495,6 +495,12 @@ export const ListView: React.FC = ({ return () => { isMounted = false; }; }, [schema.objectName, dataSource]); + // Auto-compute $expand fields from objectDef (lookup / master_detail) + const expandFields = React.useMemo( + () => buildExpandFields(objectDef?.fields, schema.fields), + [objectDef?.fields, schema.fields], + ); + // Fetch data effect — supports schema.data (ViewDataSchema) provider modes React.useEffect(() => { let isMounted = true; @@ -567,6 +573,7 @@ export const ListView: React.FC = ({ $filter: finalFilter, $orderby: sort, $top: effectivePageSize, + ...(expandFields.length > 0 ? { $expand: expandFields } : {}), ...(searchTerm ? { $search: searchTerm, ...(schema.searchableFields && schema.searchableFields.length > 0 @@ -606,7 +613,7 @@ export const ListView: React.FC = ({ fetchData(); return () => { isMounted = false; }; - }, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields]); // Re-fetch on filter/sort/search change + }, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields, expandFields]); // Re-fetch on filter/sort/search change // Available view types based on schema configuration const availableViews = React.useMemo(() => { diff --git a/packages/plugin-list/src/ObjectGallery.tsx b/packages/plugin-list/src/ObjectGallery.tsx index 9c52bd644..15d1293d3 100644 --- a/packages/plugin-list/src/ObjectGallery.tsx +++ b/packages/plugin-list/src/ObjectGallery.tsx @@ -8,7 +8,7 @@ import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; import { useDataScope, SchemaRendererContext, useNavigationOverlay } from '@object-ui/react'; -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, buildExpandFields } from '@object-ui/core'; import { cn, Card, CardContent, NavigationOverlay } from '@object-ui/components'; import type { GalleryConfig, ViewNavigationConfig, GroupingConfig } from '@object-ui/types'; import { ChevronRight, ChevronDown } from 'lucide-react'; @@ -58,6 +58,7 @@ export const ObjectGallery: React.FC = (props) => { const [fetchedData, setFetchedData] = useState[]>([]); const [loading, setLoading] = useState(false); + const [objectDef, setObjectDef] = useState(null); // --- NavigationConfig support --- const navigation = useNavigationOverlay({ @@ -74,6 +75,22 @@ export const ObjectGallery: React.FC = (props) => { const titleField = gallery?.titleField ?? schema.titleField ?? 'name'; const visibleFields = gallery?.visibleFields; + // Fetch object definition for metadata + useEffect(() => { + let isMounted = true; + const fetchMeta = async () => { + if (!dataSource || typeof dataSource.getObjectSchema !== 'function' || !schema.objectName) return; + try { + const def = await dataSource.getObjectSchema(schema.objectName); + if (isMounted) setObjectDef(def); + } catch (e) { + console.warn('Failed to fetch object def for ObjectGallery', e); + } + }; + fetchMeta(); + return () => { isMounted = false; }; + }, [schema.objectName, dataSource]); + useEffect(() => { let isMounted = true; @@ -86,8 +103,11 @@ export const ObjectGallery: React.FC = (props) => { if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return; if (isMounted) setLoading(true); try { + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectDef?.fields); const results = await dataSource.find(schema.objectName, { $filter: schema.filter, + ...(expand.length > 0 ? { $expand: expand } : {}), }); let data: Record[] = []; @@ -116,7 +136,7 @@ export const ObjectGallery: React.FC = (props) => { fetchData(); } return () => { isMounted = false; }; - }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data]); + }, [schema.objectName, dataSource, boundData, schema.data, schema.filter, props.data, objectDef]); const items: Record[] = props.data || boundData || schema.data || fetchedData || []; diff --git a/packages/plugin-map/src/ObjectMap.tsx b/packages/plugin-map/src/ObjectMap.tsx index 99833f691..c62b69c2d 100644 --- a/packages/plugin-map/src/ObjectMap.tsx +++ b/packages/plugin-map/src/ObjectMap.tsx @@ -24,7 +24,7 @@ import React, { useEffect, useState, useMemo } from 'react'; import type { ObjectGridSchema, DataSource, ViewData } from '@object-ui/types'; import { useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay, cn } from '@object-ui/components'; -import { extractRecords } from '@object-ui/core'; +import { extractRecords, buildExpandFields } from '@object-ui/core'; import { z } from 'zod'; import MapGL, { NavigationControl, Marker, Popup } from 'react-map-gl/maplibre'; import maplibregl from 'maplibre-gl'; @@ -350,9 +350,12 @@ export const ObjectMap: React.FC = ({ if (dataConfig?.provider === 'object') { const objectName = dataConfig.object; + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectSchema?.fields); const result = await dataSource.find(objectName, { $filter: schema.filter, $orderby: convertSortToQueryParams(schema.sort), + ...(expand.length > 0 ? { $expand: expand } : {}), }); let items: any[] = extractRecords(result); @@ -370,7 +373,7 @@ export const ObjectMap: React.FC = ({ }; fetchData(); - }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]); + }, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]); // Fetch object schema for field metadata useEffect(() => { diff --git a/packages/plugin-timeline/src/ObjectTimeline.tsx b/packages/plugin-timeline/src/ObjectTimeline.tsx index e61073c8d..cd214d96b 100644 --- a/packages/plugin-timeline/src/ObjectTimeline.tsx +++ b/packages/plugin-timeline/src/ObjectTimeline.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import type { DataSource, TimelineSchema, TimelineConfig } from '@object-ui/types'; import { useDataScope, useNavigationOverlay } from '@object-ui/react'; import { NavigationOverlay } from '@object-ui/components'; -import { extractRecords } from '@object-ui/core'; +import { extractRecords, buildExpandFields } from '@object-ui/core'; import { usePullToRefresh } from '@object-ui/mobile'; import { z } from 'zod'; import { TimelineRenderer } from './renderer'; @@ -82,6 +82,7 @@ export const ObjectTimeline: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); + const [objectDef, setObjectDef] = useState(null); // Resolve nested TimelineConfig (spec-compliant) const timelineConfig = schema.timeline; @@ -95,13 +96,32 @@ export const ObjectTimeline: React.FC = ({ const boundData = useDataScope(schema.bind); + // Fetch object definition for metadata + useEffect(() => { + let isMounted = true; + const fetchMeta = async () => { + if (!dataSource || typeof dataSource.getObjectSchema !== 'function' || !schema.objectName) return; + try { + const def = await dataSource.getObjectSchema(schema.objectName); + if (isMounted) setObjectDef(def); + } catch (e) { + console.warn('Failed to fetch object def for ObjectTimeline', e); + } + }; + fetchMeta(); + return () => { isMounted = false; }; + }, [schema.objectName, dataSource]); + useEffect(() => { const fetchData = async () => { if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return; setLoading(true); try { + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields(objectDef?.fields); const results = await dataSource.find(schema.objectName, { - options: { $top: 100 } + options: { $top: 100 }, + ...(expand.length > 0 ? { $expand: expand } : {}), }); const data = extractRecords(results); setFetchedData(data); @@ -116,7 +136,7 @@ export const ObjectTimeline: React.FC = ({ if (schema.objectName && !boundData && !schema.items && !(props as any).data) { fetchData(); } - }, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey]); + }, [schema.objectName, dataSource, boundData, schema.items, (props as any).data, refreshKey, objectDef]); const rawData = (props as any).data || boundData || fetchedData; diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 4d4e86a06..0a2b7731f 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -56,6 +56,7 @@ import { TabsTrigger, } from '@object-ui/components'; import { Plus } from 'lucide-react'; +import { buildExpandFields } from '@object-ui/core'; import { ViewSwitcher } from './ViewSwitcher'; /** @@ -309,10 +310,13 @@ export const ObjectView: React.FC = ({ ? sortConfig.map(s => ({ field: s.field, order: s.direction })) : (currentNamedViewConfig?.sort || activeView?.sort || schema.table?.defaultSort || undefined); + // Auto-inject $expand for lookup/master_detail fields + const expand = buildExpandFields((objectSchema as any)?.fields); const results = await dataSource.find(schema.objectName, { $filter: finalFilter.length > 0 ? finalFilter : undefined, $orderby: sort, $top: 100, + ...(expand.length > 0 ? { $expand: expand } : {}), }); let items: any[] = []; @@ -337,7 +341,7 @@ export const ObjectView: React.FC = ({ fetchData(); return () => { isMounted = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView]); + }, [schema.objectName, dataSource, currentViewType, filterValues, sortConfig, refreshKey, currentNamedViewConfig, activeView, renderListView, objectSchema]); // Determine layout mode const layout = schema.layout || 'drawer';