Skip to content

Commit c993ca6

Browse files
authored
Merge pull request #892 from objectstack-ai/copilot/support-auto-inject-expand
2 parents 0ccb1df + f266b8c commit c993ca6

File tree

14 files changed

+292
-18
lines changed

14 files changed

+292
-18
lines changed

ROADMAP.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,7 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th
873873
- [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`.
874874
- [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.
875875
- [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.
876+
- [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.
876877

877878
### P2.7 Platform UI Consistency & Interaction Optimization ✅
878879

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export * from './builder/schema-builder.js';
1616
export * from './utils/filter-converter.js';
1717
export * from './utils/normalize-quick-filter.js';
1818
export * from './utils/extract-records.js';
19+
export * from './utils/expand-fields.js';
1920
export * from './evaluator/index.js';
2021
export * from './actions/index.js';
2122
export * from './query/index.js';
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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 { buildExpandFields } from '../expand-fields';
11+
12+
describe('buildExpandFields', () => {
13+
const sampleFields = {
14+
name: { type: 'text', label: 'Name' },
15+
email: { type: 'email', label: 'Email' },
16+
account: { type: 'lookup', label: 'Account', reference_to: 'accounts' },
17+
parent: { type: 'master_detail', label: 'Parent', reference_to: 'contacts' },
18+
status: { type: 'select', label: 'Status' },
19+
};
20+
21+
it('should return lookup and master_detail field names', () => {
22+
const result = buildExpandFields(sampleFields);
23+
expect(result).toEqual(['account', 'parent']);
24+
});
25+
26+
it('should return empty array when no lookup/master_detail fields exist', () => {
27+
const fields = {
28+
name: { type: 'text' },
29+
age: { type: 'number' },
30+
};
31+
expect(buildExpandFields(fields)).toEqual([]);
32+
});
33+
34+
it('should return empty array for null/undefined schema', () => {
35+
expect(buildExpandFields(null)).toEqual([]);
36+
expect(buildExpandFields(undefined)).toEqual([]);
37+
});
38+
39+
it('should return empty array for empty fields object', () => {
40+
expect(buildExpandFields({})).toEqual([]);
41+
});
42+
43+
it('should filter by string columns when provided', () => {
44+
const result = buildExpandFields(sampleFields, ['name', 'account']);
45+
expect(result).toEqual(['account']);
46+
});
47+
48+
it('should filter by ListColumn objects with field property', () => {
49+
const columns = [
50+
{ field: 'name', label: 'Name' },
51+
{ field: 'parent', label: 'Parent Contact' },
52+
];
53+
const result = buildExpandFields(sampleFields, columns);
54+
expect(result).toEqual(['parent']);
55+
});
56+
57+
it('should support columns with name property', () => {
58+
const columns = [
59+
{ name: 'account', label: 'Account' },
60+
];
61+
const result = buildExpandFields(sampleFields, columns);
62+
expect(result).toEqual(['account']);
63+
});
64+
65+
it('should support columns with fieldName property', () => {
66+
const columns = [
67+
{ fieldName: 'parent', label: 'Parent' },
68+
];
69+
const result = buildExpandFields(sampleFields, columns);
70+
expect(result).toEqual(['parent']);
71+
});
72+
73+
it('should return empty array when columns have no lookup fields', () => {
74+
const result = buildExpandFields(sampleFields, ['name', 'email']);
75+
expect(result).toEqual([]);
76+
});
77+
78+
it('should handle mixed string and object columns', () => {
79+
const columns = [
80+
'name',
81+
{ field: 'account' },
82+
'parent',
83+
];
84+
const result = buildExpandFields(sampleFields, columns);
85+
expect(result).toEqual(['account', 'parent']);
86+
});
87+
88+
it('should return all lookup fields when columns is empty array', () => {
89+
// Empty columns array does not satisfy the length > 0 check,
90+
// so no column restriction is applied → all lookup fields returned
91+
const result = buildExpandFields(sampleFields, []);
92+
expect(result).toEqual(['account', 'parent']);
93+
});
94+
95+
it('should handle malformed field definitions gracefully', () => {
96+
const fields = {
97+
name: null,
98+
account: { type: 'lookup' },
99+
broken: 'not-an-object',
100+
empty: {},
101+
};
102+
const result = buildExpandFields(fields as any);
103+
expect(result).toEqual(['account']);
104+
});
105+
106+
it('should handle only lookup fields', () => {
107+
const fields = {
108+
ref1: { type: 'lookup', reference_to: 'obj1' },
109+
ref2: { type: 'lookup', reference_to: 'obj2' },
110+
};
111+
expect(buildExpandFields(fields)).toEqual(['ref1', 'ref2']);
112+
});
113+
114+
it('should handle only master_detail fields', () => {
115+
const fields = {
116+
detail1: { type: 'master_detail', reference_to: 'obj1' },
117+
};
118+
expect(buildExpandFields(fields)).toEqual(['detail1']);
119+
});
120+
});
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/**
2+
* ObjectUI — expand-fields utility
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+
/**
10+
* Build an array of field names that should be included in `$expand`
11+
* when fetching data. This scans the given object schema fields
12+
* (and optional column configuration) for `lookup` and `master_detail`
13+
* field types, so the backend (e.g. objectql) returns expanded objects
14+
* instead of raw foreign-key IDs.
15+
*
16+
* @param schemaFields - Object map of field metadata from `getObjectSchema()`,
17+
* e.g. `{ account: { type: 'lookup', reference_to: 'accounts' }, ... }`.
18+
* @param columns - Optional explicit column list. When provided, only
19+
* lookup/master_detail fields that appear in `columns` are expanded.
20+
* Accepts `string[]` or `ListColumn[]` (objects with a `field` property).
21+
* @returns Array of field names to pass as `$expand`.
22+
*
23+
* @example
24+
* ```ts
25+
* const fields = {
26+
* name: { type: 'text' },
27+
* account: { type: 'lookup', reference_to: 'accounts' },
28+
* parent: { type: 'master_detail', reference_to: 'contacts' },
29+
* };
30+
* buildExpandFields(fields);
31+
* // → ['account', 'parent']
32+
*
33+
* buildExpandFields(fields, ['name', 'account']);
34+
* // → ['account']
35+
* ```
36+
*/
37+
export function buildExpandFields(
38+
schemaFields?: Record<string, any> | null,
39+
columns?: (string | { field?: string; name?: string; fieldName?: string })[],
40+
): string[] {
41+
if (!schemaFields || typeof schemaFields !== 'object') {
42+
return [];
43+
}
44+
45+
// Collect all lookup / master_detail field names from the schema
46+
const lookupFieldNames: string[] = [];
47+
for (const [fieldName, fieldDef] of Object.entries(schemaFields)) {
48+
if (
49+
fieldDef &&
50+
typeof fieldDef === 'object' &&
51+
(fieldDef.type === 'lookup' || fieldDef.type === 'master_detail')
52+
) {
53+
lookupFieldNames.push(fieldName);
54+
}
55+
}
56+
57+
if (lookupFieldNames.length === 0) {
58+
return [];
59+
}
60+
61+
// When columns are provided, restrict expansion to visible columns only
62+
if (columns && Array.isArray(columns) && columns.length > 0) {
63+
const columnFieldNames = new Set<string>();
64+
for (const col of columns) {
65+
if (typeof col === 'string') {
66+
columnFieldNames.add(col);
67+
} else if (col && typeof col === 'object') {
68+
const name = col.field ?? col.name ?? col.fieldName;
69+
if (name) columnFieldNames.add(name);
70+
}
71+
}
72+
return lookupFieldNames.filter((f) => columnFieldNames.has(f));
73+
}
74+
75+
return lookupFieldNames;
76+
}

packages/plugin-aggrid/src/ObjectAgGridImpl.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type { FieldMetadata, ObjectSchemaMetadata } from '@object-ui/types';
2424
import type { ObjectAgGridImplProps } from './object-aggrid.types';
2525
import { FIELD_TYPE_TO_FILTER_TYPE } from './object-aggrid.types';
2626
import { createFieldCellRenderer, createFieldCellEditor } from './field-renderers';
27+
import { buildExpandFields } from '@object-ui/core';
2728

2829
/**
2930
* ObjectAgGridImpl - Metadata-driven AG Grid implementation
@@ -112,6 +113,12 @@ export default function ObjectAgGridImpl({
112113
queryParams.$orderby = sort;
113114
}
114115

116+
// Auto-inject $expand for lookup/master_detail fields
117+
const expand = buildExpandFields(objectSchema?.fields);
118+
if (expand.length > 0) {
119+
queryParams.$expand = expand;
120+
}
121+
115122
const result = await dataSource.find(objectName, queryParams);
116123
setRowData(result.data || []);
117124
callbacks?.onDataLoaded?.(result.data || []);

packages/plugin-calendar/src/ObjectCalendar.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import { CalendarView, type CalendarEvent } from './CalendarView';
2828
import { usePullToRefresh } from '@object-ui/mobile';
2929
import { useNavigationOverlay } from '@object-ui/react';
3030
import { NavigationOverlay } from '@object-ui/components';
31-
import { extractRecords } from '@object-ui/core';
31+
import { extractRecords, buildExpandFields } from '@object-ui/core';
3232

3333
export interface CalendarSchema {
3434
type: 'calendar';
@@ -215,9 +215,12 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
215215

216216
if (dataConfig?.provider === 'object') {
217217
const objectName = dataConfig.object;
218+
// Auto-inject $expand for lookup/master_detail fields
219+
const expand = buildExpandFields(objectSchema?.fields);
218220
const result = await dataSource.find(objectName, {
219221
$filter: schema.filter,
220222
$orderby: convertSortToQueryParams(schema.sort),
223+
...(expand.length > 0 ? { $expand: expand } : {}),
221224
});
222225

223226
let items: any[] = extractRecords(result);
@@ -242,7 +245,7 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({
242245

243246
fetchData();
244247
return () => { isMounted = false; };
245-
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey]);
248+
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, refreshKey, objectSchema]);
246249

247250
// Fetch object schema for field metadata
248251
useEffect(() => {

packages/plugin-gantt/src/ObjectGantt.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type { ObjectGridSchema, DataSource, ViewData, GanttConfig } from '@objec
2727
import { GanttConfigSchema } from '@objectstack/spec/ui';
2828
import { useNavigationOverlay } from '@object-ui/react';
2929
import { NavigationOverlay } from '@object-ui/components';
30-
import { extractRecords } from '@object-ui/core';
30+
import { extractRecords, buildExpandFields } from '@object-ui/core';
3131
import { GanttView, type GanttTask } from './GanttView';
3232

3333
export interface ObjectGanttProps {
@@ -174,9 +174,12 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
174174

175175
if (dataConfig?.provider === 'object') {
176176
const objectName = dataConfig.object;
177+
// Auto-inject $expand for lookup/master_detail fields
178+
const expand = buildExpandFields(objectSchema?.fields);
177179
const result = await dataSource.find(objectName, {
178180
$filter: schema.filter,
179181
$orderby: convertSortToQueryParams(schema.sort),
182+
...(expand.length > 0 ? { $expand: expand } : {}),
180183
});
181184
let items: any[] = extractRecords(result);
182185
setData(items);
@@ -193,7 +196,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
193196
};
194197

195198
fetchData();
196-
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
199+
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]);
197200

198201
// Fetch object schema for field metadata
199202
useEffect(() => {

packages/plugin-grid/src/ObjectGrid.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
Popover, PopoverContent, PopoverTrigger,
3131
} from '@object-ui/components';
3232
import { usePullToRefresh } from '@object-ui/mobile';
33-
import { evaluatePlainCondition } from '@object-ui/core';
33+
import { evaluatePlainCondition, buildExpandFields } from '@object-ui/core';
3434
import { ChevronRight, ChevronDown, Download, Rows2, Rows3, Rows4, AlignJustify, Type, Hash, Calendar, CheckSquare, User, Tag, Clock } from 'lucide-react';
3535
import { useRowColor } from './useRowColor';
3636
import { useGroupedData } from './useGroupedData';
@@ -308,6 +308,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
308308
params.$orderby = `${(schema.defaultSort as any).field} ${(schema.defaultSort as any).order}`;
309309
}
310310

311+
// Auto-inject $expand for lookup/master_detail fields
312+
const expand = buildExpandFields(resolvedSchema?.fields, schemaColumns ?? schemaFields);
313+
if (expand.length > 0) {
314+
params.$expand = expand;
315+
}
316+
311317
const result = await dataSource.find(objectName, params);
312318
if (cancelled) return;
313319
setData(result.data || []);

packages/plugin-kanban/src/ObjectKanban.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import React, { useEffect, useState, useMemo } from 'react';
1010
import type { DataSource } from '@object-ui/types';
1111
import { useDataScope, useNavigationOverlay } from '@object-ui/react';
1212
import { NavigationOverlay } from '@object-ui/components';
13-
import { extractRecords } from '@object-ui/core';
13+
import { extractRecords, buildExpandFields } from '@object-ui/core';
1414
import { KanbanRenderer } from './index';
1515
import { KanbanSchema } from './types';
1616

@@ -61,9 +61,12 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
6161
if (!dataSource || typeof dataSource.find !== 'function' || !schema.objectName) return;
6262
if (isMounted) setLoading(true);
6363
try {
64+
// Auto-inject $expand for lookup/master_detail fields
65+
const expand = buildExpandFields(objectDef?.fields);
6466
const results = await dataSource.find(schema.objectName, {
6567
options: { $top: 100 },
66-
$filter: schema.filter
68+
$filter: schema.filter,
69+
...(expand.length > 0 ? { $expand: expand } : {}),
6770
});
6871

6972
// Handle { value: [] } OData shape or { data: [] } shape or direct array
@@ -88,7 +91,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
8891
fetchData();
8992
}
9093
return () => { isMounted = false; };
91-
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data]);
94+
}, [schema.objectName, dataSource, boundData, schema.data, schema.filter, (props as any).data, objectDef]);
9295

9396
// Determine which data to use: props.data -> bound -> inline -> fetched
9497
const rawData = (props as any).data || boundData || schema.data || fetchedData;

packages/plugin-list/src/ListView.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { SchemaRenderer, useNavigationOverlay } from '@object-ui/react';
1919
import { useDensityMode } from '@object-ui/react';
2020
import type { ListViewSchema } from '@object-ui/types';
2121
import { usePullToRefresh } from '@object-ui/mobile';
22-
import { evaluatePlainCondition, normalizeQuickFilter, normalizeQuickFilters } from '@object-ui/core';
22+
import { evaluatePlainCondition, normalizeQuickFilter, normalizeQuickFilters, buildExpandFields } from '@object-ui/core';
2323
import { useObjectTranslation } from '@object-ui/i18n';
2424

2525
export interface ListViewProps {
@@ -495,6 +495,12 @@ export const ListView: React.FC<ListViewProps> = ({
495495
return () => { isMounted = false; };
496496
}, [schema.objectName, dataSource]);
497497

498+
// Auto-compute $expand fields from objectDef (lookup / master_detail)
499+
const expandFields = React.useMemo(
500+
() => buildExpandFields(objectDef?.fields, schema.fields),
501+
[objectDef?.fields, schema.fields],
502+
);
503+
498504
// Fetch data effect — supports schema.data (ViewDataSchema) provider modes
499505
React.useEffect(() => {
500506
let isMounted = true;
@@ -567,6 +573,7 @@ export const ListView: React.FC<ListViewProps> = ({
567573
$filter: finalFilter,
568574
$orderby: sort,
569575
$top: effectivePageSize,
576+
...(expandFields.length > 0 ? { $expand: expandFields } : {}),
570577
...(searchTerm ? {
571578
$search: searchTerm,
572579
...(schema.searchableFields && schema.searchableFields.length > 0
@@ -606,7 +613,7 @@ export const ListView: React.FC<ListViewProps> = ({
606613
fetchData();
607614

608615
return () => { isMounted = false; };
609-
}, [schema.objectName, schema.data, dataSource, schema.filters, effectivePageSize, currentSort, currentFilters, activeQuickFilters, normalizedQuickFilters, userFilterConditions, refreshKey, searchTerm, schema.searchableFields]); // Re-fetch on filter/sort/search change
616+
}, [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
610617

611618
// Available view types based on schema configuration
612619
const availableViews = React.useMemo(() => {

0 commit comments

Comments
 (0)