From b172bf0fb8421d2fcb8feb1290ef9221532e9ca3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 14:43:36 +0000 Subject: [PATCH 1/3] fix(app-shell): an interface page relays its source view's hiddenFields and fieldOrder InterfaceListPage hand-projects the source view's columns, filter and sort into the list schema it hands ListView, and carried neither hiddenFields nor fieldOrder. Both now ride with the view's columns, as one unit: they apply only when the view supplies the columns (the page's own columns, or an empty view projection, carry neither). ListView's effectiveFields composes them. Pin: InterfaceListPage.viewFieldKeysRelay-10638.test.tsx. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .../10638-interface-page-view-field-keys.md | 24 ++ .../app-shell/src/views/InterfaceListPage.tsx | 21 +- ...ListPage.viewFieldKeysRelay-10638.test.tsx | 221 ++++++++++++++++++ 3 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 .changeset/10638-interface-page-view-field-keys.md create mode 100644 packages/app-shell/src/views/InterfaceListPage.viewFieldKeysRelay-10638.test.tsx diff --git a/.changeset/10638-interface-page-view-field-keys.md b/.changeset/10638-interface-page-view-field-keys.md new file mode 100644 index 0000000000..5ed24112ff --- /dev/null +++ b/.changeset/10638-interface-page-view-field-keys.md @@ -0,0 +1,24 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): an interface page's source view now hides and orders its columns + +An ADR-0047 interface list page (`InterfaceListPage`) builds its list schema +from the view its `interfaceConfig.sourceView` names, and carried that view's +`columns`, `filter` and `sort` but not its `hiddenFields` or `fieldOrder`. A +source view that authored either still showed every column of its `columns`, in +`columns` order: accepted, served, then dropped at the page. Both keys now reach +`ListView` beside the view's `columns`, which composes them as it always has — +`columns` projects, `hiddenFields` subtracts, `fieldOrder` sorts what survives, +and a surviving column it does not list sorts last (objectstack#15184). + +The two keys travel with the view's column list, as one unit: + +- A page that defines its own `columns` uses that list as it stands, and neither + view key applies. The page config has no spelling of either key, and its own + `columns` are not inherited from any view. This also keeps a design-mode column + drag in place: the drag saves the order it shows as the page's `columns`, and a + view `fieldOrder` would otherwise sort it back. +- A view whose `columns` is empty declares no projection, so the page derives + the object's default columns and neither key applies. diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index f3e2e80e05..00977917bc 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -6,8 +6,9 @@ * object's list views as switcher tabs and lets users create views, this * surface is deliberately closed: * - * • the page REFERENCES one view (`interfaceConfig.sourceView`) — columns, - * base filter and sort are inherited, never restated (the iron rule); + * • the page REFERENCES one view (`interfaceConfig.sourceView`) — columns + * (with the view's `hiddenFields` / `fieldOrder`), base filter and sort + * are inherited, never restated (the iron rule); * • end users get exactly the `userFilters` the author enabled; * • the visualization comes from `appearance.allowedVisualizations` * (a single entry renders no switcher); @@ -439,9 +440,21 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // Columns: the page's own `columns` win; else the legacy referenced view's; // else a default from the object so the grid never renders just the // row-number column. + // + // The view's `hiddenFields` / `fieldOrder` travel WITH the view's columns + // (objectui#10638): the spec composes the three per view — `columns` + // projects, `hiddenFields` subtracts, `fieldOrder` sorts the survivors + // (objectstack#15184 ruling B) — and `ListView`'s `effectiveFields` runs + // that composition, so this page only delivers the two values. They apply + // on the middle branch alone. The page config declares neither key, and + // its own `columns` are "defined directly on the page (no view + // inheritance)" — a view order would otherwise re-sort the very list the + // design-mode column drag saves as `columns`. An empty view `columns` + // "declares no projection, so neither of them applies". + const viewComposes = !hasColumns(cfg) && hasColumns(view); const columns = hasColumns(cfg) ? (cfg.columns as any) - : hasColumns(view) + : viewComposes ? view.columns : defaultColumnsFromObject(objectDef, { orgAttribution }); @@ -459,6 +472,8 @@ export function InterfaceListPage({ page, className, onConfigChange, reserveEdit // The assertion changes no value; the runtime string is what it was. viewType: (allowed[0] ?? view.type ?? 'grid') as ListViewSchema['viewType'], columns, + ...(viewComposes && view.hiddenFields !== undefined ? { hiddenFields: view.hiddenFields } : {}), + ...(viewComposes && view.fieldOrder !== undefined ? { fieldOrder: view.fieldOrder } : {}), ...(filters.length ? { filter: filters } : {}), ...(sort?.length ? { sort } : {}), grouping: view.grouping, diff --git a/packages/app-shell/src/views/InterfaceListPage.viewFieldKeysRelay-10638.test.tsx b/packages/app-shell/src/views/InterfaceListPage.viewFieldKeysRelay-10638.test.tsx new file mode 100644 index 0000000000..d647f6aee0 --- /dev/null +++ b/packages/app-shell/src/views/InterfaceListPage.viewFieldKeysRelay-10638.test.tsx @@ -0,0 +1,221 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#10638 — an interface page relays its source view's `hiddenFields` + * and `fieldOrder`. + * + * ## The defect this pins + * + * An ADR-0047 interface page does not hand `ListView` the object's list views; + * it builds ONE list schema itself, hand-projecting keys off the view its + * `interfaceConfig.sourceView` resolves to (`filter`, `columns`, `sort`, …). + * `hiddenFields` and `fieldOrder` — the subtraction and the ordering of the + * per-view field composition — were not among them. So a source view that + * authored either showed every column of its `columns`, in `columns` order: + * declared by the protocol, accepted and served, then dropped at this page. + * It is the objectui#7516 relay on the second door (`ObjectView` carries both + * since that card). + * + * ## What the relay carries, and what it does not decide + * + * objectstack#15184 ruling B wrote the composition into the contract, per + * view: `columns` projects, `hiddenFields` subtracts, `fieldOrder` sorts what + * survives (an unlisted survivor sorts last). `ListView`'s `effectiveFields` + * memo runs those steps on whatever it receives, so this page composes + * nothing — it only delivers the view's two values beside the view's + * `columns`. + * + * ## The precedence, and why it is whole-composition + * + * The page's column list resolves `page → source view → object default`, and + * the two keys resolve with it, as one unit from one source: + * + * - the page's own `columns` win, and then NEITHER view key applies. The page + * config declares no spelling of either key (`InterfacePageConfigSchema` is + * strict), its `columns` is "defined directly on the page (no view + * inheritance)", and `sourceView` is "still honored at runtime as a fallback + * when the page has no own `columns`" — the spec's own words. It is also what + * keeps the design-mode column drag honest: that drag persists the order it + * drew as the page's `columns`, which a relayed view `fieldOrder` would + * re-sort straight back. + * - else the view's `columns`, and its `hiddenFields` / `fieldOrder` with them; + * - else the object-derived default columns, with neither key: an empty + * `columns` "declares no projection, so neither of them applies" (the spec, + * on `ListViewSchema.columns`). + * + * ## Why the schema is captured rather than rendered + * + * The claim is about what THIS page hands down, so `ListView` is stubbed and + * its `schema` prop recorded — the posture of + * `ObjectView.fieldOrderRelay-7516.test.tsx`. How the captured keys then shape + * the columns is `plugin-list`'s half, and is not re-pinned here. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; + +vi.mock('react-router-dom', () => ({ + useSearchParams: () => [new URLSearchParams(), vi.fn()], + useNavigate: () => vi.fn(), +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => { + const actual = await (importOriginal as any)(); + return { + ...actual, + useObjectTranslation: () => ({ t: (_k: string, o?: any) => o?.defaultValue ?? _k }), + }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const actual = await (importOriginal as any)(); + return { ...actual, useAuth: () => ({}) }; +}); + +/** The list schema this page hands down — captured, not rendered. */ +let captured: any = null; +vi.mock('@object-ui/plugin-list', async (importOriginal) => ({ + ...(await importOriginal()), + ListView: (props: any) => { + captured = props.schema; + return null; + }, +})); + +let testObjects: any[]; + +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await (importOriginal as any)(); + return { + ...actual, + useAdapter: () => ({}), + useMetadata: () => ({ objects: testObjects }), + }; +}); + +import { InterfaceListPage } from './InterfaceListPage'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_KEY = 'by_stage'; + +/** The view's projection — the H1 fixture's three columns, in this order. */ +const VIEW_COLUMNS = ['name', 'owner', 'stage']; +const VIEW_HIDDEN = ['owner']; +const VIEW_FIELD_ORDER = ['stage', 'name']; +/** A page-level projection. Distinct from the view's so a crossed wire fails. */ +const PAGE_COLUMNS = ['owner', 'name']; + +function objectWith(view: Record) { + return { + name: OBJECT_NAME, + label: 'Task', + fields: { + name: { type: 'text', label: 'Name' }, + stage: { type: 'text', label: 'Stage' }, + owner: { type: 'text', label: 'Owner' }, + }, + listViews: { + [`${OBJECT_NAME}.${VIEW_KEY}`]: { + name: `${OBJECT_NAME}.${VIEW_KEY}`, + label: 'By stage', + type: 'grid', + columns: VIEW_COLUMNS, + ...view, + }, + }, + }; +} + +function pageWith(cfg: Record = {}) { + return { + name: 'duly_task_board', + label: 'Task board', + interfaceConfig: { + source: OBJECT_NAME, + sourceView: VIEW_KEY, + recordAction: 'none', + ...cfg, + }, + }; +} + +/** Render the page and return the whole schema it handed `ListView`. */ +async function relayed(view: Record, cfg?: Record): Promise { + captured = null; + testObjects = [objectWith(view)]; + render(); + // `columns` is written unconditionally by the same object literal as the + // keys under test, so its arrival is the signal the page built its schema — + // waiting on `hiddenFields` itself would hang rather than fail on a + // regression. + await waitFor(() => { + expect(captured?.columns).toBeTruthy(); + }); + return captured; +} + +beforeEach(() => { + cleanup(); + captured = null; + testObjects = []; +}); + +describe("InterfaceListPage relays its source view's hiddenFields and fieldOrder (objectui#10638)", () => { + it("THE FIX: the source view's `hiddenFields` reaches the renderer, verbatim", async () => { + const schema = await relayed({ hiddenFields: VIEW_HIDDEN }); + expect(schema.hiddenFields).toEqual(VIEW_HIDDEN); + }); + + it("THE FIX: the source view's `fieldOrder` reaches the renderer, verbatim", async () => { + // Verbatim, because the order IS the value: the page sorts nothing and + // filters nothing — composing it with `columns` and `hiddenFields` is + // `ListView`'s job. + const schema = await relayed({ fieldOrder: VIEW_FIELD_ORDER }); + expect(schema.fieldOrder).toEqual(VIEW_FIELD_ORDER); + }); + + it("THE FIX: the view's whole composition arrives together — `columns`, `hiddenFields`, `fieldOrder`", async () => { + // The card's first measurement, as a pin: before the relay this schema + // carried the three columns and nothing else, so the page drew + // name / owner / stage in `columns` order. + const schema = await relayed({ hiddenFields: VIEW_HIDDEN, fieldOrder: VIEW_FIELD_ORDER }); + expect(schema.columns).toEqual(VIEW_COLUMNS); + expect(schema.hiddenFields).toEqual(VIEW_HIDDEN); + expect(schema.fieldOrder).toEqual(VIEW_FIELD_ORDER); + }); + + it("PRECEDENCE: the page's own `columns` replace the view's composition whole — neither view key applies", async () => { + // Green on the base by construction (the base relays neither key); what + // this case fails is a relay that ignores the precedence and lets the + // view's `fieldOrder` re-sort, or its `hiddenFields` thin, a column list + // the page defined for itself. + const schema = await relayed( + { hiddenFields: VIEW_HIDDEN, fieldOrder: VIEW_FIELD_ORDER }, + { columns: PAGE_COLUMNS }, + ); + expect(schema.columns).toEqual(PAGE_COLUMNS); + expect('hiddenFields' in schema).toBe(false); + expect('fieldOrder' in schema).toBe(false); + }); + + it('CONTROL: a view with an empty `columns` declares no projection — defaults are derived and neither key applies', async () => { + const schema = await relayed({ + columns: [], + hiddenFields: VIEW_HIDDEN, + fieldOrder: VIEW_FIELD_ORDER, + }); + // The object-derived default, not the view's (empty) list. + expect(schema.columns).toEqual(['name', 'stage', 'owner']); + expect('hiddenFields' in schema).toBe(false); + expect('fieldOrder' in schema).toBe(false); + }); + + it('CONTROL: a view that authors neither key leaves the schema without them', async () => { + // Absent, not `undefined`-valued: the schema's key set is what it was. + const schema = await relayed({}); + expect(schema.columns).toEqual(VIEW_COLUMNS); + expect('hiddenFields' in schema).toBe(false); + expect('fieldOrder' in schema).toBe(false); + }); +}); From 2a26e30ec16dc95c0154788783770b41fa122c0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:27:21 +0000 Subject: [PATCH 2/3] docs(app-shell): interface page header states sourceView as a fallback; changeset names the other carried keys Round 2 wording only, no code change. The InterfaceListPage header clause said the source view's columns, filter and sort are "never restated"; the page defines its own columns / filterBy / sort and the view is the ADR-0047 (revised) fallback. The changeset's list of carried keys now reads as non-exhaustive. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- .changeset/10638-interface-page-view-field-keys.md | 3 ++- packages/app-shell/src/views/InterfaceListPage.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/10638-interface-page-view-field-keys.md b/.changeset/10638-interface-page-view-field-keys.md index 5ed24112ff..f869421c49 100644 --- a/.changeset/10638-interface-page-view-field-keys.md +++ b/.changeset/10638-interface-page-view-field-keys.md @@ -6,7 +6,8 @@ fix(app-shell): an interface page's source view now hides and orders its columns An ADR-0047 interface list page (`InterfaceListPage`) builds its list schema from the view its `interfaceConfig.sourceView` names, and carried that view's -`columns`, `filter` and `sort` but not its `hiddenFields` or `fieldOrder`. A +`columns`, `filter`, `sort` (and its other keys) but not its `hiddenFields` or +`fieldOrder`. A source view that authored either still showed every column of its `columns`, in `columns` order: accepted, served, then dropped at the page. Both keys now reach `ListView` beside the view's `columns`, which composes them as it always has — diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index 00977917bc..a69dd698a9 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -6,9 +6,10 @@ * object's list views as switcher tabs and lets users create views, this * surface is deliberately closed: * - * • the page REFERENCES one view (`interfaceConfig.sourceView`) — columns - * (with the view's `hiddenFields` / `fieldOrder`), base filter and sort - * are inherited, never restated (the iron rule); + * • the page REFERENCES one view (`interfaceConfig.sourceView`) as a + * fallback — its columns (with its `hiddenFields` / `fieldOrder`), base + * filter and sort are inherited unless the page defines its own + * `columns` / `filterBy` / `sort` (ADR-0047 revised); * • end users get exactly the `userFilters` the author enabled; * • the visualization comes from `appearance.allowedVisualizations` * (a single entry renders no switcher); From 106a488031501844479b056b9a7a52502a5c4bfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:45:18 +0000 Subject: [PATCH 3/3] docs(app-shell): interface page header states the base filter is always inherited Round 3, comment only. The page's filterBy is appended to the source view's base filter and never displaces it; only columns (with their hiddenFields / fieldOrder) and sort fall back to the view. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN --- packages/app-shell/src/views/InterfaceListPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/app-shell/src/views/InterfaceListPage.tsx b/packages/app-shell/src/views/InterfaceListPage.tsx index a69dd698a9..0e0f55eb14 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -7,9 +7,10 @@ * surface is deliberately closed: * * • the page REFERENCES one view (`interfaceConfig.sourceView`) as a - * fallback — its columns (with its `hiddenFields` / `fieldOrder`), base - * filter and sort are inherited unless the page defines its own - * `columns` / `filterBy` / `sort` (ADR-0047 revised); + * fallback — its columns (with its `hiddenFields` / `fieldOrder`) and + * sort are inherited unless the page defines its own `columns` / + * `sort`; its base filter is always inherited, with the page's + * `filterBy` appended (ADR-0047 revised); * • end users get exactly the `userFilters` the author enabled; * • the visualization comes from `appearance.allowedVisualizations` * (a single entry renders no switcher);