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..f869421c49 --- /dev/null +++ b/.changeset/10638-interface-page-view-field-keys.md @@ -0,0 +1,25 @@ +--- +'@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`, `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 — +`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..0e0f55eb14 100644 --- a/packages/app-shell/src/views/InterfaceListPage.tsx +++ b/packages/app-shell/src/views/InterfaceListPage.tsx @@ -6,8 +6,11 @@ * 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`) as a + * 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); @@ -439,9 +442,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 +474,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); + }); +});