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
50 changes: 50 additions & 0 deletions .changeset/7245-default-view-namefield-lead.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
'@object-ui/core': patch
'@object-ui/app-shell': patch
'@object-ui/plugin-grid': patch
---

A synthesized default list view now always leads with the object's name field
(objectui#7245).

**The defect.** An object that declares no list view gets its default grid columns
synthesized from `highlightFields`, taken verbatim. But `highlightFields` is ADR-0085's
*"most important fields"* role, not a column list — and its first consumer, the
detail-page highlight strip, **deliberately removes the title field**, because the page
H1 directly above it already shows one. So metadata that is entirely correct routinely
omits the record's name from `highlightFields`. The showcase `showcase_account` declares
`nameField: "name"` and `highlightFields: ["status", "industry", "annual_revenue"]`, and
its default `所有记录` grid rendered 14 rows whose columns were `#` / Lifecycle / Industry
/ Annual Revenue / actions — no name column, and no way to tell one account from another.

A list has no H1 to lean on, so the same declaration needs the opposite treatment here.
This is not a new convention: `deriveLookupColumns` in `@object-ui/fields` already leads
its record-picker columns with the display field and filters it out of the declared list.
The list faces now agree with it.

**What changed.** `@object-ui/core` gains two exports on the ADR-0079 title ladder:

- `resolveNameField(objectDef)` — *which field* titles an object: the declared
`nameField` (then its deprecated `displayNameField` / `NAME_FIELD_KEY` aliases), else
the type-aware derivation. The name-space twin of `getRecordDisplayName`, which answers
what that field *says* on one record. Both now read one spelling of the declared
pointer, so they cannot drift into naming different fields.
- `leadWithNameField(objectDef, columns)` — moves that field to the front of a
**synthesized** column list.

All three faces that synthesize default list columns call it: `ObjectView`
(`defaultListColumnsFromObject`), `InterfaceListPage` (`defaultColumnsFromObject`) and
`ObjectGrid`'s own derivation. The name field is **moved**, not merely appended, so an
author who lists it third still gets it first — "the column that identifies the row"
means first. On the two capped faces the lead is applied *before* the 5 / 6-column slice,
so an object declaring its name field late no longer loses it off the end.

**Scope, deliberately narrow.** Author-declared column lists are untouched — a view or
grid that declares `columns` / `fields` said what it wants, and reordering it would be
renderer-side second-guessing of metadata. Three cases also decline to lead: a name field
the object carries no field def for (never fabricate a column), one marked
`hidden: true` (the author said don't show it), and a *derived* pick that lands on a
system-managed column — `deriveTitleField` filters by type only, and leading a default
list with a raw id is the regression objectui#2702 / #2777 fixed. A *declared*
`nameField` pointing at a system field still leads: `sys_migration` really does point at
`id`, and an explicit designation is not a heuristic misfire.
52 changes: 52 additions & 0 deletions packages/app-shell/src/views/InterfaceListPage.defaults.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,58 @@ describe('defaultColumnsFromObject', () => {
expect(cols).toEqual(['name', 'owner_id']);
});

// objectui#7245 — the same fix as `ObjectView.defaultListColumnsFromObject`.
// These two are documented mirrors of one another, so the pin is mirrored too:
// a curated `highlightFields` that (correctly) omits the title field left an
// interface page's rows with no column identifying them.
describe('#7245: the synthesized default always leads with the name field', () => {
const showcaseAccount = {
nameField: 'name',
highlightFields: ['status', 'industry', 'annual_revenue'],
fields: {
name: { type: 'text', label: 'Account Name' },
industry: { type: 'select' },
annual_revenue: { type: 'currency' },
status: { type: 'select' },
organization_id: { type: 'lookup', system: true, hidden: true },
},
};

it('THE REPRO: prepends nameField to a curated list that omits it', () => {
expect(defaultColumnsFromObject(showcaseAccount)).toEqual([
'name',
'status',
'industry',
'annual_revenue',
]);
});

it('does not duplicate, and moves a late-listed nameField to the front', () => {
expect(
defaultColumnsFromObject({ ...showcaseAccount, highlightFields: ['status', 'name'] }),
).toEqual(['name', 'status']);
});

it('leads the derived walk BEFORE the six-column cap', () => {
const fields: Record<string, any> = {};
for (let i = 0; i < 9; i++) fields[`b_${i}`] = { type: 'text' };
fields.headline = { type: 'text' };
const cols = defaultColumnsFromObject({ nameField: 'headline', fields });
expect(cols).toHaveLength(6);
expect(cols[0]).toBe('headline');
});

it('still appends the org attribution column last', () => {
expect(defaultColumnsFromObject(showcaseAccount, { orgAttribution: true })).toEqual([
'name',
'status',
'industry',
'annual_revenue',
'organization_id',
]);
});
});

it('caps the auto-derived business columns at six', () => {
const fields: Record<string, any> = { owner_id: { type: 'lookup', system: true } };
for (let i = 0; i < 10; i++) fields[`b_${i}`] = { type: 'text' };
Expand Down
32 changes: 23 additions & 9 deletions packages/app-shell/src/views/InterfaceListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Empty, EmptyTitle, EmptyDescription, NavigationOverlay } from '@object-
import { Database } from 'lucide-react';
import { useObjectTranslation } from '@object-ui/i18n';
import { isSystemManagedField } from '@object-ui/types';
import { leadWithNameField } from '@object-ui/core';
import type { ListViewSchema } from '@object-ui/types';
import { useMetadata } from '../providers/MetadataProvider.js';
import { useTenancyPosture } from '../hooks/useTenancyPosture.js';
Expand Down Expand Up @@ -75,10 +76,19 @@ function resolveSourceView(objectDef: any, sourceView?: string): any | undefined
/**
* Default column set when the resolved view carries none — mirrors
* ObjectView's data-mode fallback so an interface page never renders a
* column-less grid. Priority: the `highlightFields` semantic role
* (ADR-0085), else the first business fields — framework-managed
* system/audit/ownership columns (including the injected, editable `owner_id`)
* are excluded via the shared `isSystemManagedField` classifier.
* column-less grid. Priority: the object's name field always leads
* (`leadWithNameField`, objectui#7245 — `highlightFields` is ADR-0085's
* "most important fields" role, which the detail highlight strip deliberately
* strips the title out of, so well-authored metadata often omits it and the
* synthesized list had no column identifying the row); then the
* `highlightFields` semantic role (ADR-0085), else the first business fields —
* framework-managed system/audit/ownership columns (including the injected,
* editable `owner_id`) are excluded via the shared `isSystemManagedField`
* classifier.
*
* The lead is applied on BOTH branches, and before the slice on the fallback
* walk, exactly as in `ObjectView.defaultListColumnsFromObject` — the two are
* documented as mirrors, so they must not drift on this.
*
* `opts.orgAttribution` (ADR-0105 group posture): reads span every
* organization the member belongs to, so cross-org rows need attribution —
Expand All @@ -95,15 +105,19 @@ export function defaultColumnsFromObject(
: cols;
const curated = objectDef?.highlightFields;
if (Array.isArray(curated) && curated.length > 0) {
return withOrgAttribution(curated.filter((n: string) => objectDef.fields?.[n]));
return withOrgAttribution(
leadWithNameField(objectDef, curated.filter((n: string) => objectDef.fields?.[n])),
);
}
const fields = objectDef?.fields;
if (fields && typeof fields === 'object') {
return withOrgAttribution(
Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name)
.slice(0, 6),
leadWithNameField(
objectDef,
Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name),
).slice(0, 6),
);
}
return [];
Expand Down
72 changes: 72 additions & 0 deletions packages/app-shell/src/views/ObjectView.defaultColumns.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,78 @@ describe('defaultListColumnsFromObject', () => {
expect(cols).toEqual(['invoice_no', 'owner_id']);
});

// objectui#7245. `highlightFields` is ADR-0085's "most important fields", not
// a column list — the detail highlight strip, its first consumer, strips the
// title field out because the page H1 above it already shows one. A grid has
// no H1, so a curated list that (correctly) omits the name left every row
// unidentifiable. The synthesized default therefore always leads with the
// object's name field.
describe('#7245: the synthesized default always leads with the name field', () => {
// The served `showcase_account`: `nameField: "name"`, three curated
// highlight fields that do not include it, and no list views at all.
const showcaseAccount = {
nameField: 'name',
highlightFields: ['status', 'industry', 'annual_revenue'],
fields: {
name: { type: 'text', label: 'Account Name', required: true },
industry: { type: 'select', label: 'Industry' },
annual_revenue: { type: 'currency', label: 'Annual Revenue' },
status: { type: 'select', label: 'Lifecycle' },
organization_id: { type: 'lookup', system: true, hidden: true },
},
};

it('THE REPRO: prepends nameField to a curated list that omits it', () => {
expect(defaultListColumnsFromObject(showcaseAccount, 5)).toEqual([
'name',
'status',
'industry',
'annual_revenue',
]);
});

it('does not duplicate a nameField the curated list already carries', () => {
const cols = defaultListColumnsFromObject(
{ ...showcaseAccount, highlightFields: ['name', 'status'] },
5,
);
expect(cols).toEqual(['name', 'status']);
});

it('moves a late-listed nameField to the front', () => {
const cols = defaultListColumnsFromObject(
{ ...showcaseAccount, highlightFields: ['status', 'name', 'industry'] },
5,
);
expect(cols).toEqual(['name', 'status', 'industry']);
});

it('still appends the org attribution column last', () => {
const cols = defaultListColumnsFromObject(showcaseAccount, 5, { orgAttribution: true });
expect(cols).toEqual(['name', 'status', 'industry', 'annual_revenue', 'organization_id']);
});

it('leads the derived walk too, and BEFORE the limit slice', () => {
// The walk is declaration-ordered, so a nameField declared after `limit`
// other fields used to fall off the end of the slice entirely.
const fields: Record<string, any> = {};
for (let i = 0; i < 8; i++) fields[`b_${i}`] = { type: 'text' };
fields.headline = { type: 'text' };
const cols = defaultListColumnsFromObject({ nameField: 'headline', fields }, 5);
expect(cols).toHaveLength(5);
expect(cols[0]).toBe('headline');
expect(cols).toEqual(['headline', 'b_0', 'b_1', 'b_2', 'b_3']);
});

it('does not lead with a nameField the object has no field def for', () => {
const cols = defaultListColumnsFromObject(
{ nameField: 'ghost', highlightFields: ['status'], fields: showcaseAccount.fields },
5,
);
expect(cols).toEqual(['status']);
});
});

it('caps the auto-derived business columns at the requested limit', () => {
const fields: Record<string, any> = { owner_id: { type: 'lookup', system: true } };
for (let i = 0; i < 10; i++) fields[`b_${i}`] = { type: 'text' };
Expand Down
49 changes: 34 additions & 15 deletions packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, type FilterTokenScope } from '@object-ui/core';
import { resolveFilterPlaceholders, DENSITY_MODE_TO_ROW_HEIGHT, normalizeListViewSchema, leadWithNameField, type FilterTokenScope } from '@object-ui/core';
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState.js';
import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage.js';
import { VALUELESS_FILTER_OPERATORS } from './viewFilterFold.js';
Expand Down Expand Up @@ -119,15 +119,27 @@ function substituteFilterTokens(filter: any, scope: FilterTokenScope): any {
/**
* Default list columns for an object that declares no explicit list view.
*
* Priority: the `highlightFields` semantic role (ADR-0085) wins verbatim
* (only dropping names with no field def); otherwise the first `limit`
* business fields in declared order. Framework-injected system / audit /
* ownership columns are excluded via the shared `isSystemManagedField`
* classifier — its single source of truth is the spec `system` flag stamped
* by `applySystemFields`, with a name-set fallback covering the injected,
* non-hidden / non-readonly `owner_id` and `organization_id`. Without this,
* `applySystemFields` (which spreads injected fields to the FRONT of the field
* map) would surface `owner_id` as a leading raw-id column (#2702, #2777).
* Priority: the object's name field ALWAYS leads (see below); then the
* `highlightFields` semantic role (ADR-0085) verbatim (only dropping names with
* no field def); otherwise the first `limit` business fields in declared order.
* Framework-injected system / audit / ownership columns are excluded via the
* shared `isSystemManagedField` classifier — its single source of truth is the
* spec `system` flag stamped by `applySystemFields`, with a name-set fallback
* covering the injected, non-hidden / non-readonly `owner_id` and
* `organization_id`. Without this, `applySystemFields` (which spreads injected
* fields to the FRONT of the field map) would surface `owner_id` as a leading
* raw-id column (#2702, #2777).
*
* The name-field lead is `leadWithNameField` from `@object-ui/core`
* (objectui#7245). `highlightFields` is NOT a column list — it is ADR-0085's
* "most important fields", and its first consumer, the detail highlight strip,
* deliberately DROPS the title field because the page H1 above it already shows
* one. So metadata that is entirely well-authored routinely omits the name:
* `showcase_account` declares `["status", "industry", "annual_revenue"]` and its
* default grid rendered 14 rows a user could not tell apart. A list has no H1,
* so the same role needs the opposite treatment here. Applied to BOTH branches:
* the fallback walk is declaration-ordered, so an object whose name field is
* declared late lost it off the end of the `limit` slice.
*
* `opts.orgAttribution` (ADR-0105 group posture): reads span every
* organization the member belongs to, so cross-org rows need attribution —
Expand Down Expand Up @@ -365,15 +377,22 @@ export function defaultListColumnsFromObject(
: cols;
const curated = objectDef?.highlightFields;
if (Array.isArray(curated) && curated.length > 0) {
return withOrgAttribution(curated.filter((n: string) => objectDef?.fields?.[n]));
return withOrgAttribution(
leadWithNameField(objectDef, curated.filter((n: string) => objectDef?.fields?.[n])),
);
}
const fields = objectDef?.fields;
if (fields && typeof fields === 'object') {
return withOrgAttribution(
Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name)
.slice(0, limit),
// Lead BEFORE the slice: slicing first could drop the very column
// the lead exists to guarantee, on an object that declares its name
// field after `limit` others.
leadWithNameField(
objectDef,
Object.entries(fields)
.filter(([name, f]: [string, any]) => f && !f.hidden && !isSystemManagedField(name, f))
.map(([name]) => name),
).slice(0, limit),
);
}
return [];
Expand Down
Loading
Loading