Skip to content

Commit 09d4968

Browse files
committed
fix(app-shell): relay a list view's own fieldOrder to ListView
renderListView's relay carried the active view's hiddenFields onto the schema it hands ListView but had no rung for fieldOrder, the other per-view half of the columns x hiddenFields x fieldOrder composition, so a per-view order was accepted, served and then dropped. Add the rung beside hiddenFields with the same precedence (view over list node), pin it at the relay, and replace the census ledger's known-gap row with a named check for the rung. Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7df45f commit 09d4968

4 files changed

Lines changed: 323 additions & 13 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): a list view's own `fieldOrder` now reaches the list
6+
7+
A `fieldOrder` authored on a per-list-view entry (`listViews.VIEW_NAME.fieldOrder`)
8+
was accepted and served, then silently dropped: `ObjectView`'s `renderListView`
9+
relay carried the view's `hiddenFields` onto the schema it hands `ListView` but
10+
had no rung for `fieldOrder`, so the list could only ever be ordered by a
11+
`fieldOrder` already on the host's list schema. It is relayed now, beside
12+
`hiddenFields` and with the same precedence: the view's value wins, and a view
13+
that authors none keeps the list's own.
14+
15+
`ListView` composes it exactly as it always has — `columns` projects,
16+
`hiddenFields` subtracts, `fieldOrder` sorts what survives, and a surviving
17+
column it does not list sorts last in its `columns` order — the composition
18+
objectstack#15184 wrote into the protocol.
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
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+
/**
10+
* objectui#7516 — the object page relays a per-view `fieldOrder`.
11+
*
12+
* ## The defect this pins
13+
*
14+
* `renderListView` builds `fullSchema` by spreading the host's `listSchema`
15+
* and then relaying keys off the active `viewDef`. `hiddenFields` had a rung;
16+
* `fieldOrder`, the other per-view half of the same field composition, had
17+
* NONE. So `schema.fieldOrder` at the `ListView` end could only ever be what
18+
* the host put on the list schema, and a per-view order was unreachable —
19+
* declared by the protocol, accepted and served, then dropped here. The
20+
* relay-rung census (`ObjectView.relayRungCensus-7559.test.ts`) carried it as a
21+
* `known-gap` until this card.
22+
*
23+
* ## What the rung carries, and what it does not decide
24+
*
25+
* objectstack#15184 ruling B kept `fieldOrder` and wrote the composition into
26+
* the contract: `columns` projects, `hiddenFields` subtracts from the
27+
* projection, `fieldOrder` sorts what survives (a surviving column it does not
28+
* list sorts last, keeping its `columns`-relative order). `ListView`'s
29+
* `effectiveFields` memo already runs those steps in that order on whatever
30+
* `schema.fieldOrder` it receives. The rung therefore only DELIVERS the view's
31+
* value into that slot — the same slot the list-node spelling fills — so the
32+
* two spellings compose identically by construction. Where both are present,
33+
* the view wins, the precedence the `hiddenFields` rung beside it uses.
34+
*
35+
* ## The value DOES arrive here — the relay is where it died
36+
*
37+
* `buildViewTabs` composes each entry through `viewEntry`, which is
38+
* `Object.assign` over the authored body and stamps only `id` afterwards, so an
39+
* authored `fieldOrder` is present on `viewDef`. The in-tree host
40+
* (`plugin-view`'s `ObjectView`) fills its own `fieldOrder` slot from a NAMED
41+
* list view only, and this page hands it no `listViews`, so on this route the
42+
* spread carries nothing for the key.
43+
*
44+
* ## Why the schema is captured rather than rendered
45+
*
46+
* The claim is about what THIS file hands down, so `ListView` is stubbed and
47+
* its `schema` prop recorded — the same posture as
48+
* `ObjectView.rowColorRelay-7218.test.tsx`. How the captured order then sorts
49+
* the columns is `plugin-list`'s half, and is not re-pinned here.
50+
*/
51+
52+
import * as React from 'react';
53+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
54+
import { render, cleanup, waitFor } from '@testing-library/react';
55+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
56+
57+
vi.mock('@object-ui/permissions', async (importOriginal) => {
58+
const actual = await importOriginal<typeof import('@object-ui/permissions')>();
59+
return {
60+
...actual,
61+
usePermissions: () => ({
62+
check: () => ({ allowed: true }),
63+
checkField: () => true,
64+
getFieldPermissions: () => [],
65+
getRowFilter: () => undefined,
66+
getObjectApiOperations: () => undefined,
67+
roles: [],
68+
isLoaded: false,
69+
hasCapabilities: () => true,
70+
can: () => true,
71+
cannot: () => false,
72+
}),
73+
useFieldPermissions: () => ({ canRead: () => true, canWrite: () => true, permissions: [] }),
74+
};
75+
});
76+
77+
vi.mock('@object-ui/auth', async (importOriginal) => ({
78+
...(await importOriginal<Record<string, unknown>>()),
79+
useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }),
80+
useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }),
81+
createAuthenticatedFetch: () => vi.fn(),
82+
}));
83+
84+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
85+
...(await importOriginal<Record<string, unknown>>()),
86+
useRealtimeSubscription: () => ({ lastMessage: null }),
87+
useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }),
88+
}));
89+
90+
vi.mock('sonner', () => ({
91+
toast: Object.assign(vi.fn(), {
92+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
93+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
94+
}),
95+
}));
96+
97+
/** The list schema this page hands down — captured, not rendered. */
98+
let captured: any = null;
99+
vi.mock('@object-ui/plugin-list', async (importOriginal) => ({
100+
...(await importOriginal<typeof import('@object-ui/plugin-list')>()),
101+
ListView: (props: any) => {
102+
captured = props.schema;
103+
return null;
104+
},
105+
}));
106+
107+
/**
108+
* What the HOST puts on the list schema before this page's relay runs — the
109+
* `listSchema` the `...listSchema` spread carries in. It stands in for the
110+
* list-node spelling (the in-tree host fills it from a named list view), and
111+
* it is the only way to exercise the rung's second limb from here.
112+
*/
113+
let hostList: Record<string, unknown> = {};
114+
115+
vi.mock('@object-ui/plugin-view', async (importOriginal) => ({
116+
...(await importOriginal<Record<string, unknown>>()),
117+
ObjectView: (props: any) =>
118+
props.renderListView?.({
119+
schema: { ...(props.schema ?? {}), ...hostList },
120+
dataSource: props.dataSource,
121+
onEdit: props.onEdit,
122+
className: '',
123+
refreshKey: 0,
124+
}) ?? null,
125+
ViewTabBar: () => null,
126+
ManageViewsDialog: () => null,
127+
}));
128+
129+
vi.mock('./MetadataInspector', () => ({
130+
MetadataPanel: () => null,
131+
useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }),
132+
}));
133+
vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null }));
134+
135+
import { ObjectView } from './ObjectView';
136+
import { ExpressionProvider } from '../providers/ExpressionProvider';
137+
138+
const OBJECT_NAME = 'duly_task';
139+
140+
/** The per-view order — the value this card is about. */
141+
const VIEW_FIELD_ORDER = ['stage', 'name'];
142+
/** A list-node one. Distinct so a crossed wire fails instead of passing. */
143+
const LIST_FIELD_ORDER = ['owner', 'stage', 'name'];
144+
145+
function objectsWith(view: Record<string, unknown>) {
146+
return [
147+
{
148+
name: OBJECT_NAME,
149+
label: 'Task',
150+
fields: {
151+
id: { type: 'text', label: 'Id' },
152+
name: { type: 'text', label: 'Name' },
153+
stage: { type: 'text', label: 'Stage' },
154+
owner: { type: 'text', label: 'Owner' },
155+
},
156+
listViews: {
157+
by_stage: { label: 'By stage', type: 'grid', columns: ['name', 'stage', 'owner'], ...view },
158+
},
159+
},
160+
];
161+
}
162+
163+
function makeDataSource() {
164+
return {
165+
find: vi.fn(async () => ({ data: [], total: 0 })),
166+
findOne: vi.fn(async () => null),
167+
create: vi.fn(async () => ({})),
168+
update: vi.fn(async () => ({})),
169+
delete: vi.fn(async () => ({})),
170+
} as any;
171+
}
172+
173+
/** Render the object list and return the whole schema the relay handed down. */
174+
async function relayed(objects: any[]): Promise<any> {
175+
captured = null;
176+
render(
177+
<ExpressionProvider user={{ id: 'u1', name: 'Ada', profile: 'admin' }}>
178+
<MemoryRouter initialEntries={[`/apps/demo/${OBJECT_NAME}`]}>
179+
<Routes>
180+
<Route
181+
path="/apps/:appName/:objectName"
182+
element={<ObjectView dataSource={makeDataSource()} objects={objects} onEdit={() => {}} />}
183+
/>
184+
</Routes>
185+
</MemoryRouter>
186+
</ExpressionProvider>,
187+
);
188+
// `options` is built unconditionally by the same object literal as the rung
189+
// under test, so its arrival is the signal that the relay actually ran —
190+
// waiting on `fieldOrder` itself would hang rather than fail on a regression.
191+
await waitFor(() => {
192+
expect(captured?.options).toBeTruthy();
193+
});
194+
return captured;
195+
}
196+
197+
beforeEach(() => {
198+
cleanup();
199+
captured = null;
200+
hostList = {};
201+
vi.stubGlobal(
202+
'fetch',
203+
vi.fn(async () =>
204+
new Response(JSON.stringify({ data: [] }), {
205+
status: 200,
206+
headers: { 'content-type': 'application/json' },
207+
}),
208+
),
209+
);
210+
});
211+
212+
afterEach(() => {
213+
vi.unstubAllGlobals();
214+
vi.clearAllMocks();
215+
});
216+
217+
describe("ObjectView relays the active view's own fieldOrder (objectui#7516)", () => {
218+
it('THE FIX: a per-view `fieldOrder` reaches the renderer, verbatim', async () => {
219+
// Before the rung existed this was `undefined` for every object on this
220+
// route, which is the whole of the defect. Verbatim, because the order IS
221+
// the value: the relay sorts nothing and filters nothing — composing it
222+
// with `columns` and `hiddenFields` is `ListView`'s job.
223+
const schema = await relayed(objectsWith({ fieldOrder: VIEW_FIELD_ORDER }));
224+
expect(schema.fieldOrder).toEqual(VIEW_FIELD_ORDER);
225+
});
226+
227+
it('THE FIX: the per-view value OVERRIDES a list-node `fieldOrder`', async () => {
228+
hostList = { fieldOrder: LIST_FIELD_ORDER };
229+
const schema = await relayed(objectsWith({ fieldOrder: VIEW_FIELD_ORDER }));
230+
expect(schema.fieldOrder).toEqual(VIEW_FIELD_ORDER);
231+
});
232+
233+
it('THE FIX: `fieldOrder` resolves by the same precedence as the `hiddenFields` rung beside it', async () => {
234+
// The two per-view halves of one composition. Both are supplied by the
235+
// list node AND by the view; both must come from the view, or `ListView`
236+
// would sort the view's subtraction by the list node's order.
237+
hostList = { hiddenFields: ['owner'], fieldOrder: LIST_FIELD_ORDER };
238+
const schema = await relayed(
239+
objectsWith({ hiddenFields: ['name'], fieldOrder: VIEW_FIELD_ORDER }),
240+
);
241+
expect(schema.hiddenFields).toEqual(['name']);
242+
expect(schema.fieldOrder).toEqual(VIEW_FIELD_ORDER);
243+
});
244+
245+
it("CONTROL: the list node's own `fieldOrder` is unchanged when the view authors none", async () => {
246+
// The rung is a fallback, not a replacement: a fix that stomped the
247+
// incoming value with `undefined` fails here.
248+
hostList = { fieldOrder: LIST_FIELD_ORDER };
249+
const schema = await relayed(objectsWith({}));
250+
expect(schema.fieldOrder).toEqual(LIST_FIELD_ORDER);
251+
});
252+
253+
it('CONTROL: no `fieldOrder` anywhere stays absent', async () => {
254+
const schema = await relayed(objectsWith({}));
255+
expect(schema.fieldOrder).toBeUndefined();
256+
});
257+
});

‎packages/app-shell/src/views/ObjectView.relayRungCensus-7559.test.ts‎

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
* not a type error, not a lint finding and not a test failure. It is silence,
2020
* and the silence has now produced the same defect three times —
2121
* objectui#7199 (`description`), objectui#7218 (`rowColor`), objectui#7516
22-
* (`fieldOrder`, still open).
22+
* (`fieldOrder`). Each of the three now has its rung, and each rung is named
23+
* by a check below.
2324
*
2425
* This file is the ruling on that card: option (b). The census is not a list
2526
* somebody typed once — it is RE-DERIVED here, at test time, from four sources
@@ -84,8 +85,9 @@
8485
* ## What this file does NOT do
8586
*
8687
* ⛔ It does not fill in a missing rung. Each absent rung is its own card
87-
* (`fieldOrder` is objectui#7516, which carries `needs-user-decision`);
88-
* this file only makes absences visible and keeps them declared.
88+
* (`fieldOrder` was objectui#7516, and that card added the rung together
89+
* with its check below); this file only makes absences visible and keeps
90+
* them declared.
8991
* ⛔ It does not touch `plugin-view`'s `ObjectView` — that host is the
9092
* objectui#5043 family, and its own pin
9193
* (`plugin-view/src/__tests__/objectViewHostSurface.test.tsx`) is the
@@ -476,9 +478,6 @@ const ABSENCES: Record<string, Absence> = {
476478
// ── Authored on the NODE, resolved by the host ────────────────────────────
477479
operations: { kind: 'node-authored', reason: "Legacy CRUD affordance authored on the object-view node (`examples/.../object-view-record-surface.json`), not on a view record; the host resolves it upstream (`schema.operations || schema.table?.operations || …`). ListView does read `schema.operations?.export`, but what would feed it here is the NODE's value, and forwarding it is the caller's composition to make — objectui#5097's surface, not a per-view rung." },
478480

479-
// ── Real absences, owned elsewhere ────────────────────────────────────────
480-
fieldOrder: { kind: 'known-gap', card: 'objectui#7516', reason: "ListView reads `schema.fieldOrder` and orders its columns by it; no rung carries the view's value here. That is this defect class's third instance and it is OPEN with `needs-user-decision` — ⛔ do not close it by adding a rung in passing." },
481-
482481
// ── The runtime-only half of the intersection ─────────────────────────────
483482
onNavigate: { kind: 'host-runtime', reason: 'Host callback. This host wires record navigation through the `onRowClick` prop on the `<ListView>` element instead; a view record cannot carry a function.' },
484483
refreshTrigger: { kind: 'host-runtime', reason: 'Host refresh counter, supplied by the caller; not view metadata.' },
@@ -595,6 +594,21 @@ describe('objectui#7559 — every `ListViewSchema` member has a rung or a declar
595594
).toBe(true);
596595
});
597596

597+
it('objectui#7516 — the per-view `fieldOrder` rung is present', () => {
598+
// The third instance, carried in `ABSENCES` as a `known-gap` until its card
599+
// added the rung. The ledger row is gone, so without this rung the key
600+
// would be neither relayed nor declared and the property above would name
601+
// it — but the instance is named here too, as the other two are.
602+
expect(
603+
readsView(writtenAt('fieldOrder')),
604+
'The `fieldOrder` rung is gone — objectui#7516, the third instance of the same class.\n'
605+
+ '`ListView` still orders its columns by `schema.fieldOrder`, so a per-view order is\n'
606+
+ 'authored, served and then silently dropped. Restore\n'
607+
+ '`fieldOrder: viewDef.fieldOrder ?? listSchema.fieldOrder` beside the `hiddenFields` rung.',
608+
).toBe(true);
609+
expect(listViewReads('fieldOrder')).toBe(true);
610+
});
611+
598612
it('a member with a rung that does NOT read the active view is not counted as relayed', () => {
599613
// The reading, asserted rather than described: `...listSchema` carries the
600614
// host's value and cannot stand in for the view's. This is what made all
@@ -763,13 +777,16 @@ describe('objectui#7559 — a declared absence keeps its evidence', () => {
763777
// literal. A control that reddens whenever anything else reddens says
764778
// nothing — and the first person to see it red would delete it.
765779
//
766-
// `fieldOrder` is a real, card-owned, reader-having absence (objectui#7516)
767-
// and `quickFilters` is the deliberate suppression the card itself named.
768-
// Neither is a failure, and neither may become one.
780+
// `operations` is a real, reader-having absence (`ListView` reads it, and
781+
// the relay deliberately does not carry it — `node-authored`, objectui#5097's
782+
// surface) and `quickFilters` is the deliberate suppression the card itself
783+
// named. Neither is a failure, and neither may become one. (This control
784+
// used `fieldOrder` until objectui#7516 gave that key its rung.)
769785
const findings = MEMBERS.filter((m) => !RELAYED.includes(m) && !(m in ABSENCES));
770-
expect(MEMBERS).toContain('fieldOrder');
771-
expect(RELAYED).not.toContain('fieldOrder');
772-
expect(findings).not.toContain('fieldOrder');
786+
expect(MEMBERS).toContain('operations');
787+
expect(RELAYED).not.toContain('operations');
788+
expect(listViewReads('operations')).toBe(true);
789+
expect(findings).not.toContain('operations');
773790

774791
expect(MEMBERS).not.toContain('quickFilters');
775792
expect(WRITTEN_TOP).toContain('quickFilters');

‎packages/app-shell/src/views/ObjectView.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2608,7 +2608,8 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
26082608
* lint and to the tests — `viewDef` is `Record<string, any>`, so
26092609
* nothing REQUIRES a key to be written. That silence shipped the same
26102610
* defect three times (objectui#7199 `description`, objectui#7218
2611-
* `rowColor`, objectui#7516 `fieldOrder`), and objectui#7559 ended it:
2611+
* `rowColor`, objectui#7516 `fieldOrder` — all three carry their rung
2612+
* below now), and objectui#7559 ended it:
26122613
* `ObjectView.relayRungCensus-7559.test.ts` re-derives the member set
26132614
* from the zod mirror at test time and requires every member to have
26142615
* either a rung here or a DECLARED absence with a reason.
@@ -2684,6 +2685,23 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
26842685
return resolved;
26852686
})(),
26862687
hiddenFields: (viewDef as any).hiddenFields ?? listSchema.hiddenFields,
2688+
/**
2689+
* The per-view ORDERING of the field composition
2690+
* `columns` x `hiddenFields` x `fieldOrder` (objectui#7516) —
2691+
* objectstack#15184 ruling B kept the key and wrote the
2692+
* composition into the contract: `columns` projects,
2693+
* `hiddenFields` (the rung above) subtracts, `fieldOrder` sorts
2694+
* what survives, an unlisted survivor sorting last. `ListView`'s
2695+
* `effectiveFields` memo runs those steps on `schema.fieldOrder`;
2696+
* this rung only delivers the view's value into that slot, the
2697+
* one the list-node spelling fills, so the two compose alike.
2698+
*
2699+
* View over list node — the precedence of the `hiddenFields` rung
2700+
* above; a view that authors no order keeps the list node's. It
2701+
* was the one per-view half of the composition with no rung:
2702+
* authored, served, then dropped here.
2703+
*/
2704+
fieldOrder: viewDef.fieldOrder ?? listSchema.fieldOrder,
26872705
columnState: (viewDef as any).columnState ?? (listSchema as any).columnState,
26882706
onDensityChange: (mode) => {
26892707
// Persist the spec-canonical `rowHeight` (#2890). Writing the

0 commit comments

Comments
 (0)