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
41 changes: 41 additions & 0 deletions .changeset/6557-objectview-titlefield-middle-leg.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
---
'@object-ui/app-shell': patch
'@object-ui/react': patch
---

Drop the undeclared object-level `titleField` from the object page's five
remaining view-config seams, and from the record-search memo key

`ObjectView` resolves a title field for seven view kinds. Two of them —
calendar and gantt — already read `viewDef.<kind>?.titleField || 'name'`. The
other five (timeline, kanban, map, gallery, tree) carried a three-rung chain
with `objectDef.titleField` in the middle, so one file answered the same
question two different ways. This converges the five on the shape the two
siblings already had; it is a convergence, not a removal.

The middle rung could never fire for legal metadata. `@objectstack/spec`'s
object schema is a `strictObject`, so
`ObjectSchema.safeParse({ …, titleField: 'x' })` is rejected with
`unrecognized_keys` — the same issue code a nonsense key gets — while
`nameField`, `displayNameField` and `titleFormat` all parse (measured against
`@objectstack/spec@17.2.0`, the dist this repo installs). objectui#6531
established that measurement and dropped the twin read inside
`getRecordDisplayName`. Reading a key no producer can ship is the
consumer-side alias AGENTS.md Commandment #0.1 bans.

Behaviour for every legal config is unchanged, and both directions are pinned:
a view that declares its own `titleField` still wins on every kind, a view that
declares none still floors at `'name'`, and an object carrying the
contract-rejected key is now honoured by no kind. Re-pointing the middle rung
at the declared `nameField` was considered and rejected: it would have added a
rung calendar and gantt do not have — increasing the divergence — and, unlike
this change, it would have altered behaviour for legal configs.

`useRecordSearch`'s candidate signature — the memo key that decides when the
cross-object fanout re-runs — appended `o?.titleField ?? ''` to every entry.
Because no legal object definition can carry the key, that half was permanently
`''`: a constant suffix in a cache signature, and the last thing in the repo
that read as evidence some producer supplies it. The signature is now the object
name alone, which is the only field of an object definition the effect actually
consumes. Change detection is unaffected — a changed candidate name still
re-runs the fanout, and a new array with identical content still does not.
36 changes: 23 additions & 13 deletions packages/app-shell/src/views/ObjectView.timelineBinding.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,47 +23,57 @@
* (they read `'due_date'`), while every other case in this file and in
* `ListView.timeline-binding.test.tsx` stays green — the fabricated value is
* only ever observable when the view declared nothing.
*
* FIXTURE TRIAGE (objectui#6557). The shared `objectDef` here used to be
* `{ name: 'crm_campaign', titleField: 'name' }`, and the no-timeline-config
* case below asserted `out.titleField === 'name'` while its comment credited
* the OBJECT with supplying it. That fixture pinned nothing: `'name'` is also
* the literal floor, so the assertion resolved identically with the object leg
* deleted — and the key itself is one `@objectstack/spec`'s `strictObject`
* REJECTS (`unrecognized_keys`), so no legal object could carry it. The leg is
* gone and the function now takes only `viewDef`; what the object-level key
* does NOT do is pinned with a DISTINGUISHABLE value in
* `ObjectView.titleFieldConvergence.test.tsx`, where `'name'`-vs-`'headline'`
* can actually tell the two worlds apart.
*/

import { describe, it, expect } from 'vitest';
import { timelineViewOptions } from './ObjectView';

const objectDef = { name: 'crm_campaign', titleField: 'name' };

describe('timelineViewOptions — the object page forwards, it does not resolve (#3129)', () => {
it('forwards a declared spec binding untouched', () => {
const out = timelineViewOptions(
{ timeline: { startDateField: 'start_date', endDateField: 'end_date', scale: 'month' } },
objectDef,
);
const out = timelineViewOptions({
timeline: { startDateField: 'start_date', endDateField: 'end_date', scale: 'month' },
});
expect(out.startDateField).toBe('start_date');
expect(out.endDateField).toBe('end_date');
// Every spec key survives — the whole config is spread, not whitelisted.
expect(out.scale).toBe('month');
});

it('promotes the legacy `dateField` alias onto the spec key', () => {
expect(timelineViewOptions({ timeline: { dateField: 'start_date' } }, objectDef).startDateField)
expect(timelineViewOptions({ timeline: { dateField: 'start_date' } }).startDateField)
.toBe('start_date');
});

it('invents NO date field when the view declares none', () => {
const out = timelineViewOptions({ timeline: { titleField: 'campaign_name' } }, objectDef);
const out = timelineViewOptions({ timeline: { titleField: 'campaign_name' } });
expect(out.startDateField).toBeUndefined();
expect(out.titleField).toBe('campaign_name');
});

it('invents NO date field for a view with no timeline config at all', () => {
// The calendar-bound view from the report: the axis lives under `calendar`,
// and leaving `startDateField` absent here is what lets ListView find it.
const out = timelineViewOptions({ calendar: { startDateField: 'start_date' } }, objectDef);
const out = timelineViewOptions({ calendar: { startDateField: 'start_date' } });
expect(out.startDateField).toBeUndefined();
// The object's declared title field is the one thing this layer still
// contributes — ListView has no access to objectDef.
// With no timeline config at all there is nothing to forward, so the
// literal floor answers — the same two-rung shape the calendar and gantt
// branches of this page have always used.
expect(out.titleField).toBe('name');
});

it("falls back to 'name' when the object declares no titleField", () => {
expect(timelineViewOptions({}, { name: 'crm_campaign' }).titleField).toBe('name');
it("falls back to 'name' when the view declares no titleField", () => {
expect(timelineViewOptions({}).titleField).toBe('name');
});
});
Loading
Loading