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 ; /** 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-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-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..494cfc8476 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,434 @@ 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 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': + 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); + // 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; + } + 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);", + 'const n = toString(x) + x.valueOf() + hasOwnProperty(y);', + ].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.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-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 2ed855c6da..0ca4a91914 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'; @@ -3266,7 +3268,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(); 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(); + }); +});