From 325cada800bb02636bae9c90ca86024e322defc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:42:50 +0000 Subject: [PATCH 1/5] fix(app-shell): a saved view's addRecord on the object list opens the header New's create (objectui#13089) renderListView forwarded addRecord and no onAddRecord, so ListView drew an Add button whose click did nothing, also for a viewer who may not create. The button now calls the header New's create and follows its verdict. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- ...ctView.addRecordOpensCreate-13089.test.tsx | 181 ++++++++++++++++++ packages/app-shell/src/views/ObjectView.tsx | 21 +- 2 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 packages/app-shell/src/views/ObjectView.addRecordOpensCreate-13089.test.tsx diff --git a/packages/app-shell/src/views/ObjectView.addRecordOpensCreate-13089.test.tsx b/packages/app-shell/src/views/ObjectView.addRecordOpensCreate-13089.test.tsx new file mode 100644 index 0000000000..316ed5e5cb --- /dev/null +++ b/packages/app-shell/src/views/ObjectView.addRecordOpensCreate-13089.test.tsx @@ -0,0 +1,181 @@ +/** + * 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#13089 item 2 — on the console's object list, a saved view that + * declares `addRecord: { enabled: true }` draws `ListView`'s Add button, and + * the button opens the object list's own create flow: the same `onEdit(null)` + * the header's New calls (`useObjectActions().create`), which `AppContent` + * turns into the record-form overlay or, for an `editMode: 'page'` object, the + * full-page create route. + * + * Before the fix `renderListView` forwarded `addRecord` and no `onAddRecord`, + * so the button drew and its click did nothing. + * + * The button follows the verdict the header's New follows: a viewer who may + * not create (or whose create the object greys) gets no Add button, rather + * than one that would open a form the server refuses. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const { allowCreate } = vi.hoisted(() => ({ allowCreate: { value: true } })); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities — `ListView` names `perms` in its fetch dependencies. + const perms = { + check: (_object: string, action: string) => ({ allowed: action === 'create' ? allowCreate.value : true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: (_object: string, action: string) => (action === 'create' ? allowCreate.value : true), + cannot: (_object: string, action: string) => (action === 'create' ? !allowCreate.value : false), + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [] }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada' }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRealtimeSubscription: () => ({ lastMessage: null }), + useConflictResolution: () => ({ hasConflicts: false, resolveAllConflicts: () => {} }), +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), error: vi.fn(), info: vi.fn(), + warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), + }), +})); + +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); +vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); + +vi.mock('./metadata-admin/useMetadata', async (importOriginal) => ({ + ...(await importOriginal>()), + useMetadataClient: () => ({ save: vi.fn(async () => ({})), get: vi.fn(async () => null) }), +})); + +import { ObjectView } from './ObjectView'; +import { ExpressionProvider } from '../providers/ExpressionProvider'; + +const OBJECT_NAME = 'duly_task'; +const VIEW_ID = `${OBJECT_NAME}.all`; + +function objects(addRecord?: Record) { + return [ + { + name: OBJECT_NAME, + label: 'Task', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + listViews: { + [VIEW_ID]: { + name: VIEW_ID, + label: 'Everything', + type: 'grid', + columns: ['name'], + ...(addRecord ? { addRecord } : {}), + }, + }, + }, + ]; +} + +beforeEach(() => { + allowCreate.value = true; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mount(addRecord?: Record) { + const onEdit = vi.fn(); + const dataSource = { + find: vi.fn(async () => ({ data: [{ id: 'r1', name: 'Alpha' }], total: 1 })), + findOne: vi.fn(async () => null), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; + render( + + + + } + /> + + + , + ); + return { onEdit, dataSource }; +} + +describe('objectui#13089 — a saved view’s `addRecord` on the console object list opens the object list’s create', () => { + it('draws the Add button, and clicking it calls the same create the header’s New calls', async () => { + const { onEdit, dataSource } = mount({ enabled: true }); + await screen.findByText('Alpha', undefined, { timeout: 5000 }); + const add = await screen.findByTestId('add-record-button'); + fireEvent.click(add); + await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(1)); + // A NEW record: no record handed over, as the header's New hands none. + expect(onEdit).toHaveBeenCalledWith(null); + expect(dataSource.create).not.toHaveBeenCalled(); + + // The header's New is the same create. + fireEvent.click(screen.getByTestId('object-view-new-button')); + await waitFor(() => expect(onEdit).toHaveBeenCalledTimes(2)); + expect(onEdit.mock.calls[1]).toEqual(onEdit.mock.calls[0]); + }); + + it('a viewer who may not create gets no Add button, as they get no New', async () => { + allowCreate.value = false; + mount({ enabled: true }); + await screen.findByText('Alpha', undefined, { timeout: 5000 }); + expect(screen.queryByTestId('object-view-new-button')).toBeNull(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); + + it('CONTROL: a view that declares no `addRecord` draws no Add button', async () => { + mount(); + await screen.findByText('Alpha', undefined, { timeout: 5000 }); + expect(screen.getByTestId('object-view-new-button')).toBeTruthy(); + expect(screen.queryByTestId('add-record-button')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 6cc2e5cc98..346f317b63 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -1829,6 +1829,15 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co * so `renderListView` depends on a primitive, not a memoised identity * (AGENTS.md #10). */ + /** + * objectui#13089 — the view's `addRecord` Add button opens this page's own + * create (`actions.create`, the header New's), so it is offered exactly + * where that New is offered and live: the object-level verdict, the + * toolbar-scope `visibleWhen`, and not greyed by `disabledWhen` (`ListView` + * draws no greyed Add button). A boolean, so `renderListView` depends on + * a primitive (AGENTS.md #10). + */ + const listAddRecordOpensCreate = objectCanCreate && createVisible && !createDisabled; const mayInvokeToolbarAction = useCapabilityGate(); const toolbarPredicateScope = usePredicateScope(); const pageOffersCreate = @@ -3487,7 +3496,11 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co bulkActions: viewDef.bulkActions ?? listSchema.bulkActions, bulkActionDefs: (viewDef as any).bulkActionDefs ?? (listSchema as any).bulkActionDefs, sharing: viewDef.sharing ?? listSchema.sharing, - addRecord: viewDef.addRecord ?? listSchema.addRecord, + // objectui#13089 — the Add button `ListView` draws from + // `addRecord.enabled` opens the header New's create (`onAddRecord` + // below), so it follows the New's verdict: withheld, not drawn + // dead, for a viewer who may not create or whose create is greyed. + addRecord: listAddRecordOpensCreate ? viewDef.addRecord ?? listSchema.addRecord : undefined, conditionalFormatting: viewDef.conditionalFormatting ?? listSchema.conditionalFormatting, // ADR-0047 (amended, objectui #2338): this is the object default // list = "views" mode. `dropdown` (value-chip) userFilters ARE @@ -3661,6 +3674,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co viewDefaults={{ hiddenFields: viewHiddenFields, rowHeight: viewRowHeight }} className={className} onEdit={editHandler} + // objectui#13089 — the view's Add button is the header New: + // `onEdit(null)`, which the host routes to the record-form + // overlay or an `editMode: 'page'` object's create route. + onAddRecord={listAddRecordOpensCreate ? actions.create : undefined} onDelete={(record: any) => { if (record?.id != null) { // useObjectActions.deleteRecord wraps execute() which @@ -3724,7 +3741,7 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: Co dataSource={ds} /> ); - }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]); + }, [inSetup, activeView, activeViewDeclaresColumns, activeViewIsSaved, objectDef, objectName, refreshKey, navOverlay, actions, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, writeListUrlState, user?.id, pageOffersCreate, listAddRecordOpensCreate, listIdentityKey, listPrefs.ready, listPrefs.preference, listPrefs.patchView, viewTabBar]); // Memoize the merged views array so PluginObjectView doesn't get a new // reference on every render (which would trigger unnecessary data refetches). From ec3722e03fe4c550a1cb982caa7e7b9ae283a80c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:52:55 +0000 Subject: [PATCH 2/5] feat(app-shell,plugin-list): a page list-view node's addRecord opens the object list's create; publish list-view.addRecord (objectui#13089) The console's page host answered every parent-less resolve with no handlers, so a page list-view node's Add button was withheld. It now serves the bridge's create pair, and nothing else, to an object-scoped list: one that names no relationshipField and no parent. A related list whose parent did not resolve is still served nothing. ResolveRelatedRecordActionsInput documents the shape. An unlinked create opens an editMode 'page' object's full-page create route, as the object list does. list-view.addRecord is published. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13089-page-list-create.md | 13 + .../registry-inputs-spec-parity.test.ts | 11 +- content/docs/plugins/plugin-list.mdx | 22 +- .../src/views/RelatedRecordActionsBridge.tsx | 20 +- .../PageView.listViewAddRecord-13089.test.tsx | 306 ++++++++++++++++++ .../app-shell/src/views/pageRecordActions.ts | 58 ++-- packages/plugin-list/README.md | 28 +- packages/plugin-list/src/ListViewBlock.tsx | 7 +- .../ListViewBlock.inertKeys-13074.test.tsx | 11 +- .../ListViewBlock.specInputs-13047.test.tsx | 13 +- packages/plugin-list/src/index.tsx | 20 +- .../context/RelatedRecordActionsContext.tsx | 21 +- 12 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 .changeset/13089-page-list-create.md create mode 100644 packages/app-shell/src/views/__tests__/PageView.listViewAddRecord-13089.test.tsx diff --git a/.changeset/13089-page-list-create.md b/.changeset/13089-page-list-create.md new file mode 100644 index 0000000000..9ce0f833ab --- /dev/null +++ b/.changeset/13089-page-list-create.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-list': patch +'@object-ui/react': patch +--- + +A list's `addRecord` Add button opens the create form on a console page and on the console's object list (objectui#13089). + +- **Page `list-view` nodes.** The console's page host now serves the record-form create to a list that names no `relationshipField` and has no parent: a page `list-view` node. So `addRecord: { enabled: true }` draws its Add button, and a click opens the create form for the listed object. A related list whose parent did not resolve is still served nothing, so it draws no "+ New" that would open a child form linked to no parent. No Edit, Delete or row click is served to a list without a parent. +- **`list-view.addRecord` is a published input** of the `list-view` and `list` registrations. `enabled` and `position` are read; `mode` and `formView` are not. +- **The console's object list.** A saved view that declares `addRecord.enabled` used to draw an Add button whose click did nothing, also for a viewer who may not create. The button now opens the header New's create, and is withheld wherever that New is not offered or is greyed. +- **`editMode: 'page'`.** A create linked to no parent, from the record-actions bridge, now opens an `editMode: 'page'` object's full-page create route, as the object list does, instead of the overlay. +- **`ResolveRelatedRecordActionsInput`** (`@object-ui/react`) documents what an input without `relationshipField` asks for: an object-scoped list's handlers. No type or member changes. diff --git a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts index 61d07e0f9f..e870517dde 100644 --- a/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts +++ b/apps/console/src/__tests__/registry-inputs-spec-parity.test.ts @@ -1652,14 +1652,14 @@ const UNPUBLISHED_EXEMPTIONS: Record = { * renderer does instead, and each leaves this map the day a reader lands * and the key is published. objectui#13074 struck one of the five: * `requiredPermissions`, which `ListViewBlock` now honours on a page node - * (`ListViewBlock.inertKeys-13074.test.tsx`). + * (`ListViewBlock.inertKeys-13074.test.tsx`). objectui#13089 struck + * `addRecord`, whose Add button the console's page host now serves a create + * for (`PageView.listViewAddRecord-13089.test.tsx`). */ 'list-view.name': 'Unpublished by measurement (objectui#13047): read by nothing on a page `list-view` node. The node draws, queries and hands its view the same thing with and without it — the view\'s own `name` is its identity in metadata, which an embedded list has no use for.', 'list-view.sharing': 'Unpublished by measurement (objectui#13047): `sharing.type` draws a Share button with no click handler, and the sharing config is enforced nowhere in the renderer. Publishing it would advertise a dead control (objectui#13047\'s report).', - 'list-view.addRecord': - 'Unpublished by measurement (objectui#13047, objectui#13074): on a page node the Add button is withheld, because the page host serves no `onCreate`. The block routes `addRecord.enabled` to the `onCreate` a `RelatedRecordActionsContext` provider serves for the listed object, and the console\'s page host answers a parent-less list with no handlers, so a console page draws no Add button (`ListViewBlock.specInputs-13047.test.tsx`). Serving `onCreate` there is objectui#13089\'s; the key is published when it lands.', 'list-view.filterableFields': 'Unpublished by measurement (objectui#13047): the row declares it as the legacy shorthand of `userFilters.fields` (end-user quick filters), and the list reads it as something else — the field list of the Filter panel — while it draws no quick-filter bar. Publishing it would advertise the row\'s meaning, which nothing honours; which meaning stands is a contract question for the seat (objectui#13047\'s report).', }; @@ -2838,6 +2838,7 @@ const actionLeafMemberPins = (type: 'action:button' | 'action:icon'): Record = { ...actionLeafMemberPins('action:button'), @@ -3028,6 +3029,10 @@ const MEMBER_PINS: Record = { file: LIST_VIEW_PIN_FILE, pins: 'The `pageSize` member caps the query (`$top`), and the same node with no `pagination` sends the default page (objectui#13047).', }, + 'list-view.addRecord': { + file: LIST_VIEW_13089_PIN_FILE, + pins: 'Through the real `PageView`: `enabled: true` draws the Add button, and a click pushes `form=new` + `formObject` for the listed object (no `formLink`), or navigates to the full-page create route for an `editMode: \'page\'` object; no create grant, and a create the object greys, withhold it; a node without the key draws none. Controls: an unbound `record:related_list` on the same page draws no `+ New`, and a parent-less resolve is served `onCreate` alone (objectui#13089).', + }, 'list-view.requiredPermissions': { file: LIST_VIEW_13074_PIN_FILE, pins: 'Members are CAPABILITY names, ALL required, read through `hasCapabilities` under a real stock `MePermissionsProvider`: an unheld capability puts the insufficient-permissions notice in place of the list and no `find` is sent; a held one draws and queries the list; a PARTIAL grant on a two-entry array is gated (the `.every` row, with the full grant as its control); an empty array and an absent key are no gate; and a provider that reports no `systemPermissions` fails open (objectui#13074).', diff --git a/content/docs/plugins/plugin-list.mdx b/content/docs/plugins/plugin-list.mdx index 65e171e93f..5ab24c86ca 100644 --- a/content/docs/plugins/plugin-list.mdx +++ b/content/docs/plugins/plugin-list.mdx @@ -32,9 +32,25 @@ with its keys in `properties`. `requiredPermissions` gates what the node draws: missing one sees "Insufficient permissions to view list." in place of the list, as the record blocks show, and no records are queried. -`addRecord` and `sharing` are not published inputs of a page node yet: the Add -button is drawn only under a host that serves the object's create handler, and -the Share button has no action the spec declares. +`addRecord` draws an Add button: `enabled: true` turns it on, and `position` +places it (`"top"`, the default here, `"bottom"` or `"both"`). A click opens the +create form the object list's New opens: the record-form overlay, or the +full-page create for an object whose `editMode` is `page`. The button is not +drawn for a viewer who may not create, or under a host that serves no create. +`mode` and `formView` are not read. + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "addRecord": { "enabled": true, "position": "top" } + } +} +``` + +`sharing` is not a published input of a page node yet: the Share button has no +action the spec declares. ## Status diff --git a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx index 8415219503..b78734b322 100644 --- a/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx +++ b/packages/app-shell/src/views/RelatedRecordActionsBridge.tsx @@ -90,6 +90,7 @@ import { usePermissions } from '@object-ui/permissions'; import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM, RECORD_TRAIL_PARAM, appendRecordTrail } from '../urlParams.js'; import { documentHref } from './documentHref.js'; import { recordListPath } from './useOpenRecordList.js'; +import { resolveRecordFormTarget } from '../utils/recordFormNavigation.js'; /** * Notify open related lists for `objectName` to refetch. @@ -398,8 +399,25 @@ export function RelatedRecordActionsBridge({ // form overlay, by URL params. Pushes ONE history entry (Back = close, the // parent detail stays mounted underneath). The read side lives in // `AppContent` (see its record-form URL contract). + // + // objectui#13089 — a create linked to NO parent is not a child task: it is + // the object's own create (a page `list-view` node's Add button), so it + // takes the object list's create semantics. For an `editMode: 'page'` + // object that is the full-page create route `AppContent`'s `handleEdit` + // navigates to (`resolveRecordFormTarget`), not the overlay. const openChildForm = useCallback( (opts: { objectName: string; recordId?: string; link?: { field: string; parentId: string | number } }) => { + if (opts.recordId == null && !opts.link) { + const target = resolveRecordFormTarget({ + objectDef: objects.find((o: any) => o?.name === opts.objectName), + baseUrl: base, + record: null, + }); + if (target.kind === 'page') { + navigate(target.url); + return; + } + } const sp = new URLSearchParams(window.location.search); sp.set(RECORD_FORM_PARAM, opts.recordId ?? 'new'); sp.set(RECORD_FORM_OBJECT_PARAM, opts.objectName); @@ -407,7 +425,7 @@ export function RelatedRecordActionsBridge({ else sp.delete(RECORD_FORM_LINK_PARAM); setSearchParams(sp); // push → Back closes the overlay }, - [setSearchParams], + [setSearchParams, objects, base, navigate], ); // Execute a child object's row action against the clicked record. Reuses the diff --git a/packages/app-shell/src/views/__tests__/PageView.listViewAddRecord-13089.test.tsx b/packages/app-shell/src/views/__tests__/PageView.listViewAddRecord-13089.test.tsx new file mode 100644 index 0000000000..a2072de231 --- /dev/null +++ b/packages/app-shell/src/views/__tests__/PageView.listViewAddRecord-13089.test.tsx @@ -0,0 +1,306 @@ +/** + * 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#13089 — a page `list-view` node's `addRecord` opens the create the + * object list opens. + * + * `ListViewBlock` routes `addRecord.enabled` to the `onCreate` a + * `RelatedRecordActionsContext` provider serves for the listed object + * (`resolve({ objectName })`, objectui#13074), and the console's page host + * answered every parent-less resolve with no handlers, so on a console page + * the button was withheld. `pageRelatedHandlers` now serves the bridge's + * create pair, `onCreate` / `createDisabled` and nothing else, to an + * object-scoped list: one that names no `relationshipField` and no parent. + * + * Every case mounts the real `PageView`, the real console action runtime, the + * real page renderer, `@object-ui/plugin-list`'s and `@object-ui/plugin-detail`'s + * registrations, and reads the DOM or the location the click pushes. + * + * The CONTROLS are the other lists on a page, whose served set must not move: + * - an unbound `record:related_list` (it names its `relationshipField`, its + * parent did not resolve) draws no "+ New", which would open a child form + * linked to no parent; + * - no Edit, Delete, row click, "View all" or action executor is served to a + * parent-less resolve, read off the served handler set by a probe block on + * the page; + * - under a parent the served set is the objectui#12870 executors, as before + * (`PageView.relatedListActions-12870.test.tsx` keeps its own CONTROL row). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import '@testing-library/jest-dom'; + +const { createGrant } = vi.hoisted(() => ({ createGrant: { value: true } })); + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', role: 'user', image: null }, activeOrganization: null }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/permissions', async (importOriginal) => { + const actual = await importOriginal(); + // Stable identities: the bridge and the list name `perms` in memo deps. The + // create grant is read per call, so one case can withdraw it. + const perms = { + check: (_object: string, action: string) => ({ allowed: action === 'create' ? createGrant.value : true }), + checkField: () => true, + getFieldPermissions: () => [], + getRowFilter: () => undefined, + getObjectApiOperations: () => undefined, + roles: [], + isLoaded: false, + hasCapabilities: () => true, + can: (_object: string, action: string) => (action === 'create' ? createGrant.value : true), + cannot: (_object: string, action: string) => (action === 'create' ? !createGrant.value : false), + }; + const fieldPerms = { canRead: () => true, canWrite: () => true, permissions: [], readableFields: (f: string[]) => f }; + return { ...actual, usePermissions: () => perms, useFieldPermissions: () => fieldPerms }; +}); + +const APP = 'cloud'; +const PARENT = 'sys_organization'; +/** Created through the record-form overlay (no `editMode`). */ +const CHILD = 'sys_member'; +/** Created on the full-page create route (`editMode: 'page'`). */ +const PAGE_MODE = 'sys_contract'; +/** Its create is greyed for every record (`userActions.create.disabledWhen`). */ +const GREYED = 'sys_ledger'; + +const FIELDS = { + name: { name: 'name', type: 'text', label: 'Name' }, + organization_id: { name: 'organization_id', type: 'lookup', reference: PARENT, label: 'Organization' }, +}; +const OBJECTS = [ + { name: PARENT, label: 'Organization', fields: { name: FIELDS.name } }, + { + name: CHILD, + label: 'Member', + fields: FIELDS, + // One of each placement, so a served executor would show up as a key. + actions: [ + { name: 'invite_user', label: 'Invite user', type: 'api', target: '/api/v1/org/invite', locations: ['list_toolbar'] }, + { name: 'remove', label: 'Remove', type: 'api', target: '/api/v1/members/{id}/remove', locations: ['list_item'] }, + ], + }, + { name: PAGE_MODE, label: 'Contract', editMode: 'page', fields: { name: FIELDS.name } }, + { name: GREYED, label: 'Ledger', userActions: { create: { disabledWhen: 'true' } }, fields: { name: FIELDS.name } }, +]; + +/** The page's blocks, varied per case. */ +let blocks: Array> = []; + +/** A page `list-view` node over `object`. */ +const listView = (object: string, extra: Record = {}) => ({ + type: 'list-view', + id: `list-${object}`, + properties: { data: { provider: 'object', object }, columns: ['name'], ...extra }, +}); +/** A `record:related_list` with no parent bound: it names its relationship, its parent does not resolve. */ +const unboundRelatedList = { + type: 'record:related_list', + id: 'members', + properties: { objectName: CHILD, relationshipField: 'organization_id', title: 'Members', columns: ['name'] }, +}; + +vi.mock('../../providers/MetadataProvider', () => ({ + useMetadata: () => ({ + pages: [{ name: 'team', label: 'Team', type: 'app', regions: [{ name: 'main', components: blocks }] }], + objects: OBJECTS, + getTypeStatus: () => 'ready', + }), +})); + +vi.mock('../MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false }), +})); + +import { ComponentRegistry } from '@object-ui/core'; +import { + AdapterCtx, + MetadataCtx, + SchemaRendererProvider, + useRelatedRecordActions, + type MetadataContextValue, + type ResolveRelatedRecordActionsInput, +} from '@object-ui/react'; +// The real page renderer and the two list blocks (module scope, AGENTS.md §测试纪律). +import '@object-ui/components'; +import '@object-ui/plugin-list'; +import '@object-ui/plugin-detail'; +import { PageView } from '../PageView'; + +/** + * A page block that prints the handler set the page serves for each input + * shape a list resolves with: its keys, sorted, per input. + */ +const PROBE = 'probe-page-served-handlers-13089'; +const PROBE_INPUTS: Record = { + objectScoped: { objectName: CHILD }, + unboundRelated: { objectName: CHILD, relationshipField: 'organization_id', parentId: null }, + emptyRelationship: { objectName: CHILD, relationshipField: '', parentId: '' }, + boundRelated: { objectName: CHILD, relationshipField: 'organization_id', parentId: 'org-1' }, +}; +function ServedHandlersProbe() { + const host = useRelatedRecordActions(); + return ( +
    + {Object.entries(PROBE_INPUTS).map(([name, input]) => ( +
  • + {host ? Object.keys(host.resolve(input)).sort().join(',') : 'NO-HOST'} +
  • + ))} +
+ ); +} +ComponentRegistry.register(PROBE, ServedHandlersProbe as never); + +const METADATA: MetadataContextValue = { + apps: [], + objects: OBJECTS, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: (async (type: string, name: string) => + type === 'object' ? OBJECTS.find((o) => o.name === name) ?? null : null) as unknown as MetadataContextValue['getItem'], + getItemsByType: () => [], + getTypeStatus: () => 'ready' as const, +}; + +function makeDataSource() { + return { + find: vi.fn(async (object: string) => + object === PARENT + ? { data: [], total: 0 } + : { data: [{ id: `${object}-1`, name: 'Alice', organization_id: 'org-1' }], total: 1 }, + ), + findOne: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(async () => 1), + getObjectSchema: vi.fn(async (name: string) => OBJECTS.find((o) => o.name === name)), + getObjects: vi.fn(async () => OBJECTS), + onMutation: () => () => {}, + }; +} + +/** Where the router is: what a click pushed. */ +function LocationProbe() { + const location = useLocation(); + return
{location.pathname + location.search}
; +} + +function mount(nodes: Array>) { + blocks = nodes; + const ds = makeDataSource(); + render( + + + + + + + } /> + + + + + + , + ); + return ds; +} + +const location = () => new URL(screen.getByTestId('location').textContent ?? '', 'http://x'); +const addButtons = () => screen.queryAllByTestId('add-record-button'); +/** The list's toolbar is up: the Add button, when offered, is drawn in it. */ +const listDrawn = () => screen.findByTestId('toolbar-filter-trigger', undefined, { timeout: 5000 }); + +beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); +}); + +afterEach(() => { + cleanup(); + createGrant.value = true; +}); + +describe('objectui#13089 — a page `list-view` node’s `addRecord` opens the object list’s create', () => { + it('draws the Add button, and clicking it opens the record-form create for the listed object', async () => { + const ds = mount([listView(CHILD, { addRecord: { enabled: true } })]); + await listDrawn(); + await waitFor(() => expect(addButtons()).toHaveLength(1)); + + fireEvent.click(addButtons()[0]); + await waitFor(() => expect(location().searchParams.get('form')).toBe('new')); + expect(location().pathname).toBe(`/apps/${APP}/page/team`); + expect(location().searchParams.get('formObject')).toBe(CHILD); + // An object-scoped create links no parent. + expect(location().searchParams.get('formLink')).toBeNull(); + expect(ds.create).not.toHaveBeenCalled(); + }); + + it('an `editMode: \'page\'` object opens its full-page create route, as the object list does', async () => { + mount([listView(PAGE_MODE, { addRecord: { enabled: true } })]); + await listDrawn(); + await waitFor(() => expect(addButtons()).toHaveLength(1)); + + fireEvent.click(addButtons()[0]); + await waitFor(() => expect(location().pathname).toBe(`/apps/${APP}/${PAGE_MODE}/new`)); + expect(location().searchParams.get('form')).toBeNull(); + }); + + it('a viewer with no create grant gets no Add button: withheld, not drawn dead', async () => { + createGrant.value = false; + mount([listView(CHILD, { addRecord: { enabled: true } })]); + await listDrawn(); + expect(addButtons()).toHaveLength(0); + }); + + it('a create the object greys (`createDisabled`) withholds the Add button', async () => { + mount([listView(GREYED, { addRecord: { enabled: true } })]); + await listDrawn(); + expect(addButtons()).toHaveLength(0); + }); + + it('CONTROL: a node without `addRecord` draws no Add button', async () => { + mount([listView(CHILD)]); + await listDrawn(); + expect(addButtons()).toHaveLength(0); + }); + + it('CONTROL: an unbound related list on the same page draws no “+ New”', async () => { + mount([unboundRelatedList, listView(CHILD, { addRecord: { enabled: true } })]); + await screen.findByText('Members'); + // The list-view beside it is served, so the page host is answering. + await waitFor(() => expect(addButtons()).toHaveLength(1)); + expect(screen.queryByTestId('related-list-new')).toBeNull(); + }); + + it('CONTROL: a parent-less resolve is served the create pair only, and an unbound related list nothing; under a parent the executors, as before', async () => { + mount([{ type: PROBE, id: 'probe' }]); + // No Edit, Delete, row click, "View all" or executor parent-less. + await waitFor(() => expect(screen.getByTestId('served-objectScoped')).toHaveTextContent(/^onCreate$/)); + // An empty `relationshipField` names no relationship: the object-scoped case. + expect(screen.getByTestId('served-emptyRelationship')).toHaveTextContent(/^onCreate$/); + expect(screen.getByTestId('served-unboundRelated')).toHaveTextContent(/^$/); + expect(screen.getByTestId('served-boundRelated')).toHaveTextContent(/^onRowAction,onToolbarAction$/); + }); +}); diff --git a/packages/app-shell/src/views/pageRecordActions.ts b/packages/app-shell/src/views/pageRecordActions.ts index ee051d0ce0..47fc21a275 100644 --- a/packages/app-shell/src/views/pageRecordActions.ts +++ b/packages/app-shell/src/views/pageRecordActions.ts @@ -17,28 +17,42 @@ import { documentHref } from './documentHref.js'; const NO_RELATED_HANDLERS: RelatedRecordHandlers = Object.freeze({}); /** - * The page's related-list handlers for one list (objectui#12870): the - * record page's executor for the list's ACTIONS, and nothing else. + * The page's handlers for one list, three cases by the input's shape. * - * - A list with no parent in scope (`parentId` empty: no `parentDataSource`, - * or a bound row without a key) gets no handlers, as before. - * - A list whose parent resolved gets `onRowAction` / `onToolbarAction` from - * the bridge the record page mounts — the same closures, so an authored - * action runs through the same `ActionRunner` dispatch it runs through on a - * record page. + * - **An object-scoped list** (no `relationshipField`, no `parentId`: a page + * `list-view` node, `ListViewBlock`'s `resolve({ objectName })`) gets the + * bridge's create pair, `onCreate` / `createDisabled`, and nothing else + * (objectui#13089). Its `addRecord` Add button opens the record-form + * create the object list opens. `ResolveRelatedRecordActionsInput` + * declares what an input without `relationshipField` asks for. + * - **A related list with no parent in scope** (it names its + * `relationshipField`, but `parentId` is empty: no `parentDataSource`, or a + * bound row without a key) gets no handlers, as before. A "+ New" there + * would open a child form linked to no parent. + * - **A related list whose parent resolved** gets `onRowAction` / + * `onToolbarAction` from the bridge the record page mounts (objectui#12870) + * — the same closures, so an authored action runs through the same + * `ActionRunner` dispatch it runs through on a record page. * - * Only the two executors are served. The bridge's other members (New, Edit, - * Delete, row click, "View all", and the child object's default - * `rowActions` / `toolbarActions`) would change what a page list that authors - * no `actions` draws, and that list is unchanged by this card. The renderer - * draws an authored action only beside its executor, so the executors alone - * are what an authored `actions` needs. + * Nothing else is served. The bridge's other members (Edit, Delete, row + * click, "View all", New under a parent, and the child object's default + * `rowActions` / `toolbarActions`) would change what a page list draws when + * it authors none of them. The renderer draws an authored action only beside + * its executor, so the executors alone are what an authored `actions` needs. */ function pageRelatedHandlers( related: RelatedRecordActionsValue | undefined, input: ResolveRelatedRecordActionsInput, ): RelatedRecordHandlers { - if (!related || input.parentId == null || input.parentId === '') return NO_RELATED_HANDLERS; + if (!related) return NO_RELATED_HANDLERS; + const hasParent = input.parentId != null && input.parentId !== ''; + if (!hasParent) { + const relatedList = input.relationshipField != null && input.relationshipField !== ''; + if (relatedList) return NO_RELATED_HANDLERS; + const { onCreate, createDisabled } = related.resolve(input); + if (!onCreate) return NO_RELATED_HANDLERS; + return { onCreate, ...(createDisabled ? { createDisabled } : {}) }; + } const { onRowAction, onToolbarAction } = related.resolve(input); if (!onRowAction && !onToolbarAction) return NO_RELATED_HANDLERS; return { @@ -64,14 +78,16 @@ function pageRelatedHandlers( * * Deliberate limits, on the model of `listRecordActionsValue`: * - * - **`resolve` serves a list's action executors only, and only under a - * parent (objectui#12870).** A page has no parent record of its own; a + * - **`resolve` serves a related list's action executors only under a + * parent (objectui#12870), and an object-scoped list its create pair only + * (objectui#13089).** A page has no parent record of its own; a * `record:related_list` that bound one through `parentDataSource` has, and * gets the record page's executor for its authored `actions`, from the - * bridge passed as `related` (see `pageRelatedHandlers`). Every other list - * gets an empty handler set, which the context documents as "capability - * unavailable", the same read-only outcome a consumer gets with no provider - * at all. + * bridge passed as `related` (see `pageRelatedHandlers`). A `list-view` + * node gets `onCreate` / `createDisabled` for its `addRecord`. A related + * list with no parent gets an empty handler set, which the context + * documents as "capability unavailable", the same read-only outcome a + * consumer gets with no provider at all. * - **Only objects this console can route to.** `recordHref` answers `null` * for an object outside the app's metadata, or outside an app route, and * `openRecord` then does nothing — the shape of the record page's own diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index 8ba4cd2b3b..cb2c16af69 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -445,11 +445,29 @@ provider that reports no capabilities at all leaves the list shown; the server stays the authority. Only the page node reads this key: the object list page draws `ListView` itself, and the server gates the views it serves. -`addRecord` is not a published input. On a page node, `enabled: true` draws the -Add button only when a `RelatedRecordActionsContext` provider (`@object-ui/react`) -serves an `onCreate` for the listed object, and a click calls it; with no such -provider the button is not drawn, rather than drawn with no action. A host that -passes `ListView`'s own `onAddRecord` prop keeps it. +`addRecord` draws an Add button. `enabled: true` turns it on, and `position` +places it: `"top"` (the toolbar, also when absent), `"bottom"` or `"both"`. +`mode` and `formView` are not read. + +```json +{ + "type": "list-view", + "properties": { + "data": { "provider": "object", "object": "deal" }, + "addRecord": { "enabled": true, "position": "top" } + } +} +``` + +On a page node the click calls the `onCreate` a `RelatedRecordActionsContext` +provider (`@object-ui/react`) serves for the listed object, resolved with +`{ objectName }` and no `relationshipField`. The console's page host serves the +create the object list's New opens: the record-form overlay, or the full-page +create route for an object whose `editMode` is `page`. With no such provider, +for a viewer who may not create, or for a create the object greys, the button +is not drawn, rather than drawn with no action. A host that passes `ListView`'s +own `onAddRecord` prop keeps it; the console's object list passes its header +New's create. `sharing` does nothing on a page node yet: it draws a Share button with no action, because the spec declares none for it. It is not a published input. diff --git a/packages/plugin-list/src/ListViewBlock.tsx b/packages/plugin-list/src/ListViewBlock.tsx index 3eff70d41e..594fa20d9a 100644 --- a/packages/plugin-list/src/ListViewBlock.tsx +++ b/packages/plugin-list/src/ListViewBlock.tsx @@ -187,9 +187,10 @@ function boundObjectName(schema: ListViewSchema): string | undefined { * the `onCreate` a `RelatedRecordActionsContext` provider serves for the * object this block lists (`resolve({ objectName })`), the handler the * `record:related_list` block's "+ New" calls. No host prop and no new export. - * The console's page host serves no `onCreate` for a parent-less list, so on a - * console page the button is withheld; serving it there is objectui#13089's, - * and `addRecord` stays unpublished until then. + * The input names no `relationshipField`, which is how a host tells this + * object-scoped list from a related list whose parent did not resolve + * (`ResolveRelatedRecordActionsInput`). The console's page host serves it the + * record-form create the object list opens (objectui#13089). * * - A host that hands this block `onAddRecord` itself keeps it: an explicit * prop outranks the provider, as the explicit `dataSource` adapter does. diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx index 7aad5e7114..b488dc2abe 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx @@ -14,12 +14,13 @@ * audience gate, read the way the record blocks read it (`hasCapabilities` * over the reported `systemPermissions`), with the same * insufficient-permissions notice in place of the list and no query sent. - * - `addRecord`, routed but NOT published: the Add button calls the `onCreate` - * a `RelatedRecordActionsContext` provider serves for the listed object + * - `addRecord`, routed: the Add button calls the `onCreate` a + * `RelatedRecordActionsContext` provider serves for the listed object * (`resolve({ objectName })`), and is withheld under a provider that serves - * none. The console's page host serves none for a parent-less list, so the - * key stays out of `inputs` until objectui#13089 lands that host. The rows - * below pin the block's contract against a provider, not a console page. + * none. The rows below pin the block's contract against a provider, not a + * console page; objectui#13089 published the key once the console's page + * host served that `onCreate` (`PageView.listViewAddRecord-13089.test.tsx` + * in `@object-ui/app-shell`). * * The third key the card names, `sharing`, stays inert and unpublished: the * spec declares no action for its Share button. Its control row is below. diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx index 82b2059be7..82ac39a0d2 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.specInputs-13047.test.tsx @@ -38,13 +38,14 @@ * The last describe block is the keys measured NOT honoured on a page node, * each held out of `inputs` with its reason in the console's repo-wide parity * gate: `name` (read by nothing here), `sharing` (draws a button that does - * nothing on a page node), `addRecord` (its Add button is withheld on a page - * node under no `onCreate` host, objectui#13089), and `filterableFields` + * nothing on a page node), and `filterableFields` * (read, but as the Filter panel's field list, not as the quick-filter * shorthand the row declares it to be). `requiredPermissions` was measured * here too, until objectui#13074 honoured it on a page node; its pins now live * in `ListViewBlock.inertKeys-13074.test.tsx`, beside the `addRecord` routing - * rows under a provider that serves `onCreate`. One MEMBER is + * rows under a provider that serves `onCreate`. `addRecord` was held out too, + * until objectui#13089 had the console's page host serve that `onCreate`; the + * row below stays as the block's half: no host create, no button. One MEMBER is * recorded the same way beside the key that carries it: * `appearance.allowedVisualizations` narrows a view switcher a page node does * not draw, while `appearance.showDescription` acts. @@ -807,7 +808,7 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h expect(await firstFilterField()).toBe('Amount'); }); - it('`addRecord.enabled`: no Add button on a page node under no `onCreate` host — withheld, not drawn dead (objectui#13089)', async () => { + it('`addRecord.enabled`: no Add button on a page node under no `onCreate` host — withheld, not drawn dead', async () => { // No provider at all. const bare = mount({ addRecord: { enabled: true } }); await loaded(bare.dataSource); @@ -815,8 +816,8 @@ describe('objectui#13047 — measured NOT honoured on a page `list-view` node (h expect(bare.dataSource.create).not.toHaveBeenCalled(); cleanup(); - // A provider that, like the console's page host for a parent-less list, - // answers the object with no handlers at all. + // A provider that answers the object with no handlers at all, as a host + // that serves no create does. const dataSource = makeDataSource(ROWS, {}); const resolve = vi.fn(() => Object.freeze({})); render( diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 9566b2c61f..61b21dc24b 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -118,18 +118,18 @@ const LIST_VIEW_TYPE_INPUT: ComponentInput = { * objectui#13047). The member shapes are the spec's, named in each * description; the spec row is the judge of values. * - * Three keys of the row are NOT here, by the same measurement: `name` (read - * by nothing on a page node), `sharing` (draws a Share button that does - * nothing: the spec declares no action for it) and `addRecord` (the block - * routes its Add button to an `onCreate` its host serves, and the console page - * host serves none, so on a console page the button is withheld; objectui#13089 - * carries that host). So is `filterableFields`: the row declares it as the + * Two keys of the row are NOT here, by the same measurement: `name` (read + * by nothing on a page node) and `sharing` (draws a Share button that does + * nothing: the spec declares no action for it). So is `filterableFields`: the row declares it as the * shorthand of `userFilters.fields`, and the list reads it as the Filter * panel's field list instead. The console's repo-wide parity gate * (`apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`) records * each with its reason. `requiredPermissions` was among them until * `ListViewBlock` honoured it on a page node (objectui#13074, - * `__tests__/ListViewBlock.inertKeys-13074.test.tsx`). + * `__tests__/ListViewBlock.inertKeys-13074.test.tsx`), and so was `addRecord` + * until the console's page host served the create its Add button opens + * (objectui#13089, `PageView.listViewAddRecord-13089.test.tsx` in + * `@object-ui/app-shell`). * * `data` is NOT required (objectui#11605): the page compile reads this list, * so `required: true` would refuse a node bound by `dataSource.object`, which @@ -202,6 +202,12 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ description: 'Which toolbar controls the user gets, the spec\'s `UserActionsConfig` of per-control toggles: a control set to `false` (for example `sort: false`) is taken off the toolbar, and the controls it does not name stay.', }, + { + name: 'addRecord', + type: 'object', + description: + 'The Add button, the spec\'s `AddRecordConfig`. `enabled: true` draws it and `position` places it: `"top"` (the toolbar, also when absent), `"bottom"` (under the list) or `"both"`. A click opens the create form for the listed object, the one the object list\'s New opens: the record-form overlay, or the full-page create for an object whose `editMode` is `page`. The button is withheld for a viewer who may not create, or whose create the object greys, and on a host that serves no create. `mode` and `formView` are not read. `userActions.addRecordForm: false` also takes the button off.', + }, { name: 'compactToolbar', type: 'boolean', diff --git a/packages/react/src/context/RelatedRecordActionsContext.tsx b/packages/react/src/context/RelatedRecordActionsContext.tsx index 07119a3e09..3c4aacb389 100644 --- a/packages/react/src/context/RelatedRecordActionsContext.tsx +++ b/packages/react/src/context/RelatedRecordActionsContext.tsx @@ -123,7 +123,26 @@ export interface RelatedRecordHandlers { onViewAll?: () => void; } -/** Input to {@link RelatedRecordActionsValue.resolve}. */ +/** + * Input to {@link RelatedRecordActionsValue.resolve}. + * + * Its SHAPE says which kind of list asks (objectui#13089): + * + * - **A related list always names its `relationshipField`.** The spec's + * `record:related_list` declares it required, so a spec-valid related list + * resolves with it, with or without a resolved `parentId`. + * - **An input with no `relationshipField` (absent or empty) asks for an + * OBJECT-SCOPED list's handlers**, a list of the object's own records with + * no parent: a page `list-view` node resolves `{ objectName }` for its + * `addRecord` Add button. A host may serve such an input a create that + * links no parent, which it must not serve a related list whose parent did + * not resolve. + * + * ⚠️ The limit of reading the shape: a future consumer that resolves a list + * with no parent and no relationship, and must NOT be offered a create, cannot + * say so here. It needs an explicit member on this input, not a second reading + * of the same shape. + */ export interface ResolveRelatedRecordActionsInput { /** Child object machine name (the related list's `api`). */ objectName: string; From f54cbf27fee28f0f8bfb37c4cfa38183faf387c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:00:48 +0000 Subject: [PATCH 3/5] test(app-shell): the 11293 navigator pin reads the page's served set by input shape (objectui#13089) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../__tests__/PageView.recordNavigator-11293.test.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/app-shell/src/views/__tests__/PageView.recordNavigator-11293.test.tsx b/packages/app-shell/src/views/__tests__/PageView.recordNavigator-11293.test.tsx index 90e98f1bd3..5e9cfd936d 100644 --- a/packages/app-shell/src/views/__tests__/PageView.recordNavigator-11293.test.tsx +++ b/packages/app-shell/src/views/__tests__/PageView.recordNavigator-11293.test.tsx @@ -109,8 +109,13 @@ describe('objectui#11293 — PageView publishes the record navigator its blocks seen!.openRecord!('not_an_object', 'r1'); expect(navigate).not.toHaveBeenCalled(); expect(seen!.recordHref!('not_an_object', 'r1')).toBeNull(); - // A page has no parent record: the related-list half stays read-only. - expect(seen!.resolve({ objectName: 'account' })).toEqual({}); + // A page has no parent record: a related list (it names its + // relationship) with no parent bound stays read-only. + expect(seen!.resolve({ objectName: 'account', relationshipField: 'owner_id', parentId: null })).toEqual({}); + // objectui#13089 — an object-scoped list (no relationship, no parent: a + // page `list-view` node) is served the create pair alone, for its Add + // button; no Edit, Delete or row click. + expect(Object.keys(seen!.resolve({ objectName: 'account' }))).toEqual(['onCreate']); }); }); From 1fbb3d66ac2bf3d7cc8e122032759b6122456630 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:19:48 +0000 Subject: [PATCH 4/5] test(plugin-list): the list-view registration publishes addRecord (objectui#13089) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../src/__tests__/ListViewBlock.inertKeys-13074.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx index b488dc2abe..f64fef5d5b 100644 --- a/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx +++ b/packages/plugin-list/src/__tests__/ListViewBlock.inertKeys-13074.test.tsx @@ -280,10 +280,12 @@ describe('objectui#13074 — `sharing` stays inert and unpublished (CONTROL)', ( expect(document.body.innerHTML).toBe(before); }); - it('the registration publishes `requiredPermissions`, and neither `addRecord` (objectui#13089) nor `sharing`', () => { + it('the registration publishes `requiredPermissions` and `addRecord` (objectui#13089), and not `sharing`', () => { const inputs = (ComponentRegistry.getConfig('list-view')?.inputs ?? []).map((input: { name: string }) => input.name); expect(inputs).toContain('requiredPermissions'); - expect(inputs).not.toContain('addRecord'); + // Published once the console's page host served the create its Add button + // opens (`PageView.listViewAddRecord-13089.test.tsx` in `@object-ui/app-shell`). + expect(inputs).toContain('addRecord'); expect(inputs).not.toContain('sharing'); }); }); From 3b8fab4ff1010b45ce8fba9192e4b4bad679d4de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:45:55 +0000 Subject: [PATCH 5/5] docs(plugin-list): addRecord names the spec's declared defaults and the renderer's divergence; changeset levels minor (objectui#13089) The description, README and mdx no longer present "top" as the default: the spec row declares enabled true and position bottom, the renderer reads only an explicit enabled: true and puts an absent position at the top, and objectui#13099 owns the gap. plugin-list and app-shell bump minor. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13089-page-list-create.md | 4 ++-- content/docs/plugins/plugin-list.mdx | 7 +++++-- packages/plugin-list/README.md | 10 +++++++--- packages/plugin-list/src/index.tsx | 2 +- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.changeset/13089-page-list-create.md b/.changeset/13089-page-list-create.md index 9ce0f833ab..b21319fd83 100644 --- a/.changeset/13089-page-list-create.md +++ b/.changeset/13089-page-list-create.md @@ -1,6 +1,6 @@ --- -'@object-ui/app-shell': patch -'@object-ui/plugin-list': patch +'@object-ui/app-shell': minor +'@object-ui/plugin-list': minor '@object-ui/react': patch --- diff --git a/content/docs/plugins/plugin-list.mdx b/content/docs/plugins/plugin-list.mdx index 5ab24c86ca..895a686b4e 100644 --- a/content/docs/plugins/plugin-list.mdx +++ b/content/docs/plugins/plugin-list.mdx @@ -32,8 +32,11 @@ with its keys in `properties`. `requiredPermissions` gates what the node draws: missing one sees "Insufficient permissions to view list." in place of the list, as the record blocks show, and no records are queried. -`addRecord` draws an Add button: `enabled: true` turns it on, and `position` -places it (`"top"`, the default here, `"bottom"` or `"both"`). A click opens the +`addRecord` draws an Add button, and `position` places it (`"top"`, `"bottom"` +or `"both"`). The spec row declares `enabled` default `true` and `position` +default `"bottom"`, but the renderer does not apply those defaults: it draws the +button only for an explicit `enabled: true`, and places an absent `position` at +the top. objectui#13099 owns that gap; until it lands, write both keys. A click opens the create form the object list's New opens: the record-form overlay, or the full-page create for an object whose `editMode` is `page`. The button is not drawn for a viewer who may not create, or under a host that serves no create. diff --git a/packages/plugin-list/README.md b/packages/plugin-list/README.md index cb2c16af69..acaedd2194 100644 --- a/packages/plugin-list/README.md +++ b/packages/plugin-list/README.md @@ -445,9 +445,13 @@ provider that reports no capabilities at all leaves the list shown; the server stays the authority. Only the page node reads this key: the object list page draws `ListView` itself, and the server gates the views it serves. -`addRecord` draws an Add button. `enabled: true` turns it on, and `position` -places it: `"top"` (the toolbar, also when absent), `"bottom"` or `"both"`. -`mode` and `formView` are not read. +`addRecord` draws an Add button. `position` places it: `"top"` (the toolbar), +`"bottom"` or `"both"`. `mode` and `formView` are not read. + +The spec row declares `enabled` default `true` and `position` default +`"bottom"`. The renderer does not apply those defaults: it draws the button only +for an explicit `enabled: true`, and places an absent `position` at the top. +objectui#13099 owns that gap; until it lands, write both keys. ```json { diff --git a/packages/plugin-list/src/index.tsx b/packages/plugin-list/src/index.tsx index 61b21dc24b..3af6e64be1 100644 --- a/packages/plugin-list/src/index.tsx +++ b/packages/plugin-list/src/index.tsx @@ -206,7 +206,7 @@ const LIST_VIEW_INPUTS: ComponentInput[] = [ name: 'addRecord', type: 'object', description: - 'The Add button, the spec\'s `AddRecordConfig`. `enabled: true` draws it and `position` places it: `"top"` (the toolbar, also when absent), `"bottom"` (under the list) or `"both"`. A click opens the create form for the listed object, the one the object list\'s New opens: the record-form overlay, or the full-page create for an object whose `editMode` is `page`. The button is withheld for a viewer who may not create, or whose create the object greys, and on a host that serves no create. `mode` and `formView` are not read. `userActions.addRecordForm: false` also takes the button off.', + 'The Add button, the spec\'s `AddRecordConfig`. The spec row declares `enabled` default `true` and `position` default `"bottom"`; the renderer does not apply those defaults: it draws the button only for an explicit `enabled: true`, and places an absent `position` at the top (objectui#13099 owns that gap). `position` places it: `"top"` (the toolbar), `"bottom"` (under the list) or `"both"`. A click opens the create form for the listed object, the one the object list\'s New opens: the record-form overlay, or the full-page create for an object whose `editMode` is `page`. The button is withheld for a viewer who may not create, or whose create the object greys, and on a host that serves no create. `mode` and `formView` are not read. `userActions.addRecordForm: false` also takes the button off.', }, { name: 'compactToolbar',