From f67b80bdc0bde47b9aaca911924be3a4917a8266 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:59:45 +0000 Subject: [PATCH 1/4] test(console): pin the five no-grant write affordances against the map's four grant shapes (objectui#12082) The object-view create button, the calendar quick-create and drag-to-reschedule, the kanban card move and the line-items panel's add / remove, each measured through the real MePermissionsProvider over create-only, edit-only, read-only and full, plus fail-open and the effective operation set. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .../ObjectCalendar.writeGrant-12082.test.tsx | 165 +++++++++++++++++ .../LineItemsPanel.lineGrant-12082.test.tsx | 125 +++++++++++++ .../src/ObjectKanban.moveGrant-12082.test.tsx | 166 ++++++++++++++++++ .../ObjectView.createGrant-12082.test.tsx | 121 +++++++++++++ 4 files changed, 577 insertions(+) create mode 100644 packages/plugin-calendar/src/ObjectCalendar.writeGrant-12082.test.tsx create mode 100644 packages/plugin-form/src/LineItemsPanel.lineGrant-12082.test.tsx create mode 100644 packages/plugin-kanban/src/ObjectKanban.moveGrant-12082.test.tsx create mode 100644 packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx diff --git a/packages/plugin-calendar/src/ObjectCalendar.writeGrant-12082.test.tsx b/packages/plugin-calendar/src/ObjectCalendar.writeGrant-12082.test.tsx new file mode 100644 index 0000000000..7d335aaa62 --- /dev/null +++ b/packages/plugin-calendar/src/ObjectCalendar.writeGrant-12082.test.tsx @@ -0,0 +1,165 @@ +/** + * 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#12082 — the calendar's two default writes read the affordance-to- + * grant map: quick-create (an empty-day click, or a time-range drag in the + * week / day grid) reads `calendarQuickCreate`, and drag-to-reschedule reads + * `calendarReschedule`. + * + * Before, neither read a grant. A read-only caller could open the quick-create + * dialog and submit it, and could drag an event to a new day; both writes went + * out and the server refused them. The fix is the one `CalendarView` already + * understands: an affordance is the PRESENCE of its handler (an absent + * `onEventDrop` draws no draggable event, an absent `onTimeRangeSelect` starts + * no range drag, an absent `onDateClick` opens nothing), so `ObjectCalendar` + * hands the grid a handler only when the row allows the write. + * + * `CalendarView` is replaced by a probe that records the handlers it is given: + * the grid's own "capability = handler presence" contract is pinned in its own + * suite (`CalendarView.dnd.test.tsx`), and what is measured here is which + * handlers `ObjectCalendar` hands it under each grant. Driving the recorded + * handler is the real path into `ObjectCalendar`'s own write. + * + * A caller-supplied `onDateClick` / `onEventDrop` is the HOST's channel — the + * host decides what a click or a drop does — and is handed through unchanged. + */ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const probe = vi.hoisted(() => ({ + props: null as null | Record, +})); + +vi.mock('./CalendarView', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + CalendarView: (props: Record) => { + probe.props = props; + return
; + }, + }; +}); + +import { ObjectCalendar } from './ObjectCalendar'; + +const OBJECT = 'crm_leave_request'; +const ROWS = [{ id: 'r1', name: 'Ada out', start_date: '2020-03-10' }]; + +const SCHEMA = { + type: 'object-calendar', + objectName: OBJECT, + calendar: { startDateField: 'start_date', titleField: 'name' }, +} as any; + +const makeDataSource = () => + ({ + find: vi.fn().mockResolvedValue({ data: ROWS }), + getObjectSchema: vi.fn().mockResolvedValue({ + name: OBJECT, + fields: { id: { type: 'text' }, name: { type: 'text' }, start_date: { type: 'date' } }, + }), + create: vi.fn().mockResolvedValue({ id: 'r2' }), + update: vi.fn().mockResolvedValue({}), + }) as any; + +const SHAPES: Array<{ shape: string; bits: Record; create: boolean; update: boolean }> = [ + { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true, update: false }, + { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false, update: true }, + { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false, update: false }, + { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true, update: true }, +]; + +const envelope = (bits: Record): MePermissionsResponse => ({ + authenticated: true, + userId: 'u-pin', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits as never }, + fields: {}, +}); + +beforeEach(() => { + probe.props = null; +}); +afterEach(cleanup); + +type Handler = ((...args: unknown[]) => unknown) | undefined; +const handler = (name: string): Handler => probe.props?.[name] as Handler; + +async function mount(perms: MePermissionsResponse | null, ds = makeDataSource(), props: Record = {}) { + const cal = ; + render(perms ? {cal} : cal); + await screen.findByTestId('calendar-probe'); + // The object schema settles after the first paint; the drop's date + // conversion and the policy layer both read it. + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + return ds; +} + +describe.each(SHAPES)('ObjectCalendar under a $shape grant (objectui#12082)', ({ bits, create, update }) => { + it('quick-create (day click and time-range drag) is offered exactly when the create grant allows it', async () => { + await mount(envelope(bits)); + expect(typeof handler('onDateClick') === 'function', 'onDateClick').toBe(create); + expect(typeof handler('onTimeRangeSelect') === 'function', 'onTimeRangeSelect').toBe(create); + if (create) { + act(() => { + handler('onDateClick')!(new Date(2020, 2, 18)); + }); + expect(await screen.findByRole('dialog')).toBeTruthy(); + } + }); + + it('drag-to-reschedule is offered exactly when the update grant allows it, and only then writes', async () => { + const ds = await mount(envelope(bits)); + expect(typeof handler('onEventDrop') === 'function', 'onEventDrop').toBe(update); + if (update) { + await act(async () => { + await handler('onEventDrop')!({ id: 'r1', data: ROWS[0] }, new Date(2020, 2, 12)); + }); + await waitFor(() => expect(ds.update).toHaveBeenCalledTimes(1)); + expect(ds.update.mock.calls[0][0]).toBe(OBJECT); + } + }); +}); + +describe('ObjectCalendar: the map\'s other layers and fail-open (objectui#12082)', () => { + it('fail-open: with no permission provider every default handler is offered, as before', async () => { + await mount(null); + for (const name of ['onDateClick', 'onTimeRangeSelect', 'onEventDrop']) { + expect(typeof handler(name), name).toBe('function'); + } + }); + + it('an effective operation set without create and update withholds both writes under the full grant', async () => { + await mount(envelope({ ...SHAPES[3].bits, apiOperations: ['read'] })); + for (const name of ['onDateClick', 'onTimeRangeSelect', 'onEventDrop']) { + expect(handler(name), name).toBeUndefined(); + } + }); + + it('a host-supplied onDateClick / onEventDrop is handed through unchanged, whatever the grant', async () => { + const onDateClick = vi.fn(); + const onEventDrop = vi.fn(); + const ds = await mount(envelope(SHAPES[2].bits), makeDataSource(), { onDateClick, onEventDrop }); + act(() => { + handler('onDateClick')!(new Date(2020, 2, 18)); + }); + await act(async () => { + await handler('onEventDrop')!({ id: 'r1', data: ROWS[0] }, new Date(2020, 2, 12)); + }); + expect(onDateClick).toHaveBeenCalledTimes(1); + expect(onEventDrop).toHaveBeenCalledTimes(1); + expect(ds.update).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); +}); diff --git a/packages/plugin-form/src/LineItemsPanel.lineGrant-12082.test.tsx b/packages/plugin-form/src/LineItemsPanel.lineGrant-12082.test.tsx new file mode 100644 index 0000000000..15ca7169a2 --- /dev/null +++ b/packages/plugin-form/src/LineItemsPanel.lineGrant-12082.test.tsx @@ -0,0 +1,125 @@ +/** + * 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#12082 — a `record:line_items` panel's add and remove read the + * affordance-to-grant map, asked of the CHILD object: adding a line is the + * `relatedNew` row (a child created under this parent), removing one the + * `relatedRowDelete` row (a child deleted). + * + * Before, both read `schema.readonly` and nothing else, so a caller who may not + * create or delete the child object was offered both, and Save sent a batch the + * server refused. Cell editing is not this card's: it already asks the field + * question per column through `applyColumnPermissions` + * (`LineItemsPanel.fieldWriteGate-10163.test.tsx`). + * + * The grid's add affordances are the Add button, the always-present entry row + * and the per-row Duplicate (all three follow `allowAdd` in `GridField`); its + * remove affordance is the per-row Remove. Measured through the REAL + * `MePermissionsProvider` over the map pin's four grant shapes, on the child. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup } from '@testing-library/react'; +import React from 'react'; + +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { registerAllFields } from '@object-ui/fields'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { LineItemsPanel } from './LineItemsPanel'; + +registerAllFields(); +afterEach(cleanup); + +const CHILD = 'invoice_line'; + +const SCHEMA = { + childObject: CHILD, + relationshipField: 'invoice', + parentObject: 'invoice', + parentId: 'INV1', + columns: [ + { name: 'description', label: 'Description', type: 'text' }, + { name: 'amount', label: 'Amount', type: 'number' }, + ], +} as any; + +const ROWS = [ + { id: 'L1', invoice: 'INV1', description: 'Hosting', amount: 40 }, + { id: 'L2', invoice: 'INV1', description: 'Support', amount: 12 }, +]; + +const SHAPES: Array<{ shape: string; bits: Record; add: boolean; remove: boolean }> = [ + { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, add: true, remove: false }, + { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, add: false, remove: false }, + { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, add: false, remove: false }, + { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, add: true, remove: true }, +]; + +const envelope = (bits: Record): MePermissionsResponse => ({ + authenticated: true, + userId: 'u-pin', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + // The parent is fully granted: only the CHILD's grant may decide. + objects: { + [CHILD]: bits as never, + invoice: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, + }, + fields: {}, +}); + +function mount(perms: MePermissionsResponse | null) { + const panel = ( + + + + ); + return render(perms ? {panel} : panel); +} + +/** Wait until the loaded rows are drawn. */ +async function settled() { + await waitFor(() => expect(screen.getByDisplayValue('Hosting')).toBeTruthy()); +} + +const offered = () => ({ + add: screen.queryByTestId('line-items-add') !== null, + duplicate: screen.queryAllByRole('button', { name: 'Duplicate row' }).length > 0, + remove: screen.queryAllByRole('button', { name: 'Remove row' }).length > 0, +}); + +describe.each(SHAPES)('LineItemsPanel under a $shape grant on the child (objectui#12082)', ({ bits, add, remove }) => { + it('add and duplicate follow the child create grant; remove follows the child delete grant', async () => { + mount(envelope(bits)); + await settled(); + expect(offered()).toEqual({ add, duplicate: add, remove }); + }); +}); + +describe('LineItemsPanel: the map\'s other layers and fail-open (objectui#12082)', () => { + it('fail-open: with no permission provider add and remove are offered, as before', async () => { + mount(null); + await settled(); + expect(offered()).toEqual({ add: true, duplicate: true, remove: true }); + }); + + it('an effective operation set without create and delete withholds both under the full grant', async () => { + mount(envelope({ allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true, apiOperations: ['read', 'update'] })); + await settled(); + expect(offered()).toEqual({ add: false, duplicate: false, remove: false }); + }); +}); diff --git a/packages/plugin-kanban/src/ObjectKanban.moveGrant-12082.test.tsx b/packages/plugin-kanban/src/ObjectKanban.moveGrant-12082.test.tsx new file mode 100644 index 0000000000..c6ec82da19 --- /dev/null +++ b/packages/plugin-kanban/src/ObjectKanban.moveGrant-12082.test.tsx @@ -0,0 +1,166 @@ +/** + * 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#12082 — the object kanban's card move reads the `kanbanCardMove` + * row of the affordance-to-grant map. + * + * Before, a card was draggable for every caller and a cross-column drop always + * PATCHed the record's `groupBy` field: a caller without the update grant could + * drag a card, watch it land, and get a refusal toast and a rollback. Now the + * board draws the cards as NOT movable when the row is closed (dnd-kit's + * sortable is disabled, which is what `aria-disabled` reports), and the board + * is handed no mover at all, so no drop can reach the write. + * + * The drop is synthesized through `DndContext`'s real `onDragEnd`, captured by + * the module mock below — the same harness `ObjectKanban.rejectedMoveRollback. + * test.tsx` uses, for the reason it gives: dnd-kit's pointer sensors need + * layout that the DOM double does not provide. Measured through the REAL + * `MePermissionsProvider` over the map pin's four grant shapes. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, cleanup, waitFor } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { toast } from '@object-ui/components'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import type { DataSource, ObjectKanbanSchema } from '@object-ui/types'; +import { ObjectKanban } from './ObjectKanban'; + +// Pay the board's lazy chunk at import time (AGENTS.md §测试纪律); specifier +// byte-identical to the component's own lazy import. +import './KanbanImpl'; + +const dnd = vi.hoisted(() => ({ + onDragEnd: undefined as undefined | ((event: unknown) => void), +})); + +vi.mock('@dnd-kit/core', async (importOriginal) => { + const actual = await importOriginal(); + const ReactMod = await import('react'); + const CapturingDndContext = (props: Record) => { + dnd.onDragEnd = props.onDragEnd as (event: unknown) => void; + return ReactMod.createElement(actual.DndContext, props as never); + }; + return { ...actual, DndContext: CapturingDndContext }; +}); + +registerAllFields(); + +const OBJECT = 'task'; +const CARD = 'Fix the widget'; + +const objectDef = { + name: OBJECT, + fields: { + title: { type: 'text', label: 'Title' }, + status: { + type: 'picklist', + label: 'Status', + options: [ + { value: 'backlog', label: 'Backlog' }, + { value: 'in_progress', label: 'In Progress' }, + ], + }, + }, +}; + +const schema = { + type: 'object-kanban', + objectName: OBJECT, + groupBy: 'status', + cardTitle: 'title', + columns: [ + { id: 'backlog', title: 'Backlog' }, + { id: 'in_progress', title: 'In Progress' }, + ], +} satisfies ObjectKanbanSchema; + +const SHAPES: Array<{ shape: string; bits: Record; update: boolean }> = [ + { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, update: false }, + { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, update: true }, + { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, update: false }, + { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, update: true }, +]; + +const envelope = (bits: Record): MePermissionsResponse => ({ + authenticated: true, + userId: 'u-pin', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits as never }, + fields: {}, +}); + +function makeDataSource() { + return { + getObjectSchema: vi.fn(async () => objectDef), + find: vi.fn(async () => ({ value: [{ id: 't1', title: CARD, status: 'backlog' }] })), + update: vi.fn(async () => ({ id: 't1', status: 'in_progress' })), + } as unknown as DataSource & { update: ReturnType }; +} + +async function mountBoard(perms: MePermissionsResponse | null) { + const ds = makeDataSource(); + const board = ; + render(perms ? {board} : board); + expect(await screen.findByText(CARD)).toBeTruthy(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + return ds; +} + +/** The card's own element — the sortable node, whose `aria-disabled` dnd-kit sets. */ +const cardNode = () => screen.getByRole('listitem', { name: CARD }); + +async function dropOnInProgress() { + expect(dnd.onDragEnd).toBeTypeOf('function'); + await act(async () => { + dnd.onDragEnd!({ active: { id: 't1' }, over: { id: 'in_progress' } }); + }); +} + +beforeEach(() => { + dnd.onDragEnd = undefined; + vi.spyOn(toast, 'error').mockImplementation(() => 'toast-id' as never); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe.each(SHAPES)('ObjectKanban under a $shape grant (objectui#12082)', ({ bits, update }) => { + it('a card is movable, and a drop writes, exactly when the update grant allows it', async () => { + const ds = await mountBoard(envelope(bits)); + expect(cardNode().getAttribute('aria-disabled')).toBe(String(!update)); + await dropOnInProgress(); + expect(ds.update).toHaveBeenCalledTimes(update ? 1 : 0); + if (update) expect(ds.update).toHaveBeenCalledWith(OBJECT, 't1', { status: 'in_progress' }); + }); +}); + +describe('ObjectKanban: the map\'s other layers and fail-open (objectui#12082)', () => { + it('fail-open: with no permission provider a card is movable and a drop writes, as before', async () => { + const ds = await mountBoard(null); + expect(cardNode().getAttribute('aria-disabled')).toBe('false'); + await dropOnInProgress(); + expect(ds.update).toHaveBeenCalledTimes(1); + }); + + it('an effective operation set without update keeps cards still under the full grant', async () => { + const ds = await mountBoard(envelope({ ...SHAPES[3].bits, apiOperations: ['read', 'create'] })); + expect(cardNode().getAttribute('aria-disabled')).toBe('true'); + await dropOnInProgress(); + expect(ds.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx b/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx new file mode 100644 index 0000000000..58e533b021 --- /dev/null +++ b/packages/plugin-view/src/__tests__/ObjectView.createGrant-12082.test.tsx @@ -0,0 +1,121 @@ +/** + * 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#12082 — the `object-view` toolbar's create button reads the + * `listNew` row of the affordance-to-grant map. + * + * Before, the button read the node's own `showCreate` and `operations.create` + * toggles and no grant at all, so a caller without the create grant was + * offered a New that opened a form the server then refused to save. The + * console's own list pages already read `listNew` (`ObjectView` / + * `ObjectDataPage` in `@object-ui/app-shell`); this is the same affordance on + * the SDUI `object-view` node, so it reads the same row. + * + * Measured through the REAL `MePermissionsProvider`, over the four grant + * shapes the map's enumeration pin uses. The authoring toggles still narrow: + * `showCreate: false` hides the button under the full grant. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import type { ObjectViewSchema, DataSource } from '@object-ui/types'; + +vi.mock('@object-ui/react', async (importOriginal) => { + const React = await import('react'); + return { + ...(await importOriginal>()), + SchemaRenderer: ({ schema }: any) =>
{schema?.type}
, + SchemaRendererContext: React.createContext(null), + subscribeDataChanges: () => () => {}, + notifyDataChanged: () => {}, + }; +}); +vi.mock('@object-ui/plugin-grid', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectGrid: () =>
, +})); +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ + ...(await importOriginal>()), + ObjectForm: () =>
, +})); + +import { ObjectView } from '../ObjectView'; + +afterEach(cleanup); + +const OBJECT = 'contacts'; + +const dataSource = (): DataSource => ({ + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({}), + update: vi.fn().mockResolvedValue({}), + delete: vi.fn().mockResolvedValue({}), + getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, label: 'Contacts', fields: { name: { type: 'text' } } }), +} as unknown as DataSource); + +const SHAPES: Array<{ shape: string; bits: Record; create: boolean }> = [ + { shape: 'create-only', bits: { allowCreate: true, allowRead: true, allowEdit: false, allowDelete: false }, create: true }, + { shape: 'edit-only', bits: { allowCreate: false, allowRead: true, allowEdit: true, allowDelete: false }, create: false }, + { shape: 'read-only', bits: { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false }, create: false }, + { shape: 'full', bits: { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: true }, create: true }, +]; + +const envelope = (bits: Record): MePermissionsResponse => ({ + authenticated: true, + userId: 'u-pin', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT]: bits }, + fields: {}, +}); + +const schema = (extra: Partial = {}): ObjectViewSchema => ({ type: 'object-view', objectName: OBJECT, ...extra }); + +describe('object-view create button reads the listNew row (objectui#12082)', () => { + it.each(SHAPES)('$shape grant: New shows exactly when the create grant allows it', async ({ bits, create }) => { + render( + + + , + ); + // Wait for the schema read to settle so the policy layer has its answer. + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' }) !== null).toBe(create); + }); + + it('fail-open: with no permission provider mounted, New shows as before', async () => { + render(); + await screen.findByTestId('object-grid'); + expect(screen.getByRole('button', { name: 'New' })).toBeDefined(); + }); + + it('the authoring toggle still narrows: showCreate false hides New under the full grant', async () => { + render( + + + , + ); + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); + + it('the effective operation set still narrows: an apiOperations set without create hides New under the full grant', async () => { + render( + + + , + ); + await screen.findByTestId('object-grid'); + expect(screen.queryByRole('button', { name: 'New' })).toBeNull(); + }); +}); From 7ab6c53f9281920b226c7620ec685b37c890a926 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:07:53 +0000 Subject: [PATCH 2/4] fix(console): five write affordances that read no grant now read the affordance-to-grant map, and a write census holds every write call site to a row (objectui#12082) - object-view's New reads listNew; the calendar's quick-create reads the new calendarQuickCreate row and drag-to-reschedule calendarReschedule (a closed row withholds the handler CalendarView draws the affordance from); the kanban card move reads the new kanbanCardMove row (cards not movable, no mover handed to the board); a line-items panel's add / remove read relatedNew / relatedRowDelete on the child. - The enumeration pin gains a write census: every create / update / delete call site on a data source (or a write helper wrapping one) is keyed by file, enclosing symbols and verb, and has an entry naming the map row it sits behind and the file reading it, or why it is not a row's, or that it is an unmapped write affordance (the backlog the census found). Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- packages/core/src/utils/affordanceGrants.ts | 22 +- .../plugin-calendar/src/ObjectCalendar.tsx | 55 ++- packages/plugin-form/src/LineItemsPanel.tsx | 30 +- .../src/affordanceGrantMap-12082.test.tsx | 448 +++++++++++++++++- .../plugin-kanban/src/KanbanBoardCore.tsx | 9 + packages/plugin-kanban/src/KanbanImpl.tsx | 37 +- packages/plugin-kanban/src/ObjectKanban.tsx | 22 +- packages/plugin-view/src/ObjectView.tsx | 16 +- 8 files changed, 604 insertions(+), 35 deletions(-) diff --git a/packages/core/src/utils/affordanceGrants.ts b/packages/core/src/utils/affordanceGrants.ts index 58b60c8b7a..e637ba79de 100644 --- a/packages/core/src/utils/affordanceGrants.ts +++ b/packages/core/src/utils/affordanceGrants.ts @@ -116,7 +116,10 @@ export const AFFORDANCE_GRANTS = { recordEdit: { crud: 'edit', grant: 'update' }, /** The record page's Delete. */ recordDelete: { crud: 'delete', grant: 'delete' }, - /** An object list's New (toolbar button and the phone "+"). */ + /** + * An object list's New: the console list page's toolbar button and phone + * "+", and the `object-view` node's toolbar button (`@object-ui/plugin-view`). + */ listNew: { crud: 'create', grant: 'create' }, /** An object list's Import, and the import wizard's writable target fields. */ listImport: { crud: 'import', grant: 'create', field: 'create' }, @@ -132,11 +135,18 @@ export const AFFORDANCE_GRANTS = { rowDelete: { crud: 'delete', grant: 'delete' }, /** A grid's inline add-record row. */ gridAddRow: { crud: 'create', grant: 'create' }, - /** A related list's "+ New", asked of the CHILD object. */ + /** + * A related list's "+ New", asked of the CHILD object — and a line-items + * panel's add-a-line (its Add, entry row and Duplicate), which creates a child + * under the same parent. + */ relatedNew: { crud: 'create', grant: 'create' }, /** A related list row's Edit, asked of the child object. */ relatedRowEdit: { crud: 'edit', grant: 'update' }, - /** A related list row's Delete, asked of the child object. */ + /** + * A related list row's Delete, asked of the child object — and a line-items + * panel's remove-a-line, which deletes that child on Save. + */ relatedRowDelete: { crud: 'delete', grant: 'delete' }, /** A lookup picker's "Create new", asked of the TARGET object. */ lookupCreateNew: { crud: 'create', grant: 'create' }, @@ -144,6 +154,12 @@ export const AFFORDANCE_GRANTS = { attachmentUpload: { crud: null, grant: 'create' }, /** The record Attachments panel's per-row delete, asked of `sys_attachment`. */ attachmentDelete: { crud: null, grant: 'delete' }, + /** A calendar's quick-create: an empty-day click, or a time-range drag in the week / day grid. */ + calendarQuickCreate: { crud: 'create', grant: 'create' }, + /** A calendar's drag-to-reschedule: moving or resizing an event writes its date fields. */ + calendarReschedule: { crud: 'edit', grant: 'update' }, + /** A kanban's card move: a cross-column drop writes the record's `groupBy` field. */ + kanbanCardMove: { crud: 'edit', grant: 'update' }, } as const satisfies Record; /** A console affordance with a row in {@link AFFORDANCE_GRANTS}. */ diff --git a/packages/plugin-calendar/src/ObjectCalendar.tsx b/packages/plugin-calendar/src/ObjectCalendar.tsx index 9e548e2e4f..abbb63beb4 100644 --- a/packages/plugin-calendar/src/ObjectCalendar.tsx +++ b/packages/plugin-calendar/src/ObjectCalendar.tsx @@ -79,6 +79,8 @@ import { isRealCalendarDate, toDateInputValue, toDisplayDate, + resolveAffordance, + type SchemaLike, } from '@object-ui/core'; /** @@ -1043,6 +1045,27 @@ export const ObjectCalendar: React.FC = ({ // own container instead. const { anchorRef, anchorCaptureProps } = useOverlayAnchor(); + // objectui#12082 — the calendar's two default writes are rows of the + // affordance-to-grant map (`resolveAffordance` in `@object-ui/core`): + // quick-create is `calendarQuickCreate`, drag-to-reschedule is + // `calendarReschedule`, each the object's policy, the effective operation set + // and the caller's grant on the object the write goes to (`schema.objectName`, + // the object both handlers below write). `CalendarView` draws an affordance + // only when it is handed its handler, so a closed row is a handler withheld + // in the JSX below: no draggable event, no range drag, no dialog on a day + // click. They used to read no grant at all, so a read-only caller could drag + // an event or submit the dialog and the server refused the write. With no + // permission provider mounted both grants read open, as before. The policy + // half reads this object's schema only when it IS the written object (an + // authored `data` block can point the read elsewhere). + const writeAffordanceSource = { + objectSchema: schemaObjectName === schema.objectName ? (objectSchema as SchemaLike | null) : null, + objectName: schema.objectName, + perms, + }; + const quickCreateGranted = resolveAffordance('calendarQuickCreate', writeAffordanceSource).allowed; + const rescheduleGranted = resolveAffordance('calendarReschedule', writeAffordanceSource).allowed; + // Default drag-to-reschedule handler. When the caller hasn't provided an // `onEventDrop`, persist the new dates back to the data source so dragging // an event in the month view actually changes the record. Optimistic @@ -1418,14 +1441,9 @@ export const ObjectCalendar: React.FC = ({ } }} // Quick-create on empty-day click. Caller-supplied onDateClick - // wins; otherwise open the quick-create dialog. - onDateClick={(day) => { - if (onDateClick) { - onDateClick(day); - } else { - handleDateClickDefault(day); - } - }} + // wins; otherwise open the quick-create dialog — when the + // `calendarQuickCreate` row allows it (objectui#12082). + onDateClick={onDateClick ?? (quickCreateGranted ? handleDateClickDefault : undefined)} onNavigate={(date) => { setCurrentDate(date); onNavigate?.(date); @@ -1436,15 +1454,18 @@ export const ObjectCalendar: React.FC = ({ }} onAddClick={undefined} // Wire drag-to-reschedule: caller-supplied handler wins, otherwise - // fall back to persisting via dataSource.update(). - onEventDrop={(event, newStart, newEnd) => { - if (onEventDrop) { - onEventDrop(event.data, newStart, newEnd); - } else { - void handleEventDropDefault(event.data, newStart, newEnd); - } - }} - onTimeRangeSelect={handleTimeRangeSelectDefault} + // fall back to persisting via dataSource.update() — when the + // `calendarReschedule` row allows it (objectui#12082). + onEventDrop={ + onEventDrop + ? (event, newStart, newEnd) => onEventDrop(event.data, newStart, newEnd) + : rescheduleGranted + ? (event, newStart, newEnd) => { + void handleEventDropDefault(event.data, newStart, newEnd); + } + : undefined + } + onTimeRangeSelect={quickCreateGranted ? handleTimeRangeSelectDefault : undefined} />
{/* objectui#7210 — a month drawn from the first N rows of a larger set diff --git a/packages/plugin-form/src/LineItemsPanel.tsx b/packages/plugin-form/src/LineItemsPanel.tsx index e6c5a7c696..1183a4d03e 100644 --- a/packages/plugin-form/src/LineItemsPanel.tsx +++ b/packages/plugin-form/src/LineItemsPanel.tsx @@ -39,6 +39,7 @@ import { filterRefusalSubject, toFilterNodeSafely, convertSortToQueryParams, + resolveAffordance, } from '@object-ui/core'; // The panel's own chrome. A provider-less host — a standalone embed, this @@ -693,6 +694,26 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch } }, [dataSource, parentId, heldForAnotherParent, rows, original, schema, parentObject, childSchema]); + // objectui#12082 — adding and removing a line are rows of the + // affordance-to-grant map (`resolveAffordance` in `@object-ui/core`), asked + // of the CHILD object: a new line is a child created under this parent + // (`relatedNew`), a removed line a child deleted on Save (`relatedRowDelete`) + // — the same two rows a related list reads for the same two writes. Each is + // the child's policy, its effective operation set and the caller's grant on + // it. They used to read `schema.readonly` alone, so a caller who may not + // create or delete the child was offered both and Save sent a batch the + // server refused. The policy half has no child schema to read here + // (`objectSchema: null`, the default bucket). With no permission provider + // mounted both read open, as before. + const lineAddGranted = useMemo( + () => resolveAffordance('relatedNew', { objectSchema: null, objectName: schema.childObject, perms }).allowed, + [schema.childObject, perms], + ); + const lineRemoveGranted = useMemo( + () => resolveAffordance('relatedRowDelete', { objectSchema: null, objectName: schema.childObject, perms }).allowed, + [schema.childObject, perms], + ); + const gridField = useMemo( () => ({ @@ -700,7 +721,8 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch // share: a column the caller may not read is omitted, and one they may // read but not edit renders its cells locked — on the same page where // the surrounding form already disables that field (objectui#10163). - // Adding and removing lines stay on `schema.readonly` below. + // Adding and removing lines are the map rows above, on top of + // `schema.readonly` below. columns: applyColumnPermissions(schema.columns, { perms, objectName: schema.childObject }), // The grid's `totalField` is the CHILD column summed (this block's // `amountField`), shown whenever one is named, exactly as @@ -710,13 +732,13 @@ export const LineItemsPanel: React.FC<{ schema: LineItemsPanelSchema }> = ({ sch totalField: schema.amountField || (schema.totalField ? 'amount' : undefined), minRows: schema.minRows, maxRows: schema.maxRows, - allowAdd: !schema.readonly, - allowDelete: !schema.readonly, + allowAdd: !schema.readonly && lineAddGranted, + allowDelete: !schema.readonly && lineRemoveGranted, // Checked against the grid's published type (objectui#11610), so a key // the grid does not declare, or one of its retired snake_case // spellings, fails to compile here. } satisfies Partial) as GridFieldMetadata, - [schema, perms], + [schema, perms, lineAddGranted, lineRemoveGranted], ); // The config hint names the property to set as code, and the property name diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 117d868126..8c558962c7 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -41,6 +41,17 @@ * that block; a reader added later that spells its own grant read — the * shape of every earlier member of this family — turns it red, naming the * file. + * 7. **The write census** (the card's remainder): a census of grant READS + * cannot see an affordance that reads NO grant, and that was the shape of + * the five the first round left. So the second census counts the WRITES: + * every call site in the tree that invokes a create, update or delete on a + * data source (or one of the write helpers that wrap one) has an entry in + * `WRITE_SITES`, and an entry says which map row the site sits behind and + * which file reads it — or why it is not a map row's (the data door itself, + * a write helper, a write that is not a CRUD affordance), or that it is a + * write affordance with no row yet (`unmapped`, the backlog this census + * found, named one by one). A write site added later with no entry turns it + * red, naming the site; an entry whose site is gone turns it red too. */ import { describe, it, expect, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; @@ -58,6 +69,7 @@ import { } from '@object-ui/core'; import { MePermissionsProvider, usePermissions, type MePermissionsResponse } from '@object-ui/permissions'; import { closedFormAffordance, gateFormFields } from './fieldWriteGate'; +import ts from 'typescript'; // The ONE answer to "is this span a comment, or code?" — a census that strips // comments with a private regex reports clean over code a phantom comment ate. // @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) @@ -95,6 +107,10 @@ const EXPECTED_GRANT: Record = { relatedRowDelete: 'delete', attachmentUpload: 'create', attachmentDelete: 'delete', + // The rows the write census found: affordances that read no grant at all. + calendarQuickCreate: 'create', + calendarReschedule: 'update', + kanbanCardMove: 'update', }; /** The field rows, and which grant their no-entry fallback reads. */ @@ -321,7 +337,8 @@ function sourcesUnder(dir: string, out: string[] = []): string[] { } -function census(): Map { +/** Every console source file: `.ts` / `.tsx` under `packages/*/src` and `apps/*/src`. */ +function consoleSources(): string[] { const roots: string[] = []; for (const top of ['packages', 'apps']) { for (const pkg of readdirSync(path.join(repoRoot, top))) { @@ -333,8 +350,12 @@ function census(): Map { } } } + return roots.flatMap((r) => sourcesUnder(r)); +} + +function census(): Map { const hits = new Map(); - for (const file of roots.flatMap((r) => sourcesUnder(r))) { + for (const file of consoleSources()) { const rel = path.relative(repoRoot, file).split(path.sep).join('/'); const code = stripComments(readFileSync(file, 'utf8')); const n = code.split('\n').filter((line) => READS.some((re) => re.test(line))).length; @@ -372,3 +393,426 @@ describe('the census: every affordance reads the map (objectui#12082)', () => { ).toEqual(Object.keys(OUT_OF_FAMILY).sort()); }); }); + +// --------------------------------------------------------------------------- +// The write census: every write call site sits behind a map row, or says why +// not (objectui#12082, the card's remainder). +// +// Why a second census. The one above refuses a grant READ outside the map, so +// an affordance that reads NO grant is invisible to it — and that was exactly +// the shape the first round left behind (the object-view New, the calendar's +// quick-create and drag-to-reschedule, the kanban card move, the line-items +// add / remove). So this census starts from the other end, the WRITE. +// +// Population: the same console sources as above, parsed with the TypeScript +// parser (comments are trivia to it, so no comment mask is involved). A write +// call site is either +// +// - a member call naming a `DataSource` write — `create(o, data)`, +// `update(o, id, data)`, `delete(o, id)`, `bulk(o, op, rows)`, +// `bulkUpdate(…)`, `bulkDelete(…)`, `batchTransaction(ops)` — with at least +// the arity the `DataSource` interface gives it, which is what tells a +// data-source `delete(o, id)` from a `Map` / `Set` / `URLSearchParams` +// `delete(key)`; or +// - a call of one of the WRITE_HELPERS below, which wrap that door. +// +// Each site is keyed `FILE :: SYMBOLS :: VERB` — the file, the chain of named +// enclosing functions (a declared function or method, a variable or property +// or JSX attribute holding a function), and the verb it writes. Keyed by +// SYMBOL, never by line (AGENTS.md #11): a line shift moves nothing, a renamed +// handler turns this red and asks for its entry to be re-read. +// +// What it does NOT see, said here rather than left to be assumed: a write +// that bypasses the `DataSource` door with a raw HTTP call (AGENTS.md #1 +// forbids it; the attachment upload and the import template go through their +// own routes and are rows above), and an affordance that only LEADS to a write +// — a button that opens a form. The form's submit is a site here, behind the +// form rows; the opener itself is pinned by its own runtime test +// (`ObjectView.createGrant-12082.test.tsx` in `@object-ui/plugin-view`). +// --------------------------------------------------------------------------- + +type Verb = 'create' | 'update' | 'delete' | 'batch'; + +/** `DataSource` write members: the verb each writes, and the arity a data-source call has. */ +const MEMBER_WRITES: ReadonlyMap = new Map([ + ['create', { verb: 'create', arity: 2 }], + ['update', { verb: 'update', arity: 3 }], + ['delete', { verb: 'delete', arity: 2 }], + ['bulk', { verb: 'batch', arity: 3 }], + ['bulkUpdate', { verb: 'update', arity: 2 }], + ['bulkDelete', { verb: 'delete', arity: 2 }], + ['batchTransaction', { verb: 'batch', arity: 1 }], +]); + +/** + * Functions that wrap the door: a call of one IS a write site. Each has a + * `helper` entry below (its own body's door calls), and every `helper` entry + * names one of these — so a new wrapper is classified once, at its body, and + * its callers are counted from then on. + */ +const WRITE_HELPERS: Readonly> = { + runBatchTransaction: 'batch', + emulateBatchTransaction: 'batch', + 'recordDelete.run': 'delete', + saveWithOcc: 'update', +}; + +type WriteSite = + /** Behind map rows: each row is read (`resolveAffordance` / `resolveFieldAffordance` / `formFieldsAffordance`) in a `readBy` file. */ + | { kind: 'mapped'; rows: ConsoleAffordance[]; readBy: string[]; note?: string } + /** The body of a write helper — its callers are the sites. */ + | { kind: 'helper'; helper: string } + /** The data door itself: a `DataSource` implementation or engine that runs the writes it is handed. */ + | { kind: 'door'; why: string } + /** A write that is not a CRUD affordance on a record the caller browses. */ + | { kind: 'outOfFamily'; why: string } + /** A write affordance with no map row yet — the backlog this census found, named one by one. */ + | { kind: 'unmapped'; why: string }; + +const mapped = (rows: ConsoleAffordance[], ...readBy: string[]): WriteSite => ({ kind: 'mapped', rows, readBy }); +const helper = (name: string): WriteSite => ({ kind: 'helper', helper: name }); +const door = (why: string): WriteSite => ({ kind: 'door', why }); +const outOfFamily = (why: string): WriteSite => ({ kind: 'outOfFamily', why }); +const unmapped = (why: string): WriteSite => ({ kind: 'unmapped', why }); + +const FIELD_WRITE_GATE = 'packages/plugin-form/src/fieldWriteGate.ts'; +const DETAIL_VIEW = 'packages/plugin-detail/src/DetailView.tsx'; +const OBJECT_GRID = 'packages/plugin-grid/src/ObjectGrid.tsx'; +const RECORD_PAGE = 'packages/app-shell/src/views/RecordDetailView.tsx'; +const RECORD_DETAILS = 'packages/plugin-detail/src/renderers/record-details.tsx'; +const CONSOLE_LIST = 'packages/app-shell/src/views/ObjectView.tsx'; +const LIST_VIEW = 'packages/plugin-list/src/ListView.tsx'; + +const OVERLAY_INLINE_EDIT = + "the record overlay's inline field edit: `RecordDetailPanel` opens its inline-edit session on handler " + + 'PRESENCE alone (`canEdit={!!onFieldSave}`), so a caller without the update grant is offered the editors; ' + + 'the recordEdit row is the candidate'; +const ADAPTER = 'a DataSource implementation: it runs the writes its callers hand it'; + +/** + * Every write call site in the console tree, by `FILE :: SYMBOLS :: VERB`. + * Written by hand from reading each site — NOT derived from the walk. + */ +const WRITE_SITES: Record = { + // ── app-shell ── + 'packages/app-shell/src/hooks/useConsoleActionRuntime.tsx :: useConsoleActionRuntime > apiHandler :: update': + outOfFamily("an authored action's executor: the action declares the write, and its own visibility / permissions gate it"), + 'packages/app-shell/src/hooks/useObjectActions.ts :: useObjectActions :: delete': + mapped(['rowDelete', 'listBulkDelete'], OBJECT_GRID, LIST_VIEW), + 'packages/app-shell/src/hooks/useUserLocale.ts :: useLanguageSelection :: update': + outOfFamily("the language switcher changes the session's language locally and persists the caller's OWN sys_user.locale best-effort, a refusal surfacing as a toast"), + 'packages/app-shell/src/views/RecordAttachmentsPanel.tsx :: RecordAttachmentsPanel > handleFiles :: create': + mapped(['attachmentUpload'], 'packages/app-shell/src/views/RecordAttachmentsPanel.tsx'), + 'packages/app-shell/src/views/RecordAttachmentsPanel.tsx :: RecordAttachmentsPanel > handleDelete :: delete': + mapped(['attachmentDelete'], 'packages/app-shell/src/views/RecordAttachmentsPanel.tsx'), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > apiHandler :: update': + outOfFamily("an authored action's executor on the record page: the action declares the write, and its own visibility / permissions gate it"), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddComment :: create': + unmapped('the record feed\'s comment composer creates a sys_comment row and reads no grant on sys_comment'), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddReply :: create': + unmapped('the record feed\'s reply composer creates a sys_comment row and reads no grant on sys_comment'), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction :: update': + unmapped("the record feed's reaction toggle updates a sys_comment row and reads no grant on sys_comment"), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > onClick :: delete': + mapped(['recordDelete'], RECORD_PAGE), + 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx :: RelatedRecordActionsBridge > value > resolve :: delete': + mapped(['relatedRowDelete'], 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx'), + 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > addUsers :: create': + unmapped("Setup's permission-set assignment creates sys_user_permission_set rows and reads no grant on that object"), + 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > removeUser :: delete': + unmapped("Setup's permission-set assignment deletes sys_user_permission_set rows and reads no grant on that object"), + // ── core ── + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > executeBatch :: batch': + door('the action engine\'s transaction executor runs the operations its caller hands it'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > executeBatch :: create': + door('the action engine\'s transaction executor runs the operations its caller hands it'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > executeBatch :: update': + door('the action engine\'s transaction executor runs the operations its caller hands it'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > executeBatch :: delete': + door('the action engine\'s transaction executor runs the operations its caller hands it'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > rollbackOperations :: delete': + door('the transaction executor undoing its own batch'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > rollbackOperations :: update': + door('the transaction executor undoing its own batch'), + 'packages/core/src/actions/TransactionManager.ts :: TransactionManager > rollbackOperations :: create': + door('the transaction executor undoing its own batch'), + 'packages/core/src/actions/recordDelete.ts :: recordDelete > run :: delete': helper('recordDelete.run'), + 'packages/core/src/adapters/ApiDataSource.ts :: ApiDataSource > batchTransaction :: batch': door(ADAPTER), + 'packages/core/src/adapters/ValueDataSource.ts :: ValueDataSource > bulk :: create': door(ADAPTER), + 'packages/core/src/adapters/ValueDataSource.ts :: ValueDataSource > bulk :: update': door(ADAPTER), + 'packages/core/src/adapters/ValueDataSource.ts :: ValueDataSource > bulk :: delete': door(ADAPTER), + 'packages/core/src/adapters/ValueDataSource.ts :: ValueDataSource > batchTransaction :: batch': door(ADAPTER), + 'packages/core/src/adapters/batchTransaction.ts :: emulateBatchTransaction :: create': helper('emulateBatchTransaction'), + 'packages/core/src/adapters/batchTransaction.ts :: emulateBatchTransaction :: update': helper('emulateBatchTransaction'), + 'packages/core/src/adapters/batchTransaction.ts :: emulateBatchTransaction :: delete': helper('emulateBatchTransaction'), + 'packages/core/src/adapters/batchTransaction.ts :: runBatchTransaction :: batch': helper('runBatchTransaction'), + // ── data-objectstack ── + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > create :: create': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > update :: update': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > delete :: delete': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > bulkUpdate :: update': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > bulkDelete :: delete': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > batchTransaction :: batch': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > fallbackToEmulation :: batch': door(ADAPTER), + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > bulk :: update': door(ADAPTER), + 'packages/data-objectstack/src/userState.ts :: createObjectStackUserStateAdapter > upsert :: update': + outOfFamily("the caller's OWN per-user state rows (saved view state), written on the caller's behalf; no console affordance offers them as a record write"), + 'packages/data-objectstack/src/userState.ts :: createObjectStackUserStateAdapter > upsert :: create': + outOfFamily("the caller's OWN per-user state rows (saved view state), written on the caller's behalf; no console affordance offers them as a record write"), + // ── fields ── + 'packages/fields/src/widgets/LookupField.tsx :: LookupField > handleCreateNew :: create': + mapped(['lookupCreateNew'], 'packages/fields/src/widgets/LookupField.tsx'), + // ── mobile ── + 'packages/mobile/src/createOfflineDataSource.ts :: createOfflineDataSource > wrapped > replay :: create': door('the offline DataSource wrapper replaying its queue into the wrapped one'), + 'packages/mobile/src/createOfflineDataSource.ts :: createOfflineDataSource > wrapped > replay :: update': door('the offline DataSource wrapper replaying its queue into the wrapped one'), + 'packages/mobile/src/createOfflineDataSource.ts :: createOfflineDataSource > wrapped > replay :: delete': door('the offline DataSource wrapper replaying its queue into the wrapped one'), + // ── plugin-calendar ── + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > handleEventDropDefault :: update': + mapped(['calendarReschedule'], 'packages/plugin-calendar/src/ObjectCalendar.tsx'), + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > submitQuickCreate :: create': + mapped(['calendarQuickCreate'], 'packages/plugin-calendar/src/ObjectCalendar.tsx'), + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > renderRecordOverlay > onFieldSave :: update': + unmapped(OVERLAY_INLINE_EDIT), + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > renderRecordOverlay > onDelete :: delete': + mapped(['recordDelete'], DETAIL_VIEW), + // ── plugin-designer ── + 'packages/plugin-designer/src/pages/DashboardDesignPage.tsx :: DashboardDesignPage > saveSchema :: update': + unmapped("the dashboard designer's Save updates a sys_dashboard row and reads no grant on sys_dashboard"), + // ── plugin-detail ── + 'packages/plugin-detail/src/InlineEditSaveBar.tsx :: updateVia :: update': + mapped(['recordEdit'], RECORD_PAGE, RECORD_DETAILS), + 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: create': + unmapped("a related list's Add existing (picker) creates a junction row and reads no grant on it"), + 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: update': + unmapped("a related list's Add existing (picker) re-parents the picked record and reads no update grant on it"), + 'packages/plugin-detail/src/renderers/record-related-list.tsx :: RecordRelatedListBody :: delete': + unmapped("the related list's FALLBACK remove (an `add` config and no host handler mounted) deletes with no grant read; the host handler path is relatedRowDelete"), + // ── plugin-form ── + 'packages/plugin-form/src/DrawerForm.tsx :: DrawerForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/DrawerForm.tsx :: DrawerForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/EmbeddableForm.tsx :: EmbeddableForm > handleSubmit :: create': + outOfFamily('a public embeddable form submits with no signed-in principal, so there is no grant to read; the server\'s public-form policy decides'), + 'packages/plugin-form/src/LineItemsPanel.tsx :: LineItemsPanel > save :: batch': + mapped(['relatedNew', 'relatedRowDelete', 'editFormFields'], 'packages/plugin-form/src/LineItemsPanel.tsx', FIELD_WRITE_GATE), + 'packages/plugin-form/src/MasterDetailForm.tsx :: MasterDetailForm > sendBatch :: batch': + unmapped("the master's fields are behind the form rows (its header ObjectForm), but its line grids' add / remove read no child grant; relatedNew / relatedRowDelete are the candidates, except that a create-mode master's removed line is never a server delete"), + 'packages/plugin-form/src/ModalForm.tsx :: ModalForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/ModalForm.tsx :: ModalForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/ObjectForm.tsx :: SimpleObjectForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/SplitForm.tsx :: SplitForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/SplitForm.tsx :: SplitForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/TabbedForm.tsx :: TabbedForm > handleSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/TabbedForm.tsx :: TabbedForm > handleSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/WizardForm.tsx :: WizardForm > handleStepSubmit :: create': mapped(['createFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/WizardForm.tsx :: WizardForm > handleStepSubmit :: update': mapped(['editFormFields'], FIELD_WRITE_GATE), + 'packages/plugin-form/src/occSave.tsx :: useOccSave > saveWithOcc :: update': helper('saveWithOcc'), + // ── plugin-gantt ── + 'packages/plugin-gantt/src/ObjectGantt.tsx :: ObjectGantt > handleTaskUpdateDefault :: update': + unmapped("a gantt's drag / resize of a task bar writes its dates and reads no update grant (only `readOnly` and `lockField`)"), + 'packages/plugin-gantt/src/ObjectGantt.tsx :: ObjectGantt > persistDependencies :: update': + unmapped("a gantt's dependency edit writes the dependencies field and reads no update grant"), + 'packages/plugin-gantt/src/ObjectGantt.tsx :: ObjectGantt > confirmDelete :: delete': + unmapped("a gantt row kebab's Delete reads no delete grant"), + 'packages/plugin-gantt/src/ObjectGantt.tsx :: ObjectGantt > renderRecordOverlay :: update': unmapped(OVERLAY_INLINE_EDIT), + 'packages/plugin-gantt/src/ObjectGantt.tsx :: ObjectGantt > renderRecordOverlay :: delete': mapped(['recordDelete'], DETAIL_VIEW), + // ── plugin-grid ── + 'packages/plugin-grid/src/ImportWizard.tsx :: ImportWizard > legacyImport :: create': { + ...mapped(['listImport'], CONSOLE_LIST, 'packages/app-shell/src/views/importTargetFields.ts'), + note: "the console list's Import; the AI build panel's spreadsheet import (`ExcelImportBar`) mounts the same wizard with no grant read", + } as WriteSite, + 'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultRowSave :: update': mapped(['listInlineEdit'], OBJECT_GRID), + 'packages/plugin-grid/src/ObjectGrid.tsx :: ObjectGrid > defaultBatchSave :: update': mapped(['listInlineEdit'], OBJECT_GRID), + 'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > run > perRow :: delete': mapped(['rowDelete'], OBJECT_GRID), + 'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > run > perRow :: update': + unmapped("a grid's bulk field update (a `bulkActionDefs` update operation) reads no update grant; only its delete operation rides the rowDelete verdict"), + 'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > undo :: update': + unmapped("the undo of a grid bulk field update, the inverse of that unmapped write"), + 'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > retry :: delete': mapped(['rowDelete'], OBJECT_GRID), + 'packages/plugin-grid/src/hooks/useBulkExecutor.ts :: useBulkExecutor > retry :: update': + unmapped("the retry of a grid bulk field update, that same unmapped write"), + // ── plugin-kanban ── + 'packages/plugin-kanban/src/ObjectKanban.tsx :: ObjectKanban > persistCardMove :: update': + mapped(['kanbanCardMove'], 'packages/plugin-kanban/src/ObjectKanban.tsx'), + 'packages/plugin-kanban/src/ObjectKanban.tsx :: ObjectKanban > renderRecordOverlay > onFieldSave :: update': + unmapped(OVERLAY_INLINE_EDIT), + 'packages/plugin-kanban/src/ObjectKanban.tsx :: ObjectKanban > renderRecordOverlay > onDelete :: delete': + mapped(['recordDelete'], DETAIL_VIEW), + // ── plugin-view ── + 'packages/plugin-view/src/ObjectView.tsx :: ObjectView > onClick :: delete': + mapped(['rowDelete'], OBJECT_GRID), + // ── react ── + 'packages/react/src/hooks/useGlobalUndo.ts :: executeOp :: delete': + outOfFamily('the global undo / redo stack replays the inverse of a write the caller just made through an affordance; it offers no write of its own'), + 'packages/react/src/hooks/useGlobalUndo.ts :: executeOp :: update': + outOfFamily('the global undo / redo stack replays the inverse of a write the caller just made through an affordance; it offers no write of its own'), + 'packages/react/src/hooks/useGlobalUndo.ts :: executeOp :: create': + outOfFamily('the global undo / redo stack replays the inverse of a write the caller just made through an affordance; it offers no write of its own'), + // ── runner ── + 'packages/runner/src/lib/mockDataSource.ts :: MockDataSource > batchTransaction :: batch': door(ADAPTER), + // ── console ── + 'apps/console/src/pages/system/ProfilePage.tsx :: LanguageCard > handleSave :: update': + mapped(['editFormFields'], 'apps/console/src/pages/system/ProfilePage.tsx'), +}; + +const isFn = (e: ts.Node | undefined): boolean => !!e && (ts.isArrowFunction(e) || ts.isFunctionExpression(e)); + +/** Does this initializer open a named scope — a function, or an object / call carrying one? */ +function opensScope(init: ts.Node | undefined): boolean { + if (!init) return false; + if (isFn(init) || ts.isObjectLiteralExpression(init)) return true; + if (ts.isParenthesizedExpression(init) || ts.isAsExpression(init) || ts.isSatisfiesExpression(init)) return opensScope(init.expression); + if (ts.isCallExpression(init)) return init.arguments.some((a) => isFn(a) || ts.isObjectLiteralExpression(a)); + return false; +} + +function scopeName(n: ts.Node, sf: ts.SourceFile): string | null { + if ((ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n) || ts.isClassDeclaration(n)) && n.name) return n.name.getText(sf); + if ((ts.isVariableDeclaration(n) || ts.isPropertyAssignment(n) || ts.isPropertyDeclaration(n)) && ts.isIdentifier(n.name) && opensScope(n.initializer)) { + return n.name.text; + } + if (ts.isJsxAttribute(n) && n.initializer && ts.isJsxExpression(n.initializer) && isFn(n.initializer.expression)) return n.name.getText(sf); + return null; +} + +/** The write sites in one source text, and the map rows it reads. */ +function scanWrites(rel: string, text: string): { sites: Set; rowsRead: Set } { + const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, false, rel.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const sites = new Set(); + const rowsRead = new Set(); + const visit = (n: ts.Node, chain: string[]): void => { + const name = scopeName(n, sf); + const here = name ? [...chain, name] : chain; + if (ts.isCallExpression(n)) { + const callee = n.expression; + const calleeText = callee.getText(sf); + let verb: Verb | undefined = WRITE_HELPERS[calleeText]; + if (!verb && ts.isPropertyAccessExpression(callee)) { + const member = MEMBER_WRITES.get(callee.name.text); + if (member && n.arguments.length >= member.arity) verb = member.verb; + } + if (verb) sites.add(`${rel} :: ${here.join(' > ') || '(module)'} :: ${verb}`); + const first = n.arguments[0]; + if ((calleeText === 'resolveAffordance' || calleeText === 'resolveFieldAffordance') && first && ts.isStringLiteralLike(first)) { + rowsRead.add(first.text); + } + if (calleeText === 'formFieldsAffordance') { + rowsRead.add('createFormFields'); + rowsRead.add('editFormFields'); + } + } + ts.forEachChild(n, (c) => visit(c, here)); + }; + visit(sf, []); + return { sites, rowsRead }; +} + +function writeCensus(): { sites: Set; rowsReadBy: Map> } { + const sites = new Set(); + const rowsReadBy = new Map>(); + for (const file of consoleSources()) { + const rel = path.relative(repoRoot, file).split(path.sep).join('/'); + const scanned = scanWrites(rel, readFileSync(file, 'utf8')); + for (const s of scanned.sites) sites.add(s); + if (scanned.rowsRead.size) rowsReadBy.set(rel, scanned.rowsRead); + } + return { sites, rowsReadBy }; +} + +describe('the write census: every write call site sits behind a map row, or says why not (objectui#12082)', () => { + const { sites, rowsReadBy } = writeCensus(); + + it('CONTROL — the recognizer fires on each shape, and not on a collection\'s delete(key)', () => { + const { sites: found, rowsRead } = scanWrites( + 'pin/probe.tsx', + [ + 'async function save(ds: any, m: Map, ops: any[]) {', + " await ds.update('o', '1', { a: 1 });", + ' m.delete("k");', + ' await runBatchTransaction(ds, ops);', + '}', + "const Panel = () => { await (ds as any).delete?.('o', '1'); }} />;", + 'const api = { remove: async () => recordDelete.run(deps, action) };', + "const ok = resolveAffordance('rowEdit', source);", + ].join('\n'), + ); + expect([...found].sort()).toEqual([ + 'pin/probe.tsx :: Panel > onDelete :: delete', + 'pin/probe.tsx :: api > remove :: delete', + 'pin/probe.tsx :: save :: batch', + 'pin/probe.tsx :: save :: update', + ]); + expect([...rowsRead]).toEqual(['rowEdit']); + }); + + it('CONTROL — the walk reached the data door and the five sites the card names', () => { + for (const key of [ + 'packages/data-objectstack/src/index.ts :: ObjectStackAdapter > update :: update', + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > submitQuickCreate :: create', + 'packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > handleEventDropDefault :: update', + 'packages/plugin-kanban/src/ObjectKanban.tsx :: ObjectKanban > persistCardMove :: update', + 'packages/plugin-form/src/LineItemsPanel.tsx :: LineItemsPanel > save :: batch', + ]) { + expect(sites.has(key), key).toBe(true); + } + }); + + it('every write site in the tree has an entry, and every entry is a site in the tree', () => { + const unlisted = [...sites].filter((k) => !(k in WRITE_SITES)).sort(); + const gone = Object.keys(WRITE_SITES).filter((k) => !sites.has(k)).sort(); + expect( + { unlisted, gone }, + 'A write call site has no WRITE_SITES entry (unlisted), or an entry no longer matches a site (gone). ' + + "For a new write affordance, give it a row in AFFORDANCE_GRANTS in @object-ui/core, read it through " + + 'resolveAffordance where the affordance is offered, and enter the site here as `mapped`. A write that is ' + + 'not a CRUD affordance is entered with the reason. A renamed handler moves its key: re-read the site and ' + + 'move its entry.', + ).toEqual({ unlisted: [], gone: [] }); + }); + + it('every mapped site names rows of the map, of its own verb, each read by one of the files it names', () => { + const problems: string[] = []; + for (const [key, site] of Object.entries(WRITE_SITES)) { + if (site.kind !== 'mapped') continue; + const verb = key.slice(key.lastIndexOf(' :: ') + 4) as Verb; + for (const row of site.rows) { + if (!(row in AFFORDANCE_GRANTS)) { + problems.push(`${key}: ${row} is not a row of the map`); + continue; + } + const grant = AFFORDANCE_GRANTS[row].grant; + if (verb !== 'batch' && grant !== verb) problems.push(`${key}: ${row} reads the ${grant} grant for a ${verb}`); + if (!site.readBy.some((f) => rowsReadBy.get(f)?.has(row))) { + problems.push(`${key}: no file it names reads ${row}`); + } + } + for (const f of site.readBy) { + if (!site.rows.some((row) => rowsReadBy.get(f)?.has(row))) problems.push(`${key}: ${f} reads none of its rows`); + } + } + expect(problems).toEqual([]); + }); + + it('the write helpers and their entries name each other', () => { + const named = new Set( + Object.values(WRITE_SITES).flatMap((site) => (site.kind === 'helper' ? [site.helper] : [])), + ); + expect([...named].sort()).toEqual(Object.keys(WRITE_HELPERS).sort()); + }); + + it("the card's five: each sits behind its own row, read where the affordance is offered", () => { + const behind = (key: string) => { + const site = WRITE_SITES[key]; + return site?.kind === 'mapped' ? site.rows : []; + }; + expect(behind('packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > submitQuickCreate :: create')).toEqual(['calendarQuickCreate']); + expect(behind('packages/plugin-calendar/src/ObjectCalendar.tsx :: ObjectCalendar > handleEventDropDefault :: update')).toEqual(['calendarReschedule']); + expect(behind('packages/plugin-kanban/src/ObjectKanban.tsx :: ObjectKanban > persistCardMove :: update')).toEqual(['kanbanCardMove']); + expect(behind('packages/plugin-form/src/LineItemsPanel.tsx :: LineItemsPanel > save :: batch')).toEqual( + expect.arrayContaining(['relatedNew', 'relatedRowDelete']), + ); + // The object-view New leads to a write (it opens a create form) rather than + // issuing one, so it is read where it is offered, not counted as a site. + expect(rowsReadBy.get('packages/plugin-view/src/ObjectView.tsx')?.has('listNew')).toBe(true); + }); +}); diff --git a/packages/plugin-kanban/src/KanbanBoardCore.tsx b/packages/plugin-kanban/src/KanbanBoardCore.tsx index 19593bd4f9..6b79c2be5a 100644 --- a/packages/plugin-kanban/src/KanbanBoardCore.tsx +++ b/packages/plugin-kanban/src/KanbanBoardCore.tsx @@ -37,6 +37,13 @@ export interface KanbanBoardCoreProps { objectFields?: KanbanRendererProps['objectFields']; /** Forwarded unchanged — see `KanbanRendererProps.onCardMove`. */ onCardMove?: KanbanRendererProps['onCardMove']; + /** + * Whether the cards may be dragged at all (default `true`). A PROP, never a + * schema key: `ObjectKanban` passes `false` when its `kanbanCardMove` row of + * the affordance-to-grant map is closed (objectui#12082), so the board offers + * no move the caller may not make. + */ + cardsMovable?: boolean; /** Half of the Quick Add pair. Only `KanbanRenderer` supplies it. */ quickAdd?: boolean; /** The other half of the Quick Add pair. Only `KanbanRenderer` supplies it. */ @@ -84,6 +91,7 @@ export const KanbanBoardCore: React.FC = ({ schema, objectFields, onCardMove, + cardsMovable, quickAdd, onQuickAdd, }) => { @@ -106,6 +114,7 @@ export const KanbanBoardCore: React.FC = ({ void; conditionalFormatting?: KanbanConditionalFormattingRule[]; objectFields?: unknown }) { + // A disabled sortable attaches no drag listeners and reports + // `aria-disabled` (objectui#12082). + const movable = React.useContext(KanbanCardsMovable) const { attributes, listeners, @@ -194,7 +211,7 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields } transform, transition, isDragging, - } = useSortable({ id: card.id }) + } = useSortable({ id: card.id, disabled: !movable }) const style = { transform: CSS.Transform.toString(transform), @@ -209,7 +226,7 @@ function SortableCard({ card, onCardClick, conditionalFormatting, objectFields }
onCardClick?.(card, e)} > - + {card.coverImage && (
) => return <>{children(dnd)} } -export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed }: KanbanBoardProps) { +export default function KanbanBoard({ columns, onCardMove, onCardClick, className, quickAdd, onQuickAdd, coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed, cardsMovable = true }: KanbanBoardProps) { const hasDnd = useHasDndProvider() if (hasDnd) { return ( - - {(dnd) => } - + + + {(dnd) => } + + ) } - return + return ( + + + + ) } function KanbanBoardInner({ columns, onCardMove, onCardClick, className, dnd, quickAdd, onQuickAdd, coverImageField: _coverImageField, conditionalFormatting, objectFields, swimlaneField, countsAreWindowed }: KanbanBoardProps & { dnd: ReturnType | null }) { diff --git a/packages/plugin-kanban/src/ObjectKanban.tsx b/packages/plugin-kanban/src/ObjectKanban.tsx index de000271d9..d473c3b978 100644 --- a/packages/plugin-kanban/src/ObjectKanban.tsx +++ b/packages/plugin-kanban/src/ObjectKanban.tsx @@ -41,6 +41,8 @@ import { getRecordDisplayName, isEmptyValue, resolveNameField, + resolveAffordance, + type SchemaLike, } from '@object-ui/core'; import { getBadgeColorClasses, getBadgeHexAppearance, getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { usePermissions } from '@object-ui/permissions'; @@ -1533,6 +1535,20 @@ export const ObjectKanban: React.FC = ({ } | null>(null); const [pendingSubmitting, setPendingSubmitting] = useState(false); + // objectui#12082 — the card move is the `kanbanCardMove` row of the + // affordance-to-grant map (`resolveAffordance` in `@object-ui/core`): the + // object's policy, the effective operation set and the caller's update grant. + // A closed row draws the cards as not movable and hands the board no mover, + // so no drop can reach `persistCardMove`. It used to read no grant at all: a + // read-only caller could drag a card, watch it land, and get the server's + // refusal and a rollback. With no permission provider mounted the grant + // reads open, as before. + const cardMoveGranted = resolveAffordance('kanbanCardMove', { + objectSchema: objectDef as SchemaLike | null, + objectName: schema.objectName, + perms, + }).allowed; + // Pre-evaluate the target column's `requiredWhen` predicates and collect what // the move makes required BEFORE writing anything (objectui#4254). The board // used to PATCH the column value alone into a refusal the user could neither @@ -1726,7 +1742,11 @@ export const ObjectKanban: React.FC = ({ // bag below, an authored `onCardMove` was accepted by the passthrough, // substituted here, and silently dropped; the arm can only tombstone the // key once no renderer reads it off the document. - onCardMove={handleCardMove} + // + // objectui#12082: handed only when `kanbanCardMove` allows the write, + // and the cards are drawn not movable otherwise (see `cardMoveGranted`). + onCardMove={cardMoveGranted ? handleCardMove : undefined} + cardsMovable={cardMoveGranted} schema={{ ...effectiveSchema, // `5591f03bd` — the lane headers count rows that came back, so when diff --git a/packages/plugin-view/src/ObjectView.tsx b/packages/plugin-view/src/ObjectView.tsx index 5fae6dbb4b..6707d21d88 100644 --- a/packages/plugin-view/src/ObjectView.tsx +++ b/packages/plugin-view/src/ObjectView.tsx @@ -85,9 +85,11 @@ import { columnIdentity, convertSortToQueryParams, recordDelete, + resolveAffordance, resolveFilterPlaceholders, toFilterNodeSafely, type FilterTokenScope, + type SchemaLike, } from '@object-ui/core'; import { SchemaRenderer as ImportedSchemaRenderer, useSettledSchema, notifyDataChanged, useDataInvalidation, useFilterScope } from '@object-ui/react'; import type { HandleClickModifiers } from '@object-ui/react'; @@ -3250,7 +3252,19 @@ export const ObjectView: React.FC = ({ // Render toolbar — only named view tabs; filter/sort/search is handled by ListView const renderToolbar = () => { - const showCreateButton = schema.showCreate !== false && operations.create !== false; + // objectui#12082: the button is the `listNew` row of the affordance-to-grant + // map (`resolveAffordance` in `@object-ui/core`) — the object's policy, the + // effective operation set and the caller's create grant — on top of the + // node's own authoring toggles, exactly as the console's list pages read it. + // It used to read the two toggles alone, so a caller without the create + // grant was offered a New whose form the server then refused to save. With + // no permission provider mounted the grant reads open, as before. + const createGranted = resolveAffordance('listNew', { + objectSchema: objectSchema as SchemaLike | null, + objectName: schema.objectName, + perms, + }).allowed; + const showCreateButton = schema.showCreate !== false && operations.create !== false && createGranted; const showViewSwitcherToggle = schema.showViewSwitcher === true; // Changed: default to false (hidden) const namedViewTabs = renderNamedViewTabs(); From 02cc3c82f8a0db1d786bd7521b7a65c994c925f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:38:38 +0000 Subject: [PATCH 3/4] docs(console): the write census in the map's docblock, README and plugin docs; changeset; the census reads its helper table by own key (objectui#12082) Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .changeset/12082-write-affordance-census.md | 37 +++++++++++++++++++ content/docs/plugins/plugin-calendar.mdx | 12 ++++++ content/docs/plugins/plugin-form.mdx | 7 ++++ content/docs/plugins/plugin-kanban.mdx | 6 ++- content/docs/plugins/plugin-view.mdx | 6 ++- packages/core/README.md | 8 +++- packages/core/src/utils/affordanceGrants.ts | 8 ++++ .../src/affordanceGrantMap-12082.test.tsx | 8 +++- 8 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 .changeset/12082-write-affordance-census.md diff --git a/.changeset/12082-write-affordance-census.md b/.changeset/12082-write-affordance-census.md new file mode 100644 index 0000000000..c09273a474 --- /dev/null +++ b/.changeset/12082-write-affordance-census.md @@ -0,0 +1,37 @@ +--- +'@object-ui/core': minor +'@object-ui/plugin-view': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-form': patch +--- + +Five write affordances that read no grant at all now read the affordance-to-grant +map, so a caller without the grant is no longer offered a write the server then +refuses (objectui#12082, the card's remainder). + +- **`object-view`'s New** (the toolbar button of the SDUI `object-view` node) + reads the `listNew` row, as the console's list pages already did: the + object's policy, the effective API operation set and the caller's create + grant, on top of the node's `showCreate` / `operations.create` toggles. +- **The calendar's quick-create** (an empty-day click, or a time-range drag in + the week / day grid) reads the new `calendarQuickCreate` row, and + **drag-to-reschedule** reads the new `calendarReschedule` row. A closed row + withholds the handler the calendar grid draws the affordance from: no dialog + on a day click, no range drag, no draggable event. A host-supplied + `onDateClick` / `onEventDrop` is handed through unchanged. +- **The kanban card move** reads the new `kanbanCardMove` row. A closed row + draws the cards as not movable and hands the board no mover, so no drop + reaches the write. +- **A line-items panel's add and remove** (`record:line_items`) read the + `relatedNew` and `relatedRowDelete` rows on the CHILD object, the same rows a + related list reads for the same two writes. Add covers the Add button, the + entry row and Duplicate; remove covers the per-row Remove. + +With no permission provider mounted every one of them reads open, as before. + +**Clause-②: yes (widening).** `AFFORDANCE_GRANTS`, exported from +`@object-ui/core`, gains three rows: `calendarQuickCreate` (create), +`calendarReschedule` (update) and `kanbanCardMove` (update), and with them the +`ConsoleAffordance` union that `resolveAffordance` accepts. Nothing is removed or +renamed. The other four packages change behaviour only. diff --git a/content/docs/plugins/plugin-calendar.mdx b/content/docs/plugins/plugin-calendar.mdx index 3c2ecfcf5c..a2c669cfc7 100644 --- a/content/docs/plugins/plugin-calendar.mdx +++ b/content/docs/plugins/plugin-calendar.mdx @@ -56,6 +56,12 @@ default. When `ObjectCalendar` is bound to an object (i.e. it has `objectName` and a `dataSource`), the new dates are persisted automatically via `dataSource.update()` — local state is updated optimistically and rolled back on failure. To intercept (e.g. for a confirm dialog) pass an `onEventDrop` prop; supplying your own handler disables the default persistence. +The default persistence is offered only to a caller who may make the write: events +are draggable when the `calendarReschedule` row of the affordance-to-grant map +(`@object-ui/core`) allows it — the object's policy, the effective API operation +set and the caller's **update** grant. Without it the events stay where they are. +With no permission provider mounted it reads open. + ```jsx ; rowsRead: if (ts.isCallExpression(n)) { const callee = n.expression; const calleeText = callee.getText(sf); - let verb: Verb | undefined = WRITE_HELPERS[calleeText]; + // An own-key test, never a bare index: `valueOf(…)` would read the prototype. + let verb: Verb | undefined = Object.prototype.hasOwnProperty.call(WRITE_HELPERS, calleeText) + ? WRITE_HELPERS[calleeText] + : undefined; if (!verb && ts.isPropertyAccessExpression(callee)) { const member = MEMBER_WRITES.get(callee.name.text); if (member && n.arguments.length >= member.arity) verb = member.verb; @@ -734,6 +737,7 @@ describe('the write census: every write call site sits behind a map row, or says "const Panel = () => { await (ds as any).delete?.('o', '1'); }} />;", 'const api = { remove: async () => recordDelete.run(deps, action) };', "const ok = resolveAffordance('rowEdit', source);", + 'const n = toString(x) + x.valueOf() + hasOwnProperty(y);', ].join('\n'), ); expect([...found].sort()).toEqual([ From d18c611d2f98300f43d90de1102cffe553f7ec8e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 12:44:36 +0000 Subject: [PATCH 4/4] test(console): the write census enters the reaction-record sites objectui#12078 landed on main (objectui#12082) The merge of main brought the chatter's sys_comment_reaction create / delete into RecordDetailView's reaction toggle; both are entered as unmapped write affordances beside the legacy sys_comment update they sit next to. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- packages/plugin-form/src/affordanceGrantMap-12082.test.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 6cd8d69177..494cfc8476 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -512,7 +512,11 @@ const WRITE_SITES: Record = { 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddReply :: create': unmapped('the record feed\'s reply composer creates a sys_comment row and reads no grant on sys_comment'), 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction :: update': - unmapped("the record feed's reaction toggle updates a sys_comment row and reads no grant on sys_comment"), + unmapped("the record feed's reaction toggle on a deployment with no sys_comment_reaction updates the sys_comment row's reactions and reads no grant on sys_comment"), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: create': + unmapped("the record feed's reaction toggle creates the member's sys_comment_reaction row and reads no grant on sys_comment_reaction"), + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: delete': + unmapped("the record feed's reaction toggle deletes the member's sys_comment_reaction row and reads no grant on sys_comment_reaction"), 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > onClick :: delete': mapped(['recordDelete'], RECORD_PAGE), 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx :: RelatedRecordActionsBridge > value > resolve :: delete':