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
1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ✅

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/utils/__tests__/expand-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
76 changes: 76 additions & 0 deletions packages/core/src/utils/expand-fields.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | 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<string>();
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);
Comment on lines +65 to +69
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When columns are provided, the filtering only matches exact field names. If a visible column uses a nested path like account.owner, the schema will contain account (lookup) but the column set contains account.owner, so the lookup field is incorrectly excluded and $expand becomes empty. Consider treating dot-path columns as implying their root field (and/or matching lookup fields when any column starts with ${fieldName}.) and add a unit test for this case.

Suggested change
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);
const rawName =
typeof col === 'string'
? col
: col && typeof col === 'object'
? col.field ?? col.name ?? col.fieldName
: undefined;
if (!rawName) continue;
const name = String(rawName).trim();
if (!name) continue;
// Always add the full column field name
columnFieldNames.add(name);
// If the column uses a nested path (e.g. "account.owner"),
// also treat it as implying the root field ("account") so
// lookup/master_detail fields are not excluded from $expand.
const dotIndex = name.indexOf('.');
if (dotIndex > 0) {
const rootField = name.slice(0, dotIndex);
if (rootField) {
columnFieldNames.add(rootField);
}

Copilot uses AI. Check for mistakes.
}
}
return lookupFieldNames.filter((f) => columnFieldNames.has(f));
}

return lookupFieldNames;
}
7 changes: 7 additions & 0 deletions packages/plugin-aggrid/src/ObjectAgGridImpl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 || []);
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin-calendar/src/ObjectCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -215,9 +215,12 @@ export const ObjectCalendar: React.FC<ObjectCalendarProps> = ({

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 } : {}),
Comment on lines +218 to +223
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objectSchema is fetched in a separate effect defined after this one, so this effect will typically fire once with objectSchema === null (no $expand) and then fire again when objectSchema is set—duplicating the data request and briefly showing raw lookup IDs. Consider combining schema+data fetch, or early-returning from this effect until objectSchema is available for provider: 'object'.

Copilot uses AI. Check for mistakes.
});

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

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(() => {
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin-gantt/src/ObjectGantt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -174,9 +174,12 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({

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 } : {}),
});
Comment on lines +177 to 183
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objectSchema is fetched in a separate effect defined after this one, so the first fetch typically runs with objectSchema === null (no $expand) and then re-fetches once schema arrives. Consider fetching schema+data together (ObjectGrid already uses this pattern) or skipping the initial find() until objectSchema is loaded for provider: 'object'.

Copilot uses AI. Check for mistakes.
let items: any[] = extractRecords(result);
setData(items);
Expand All @@ -193,7 +196,7 @@ export const ObjectGantt: React.FC<ObjectGanttProps> = ({
};

fetchData();
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort]);
}, [dataConfig, dataSource, hasInlineData, schema.filter, schema.sort, objectSchema]);

// Fetch object schema for field metadata
useEffect(() => {
Expand Down
8 changes: 7 additions & 1 deletion packages/plugin-grid/src/ObjectGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -308,6 +308,12 @@ export const ObjectGrid: React.FC<ObjectGridProps> = ({
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;
}

Comment on lines +311 to +316
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildExpandFields(resolvedSchema?.fields, ...) will always return [] in the code path where resolvedSchema is the "minimal schema stub" (fields {}) used when explicit columns/fields are configured. That means $expand won't actually be injected for the common explicit-columns scenario. Consider fetching the real object schema (or otherwise providing field metadata) before computing expand fields.

Copilot uses AI. Check for mistakes.
const result = await dataSource.find(objectName, params);
if (cancelled) return;
setData(result.data || []);
Expand Down
9 changes: 6 additions & 3 deletions packages/plugin-kanban/src/ObjectKanban.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -61,9 +61,12 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
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 } : {}),
});
Comment on lines +64 to 70
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here: (1) options: { $top: 100 } is not honored by the built-in ApiDataSource/ValueDataSource (they read params.$top), so this likely doesn't paginate as intended; (2) because objectDef loads asynchronously, the effect will fetch once with no $expand and then refetch when objectDef arrives. Prefer {$top: 100} at the top level and avoid the double-fetch by waiting for objectDef (or fetching schema+data together).

Copilot uses AI. Check for mistakes.

// Handle { value: [] } OData shape or { data: [] } shape or direct array
Expand All @@ -88,7 +91,7 @@ export const ObjectKanban: React.FC<ObjectKanbanProps> = ({
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;
Expand Down
11 changes: 9 additions & 2 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -495,6 +495,12 @@ export const ListView: React.FC<ListViewProps> = ({
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],
);
Comment on lines +498 to +502
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expandFields depends on objectDef?.fields, but the data-fetch effect runs before getObjectSchema() resolves. This causes an initial fetch without $expand, followed by a second fetch when expandFields updates—extra network load and a brief "raw ID" render. Consider gating the initial fetch until objectDef is available (when getObjectSchema is supported) or combining schema+data fetch into one async flow with caching.

Copilot uses AI. Check for mistakes.

// Fetch data effect — supports schema.data (ViewDataSchema) provider modes
React.useEffect(() => {
let isMounted = true;
Expand Down Expand Up @@ -567,6 +573,7 @@ export const ListView: React.FC<ListViewProps> = ({
$filter: finalFilter,
$orderby: sort,
$top: effectivePageSize,
...(expandFields.length > 0 ? { $expand: expandFields } : {}),
...(searchTerm ? {
$search: searchTerm,
...(schema.searchableFields && schema.searchableFields.length > 0
Expand Down Expand Up @@ -606,7 +613,7 @@ export const ListView: React.FC<ListViewProps> = ({
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(() => {
Expand Down
Loading