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
82 changes: 81 additions & 1 deletion packages/core/src/utils/__tests__/expand-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
*/

import { describe, it, expect } from 'vitest';
import { buildExpandFields } from '../expand-fields';
import {
buildExpandFields,
isExpandableFieldType,
EXPANDABLE_FIELD_TYPES,
} from '../expand-fields';

describe('buildExpandFields', () => {
const sampleFields = {
Expand Down Expand Up @@ -117,4 +121,80 @@ describe('buildExpandFields', () => {
};
expect(buildExpandFields(fields)).toEqual(['detail1']);
});

// ── Reference-bearing types beyond lookup/master_detail ────────────────────
// A list/grid that shows a `user` or `tree` relation column must still
// request `$expand` for it, or the cell receives a bare id and renders "—".
// This is the regression these cases lock in: previously only `lookup` /
// `master_detail` were collected, so `user`/`tree` columns silently broke.
const relationalZoo = {
name: { type: 'text', label: 'Name' },
f_lookup: { type: 'lookup', label: 'Account', reference: 'showcase_account' },
f_master_detail: { type: 'master_detail', label: 'Project', reference: 'showcase_project' },
f_tree: { type: 'tree', label: 'Category', reference: 'showcase_category' },
f_user: { type: 'user', label: 'Assignee', reference: 'sys_user' },
status: { type: 'select', label: 'Status' },
cover: { type: 'image', label: 'Cover' },
};

it('should include user and tree reference types (not just lookup/master_detail)', () => {
expect(buildExpandFields(relationalZoo)).toEqual([
'f_lookup',
'f_master_detail',
'f_tree',
'f_user',
]);
});

it('should NOT include non-reference field types in $expand', () => {
const result = buildExpandFields(relationalZoo);
expect(result).not.toContain('name');
expect(result).not.toContain('status');
expect(result).not.toContain('cover');
});

it('should scope to ONLY the visible reference columns (the default behavior)', () => {
// A grid showing [name, f_lookup, f_user, status] must expand exactly the
// two visible reference columns — never the unshown f_master_detail / f_tree.
const columns = ['name', 'f_lookup', 'f_user', 'status'];
expect(buildExpandFields(relationalZoo, columns)).toEqual(['f_lookup', 'f_user']);
});

it('should produce an EMPTY set when no visible column is a reference (omit $expand)', () => {
expect(buildExpandFields(relationalZoo, ['name', 'status', 'cover'])).toEqual([]);
});

it('should expand a lone user-type column once it is visible', () => {
// The exact bug: a `user` column ("technician") shown in a list view.
const fields = { title: { type: 'text' }, technician: { type: 'user', reference: 'sys_user' } };
expect(buildExpandFields(fields, ['title'])).toEqual([]);
expect(buildExpandFields(fields, ['title', 'technician'])).toEqual(['technician']);
});
});

describe('EXPANDABLE_FIELD_TYPES / isExpandableFieldType', () => {
it('covers exactly the reference-bearing types', () => {
expect([...EXPANDABLE_FIELD_TYPES].sort()).toEqual(
['lookup', 'master_detail', 'tree', 'user'].sort(),
);
});

it('treats reference field defs as expandable', () => {
for (const type of ['lookup', 'master_detail', 'tree', 'user']) {
expect(isExpandableFieldType({ type })).toBe(true);
}
});

it('treats scalar/non-reference field defs as NOT expandable', () => {
for (const type of ['text', 'number', 'select', 'image', 'formula', 'summary', 'date']) {
expect(isExpandableFieldType({ type })).toBe(false);
}
});

it('is null/shape safe', () => {
expect(isExpandableFieldType(null)).toBe(false);
expect(isExpandableFieldType(undefined)).toBe(false);
expect(isExpandableFieldType('lookup')).toBe(false);
expect(isExpandableFieldType({})).toBe(false);
});
});
85 changes: 62 additions & 23 deletions packages/core/src/utils/expand-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,75 @@
* LICENSE file in the root directory of this source tree.
*/

/**
* Relational ("reference-bearing") field types whose stored value is a foreign
* key into another object — and which therefore benefit from `$expand` so a
* list / grid / detail cell can render the related record's display name
* instead of a bare id placeholder ("—").
*
* This mirrors the form layer's `DATA_SOURCE_FIELD_TYPES`
* (`lookup` / `master_detail` / `tree`) and additionally covers `user` — a
* lookup specialised to `sys_user`. The server resolves `user` through the
* same expand path as `lookup` / `master_detail` (it carries the same
* `reference` + id storage), so a `user` column that is NOT requested for
* expansion comes back as a raw user id and renders as "—". Keeping the set
* here as the single source of truth is what closes that gap.
*
* Note on `tree`: a self-referencing hierarchy field is a reference too, so it
* belongs in this set; whether the backend materialises the expanded object
* for it is a server concern — requesting it is harmless and forward-compatible.
*/
export const EXPANDABLE_FIELD_TYPES: ReadonlySet<string> = new Set([
'lookup',
'master_detail',
'tree',
'user',
]);

/**
* Whether a field definition is a reference-bearing type that can be `$expand`-ed.
* Only the field's `type` matters here — the `reference` / `reference_to` target
* is irrelevant to the decision, so this works regardless of which canonical key
* the schema uses to name the related object.
*/
export function isExpandableFieldType(fieldDef: unknown): boolean {
return (
!!fieldDef &&
typeof fieldDef === 'object' &&
EXPANDABLE_FIELD_TYPES.has((fieldDef as { type?: unknown }).type as string)
);
}

/**
* 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.
* (and optional column configuration) for reference-bearing field types
* (see {@link EXPANDABLE_FIELD_TYPES}: `lookup` / `master_detail` / `tree` /
* `user`), 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' }, ... }`.
* e.g. `{ account: { type: 'lookup', reference: '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`.
* reference fields that appear in `columns` are expanded — list/grid/kanban
* views pass their VISIBLE columns here so wide objects don't pay to expand
* relations no cell will show. Accepts `string[]` or `ListColumn[]` (objects
* with a `field` property).
* @returns Array of field names to pass as `$expand` (empty → omit `$expand`).
*
* @example
* ```ts
* const fields = {
* name: { type: 'text' },
* account: { type: 'lookup', reference_to: 'accounts' },
* parent: { type: 'master_detail', reference_to: 'contacts' },
* account: { type: 'lookup', reference: 'accounts' },
* parent: { type: 'master_detail', reference: 'contacts' },
* assignee: { type: 'user', reference: 'sys_user' },
* };
* buildExpandFields(fields);
* // → ['account', 'parent']
* // → ['account', 'parent', 'assignee']
*
* buildExpandFields(fields, ['name', 'account']);
* // → ['account']
* // → ['account'] (only the visible reference columns)
* ```
*/
export function buildExpandFields(
Expand All @@ -42,23 +85,19 @@ export function buildExpandFields(
return [];
}

// Collect all lookup / master_detail field names from the schema
const lookupFieldNames: string[] = [];
// Collect every reference-bearing field name from the schema.
const referenceFieldNames: 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 (isExpandableFieldType(fieldDef)) {
referenceFieldNames.push(fieldName);
}
}

if (lookupFieldNames.length === 0) {
if (referenceFieldNames.length === 0) {
return [];
}

// When columns are provided, restrict expansion to visible columns only
// 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) {
Expand All @@ -69,8 +108,8 @@ export function buildExpandFields(
if (name) columnFieldNames.add(name);
}
}
return lookupFieldNames.filter((f) => columnFieldNames.has(f));
return referenceFieldNames.filter((f) => columnFieldNames.has(f));
}

return lookupFieldNames;
return referenceFieldNames;
}
Loading