From 308661b7b61f59a48774914534547aeb323bec2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 11:38:50 +0000 Subject: [PATCH 1/5] feat(plugin-list): a page list-view node is written data / type, and the metadata page reads properties.type as the list kind The list-view and view:list registrations publish `data` and `type` in place of `objectName` and `viewType`. ListViewBlock reads a metadata page's kind from `properties.type` (the hoist never copies a bag's `type` onto the node, whose own `type` is the registry key), and no longer reads a page node's own `objectName` / `viewType`. A node that names no record source draws the "no object named" hint naming `data.object`. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- packages/plugin-list/src/ListViewBlock.tsx | 144 +++++++- ...ewBlock.pageCarrierSpelling-12099.test.tsx | 344 ++++++++++++++++++ packages/plugin-list/src/index.tsx | 70 ++-- 3 files changed, 522 insertions(+), 36 deletions(-) create mode 100644 packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 0e10e61c47..a089a684b8 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -7,12 +7,15 @@ */ import React, { useContext } from 'react'; -import { elementDataSourceBlock, isElementDataSourceConfig } from '@object-ui/core'; +import { elementDataSourceBlock, isElementDataSourceConfig, isListViewVisualization } from '@object-ui/core'; +import { useObjectTranslation } from '@object-ui/i18n'; import { ElementDataSourceGate, SchemaRendererContext, + isConfigBag, type ElementDataSourceMapping, } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; import { ListView, type ListViewHandle, type ListViewProps } from './ListView'; /** @@ -21,10 +24,13 @@ import { ListView, type ListViewHandle, type ListViewProps } from './ListView'; * * `list-view` is the block that reads all five: `columns` is a FIELD projection * here (so a saved view's column list belongs on it), `filter` and `sort` go to - * the query, the row cap is read as `pagination.pageSize`, and `viewType` picks - * which view kind renders — its registry `inputs` enumerate grid/kanban/gallery, - * so naming a saved kanban view and rendering a grid would be a silently wrong - * answer. + * the query, the row cap is read as `pagination.pageSize`, and a saved view's + * kind picks which view kind renders when the node states none — naming a saved + * kanban view and rendering a grid would be a silently wrong answer. + * + * `objectName` (the mapping's default object key) and `viewType` are the keys + * the gate WRITES for `ListView` to read. They are this block's hand-off to the + * component, not what a page node is written in: see {@link toListViewHandOff}. */ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = { columns: true, @@ -34,6 +40,113 @@ const LIST_VIEW_DATA_SOURCE: ElementDataSourceMapping = { viewType: true, }; +/** + * What `SchemaRenderer` hands this block beside the hoisted keys. Its + * `properties` hoist copies a node's bag onto the node and keeps the bag + * itself, and it never copies the bag's `type` up: the node's own `type` is the + * registry key that chose this renderer. So the bag is where this block reads + * the list kind. Typed here, beside its one reader, and not on the authoring + * face: `SchemaRenderer` is the producer, not an author. + */ +interface PageNodeBag { + properties?: unknown; +} + +/** The spellings a page `list-view` node no longer carries; see {@link toListViewHandOff}. */ +const RETIRED_PAGE_KEYS = ['objectName', 'viewType'] as const; + +/** One warning per retired key per process, as `normalizeListViewSchema` warns. */ +const warnedRetiredPageKeys = new Set(); + +const RETIRED_PAGE_KEY_REMEDY: Record<(typeof RETIRED_PAGE_KEYS)[number], string> = { + objectName: "name the object in `data: { provider: 'object', object }` or in `dataSource.object`", + viewType: 'write the list kind as `type` in the node\'s `properties`', +}; + +function warnRetiredPageKey(key: (typeof RETIRED_PAGE_KEYS)[number]): void { + if (warnedRetiredPageKeys.has(key)) return; + warnedRetiredPageKeys.add(key); + console.warn( + `[object-ui] list-view: a page node's \`${key}\` is not read. ${RETIRED_PAGE_KEY_REMEDY[key]}.`, + ); +} + +/** + * The page carrier's spelling, turned into the props `ListView` reads + * (objectstack-ai/objectstack#22444, ruling C; objectui#12099). + * + * A page `list-view` node is written in `@objectstack/spec`'s list view + * spelling: it names its object with `data: { provider: 'object', object }` + * (or the node's `dataSource` binding) and its kind with `type`. On a metadata + * page that `type` sits in `properties`, because the node's own `type` is the + * registry key. `ListView`'s own props spell the two `objectName` and + * `viewType`, so this is where one spelling becomes the other: + * + * - the kind: `properties.type`, when it names a visualization `ListView` + * draws, becomes `viewType`. A value that names none is dropped, and the + * list draws a grid, the spec's default kind. Absent, the kind is still + * open: a bound saved view's kind applies, else a grid. Written BEFORE the + * gate, so a kind the node states outranks the saved view's, like every + * other key the node writes itself. + * - the object: `data` passes through as it is, and `normalizeListViewSchema` + * lands an `object` provider's `object` on `objectName`. + * - ⛔ the node's own `objectName` and `viewType` are not read. They are + * removed here, so neither reaches the gate or `ListView`, and each draws + * one console warning naming what to write. A stored node that still + * carries them is converted on the platform side (an ADR-0087 conversion in + * the spec), never read back here. A node bound by neither `data` nor + * `dataSource.object` gets the "no object named" hint below. + * + * Returns the input by reference when there is nothing to do, so a node + * written in the spec spelling keeps its identity for `ListView`'s memos. + */ +function toListViewHandOff(schema: ListViewSchema): ListViewSchema { + const bag = (schema as ListViewSchema & PageNodeBag).properties; + const kind = isConfigBag(bag) ? bag.type : undefined; + const retired = RETIRED_PAGE_KEYS.filter((key) => schema[key] !== undefined); + if (kind === undefined && retired.length === 0) return schema; + for (const key of retired) warnRetiredPageKey(key); + const { objectName: _objectName, viewType: _viewType, ...rest } = schema; + return isListViewVisualization(kind) ? { ...rest, viewType: kind } : rest; +} + +/** + * Whether the bound schema names where its records come from: an object the + * `dataSource` binding landed on `objectName` (the node's own was removed by + * {@link toListViewHandOff}), or a `data` record source. An `object` provider + * counts only when it names a non-empty object, the test the gate applies to + * `objectName`; rows, a `value` provider and any other provider are the list's + * own source. + */ +function namesRecordSource(schema: ListViewSchema): boolean { + const bound = schema.objectName; + if (typeof bound === 'string' && bound.trim().length > 0) return true; + const data: unknown = schema.data; + if (data == null) return false; + if (isConfigBag(data) && data.provider === 'object') { + return typeof data.object === 'string' && data.object.trim().length > 0; + } + return true; +} + +/** + * The "no object named" hint, in the gate's own words (the `view.noObject` + * message), naming the page key this block reads: `data.object`. The gate's + * own hint names its mapping's object key, which here is `objectName`, the + * hand-off key a page node is not written in. + */ +function ListViewNoObjectHint(): React.ReactElement { + const { t } = useObjectTranslation(); + return ( +
+ {t('view.noObject', { + property: 'data.object', + defaultValue: 'No object named: set {{property}} or dataSource.object.', + })} +
+ ); +} + /** * Registry entry point for `` — two bridges in one component. * @@ -113,20 +226,27 @@ const ListViewBlock = elementDataSourceBlock(React.forwardRef toListViewHandOff(props.schema), [props.schema]); + + // A list that names its records in neither place has nothing to fetch, and + // drew the "Nothing here yet" empty state, which reads as an empty query + // (objectui#11605). Judged in the child below, after the binding is applied, + // and not by the gate's `requiresObject`, whose hint names the gate's object + // key (objectui#12099). return ( - {(schema) => } + {(schema) => namesRecordSource(schema) + ? + : } ); })); diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx new file mode 100644 index 0000000000..ab1db41b63 --- /dev/null +++ b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx @@ -0,0 +1,344 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12099 — a page `list-view` node is written `data` / `type`, and a + * metadata page reads `properties.type` as the list kind + * (objectstack-ai/objectstack#22444, ruling C). + * + * Every case renders through the REAL `SchemaRenderer` and the REAL `list-view` + * registration, because the defect lived between the two: the renderer's + * `properties` hoist never copies a bag's `type` onto the node (the node's own + * `type` is the registry key), so a metadata page's list kind reached nothing + * and the list drew a grid. The two view spies record which child view + * `ListView` built and the object it bound. + * + * What is pinned: + * 1. `properties.type: 'kanban'` with `properties.data` naming the object + * draws a kanban over that object, and the node's own `type` stays + * `list-view`: the kanban the spy receives is the `object-kanban` node + * `ListView` builds, so the registry resolved `list-view`, not the kind. + * 2. A grid only when `properties.type` is absent (and for a kind no list + * view draws). + * 3. ⛔ No read of `objectName` / `viewType` on a page node, in the bag or + * flat: a node that names its object only that way draws the "no object + * named" hint (naming `data.object`) and queries nothing, a `viewType` + * beside a working `data` changes nothing, and each retired key is named + * in one console warning per process. The hint and the warning are + * asserted by the key they name, not by their wording. + * 4. The kind beside a `dataSource` binding: the node's `properties.type` + * outranks a bound saved view's kind, and with no `properties.type` the + * saved view's kind applies. + * 5. The registrations publish `data` / `type` and no `objectName` / + * `viewType`, in `inputs` and `defaultProps`. + */ + +import React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, waitFor, cleanup, act } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Registers the REAL `list-view` and `view:list`. +import '../index'; + +const OBJECT = 'crm_task'; + +const rows = [ + { id: 't-1', name: 'Draft the brief', stage: 'todo' }, + { id: 't-2', name: 'Ship the fold', stage: 'doing' }, +]; + +const objectDef = { + name: OBJECT, + label: 'Task', + fields: { + id: { name: 'id', type: 'text', label: 'Id' }, + name: { name: 'name', type: 'text', label: 'Name' }, + stage: { name: 'stage', type: 'text', label: 'Stage' }, + }, + // The saved view case 4 binds by name: a kanban. + listViews: { + board: { name: 'board', label: 'Board', type: 'kanban', kanban: { groupByField: 'stage' } }, + }, +}; + +/** Props each view spy received, in order. */ +let kanbanProps: Array> = []; +let gridProps: Array> = []; + +ComponentRegistry.register( + 'object-kanban', + (props: Record) => { + kanbanProps.push(props); + return
; + }, + { namespace: 'test', label: 'Kanban spy', category: 'view' }, +); + +ComponentRegistry.register( + 'object-grid', + (props: Record) => { + gridProps.push(props); + return
; + }, + { namespace: 'test', label: 'Grid spy', category: 'view' }, +); + +const makeDataSource = () => + ({ + find: vi.fn(async () => rows), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => rows.length), + getObjectSchema: vi.fn(async () => objectDef), + getObjects: vi.fn(async () => []), + onMutation: () => () => {}, + }) as any; + +function renderNode(node: Record) { + const dataSource = makeDataSource(); + const view = render( + + + , + ); + return { dataSource, ...view }; +} + +/** + * How long an absence is watched before it is believed (objectui#8705): real + * time, longer than any deferral the test environment drains for free. + */ +const ABSENCE_SETTLE_MS = 250; + +async function settleAbsenceWindow(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ABSENCE_SETTLE_MS)); + }); +} + +/** The hint names the page key to write, and never the retired one. */ +async function expectNoObjectHint(getByTestId: (id: string) => HTMLElement): Promise { + await waitFor(() => expect(getByTestId('list-view-no-object')).toBeTruthy()); + const text = getByTestId('list-view-no-object').textContent ?? ''; + expect(text).toContain('data.object'); + expect(text).toContain('dataSource.object'); + expect(text).not.toContain('objectName'); +} + +/** The warnings issued so far that name `key` as a page node key not read. */ +const warningsNaming = (spy: ReturnType, key: string) => + spy.mock.calls.map((call) => String(call[0])).filter((line) => line.includes(`\`${key}\``) && line.includes('list-view')); + +let warn: ReturnType; +beforeEach(() => { + kanbanProps = []; + gridProps = []; + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +afterEach(() => { + warn.mockRestore(); + cleanup(); +}); + +const last = (spy: Array>) => spy[spy.length - 1]; + +describe('a metadata page list-view node reads `properties.type` as its kind (objectui#12099)', () => { + it('draws a kanban over the object `properties.data` names, and the node stays a list-view', async () => { + // THE CARD'S CRITERION, verbatim in shape. + const { dataSource, container } = renderNode({ + type: 'list-view', + properties: { + data: { provider: 'object', object: OBJECT }, + type: 'kanban', + kanban: { groupByField: 'stage' }, + }, + }); + + await waitFor(() => expect(kanbanProps.length).toBeGreaterThan(0)); + expect(gridProps).toHaveLength(0); + // The kanban `ListView` built is bound to the object `data` names, and it + // is a live binding: the list queried that object. + expect(last(kanbanProps).schema.type).toBe('object-kanban'); + expect(last(kanbanProps).schema.objectName).toBe(OBJECT); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(dataSource.find.mock.calls[0][0]).toBe(OBJECT); + // Unclobbered: only `ListView` builds that `object-kanban` node, so the + // registry resolved `list-view`. Had the hoist copied the kind over the + // node's `type`, the registry would have looked up `kanban`, which no + // registration in this file answers. + expect(container.textContent ?? '').not.toMatch(/Unknown component/i); + }); + + it('draws a grid when `properties.type` is absent', async () => { + const { dataSource } = renderNode({ + type: 'list-view', + properties: { data: { provider: 'object', object: OBJECT } }, + }); + + await waitFor(() => expect(gridProps.length).toBeGreaterThan(0)); + expect(kanbanProps).toHaveLength(0); + expect(last(gridProps).schema.objectName).toBe(OBJECT); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + }); + + it('draws a grid for a `properties.type` that names no kind a list view draws', async () => { + renderNode({ + type: 'list-view', + properties: { data: { provider: 'object', object: OBJECT }, type: 'list-view' }, + }); + + await waitFor(() => expect(gridProps.length).toBeGreaterThan(0)); + expect(kanbanProps).toHaveLength(0); + }); + + it('reads the kind on the `view:list` alias too: one renderer, one spelling', async () => { + renderNode({ + type: 'view:list', + properties: { + data: { provider: 'object', object: OBJECT }, + type: 'kanban', + kanban: { groupByField: 'stage' }, + }, + }); + + await waitFor(() => expect(kanbanProps.length).toBeGreaterThan(0)); + expect(gridProps).toHaveLength(0); + expect(last(kanbanProps).schema.objectName).toBe(OBJECT); + }); +}); + +describe('a page list-view node does not read `objectName` / `viewType` (objectui#12099)', () => { + // FIRST in this file to carry a retired key, on purpose: the warning is one + // per key per process, so this is the render that issues both. + it('names each retired key in one warning, and only once', async () => { + const first = renderNode({ type: 'list-view', properties: { objectName: OBJECT, viewType: 'kanban' } }); + await expectNoObjectHint(first.getByTestId); + expect(warningsNaming(warn, 'objectName')).toHaveLength(1); + expect(warningsNaming(warn, 'viewType')).toHaveLength(1); + cleanup(); + + const second = renderNode({ type: 'list-view', objectName: OBJECT, viewType: 'kanban' }); + await expectNoObjectHint(second.getByTestId); + expect(warningsNaming(warn, 'objectName')).toHaveLength(1); + expect(warningsNaming(warn, 'viewType')).toHaveLength(1); + }); + + it.each([ + ['in the properties bag', { properties: { objectName: OBJECT, viewType: 'kanban', kanban: { groupByField: 'stage' } } }], + ['flat on the node', { objectName: OBJECT, viewType: 'kanban', kanban: { groupByField: 'stage' } }], + ])('a node naming its object only as `objectName` (%s) draws the hint and queries nothing', async (_where, rest) => { + const { dataSource, getByTestId } = renderNode({ type: 'list-view', ...rest }); + + await expectNoObjectHint(getByTestId); + await settleAbsenceWindow(); + expect(dataSource.find).not.toHaveBeenCalled(); + expect(kanbanProps).toHaveLength(0); + expect(gridProps).toHaveLength(0); + }); + + it('a `viewType` beside a working `data` changes nothing: the list is a grid', async () => { + renderNode({ + type: 'list-view', + properties: { + data: { provider: 'object', object: OBJECT }, + viewType: 'kanban', + kanban: { groupByField: 'stage' }, + }, + }); + + await waitFor(() => expect(gridProps.length).toBeGreaterThan(0)); + expect(kanbanProps).toHaveLength(0); + }); + + it.each([ + ['no record source at all', {}], + ['the registration\'s default `data`, which names no object', { properties: { data: { provider: 'object', object: '' } } }], + ])('a node with %s draws the hint naming `data.object`', async (_case, rest) => { + const { dataSource, getByTestId } = renderNode({ type: 'list-view', ...rest }); + + await expectNoObjectHint(getByTestId); + await settleAbsenceWindow(); + expect(dataSource.find).not.toHaveBeenCalled(); + }); + + it('control: inline rows are a record source, so no hint is drawn', async () => { + const { queryByTestId } = renderNode({ + type: 'list-view', + properties: { data: { provider: 'value', items: rows } }, + }); + + await waitFor(() => expect(gridProps.length).toBeGreaterThan(0)); + expect(queryByTestId('list-view-no-object')).toBeNull(); + }); +}); + +describe('the kind beside a `dataSource` binding (objectui#12099)', () => { + it('draws a kanban over the bound object when `properties.type` says so', async () => { + const { dataSource } = renderNode({ + type: 'list-view', + dataSource: { object: OBJECT }, + properties: { type: 'kanban', kanban: { groupByField: 'stage' } }, + }); + + await waitFor(() => expect(kanbanProps.length).toBeGreaterThan(0)); + expect(last(kanbanProps).schema.objectName).toBe(OBJECT); + await waitFor(() => expect(dataSource.find).toHaveBeenCalled()); + expect(dataSource.find.mock.calls[0][0]).toBe(OBJECT); + }); + + it('the node\'s `properties.type` outranks the bound saved view\'s kind', async () => { + renderNode({ + type: 'list-view', + dataSource: { object: OBJECT, view: 'board' }, + properties: { type: 'grid' }, + }); + + await waitFor(() => expect(gridProps.length).toBeGreaterThan(0)); + expect(kanbanProps).toHaveLength(0); + }); + + it('control: with no `properties.type`, the bound saved view\'s kind applies', async () => { + renderNode({ + type: 'list-view', + dataSource: { object: OBJECT, view: 'board' }, + }); + + await waitFor(() => expect(kanbanProps.length).toBeGreaterThan(0)); + expect(gridProps).toHaveLength(0); + }); +}); + +describe('the registrations publish `data` / `type` (objectui#12099)', () => { + const inputNames = (type: string) => (ComponentRegistry.getConfig(type)?.inputs ?? []).map((input) => input.name); + + it.each(['list-view', 'view:list'])('%s publishes `data` and `type`, and no `objectName` or `viewType`', (type) => { + const names = inputNames(type); + expect(names).toEqual(expect.arrayContaining(['data', 'type'])); + expect(names).not.toContain('objectName'); + expect(names).not.toContain('viewType'); + }); + + it('the `type` input enumerates the kinds a list view draws', () => { + const input = ComponentRegistry.getConfig('list-view')?.inputs?.find((candidate) => candidate.name === 'type'); + expect((input?.enum ?? []).map((option: any) => option.value)).toEqual( + expect.arrayContaining(['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree']), + ); + expect(input?.required).not.toBe(true); + }); + + it('`defaultProps` carry the `data` shape and no `objectName`, `viewType` or `type`', () => { + const defaults = ComponentRegistry.getConfig('list-view')?.defaultProps ?? {}; + expect(defaults.data).toEqual({ provider: 'object', object: '' }); + expect(defaults).not.toHaveProperty('objectName'); + expect(defaults).not.toHaveProperty('viewType'); + expect(defaults).not.toHaveProperty('type'); + }); +}); diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index a9156267d3..47869e46d7 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -39,7 +39,7 @@ export type { ListViewSwitcherProps } from './ViewSwitcher'; * (objectstack#5576) — are documented on {@link ListViewBlock}. */ /** - * Designer label per visualization, and the `viewType` enum options both + * Designer label per visualization, and the `type` enum options both * registrations below are built from. * * A total `Record` (`ca3942729`). The two @@ -72,15 +72,36 @@ const VIEW_TYPE_OPTIONS = Object.entries(VIEW_TYPE_LABELS).map(([value, label]) const ListViewRenderer = ListViewBlock; /** - * The `objectName` input both registrations below publish, spelled once so the - * two cannot drift. Not required (objectui#11605): see the `list-view` - * registration. + * The record-source and kind inputs both registrations below publish, spelled + * once so the two cannot drift. + * + * A page `list-view` node is written in `@objectstack/spec`'s list view + * spelling, `data` and `type` (objectstack-ai/objectstack#22444, ruling C; + * objectui#12099): one list view has one set of property names wherever it is + * embedded. `objectName` and `viewType` are not published and not read on this + * node; `ListViewBlock` says what happens to a node that still carries them. + * + * `data` is not required (objectui#11605): the node's `dataSource` binding can + * name the object instead. Without either, the list shows a hint naming + * `data.object` instead of an empty list. + */ +const LIST_VIEW_DATA_INPUT: ComponentInput = { + name: 'data', + type: 'object', + description: + 'Where this list\'s records come from, the spec\'s `ViewData`: `{ provider: \'object\', object }` lists that object\'s records, and `{ provider: \'value\', items }` hands the list its rows. Not required: the node\'s `dataSource` binding can name the object instead, and `dataSource.object` outranks this key. With neither, the list shows a hint naming `data.object` instead of an empty list.', +} as const; + +/** + * The list kind. On a metadata page it is written `properties.type`, because + * the node's own `type` is the registry key and stays `list-view`. */ -const LIST_VIEW_OBJECT_NAME_INPUT: ComponentInput = { - name: 'objectName', - type: 'string', +const LIST_VIEW_TYPE_INPUT: ComponentInput = { + name: 'type', + type: 'enum', + enum: VIEW_TYPE_OPTIONS, description: - 'Object this list view lists. Not required: the node\'s `dataSource` binding can name the object instead, and `dataSource.object` lands on this key, outranking an authored value. With neither, and no inline `data`, the list shows a hint naming this key instead of an empty list.', + 'The list kind, the spec\'s list view `type`. Absent, a bound saved view\'s kind applies, else a grid. On a metadata page it is written `properties.type`; the node\'s own `type` stays `list-view`.', } as const; // Register ListView component @@ -90,24 +111,25 @@ ComponentRegistry.register('list-view', ListViewRenderer, { category: 'Views', icon: 'LayoutList', inputs: [ - // NOT required (objectui#11605). This block has no `ComponentPropsMap` - // row; the contract is the binding doc (`content/docs/guide/data-source.md`, - // "a node bound this way needs no `objectName` of its own") and the - // `list-view` record-source rule `objectui validate` runs, which counts the - // binding as a record source. `ListViewBlock` lands `dataSource.object` - // here. The page compile reads this list, so `required: true` refused a - // bound node the validator and the renderer accept. A node with neither is - // answered by the gate's "no object named" hint. - { ...LIST_VIEW_OBJECT_NAME_INPUT }, - { name: 'viewType', type: 'enum', enum: VIEW_TYPE_OPTIONS }, + // `data` is NOT required (objectui#11605). This block has no + // `ComponentPropsMap` row yet; the contract is the binding doc + // (`content/docs/guide/data-source.md`). The page compile reads this list, + // so `required: true` would refuse a node bound by `dataSource.object`, + // which the renderer accepts. A node with neither gets the "no object + // named" hint `ListViewBlock` draws. + { ...LIST_VIEW_DATA_INPUT }, + { ...LIST_VIEW_TYPE_INPUT }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array' }, { name: 'sort', type: 'array' }, { name: 'options', type: 'object' }, ], + // No `type` default: an absent kind IS the default (the spec's list view + // `type` defaults to `grid`), and leaving it absent lets a bound saved view's + // kind apply. A `type` here would also be the registry key's name to any + // reader that spreads these onto a node. defaultProps: { - objectName: '', - viewType: 'grid', + data: { provider: 'object', object: '' }, columns: [], filter: [], sort: [], @@ -121,7 +143,7 @@ ComponentRegistry.register('list-view', ListViewRenderer, { // list DISPLAY primitive (`ui:list` in @object-ui/components), used by page // schemas like `{ type: 'list', items: [...] }`. Without skipFallback this alias // clobbered the bare key, so a hand-authored bullet list resolved to the -// data-bound ListView (which requires `objectName`) instead. Object list VIEWS +// data-bound ListView (which needs an object to list) instead. Object list VIEWS // are rendered via `type: 'list-view'`, never the bare `list` lookup, so the // data view loses nothing by yielding the bare key. ComponentRegistry.register('list', ListViewRenderer, { @@ -131,9 +153,9 @@ ComponentRegistry.register('list', ListViewRenderer, { label: 'List', icon: 'LayoutList', inputs: [ - // Same renderer, same reason as `list-view` above. - { ...LIST_VIEW_OBJECT_NAME_INPUT }, - { name: 'viewType', type: 'enum', enum: VIEW_TYPE_OPTIONS }, + // Same renderer, same spelling and same reason as `list-view` above. + { ...LIST_VIEW_DATA_INPUT }, + { ...LIST_VIEW_TYPE_INPUT }, { name: 'columns', type: 'array' }, { name: 'filter', type: 'array' }, { name: 'sort', type: 'array' }, From 46a657d28c9e8ccd8bdeb4e1bcddb6ff075ae060 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:03:35 +0000 Subject: [PATCH 2/5] test(plugin-list,console): page list-view fixtures written data / type; docs name the page spelling Respells the page-path fixtures that bound a list-view through the retired objectName / viewType to `data` / `properties.type`, teaches the console's binding pins the list-view `data` key and its hint, and documents the page spelling in the plugin-list README and the binding guide. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../filterContextTokensSweep-10666.test.tsx | 2 +- ...erDataSourceInputDeclaration-6678.test.tsx | 8 +-- .../objectname-binding-required-11605.test.ts | 30 ++++++++++-- .../objectname-neither-hint-11605.test.tsx | 49 ++++++++++++++----- .../public-block-binding-reach.test.tsx | 6 ++- content/docs/guide/data-source.md | 20 +++++--- packages/plugin-list/README.md | 49 +++++++++++++++++-- packages/plugin-list/src/ListViewBlock.tsx | 4 +- .../ListView.elementDataSource.test.tsx | 4 +- ...istView.invalidationRefetch-10572.test.tsx | 2 +- .../__tests__/ListView.sharedGate.test.tsx | 14 +++--- ...Gallery.filterContextTokens-10666.test.tsx | 2 +- .../htmlTierColumnSpellings-6598.test.tsx | 31 ++++++------ ...mlTierListViewDefaultColumns-6598.test.tsx | 4 +- 14 files changed, 160 insertions(+), 65 deletions(-) diff --git a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx index a7efed4f7b..985dfdaf37 100644 --- a/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx +++ b/apps/console/src/__tests__/filterContextTokensSweep-10666.test.tsx @@ -243,7 +243,7 @@ const ROWS: NodeRow[] = [ { node: 'object-grid (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', withFilter: top({ type: 'object-grid', objectName: OBJECT, columns: ['name'] }) }, { node: 'list-view (regression)', shape: 'tuple', path: 'dataSource', filterParam: '$filter', stubChildGrid: true, - withFilter: top({ type: 'list-view', objectName: OBJECT, columns: ['name'] }) }, + withFilter: top({ type: 'list-view', data: { provider: 'object', object: OBJECT }, columns: ['name'] }) }, ]; type DS = ReturnType; diff --git a/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx b/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx index 35a5f67ee8..98a490a58d 100644 --- a/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx +++ b/apps/console/src/__tests__/htmlTierDataSourceInputDeclaration-6678.test.tsx @@ -162,11 +162,11 @@ const BINDING = `dataSource={{object: '${OBJECT}', view: 'all'}}`; describe("kind:'html' — the spec `dataSource` binding on a gate-wrapped block (#6678)", () => { it('draws no unknown-prop diagnostic', () => { - expect(unknownProps(``)).toEqual([]); + expect(unknownProps(``)).toEqual([]); }); it('still resolves the saved view it names — the binding was not silenced, it was declared', async () => { - const container = await renderHtmlPage(``); + const container = await renderHtmlPage(``); await waitFor(() => expect(headersOf(container).length).toBeGreaterThan(1)); const headers = headersOf(container); @@ -181,10 +181,10 @@ describe("kind:'html' — the spec `dataSource` binding on a gate-wrapped block // The control for the pin above. If `viewName` / `view` went quiet too, the // fix would have widened the whitelist rather than declared the one key the // block reads — and the tier would be back to reporting nothing useful. - expect(unknownProps(``)).toEqual([ + expect(unknownProps(``)).toEqual([ ' has no prop "viewName"', ]); - expect(unknownProps(``)).toEqual([ + expect(unknownProps(``)).toEqual([ ' has no prop "view"', ]); }); diff --git a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts index de09b99e25..cf86223424 100644 --- a/apps/console/src/__tests__/objectname-binding-required-11605.test.ts +++ b/apps/console/src/__tests__/objectname-binding-required-11605.test.ts @@ -306,11 +306,31 @@ const diagnosticsOf = (source: string) => compile(source, shipped).diagnostics.map((d) => [d.severity, d.code, d.message]); describe('objectui#11605 — each member compiles a bound node, and leaves the neither node to the runtime', () => { - it.each(MEMBERS)('$tag — the published objectName is not required and names the binding', ({ tag }) => { - const objectName = inputsOf(tag).find((input) => input.name === 'objectName'); - expect(objectName, `${tag} publishes no objectName input`).toBeDefined(); - expect(objectName?.required).not.toBe(true); - expect(objectName?.description ?? '').toContain('`dataSource.object`'); + // The object key is read off the binding doc (`objectKeyOf`): `objectName` + // on every member but `list-view`, whose page node names its object in + // `data` (objectui#12099). + it.each(MEMBERS)('$tag — the published object key is not required and names the binding', ({ tag }) => { + const key = objectKeyOf(tag) ?? 'objectName'; + const input = inputsOf(tag).find((candidate) => candidate.name === key); + expect(input, `${tag} publishes no ${key} input`).toBeDefined(); + expect(input?.required).not.toBe(true); + expect(input?.description ?? '').toContain('`dataSource.object`'); + }); + + it('list-view — the retired objectName / viewType are not published, so this tier reports them', () => { + // objectui#12099: the page carrier is written `data` / `type`. The tier + // forbids a `type` attribute outright (the tag is the component), so the + // kind has no attribute here; a bound saved view still supplies it. + expect(objectKeyOf('list-view')).toBe('data'); + expect(inputsOf('list-view').map((input) => input.name)).toEqual(expect.arrayContaining(['data', 'type'])); + // By code and by the key each names, not by wording. + const retired = diagnosticsOf(''); + expect(retired.map(([, code]) => code)).toEqual(['unknown-prop', 'unknown-prop']); + expect(retired.map(([, , message]) => String(message))).toEqual([ + expect.stringContaining('"objectName"'), + expect.stringContaining('"viewType"'), + ]); + expect(diagnosticsOf('')).toEqual([]); }); it.each(MEMBERS)('$tag — a dataSource-bound node with no objectName compiles ok', ({ tag, rest }) => { diff --git a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx index 71688da3a1..0550077c2b 100644 --- a/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx +++ b/apps/console/src/__tests__/objectname-neither-hint-11605.test.tsx @@ -23,10 +23,15 @@ * required for data fetching" error and is not opted in. * * Each member opts in through `ElementDataSourceGate`'s `requiresObject`, and - * the gate draws the hint after the binding is applied. Rows per member: - * 1. The neither node draws the hint, naming `objectName`, and asks the data - * layer for nothing. - * 2. Control: the same node with `objectName` draws no hint. + * the gate draws the hint after the binding is applied. `list-view` and + * `view:list` draw it themselves, after the same binding, because a page + * `list-view` names its object in `data` rather than `objectName` + * (objectui#12099): their hint names `data.object`. Rows per member: + * 1. The neither node draws the hint, naming the member's object key, and asks + * the data layer for nothing. + * 2. Control: the same node naming its object (`objectName`, or `data` on the + * two list rows) draws no hint. On those two rows the retired `objectName` + * names nothing: the hint is drawn and nothing is fetched. * 3. Control: the same node bound by `dataSource.object` draws no hint. * 4. Control, where the block has another record source: the neither node with * that source draws no hint, so the hint is not painted over a block that @@ -57,6 +62,9 @@ async function resolveLazy(types: readonly string[]): Promise { } const HINT = 'No object named: set objectName or dataSource.object.'; +/** The page `list-view` key is `data` (objectui#12099), so its hint names `data.object`. */ +const LIST_HINT = 'No object named: set data.object or dataSource.object.'; +const LIST_NAMED = { data: { provider: 'object', object: 'account' } }; /** * Each member: the tag, the gate's `testId` stem, the props a complete node @@ -68,9 +76,13 @@ const MEMBERS: ReadonlyArray<{ testId: string; base: Record; otherSource?: Record; + /** The keys that name the object, where they are not `objectName`. */ + named?: Record; + /** The hint, where it names another key than `objectName`. */ + hint?: string; }> = [ - { type: 'list-view', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } }, - { type: 'view:list', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } } }, + { type: 'list-view', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } }, named: LIST_NAMED, hint: LIST_HINT }, + { type: 'view:list', testId: 'list-view', base: {}, otherSource: { data: { provider: 'value', items: [] } }, named: LIST_NAMED, hint: LIST_HINT }, { type: 'object-form', testId: 'object-form', @@ -171,26 +183,37 @@ const SECTIONED_ROWS = (['object-form', 'view:form'] as const).flatMap((type) => ); describe('objectui#11605 — a node naming its object in neither place shows the no-object hint', () => { - it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base }) => { + it.each(MEMBERS)('$type — the neither node draws the hint and fetches nothing', async ({ type, testId, base, hint = HINT }) => { const r = await mount({ type, ...base }); expect(r.html, `<${type}> crashed instead of answering`).not.toContain('failed to render'); expect(r.hasHint(testId), `<${type}> drew no no-object hint:\n${r.text.slice(0, 300)}`).toBe(true); - expect(r.text).toContain(HINT); + expect(r.text).toContain(hint); expect(r.calls).toEqual([]); }); - it.each(MEMBERS)('$type — control: with objectName, no hint', async ({ type, testId, base }) => { - const r = await mount({ type, ...base, objectName: 'account' }); + it.each(MEMBERS)('$type — control: naming its object, no hint', async ({ type, testId, base, named = { objectName: 'account' }, hint = HINT }) => { + const r = await mount({ type, ...base, ...named }); expect(r.html).not.toContain('failed to render'); expect(r.hasHint(testId)).toBe(false); - expect(r.text).not.toContain(HINT); + expect(r.text).not.toContain(hint); }); - it.each(MEMBERS)('$type — control: bound by dataSource.object, no hint', async ({ type, testId, base }) => { + it.each(MEMBERS.filter((member) => member.named))( + '$type — the retired objectName names nothing: the hint is drawn and nothing is fetched', + async ({ type, testId, base, hint }) => { + const r = await mount({ type, ...base, objectName: 'account' }); + expect(r.html).not.toContain('failed to render'); + expect(r.hasHint(testId)).toBe(true); + expect(r.text).toContain(hint); + expect(r.calls).toEqual([]); + }, + ); + + it.each(MEMBERS)('$type — control: bound by dataSource.object, no hint', async ({ type, testId, base, hint = HINT }) => { const r = await mount({ type, ...base, dataSource: { object: 'account' } }); expect(r.html).not.toContain('failed to render'); expect(r.hasHint(testId)).toBe(false); - expect(r.text).not.toContain(HINT); + expect(r.text).not.toContain(hint); }); it.each(MEMBERS.filter((member) => member.otherSource))( diff --git a/apps/console/src/__tests__/public-block-binding-reach.test.tsx b/apps/console/src/__tests__/public-block-binding-reach.test.tsx index 1b0528dadf..1f13b366a3 100644 --- a/apps/console/src/__tests__/public-block-binding-reach.test.tsx +++ b/apps/console/src/__tests__/public-block-binding-reach.test.tsx @@ -191,7 +191,11 @@ const NO_DATA_REACH: Readonly> = { */ const EXPECTED_CANDIDATES = [ 'object-grid', - 'list-view', + // `list-view` left this population with objectui#12099: a page `list-view` + // names its object in `data: { provider: 'object', object }`, the spec's + // spelling, and publishes no `objectName`. That binding's reach to the data + // layer is pinned through the real registration by plugin-list's + // `ListViewBlock.pageCarrierSpelling-12099.test.tsx`. 'object-form', 'embeddable-form', 'object-master-detail-form', diff --git a/content/docs/guide/data-source.md b/content/docs/guide/data-source.md index a281f00b95..24799e082d 100644 --- a/content/docs/guide/data-source.md +++ b/content/docs/guide/data-source.md @@ -254,7 +254,7 @@ ignores would be accepted and dropped, which is the defect this binding removes. | block | `object` | `view` | `filter` | `sort` | `limit` | |---|---|---|---|---|---| -| `list-view` | ✅ | ✅ | ✅ | ✅ | ✅ | +| `list-view` | ✅ (`data`) | ✅ | ✅ | ✅ | ✅ | | `object-grid` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:record_picker` | ✅ | ✅ | ✅ | ✅ | ✅ | | `element:number` | ✅ | filter | ✅ | — single value | — single value | @@ -282,7 +282,10 @@ error on **every** block in the table, including the ones that take nothing else from the view — so a typo never passes silently, whatever the block. Reading the `object` column: on a block with an object key it lands on that key, -which is `objectName` everywhere except `record:line_items`, where the collection the panel +which is `objectName` everywhere except `list-view` and `record:line_items`. A +`list-view` node names its object in `data` (`{ "provider": "object", "object": … }`), +the spec's list view spelling, and a bound `dataSource.object` outranks +`data.object`; the node's own `objectName` is not read. On `record:line_items` the collection the panel lists, fetches and writes is `childObject`. Its `relationshipField` is *not* part of the binding and stays the author's — it has to name a field on the bound child object, so rebinding `object` without updating it is an authoring error the panel @@ -324,21 +327,22 @@ record block whose binding has no `limit: 1`, so the binding says which record i means. On a record page, a block with no `dataSource` still shows the page's record; a bound one shows its bound record instead, read-only. -Because the binding lands on `objectName`, a node bound this way needs no -`objectName` of its own. The schema validator (`StrictAnyComponentSchema`, which +Because the binding lands on `objectName` (on `list-view`, it stands in for +`data`), a node bound this way needs no `objectName` (or `data`) of its own. The schema validator (`StrictAnyComponentSchema`, which `objectui validate` runs, and the tolerant `safeValidateSchema` alike) counts the binding as a record source of `list-view`, `object-grid`, `object-kanban`, `object-calendar`, `object-gantt` and `object-map`, and still refuses one of those nodes that declares no other record source and names its object in neither place. A binding with an empty `object` names nothing. The page compile accepts such a node too, on every row above whose object lands on -`objectName`: those registrations do not declare `objectName` required, because the -binding can supply it. The exception is `record:related_list`, whose spec row +`objectName`, and on `list-view`: those registrations do not declare `objectName` +(on `list-view`, `data`) required, because the binding can supply it. The exception is `record:related_list`, whose spec row requires `objectName`, so the page compile still refuses one of those nodes without it. A node that names its object in neither place renders a short hint, "No object named: set objectName or dataSource.object.", in place of an empty list, board, -form, chart, metric or pivot. `object-grid` shows its own "Object name required for -data fetching" error instead. A block with another record source (inline `data` +form, chart, metric or pivot. On `list-view` the hint names the key a page node +writes: "No object named: set data.object or dataSource.object.". `object-grid` +shows its own "Object name required for data fetching" error instead. A block with another record source (inline `data` rows, a form's inline fields — `customFields`, or `sections` whose every field is inline — a chart's `dataset`, a `bind` path, or a metric's `fallbackValue`) draws from that source and shows no hint. diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 70c0361276..0968f4a25d 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -348,12 +348,50 @@ A declared `pagination.pageSize` sizes the window on every view but the calendar, as before. With no declared size, switching between the grid view and another view changes the window, so the list fetches again. +## On a page — `data` and `type` + +A `list-view` node on a page is written in `@objectstack/spec`'s list view +spelling: `data` says where its records come from, and `type` says which kind of +list it is. On a metadata page both sit in the node's `properties`. The node's +own `type` stays `list-view`, because it is the key the page renderer looks the +component up by: + + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "type": "kanban", + "columns": ["name", "amount"], + "kanban": { "groupByField": "stage" } + } +} +``` + +Without `properties.type` the list is a grid, the spec's default kind; a `type` +that names no kind this list draws is a grid too. `data` may also hand the list +its rows (`{ "provider": "value", "items": [...] }`). + +A page node's own `objectName` and `viewType` are **not read**. The page +renderer removes them before the list sees the node, and logs one warning per key +naming what to write instead. A node that names its object only as `objectName` +therefore names no object, and the list draws a hint in its place: "No object +named: set data.object or dataSource.object." Converting page nodes stored in +the old spelling is the platform's job (an ADR-0087 conversion in +`@objectstack/spec`); this renderer does not read them back. `ListView`'s own +React props (the `ListViewSchema` examples above) are a separate surface and +still spell the two `objectName` and `viewType`. + +`objectui validate` does not judge the page spelling yet: its `list-view` face +still reads `objectName` and counts no `data` as a record source. + ## Page binding — `dataSource` (referencing a saved view by name) -On a metadata page, a `list-view` component can bind its data through the spec's -per-element data source (`PageComponentSchema.dataSource`, -`ElementDataSourceSchema`) instead of spelling `objectName` and inlining the -view's configuration: +On a metadata page, a `list-view` component can also bind its data through the +spec's per-element data source (`PageComponentSchema.dataSource`, +`ElementDataSourceSchema`) instead of naming its object in `data` and inlining +the view's configuration. A bound `dataSource.object` outranks `data`: ```json { @@ -373,7 +411,8 @@ copy of a view's configuration in sync with the view itself. Both the short key this placement, and they beat the component's own same-named key. Values that come from the named view are a *baseline*: a key written on the component itself is more specific than the view it points at, so the component's key wins -(an empty `columns: []` counts as "not authored"). `filter` is the exception — +(an empty `columns: []` counts as "not authored"), and a kind written as +`properties.type` beats the view's kind. `filter` is the exception — the spec calls `dataSource.filter` "additional filter criteria", so the component's filter, the view's filter and the binding's filter all AND together. A binding can narrow what the view selects, never widen it. diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index a089a684b8..ac547ef6f2 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -59,8 +59,8 @@ const RETIRED_PAGE_KEYS = ['objectName', 'viewType'] as const; const warnedRetiredPageKeys = new Set(); const RETIRED_PAGE_KEY_REMEDY: Record<(typeof RETIRED_PAGE_KEYS)[number], string> = { - objectName: "name the object in `data: { provider: 'object', object }` or in `dataSource.object`", - viewType: 'write the list kind as `type` in the node\'s `properties`', + objectName: "Name the object in `data: { provider: 'object', object }` or in `dataSource.object`", + viewType: 'Write the list kind as `type` in the node\'s `properties`', }; function warnRetiredPageKey(key: (typeof RETIRED_PAGE_KEYS)[number]): void { diff --git a/packages/plugin-list/src/__tests__/ListView.elementDataSource.test.tsx b/packages/plugin-list/src/__tests__/ListView.elementDataSource.test.tsx index de88deb73e..f929911f64 100644 --- a/packages/plugin-list/src/__tests__/ListView.elementDataSource.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.elementDataSource.test.tsx @@ -107,7 +107,7 @@ describe('list-view — dataSource: { object, view } (objectstack#5576)', () => // function, so this panel was the whole rendered output. const adapter = makeAdapter(); const { container } = renderPageComponent( - { type: 'list-view', objectName: 'account', dataSource: { object: 'account', view: 'hot' } }, + { type: 'list-view', data: { provider: 'object', object: 'account' }, dataSource: { object: 'account', view: 'hot' } }, adapter, ); @@ -121,7 +121,7 @@ describe('list-view — dataSource: { object, view } (objectstack#5576)', () => // components on that page that worked must keep working, unchanged. const adapter = makeAdapter(); const { container } = renderPageComponent( - { type: 'list-view', objectName: 'account', columns: ['name'] }, + { type: 'list-view', data: { provider: 'object', object: 'account' }, columns: ['name'] }, adapter, ); diff --git a/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx b/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx index 11b3bc7454..ec3226bb5b 100644 --- a/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.invalidationRefetch-10572.test.tsx @@ -56,7 +56,7 @@ const renderBlock = (schema: Record, ds: ReturnType, ); -const LIST = { type: 'list-view', objectName: 'account', columns: ['name'], viewType: 'grid' }; +const LIST = { type: 'list-view', data: { provider: 'object', object: 'account' }, columns: ['name'] }; describe('list-view re-reads on the data-invalidation bus (objectui#10572)', () => { it('an unscoped change (objectName "*") re-runs its query once', async () => { diff --git a/packages/plugin-list/src/__tests__/ListView.sharedGate.test.tsx b/packages/plugin-list/src/__tests__/ListView.sharedGate.test.tsx index 7df5133b7a..7eb7d85194 100644 --- a/packages/plugin-list/src/__tests__/ListView.sharedGate.test.tsx +++ b/packages/plugin-list/src/__tests__/ListView.sharedGate.test.tsx @@ -278,18 +278,20 @@ describe('list-view — the mapping table, key by key, through the shared gate', expect((await boundSchema()).pagination).toEqual({ pageSize: 3 }); }); - it('takes the view’s KIND only when the component declared no viewType', async () => { + it('takes the view’s KIND only when the component declared none', async () => { // `list-view` is the only container that renders several kinds off - // `schema.viewType`, so naming a saved kanban view and rendering a grid - // would be a silently wrong answer. + // `schema.viewType` (the kind it hands `ListView`), so naming a saved + // kanban view and rendering a grid would be a silently wrong answer. renderBlock({ type: 'list-view', dataSource: { object: 'account', view: 'hot' } }); expect((await boundSchema()).viewType).toBe('kanban'); }); - it('leaves an authored viewType alone', async () => { + it('leaves a kind authored as `properties.type` alone', async () => { + // A page node writes its kind in `properties.type` (objectui#12099); the + // block hands it to `ListView` as `viewType`, ahead of the view's kind. renderBlock({ type: 'list-view', - viewType: 'gallery', + properties: { type: 'gallery' }, dataSource: { object: 'account', view: 'hot' }, }); expect((await boundSchema()).viewType).toBe('gallery'); @@ -298,7 +300,7 @@ describe('list-view — the mapping table, key by key, through the shared gate', it('passes a schema with NO binding through BY REFERENCE', async () => { // Identity, not equality: a new schema object on every render would remount // `ListView` and refetch. The unbound path must stay exactly as it was. - const authored = { type: 'list-view', objectName: 'account', columns: ['name'] }; + const authored = { type: 'list-view', data: { provider: 'object', object: 'account' }, columns: ['name'] }; renderBlock(authored); await waitFor(() => expect(h.captured).toBeTruthy()); expect(h.captured.schema).toBe(authored); diff --git a/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx b/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx index 2a70c2875f..d8f84f81a8 100644 --- a/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx +++ b/packages/plugin-list/src/__tests__/ObjectGallery.filterContextTokens-10666.test.tsx @@ -65,7 +65,7 @@ function galleryNode(over: Record) { /** The lit control: a directly authored `list-view` node over the same object. */ function listNode(over: Record) { - return { type: 'list-view', objectName: 'task', columns: ['name'], ...over }; + return { type: 'list-view', data: { provider: 'object', object: 'task' }, columns: ['name'], ...over }; } function ui(ds: DS, schema: Record, user: string | null = USER, org: string | null = ORG) { diff --git a/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx b/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx index f70a9b3e53..4f2cd29467 100644 --- a/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx +++ b/packages/plugin-list/src/__tests__/htmlTierColumnSpellings-6598.test.tsx @@ -158,7 +158,7 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page // The grammar case objectui#6614/#6669 legalised. Credit is #6669's; this // is the end-to-end guard that the materialised array survives all the // way to a header row. - const r = await renderSpelling(``); + const r = await renderSpelling(``); expectNotTheReportedSymptom(r); expect(r.headers).toEqual([INDEX_COLUMN, 'Opportunity Name', 'Amount']); @@ -168,7 +168,7 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page it('form 3 — an object-array with bare identifier keys', async () => { const r = await renderSpelling( - ``, + ``, ); expectNotTheReportedSymptom(r); @@ -194,7 +194,7 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page // The only one of the eight that failed with ZERO diagnostics, and the // half PR #6679 fixed: ListView must hand the grid NO projection when the // author declared none, so the grid's own defaults can run. - expectDefaults(await renderSpelling(``)); + expectDefaults(await renderSpelling(``)); }); it('form 2 — a JSON STRING where an array is declared', async () => { @@ -204,7 +204,7 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page // refused. Parsing it anyway would be exactly the consumer-side tolerance // alias the rule forbids. What matters to THIS card is that refusing it // no longer costs the page its columns. - const r = await renderSpelling(``); + const r = await renderSpelling(``); expectDefaults(r); // The string was not silently adopted as a two-column projection: the @@ -217,22 +217,22 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page // not a base prop, so it draws `unknown-prop`. It was never the spelling // for a saved view on this tier — see objectui#6678, which is about that // being undiscoverable, not about this page losing its columns. - expectDefaults(await renderSpelling(``)); + expectDefaults(await renderSpelling(``)); }); it('form 5 — a `view` reference to a saved view', async () => { - expectDefaults(await renderSpelling(``)); + expectDefaults(await renderSpelling(``)); }); it('form 8b — the kebab-case `view-name` variant', async () => { - expectDefaults(await renderSpelling(``)); + expectDefaults(await renderSpelling(``)); }); }); describe('the spellings that fail LOUDLY, which is the opposite of the report', () => { it('form 6 — child elements', async () => { const r = await renderSpelling( - ``, + ``, ); expectNotTheReportedSymptom(r); @@ -243,17 +243,20 @@ describe("#6598 — all eight reported `columns` spellings on a kind:'html' page }); it('form 8a — the kebab-case `object-name` variant', async () => { - // `object-name` is not `objectName`, so the list names no object, and - // the page says so by name. Since objectui#11605 the compile no longer - // refuses it (`objectName` is not required: a `dataSource` binding can - // supply it), so the saying moved from the compile-error block to the - // block's own hint, which names `objectName` and fetches nothing. + // `object-name` names nothing this block reads, so the list names no + // object, and the page says so by name. Since objectui#11605 the compile + // no longer refuses it (the object is not required: a `dataSource` + // binding can supply it), so the saying moved from the compile-error + // block to the block's own hint, which fetches nothing and, since + // objectui#12099, names the page key `data.object` rather than the + // retired `objectName`. const r = await renderSpelling(``); expectNotTheReportedSymptom(r); expect(r.compileFailed).toBe(false); expect(r.noObjectHint).toBe(true); - expect(r.text).toContain('objectName'); + expect(r.text).toContain('data.object'); + expect(r.text).not.toContain('objectName'); expect(r.headers).toEqual([]); }); }); diff --git a/packages/plugin-list/src/__tests__/htmlTierListViewDefaultColumns-6598.test.tsx b/packages/plugin-list/src/__tests__/htmlTierListViewDefaultColumns-6598.test.tsx index 6b8e5d30f3..8b0db13efc 100644 --- a/packages/plugin-list/src/__tests__/htmlTierListViewDefaultColumns-6598.test.tsx +++ b/packages/plugin-list/src/__tests__/htmlTierListViewDefaultColumns-6598.test.tsx @@ -91,7 +91,7 @@ const headersOf = (container: HTMLElement) => describe("kind:'html' page — renders data columns (#6598)", () => { it('renders the object\'s default columns when the author declared none', async () => { - const container = await renderHtmlPage(``); + const container = await renderHtmlPage(``); await waitFor(() => expect(headersOf(container).length).toBeGreaterThan(1)); const headers = headersOf(container); @@ -109,7 +109,7 @@ describe("kind:'html' page — renders data columns (#6598)", () => // first, and the one objectui#6669 made legal; it is the control that keeps // the default above from swallowing an authored projection. const container = await renderHtmlPage( - ``, + ``, ); await waitFor(() => expect(headersOf(container).length).toBeGreaterThan(1)); From 625279d5ec462e86a521a12a4b6a93648476f5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:06:02 +0000 Subject: [PATCH 3/5] chore(changeset): plugin-list page list-view spelling (objectui#12099) Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .changeset/12099-list-view-page-spelling.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/12099-list-view-page-spelling.md diff --git a/.changeset/12099-list-view-page-spelling.md b/.changeset/12099-list-view-page-spelling.md new file mode 100644 index 0000000000..4f415a4564 --- /dev/null +++ b/.changeset/12099-list-view-page-spelling.md @@ -0,0 +1,9 @@ +--- +'@object-ui/plugin-list': minor +--- + +A page `list-view` node is written in `@objectstack/spec`'s list view spelling, `data` and `type`, and a metadata page reads `properties.type` as the list kind (objectstack-ai/objectstack#22444, ruling C; objectui#12099). + +- The `list-view` and `view:list` registrations publish `data` (the spec's `ViewData`, `{ provider: 'object', object }` naming the object) and `type` (the list kind) in place of `objectName` and `viewType`, in their `inputs` and in `list-view`'s `defaultProps`. Neither input is required: a `dataSource` binding can name the object, and an absent kind is a grid unless a bound saved view supplies one. +- On a metadata page, `{ "type": "list-view", "properties": { "data": { "provider": "object", "object": "deal" }, "type": "kanban", "kanban": { … } } }` now draws a kanban over `deal`. Before, the page renderer never copied `properties.type` onto the node (the node's own `type` is the registry key), so every such list drew a grid. The node's own `type` stays `list-view`. A kind the node states outranks a bound saved view's kind. +- **Breaking for page nodes in the old spelling:** a page node's own `objectName` and `viewType` are no longer read. Each logs one console warning naming what to write instead, and a node that names its object only as `objectName` draws the "no object named" hint, which now names `data.object`. Converting stored page nodes is the platform's job (an ADR-0087 conversion in `@objectstack/spec`); the renderer does not read them back. `ListView`'s React props (`ListViewSchema`) are unchanged and still spell the two `objectName` and `viewType`. From eccc27ee807081af23b9fc58dbb00a86e0000504 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:11:25 +0000 Subject: [PATCH 4/5] test(plugin-list): type the warning helper in the page-carrier pin Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../ListViewBlock.pageCarrierSpelling-12099.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx index ab1db41b63..2f31c50fa9 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx @@ -134,8 +134,10 @@ async function expectNoObjectHint(getByTestId: (id: string) => HTMLElement): Pro } /** The warnings issued so far that name `key` as a page node key not read. */ -const warningsNaming = (spy: ReturnType, key: string) => - spy.mock.calls.map((call) => String(call[0])).filter((line) => line.includes(`\`${key}\``) && line.includes('list-view')); +const warningsNaming = (spy: ReturnType, key: string): string[] => + (spy.mock.calls as unknown[][]) + .map((call: unknown[]) => String(call[0])) + .filter((line: string) => line.includes(`\`${key}\``) && line.includes('list-view')); let warn: ReturnType; beforeEach(() => { From 9e4c5eea2817746e1a956d92e8a4500cc2bf6987 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 12:19:39 +0000 Subject: [PATCH 5/5] docs(plugin-list): state the page list kind in prose; type the page-carrier pin without any `check:doc-types` reads every `type` literal in a code block as a node type, so the README example keeps the node's own `type` only and names the `properties.type` kind in the prose beside it. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- packages/plugin-list/README.md | 9 +++---- ...ewBlock.pageCarrierSpelling-12099.test.tsx | 24 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 0968f4a25d..2d41a4269b 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -362,16 +362,17 @@ component up by: "type": "list-view", "properties": { "data": { "provider": "object", "object": "deal" }, - "type": "kanban", "columns": ["name", "amount"], "kanban": { "groupByField": "stage" } } } ``` -Without `properties.type` the list is a grid, the spec's default kind; a `type` -that names no kind this list draws is a grid too. `data` may also hand the list -its rows (`{ "provider": "value", "items": [...] }`). +That node draws a grid of `deal` records: without `properties.type` the list is +a grid, the spec's default kind. Add `"type": "kanban"` to the same `properties` +and it draws a kanban over `deal`, grouped by `stage`. A `type` that names no +kind this list draws is a grid too. `data` may also hand the list its rows +(`{ "provider": "value", "items": [...] }`). A page node's own `objectName` and `viewType` are **not read**. The page renderer removes them before the list sees the node, and logs one warning per key diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx index 2f31c50fa9..f76ced51bd 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.pageCarrierSpelling-12099.test.tsx @@ -67,13 +67,18 @@ const objectDef = { }, }; +/** What a view spy reads off the node `ListView` built for it. */ +interface SpyProps { + schema: { type?: string; objectName?: string }; +} + /** Props each view spy received, in order. */ -let kanbanProps: Array> = []; -let gridProps: Array> = []; +let kanbanProps: SpyProps[] = []; +let gridProps: SpyProps[] = []; ComponentRegistry.register( 'object-kanban', - (props: Record) => { + (props: SpyProps) => { kanbanProps.push(props); return
; }, @@ -82,7 +87,7 @@ ComponentRegistry.register( ComponentRegistry.register( 'object-grid', - (props: Record) => { + (props: SpyProps) => { gridProps.push(props); return
; }, @@ -91,7 +96,7 @@ ComponentRegistry.register( const makeDataSource = () => ({ - find: vi.fn(async () => rows), + find: vi.fn(async (_object: string, _params?: unknown) => rows), findOne: vi.fn(async () => null), create: vi.fn(), update: vi.fn(), @@ -100,12 +105,12 @@ const makeDataSource = () => getObjectSchema: vi.fn(async () => objectDef), getObjects: vi.fn(async () => []), onMutation: () => () => {}, - }) as any; + }); function renderNode(node: Record) { const dataSource = makeDataSource(); const view = render( - + , ); @@ -150,7 +155,7 @@ afterEach(() => { cleanup(); }); -const last = (spy: Array>) => spy[spy.length - 1]; +const last = (spy: SpyProps[]): SpyProps => spy[spy.length - 1]; describe('a metadata page list-view node reads `properties.type` as its kind (objectui#12099)', () => { it('draws a kanban over the object `properties.data` names, and the node stays a list-view', async () => { @@ -330,7 +335,8 @@ describe('the registrations publish `data` / `type` (objectui#12099)', () => { it('the `type` input enumerates the kinds a list view draws', () => { const input = ComponentRegistry.getConfig('list-view')?.inputs?.find((candidate) => candidate.name === 'type'); - expect((input?.enum ?? []).map((option: any) => option.value)).toEqual( + const options: Array = input?.enum ?? []; + expect(options.map((option) => (typeof option === 'string' ? option : option.value))).toEqual( expect.arrayContaining(['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map', 'chart', 'tree']), ); expect(input?.required).not.toBe(true);