From f97fc5f38faf59234291d73f866e3ad61e218709 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 10:54:45 +0000 Subject: [PATCH 1/5] test(plugin-form): freeze the write census's unmapped backlog and derive its host-gated sites (objectui#12082) The census's 13 `unmapped` WRITE_SITES entries are now a closed list, UNMAPPED_BACKLOG, held equal to the ledger both ways: a NEW `unmapped` entry turns the census red until the site is graded, and a key graded out of the backlog must leave the list, so the backlog only shrinks. Leg A's hand-written HOST_GATED list is replaced by a derivation: a mapped site is host-gated when a file it names as a reader mounts the component the write is written in, and then every mount must be a file the site names. On this tree it finds the one site the list held (the import wizard); what it cannot see (a host that reaches the write through a hook, a function, an alias or a wrapper) is stated beside it. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../src/affordanceGrantMap-12082.test.tsx | 135 ++++++++++++++---- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 525dabd588..c604771835 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -52,6 +52,11 @@ * 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. + * 8. **The backlog is frozen.** `UNMAPPED_BACKLOG` names every key the census + * still grades `unmapped`, and the two must be equal: a NEW `unmapped` entry + * turns it red until the site is graded (`mapped`, or the reason it is no + * affordance), and a key graded out of the backlog must leave the list. So + * the backlog can only shrink. */ import { describe, it, expect, afterEach } from 'vitest'; import { render, cleanup } from '@testing-library/react'; @@ -550,10 +555,11 @@ describe('the census: every affordance reads the map (objectui#12082)', () => { // // One site key can also hide several doors: a component that issues a write // its HOST gates (the import wizard, mounted by the console list and by the AI -// build panel's spreadsheet import) has one site and one door per mount. Those -// are listed in `HOST_GATED`, and every mount must be a file its site names — -// that is how a second mount with no grant read is seen. A host-gated component -// not listed there is not seen; the list is written by hand, like the ledger. +// build panel's spreadsheet import) has one site and one door per mount. Such a +// site is found, not listed: it is host-gated when a file it names as a reader +// mounts the component the write is written in, and then every mount must be a +// file its site names — that is how a second mount with no grant read is seen. +// What that derivation cannot see is written beside `hostGatedSites` below. // --------------------------------------------------------------------------- type Verb = 'create' | 'update' | 'delete' | 'batch'; @@ -591,7 +597,10 @@ type WriteSite = | { 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. */ + /** + * A write affordance with no map row yet — the backlog this census found, + * named one by one. FROZEN: only a key `UNMAPPED_BACKLOG` holds may carry it. + */ | { kind: 'unmapped'; why: string }; const mapped = (rows: ConsoleAffordance[], ...readBy: string[]): WriteSite => ({ kind: 'mapped', rows, readBy }); @@ -796,6 +805,39 @@ const WRITE_SITES: Record = { mapped(['editFormFields'], 'apps/console/src/pages/system/ProfilePage.tsx'), }; +/** + * The `unmapped` backlog, FROZEN (objectui#12082: the contract review's + * freeze, the first step of triage's order on the card). + * + * These are the write affordances the census found with no map row, and the + * only keys `WRITE_SITES` may grade `unmapped`. The pin below holds the two + * equal in both directions: + * + * - a write site entered as `unmapped` that this list does not hold is red — + * a new write affordance is graded when it lands (a map row read where it is + * offered, or the reason it is no affordance), never parked here; + * - a key graded out of `unmapped` (mapped, out of family, or gone from the + * tree) is red until it is deleted from this list. + * + * ⛔ Never add a key here. The list exists to shrink to nothing: the card's + * done-when is a census with no `unmapped` entry, and then this list is empty. + */ +const UNMAPPED_BACKLOG: ReadonlySet = new Set([ + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddComment :: create', + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddReply :: create', + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction :: update', + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: create', + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: delete', + 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > addUsers :: create', + 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > removeUser :: delete', + 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx :: PositionHoldersSection > addHolders :: create', + 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx :: PositionHoldersSection > removeHolder :: delete', + 'packages/plugin-designer/src/pages/DashboardDesignPage.tsx :: DashboardDesignPage > saveSchema :: update', + 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: create', + 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: update', + 'packages/plugin-detail/src/renderers/record-related-list.tsx :: RecordRelatedListBody :: delete', +]); + 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? */ @@ -816,22 +858,46 @@ function scopeName(n: ts.Node, sf: ts.SourceFile): string | null { return null; } +/** The component a write site is written in: the first enclosing symbol of its key. */ +const componentOf = (key: string): string => key.split(' :: ')[1].split(' > ')[0]; + /** - * Write sites inside a component its HOST gates — component name → the site. + * The write sites inside a component its HOST gates, as `[component, site]` — + * DERIVED from the ledger and the tree, never listed by hand. * * The component issues the write, but the affordance that opens it (an Import * button) is drawn by whoever mounts it, so each mount is a door of its own - * behind one site key: the census above cannot tell two mounts apart. So every - * console source that mounts the component (a JSX element of that name) must - * be named in the site's `readBy`, and the mapped-site check holds each named + * behind one site key: the write census cannot tell two mounts apart. A mapped + * site is host-gated exactly when a file it names in `readBy` mounts the + * component the write is written in (a JSX element of that name) — its gate + * lives in a host. Then every console source that mounts the component must be + * named in the site's `readBy` too, and the mapped-site check holds each named * file to reading one of the site's rows. A mount added later with no grant - * read turns this red, naming the file. + * read turns the pin red, naming the file; so does a NEW host-gated component, + * with no list to remember it in. + * + * What this does NOT see, said here rather than left to be assumed (AGENTS.md + * #9): a host that reaches the writing code through anything but a JSX element + * of the component's own name — a hook it calls (`useObjectActions`, + * `useBulkExecutor`), a function it calls, an alias it imports under, or a + * wrapper component it mounts instead — is not a mount to this walk. A site + * whose readers mount the component only that way is not derived as + * host-gated, and a new caller of it that offers the write with no grant read + * is not seen. */ -const HOST_GATED: Readonly> = { - ImportWizard: 'packages/plugin-grid/src/ImportWizard.tsx :: ImportWizard > legacyImport :: create', -}; +function hostGatedSites(mountsBy: ReadonlyMap>): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const [key, site] of Object.entries(WRITE_SITES)) { + if (site.kind !== 'mapped') continue; + const component = componentOf(key); + const own = key.slice(0, key.indexOf(' :: ')); + const mounts = mountsBy.get(component); + if (mounts && site.readBy.some((f) => f !== own && mounts.has(f))) out.push([component, key]); + } + return out; +} -/** The write sites in one source text, the map rows it reads, and the host-gated components it mounts. */ +/** The write sites in one source text, the map rows it reads, and the components it mounts (JSX element names). */ function scanWrites(rel: string, text: string): { sites: Set; rowsRead: Set; mounts: Set } { const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, false, rel.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS); const sites = new Set(); @@ -840,10 +906,7 @@ function scanWrites(rel: string, text: string): { sites: Set; rowsRead: const visit = (n: ts.Node, chain: string[]): void => { const name = scopeName(n, sf); const here = name ? [...chain, name] : chain; - if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) { - const tag = n.tagName.getText(sf); - if (Object.prototype.hasOwnProperty.call(HOST_GATED, tag)) mounts.add(tag); - } + if (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) mounts.add(n.tagName.getText(sf)); if (ts.isCallExpression(n)) { const callee = n.expression; const calleeText = callee.getText(sf); @@ -910,13 +973,13 @@ describe('the write census: every write call site sits behind a map row, or says 'pin/probe.tsx :: save :: update', ]); expect([...rowsRead]).toEqual(['rowEdit']); - // …and the mount walk fires on a host-gated component's JSX element, in - // either form, and on nothing else. + // …and the mount walk records each JSX element by its exact name, in either + // form: a fragment is no element, and a longer name is another component. const { mounts } = scanWrites( 'pin/mounts.tsx', 'const A = () => <>;', ); - expect([...mounts]).toEqual(['ImportWizard']); + expect([...mounts].sort()).toEqual(['ImportWizard', 'ImportWizardHelp', 'Other']); }); it('CONTROL — the walk reached the data door and the five sites the card names', () => { @@ -967,6 +1030,21 @@ describe('the write census: every write call site sits behind a map row, or says expect(problems).toEqual([]); }); + it('the unmapped backlog is frozen: no NEW unmapped entry, and a key graded out of it leaves the list', () => { + const graded = Object.entries(WRITE_SITES) + .filter(([, site]) => site.kind === 'unmapped') + .map(([key]) => key); + const added = graded.filter((key) => !UNMAPPED_BACKLOG.has(key)).sort(); + const left = [...UNMAPPED_BACKLOG].filter((key) => !graded.includes(key)).sort(); + expect( + { added, left }, + 'The unmapped backlog is frozen. A write site entered as `unmapped` that UNMAPPED_BACKLOG does not hold ' + + '(added) is graded now: give it a row in AFFORDANCE_GRANTS and read it where the affordance is offered ' + + '(`mapped`), or enter the measured reason it is no affordance (`outOfFamily`). A backlog key no longer ' + + 'graded `unmapped` (left) is deleted from UNMAPPED_BACKLOG: the list only shrinks.', + ).toEqual({ added: [], left: [] }); + }); + 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] : [])), @@ -1007,14 +1085,12 @@ describe('the write census: every write call site sits behind a map row, or says it('every mount of a host-gated write component is a file its site names, so each mount reads the row', () => { const problems: string[] = []; - for (const [component, key] of Object.entries(HOST_GATED)) { + for (const [component, key] of hostGatedSites(mountsBy)) { const site = WRITE_SITES[key]; - if (site?.kind !== 'mapped') { - problems.push(`${key}: the site ${component} hosts is not a mapped entry`); - continue; - } + if (site?.kind !== 'mapped') continue; + const own = key.slice(0, key.indexOf(' :: ')); for (const file of mountsBy.get(component) ?? []) { - if (!site.readBy.includes(file)) { + if (file !== own && !site.readBy.includes(file)) { problems.push(`${file} mounts ${component} but is not named by ${key}: read its row where the mount offers it, and name the file there`); } } @@ -1022,7 +1098,10 @@ describe('the write census: every write call site sits behind a map row, or says expect(problems).toEqual([]); }); - it('CONTROL — the mount walk reaches both mounts of the import wizard', () => { + it('CONTROL — the derivation finds the import wizard host-gated, and the mount walk reaches both its mounts', () => { + // A derivation that found nothing would pass the pin above over every + // tree; this is the site the hand-written list it replaced held. + expect(hostGatedSites(mountsBy).map(([component]) => component)).toEqual(expect.arrayContaining(['ImportWizard'])); expect([...(mountsBy.get('ImportWizard') ?? [])]).toEqual(expect.arrayContaining([CONSOLE_LIST, EXCEL_IMPORT_BAR])); }); }); From 8c8518265fe633707e2444a8d39522eaa764bec2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:01:31 +0000 Subject: [PATCH 2/5] fix(core,app-shell): the record feed's composer and reaction control read the affordance-to-grant map (objectui#12082) Leg B of the write census, the feed's five sites. The record page handed the feed's comment composer and reaction control their handlers unconditionally, so a member whose sets grant neither sys_comment nor sys_comment_reaction (the everyone baseline names neither; an application grants them) was offered a post the server refused at its object CRUD step. Four rows join AFFORDANCE_GRANTS, each asked of the object it writes: feedComment (create on sys_comment: the composer and the reply composer), feedReactionAdd and feedReactionRemove (create and delete on sys_comment_reaction: the reaction control and its take-back), and feedReactionColumn (update on sys_comment: the reaction control on a deployment with no sys_comment_reaction). The record page hands each handler over only where its row allows it; a take-back click with the remove row closed writes nothing. No provider: every grant reads open. The five census entries move to mapped and leave the frozen backlog. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../RecordDetailView.feedGrant-12082.test.tsx | 297 ++++++++++++++++++ .../app-shell/src/views/RecordDetailView.tsx | 74 ++++- packages/core/src/utils/affordanceGrants.ts | 22 ++ .../src/affordanceGrantMap-12082.test.tsx | 26 +- 4 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx diff --git a/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx b/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx new file mode 100644 index 0000000000..55aa3d090a --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.feedGrant-12082.test.tsx @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The record feed's writes read the affordance-to-grant map (objectui#12082, + * the census backlog's leg B). + * + * The feed offered its comment composer and its reaction control to every + * caller, and read no grant: the composer is drawn from `onAddComment`'s + * presence and the reaction picker from `onToggleReaction`'s, and the record + * page handed both over unconditionally. Neither object the feed writes is a + * platform default — the `everyone` baseline names neither `sys_comment` nor + * `sys_comment_reaction`, an application grants them in its own sets — so a + * member whose sets do not was offered a composer whose post the server + * refused at its object CRUD step. Now each write is handed over only where its + * row allows it, asked of the object it writes: + * + * - the composer: `feedComment`, the create grant on `sys_comment`; + * - the reaction control: `feedReactionAdd`, the create grant on + * `sys_comment_reaction`, with a take-back asking `feedReactionRemove`, its + * delete grant, per click; + * - on a deployment with no `sys_comment_reaction` (the column store), the + * reaction control: `feedReactionColumn`, the update grant on `sys_comment`. + * + * Real subjects: `RecordDetailView` rendering a record page that composes + * `record:discussion`, under the REAL `MePermissionsProvider` holding the + * caller's `/me/permissions` answer, driven through the real panel. + */ + +import * as React from 'react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, within, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'u1', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const COMMENT = 'sys_comment'; +const REACTION = 'sys_comment_reaction'; +const COMMENT_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.leaveCommentPlaceholder']; +const ADD_REACTION = DETAIL_DEFAULT_TRANSLATIONS['detail.addReaction']; +const OBJECT_NAME = 'crm_customer'; +const RECORD_ID = 'rec-1'; +const THREAD = `${OBJECT_NAME}:${RECORD_ID}`; +const COMMENT_ID = 'c1'; +const COMMENT_BODY = 'Signed the renewal'; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Customer', + managedBy: 'platform', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + }, +]; + +const PAGE = { + name: 'customer_record_page', + type: 'record', + object: OBJECT_NAME, + regions: [ + { name: 'main', components: [{ type: 'page:header', title: 'Customer' }, { type: 'record:discussion' }] }, + ], +}; + +/** The object registry: with the reaction object (the records store), or without it (the column store). */ +const REGISTRY = { + records: [...OBJECTS, { name: REACTION, label: 'Comment Reaction' }], + column: OBJECTS, +}; + +/** The four grant shapes of the map's enumeration pin, as `/me/permissions` carries them. */ +const SHAPE = { + createOnly: { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false }, + editOnly: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, + readOnly: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + full: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }, +} as const; + +type Bits = Record; + +/** The caller's answer: full on the record's own object, `feed` on the feed's objects (absent keys are absent). */ +function answer(feed: { [COMMENT]?: Bits; [REACTION]?: Bits }): MePermissionsResponse { + return { + authenticated: true, + userId: 'u1', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT_NAME]: SHAPE.full, ...feed }, + fields: {}, + } as MePermissionsResponse; +} + +/** One comment by another member; the caller (`u1`) has already reacted 👍 on it. */ +function makeDataSource() { + const comment = { + id: COMMENT_ID, + thread_id: THREAD, + author_id: 'u3', + author_name: 'u3', + body: COMMENT_BODY, + created_at: '2026-10-10T08:00:00.000Z', + reactions: JSON.stringify({ '👍': ['u1'] }), + }; + const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: 'u1', created_at: '2026-10-10T09:00:00.000Z' }; + return { + find: vi.fn(async (objectName: string) => { + if (objectName === COMMENT) return { data: [{ ...comment }] }; + if (objectName === REACTION) return { data: [{ ...ownReaction }] }; + return { data: [] }; + }), + findOne: vi.fn(async (_o: string, id: string) => ({ id, name: `Record ${id}` })), + create: vi.fn(async (_o: string, data: any) => ({ id: 'new-row', ...data })), + update: vi.fn(async (_o: string, id: string, patch: any) => ({ id, ...patch })), + delete: vi.fn(async () => true), + } as any; +} + +function metadataFor(objects: unknown[]) { + const pages = [PAGE]; + return { + objects, + pages, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => pages, + getItem: async () => null, + getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? objects : []), + getTypeStatus: () => 'ready', + } as any; +} + +/** + * Mounts the record page and waits for the comment to show. `perms` null is + * the no-provider host (a standalone embed), where every grant reads open. + */ +async function mount(perms: MePermissionsResponse | null, dataSource = makeDataSource(), registry: unknown[] = REGISTRY.records) { + const page = ( + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + + ); + const view = render(perms ? {page} : page); + const scope = within(view.container); + await scope.findByText(COMMENT_BODY); + await act(async () => {}); + return { view: scope, dataSource }; +} + +type View = Awaited>['view']; + +const composer = (view: View) => view.queryByPlaceholderText(COMMENT_PLACEHOLDER); +const addReaction = (view: View) => view.queryByRole('button', { name: ADD_REACTION }); +/** The caller's own 👍 chip: a labelled button holding the emoji and its count. */ +const thumbsChip = (view: View) => + (view.queryAllByRole('button') as HTMLElement[]).find( + (b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '👍', + ); + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe("the record feed's comment composer reads feedComment: create on sys_comment (objectui#12082)", () => { + it.each([ + ['create-only', SHAPE.createOnly, true], + ['edit-only', SHAPE.editOnly, false], + ['read-only', SHAPE.readOnly, false], + ['full', SHAPE.full, true], + ] as const)('under a %s grant on sys_comment the composer shows exactly when create is held', async (_shape, bits, shown) => { + const { view } = await mount(answer({ [COMMENT]: bits, [REACTION]: SHAPE.full })); + expect(!!composer(view)).toBe(shown); + }); + + it('a caller whose sets name no sys_comment at all is offered no composer — the server refuses that post as ungranted', async () => { + const { view } = await mount(answer({ [REACTION]: SHAPE.full })); + expect(composer(view)).toBeNull(); + }); + + it('an effective operation set without create closes it, whatever the grant', async () => { + const { view } = await mount(answer({ [COMMENT]: { ...SHAPE.full, apiOperations: ['get', 'list', 'update'] }, [REACTION]: SHAPE.full })); + expect(composer(view)).toBeNull(); + }); + + it('CONTROL — with no permission provider the composer shows, as before', async () => { + const { view } = await mount(null); + expect(composer(view)).not.toBeNull(); + }); +}); + +describe("the record feed's reaction control reads feedReactionAdd / feedReactionRemove on the records store (objectui#12082)", () => { + it.each([ + ['create-only', SHAPE.createOnly, true], + ['edit-only', SHAPE.editOnly, false], + ['read-only', SHAPE.readOnly, false], + ['full', SHAPE.full, true], + ] as const)('under a %s grant on sys_comment_reaction the control is offered exactly when create is held', async (_shape, bits, offered) => { + const { view } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: bits })); + expect(!!addReaction(view)).toBe(offered); + // Withheld, the existing chips render disabled (the panel's own rule for a + // row with no handler). + expect(thumbsChip(view)).toBeDefined(); + expect((thumbsChip(view) as HTMLButtonElement).disabled).toBe(!offered); + }); + + it('a take-back asks the delete grant: with create but not delete, clicking the own reaction writes nothing and keeps it shown', async () => { + const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly })); + fireEvent.click(thumbsChip(view)!); + await act(async () => {}); + expect(dataSource.delete).not.toHaveBeenCalled(); + expect(thumbsChip(view)!.children[1].textContent).toBe('1'); + }); + + it('CONTROL — with the delete grant the same click takes the reaction back by deleting the own row', async () => { + const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full })); + fireEvent.click(thumbsChip(view)!); + await act(async () => {}); + expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]); + }); + + it('adding through the picker creates the own row where create is held', async () => { + const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.readOnly, [REACTION]: SHAPE.createOnly })); + fireEvent.click(addReaction(view)!); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: '🎉' })); + await act(async () => {}); + expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '🎉' }]]); + }); +}); + +describe('on the column store the reaction control reads feedReactionColumn: update on sys_comment (objectui#12082)', () => { + it.each([ + ['create-only', SHAPE.createOnly, false], + ['edit-only', SHAPE.editOnly, true], + ['read-only', SHAPE.readOnly, false], + ['full', SHAPE.full, true], + ] as const)('under a %s grant on sys_comment the control is offered exactly when update is held', async (_shape, bits, offered) => { + const { view } = await mount(answer({ [COMMENT]: bits }), makeDataSource(), REGISTRY.column); + expect(!!addReaction(view)).toBe(offered); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index f546227c11..173f1dd55d 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -311,6 +311,45 @@ interface ReactionLedger { */ const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; +/** The object the record feed's comments and replies are rows of. */ +const COMMENT_OBJECT = 'sys_comment'; + +/** + * Which writes the record feed may offer: its comment and reply composers, and + * its reaction control on either store (objectui#12082). + * + * Each is a row of the affordance-to-grant map (`resolveAffordance` in + * `@object-ui/core`), asked of the object it writes, with that object's schema + * as the registry serves it: the object's managed-object policy, the server's + * effective API operation set for it, AND the caller's grant on it. The grant + * is not a platform default the panel could assume: the `everyone` baseline + * names neither object, and an application grants them in its own sets, so a + * member whose sets do not was offered a composer whose post the server + * refused. With no permission provider mounted every grant reads open, as + * before. + * + * A composer or control whose row is closed is withheld: the panel draws each + * from its handler's presence (`RecordActivityTimeline`'s + * `showCommentInput`, the reply input and the reaction picker). + */ +function resolveFeedWriteGates( + objects: readonly unknown[] | undefined, + perms: AffordanceGrantPrincipal | null | undefined, +): { comment: boolean; reactionAdd: boolean; reactionRemove: boolean; reactionColumn: boolean } { + const source = (objectName: string) => ({ + objectSchema: ((objects ?? []).find((o) => (o as { name?: unknown } | null)?.name === objectName) ?? + null) as SchemaLike | null, + objectName, + perms, + }); + return { + comment: resolveAffordance('feedComment', source(COMMENT_OBJECT)).allowed, + reactionAdd: resolveAffordance('feedReactionAdd', source(COMMENT_REACTION_OBJECT)).allowed, + reactionRemove: resolveAffordance('feedReactionRemove', source(COMMENT_REACTION_OBJECT)).allowed, + reactionColumn: resolveAffordance('feedReactionColumn', source(COMMENT_OBJECT)).allowed, + }; +} + /** * How many comment ids one `sys_comment_reaction` read carries in its * `comment_id` `$in`. A record's comment read has no page size (it reads the @@ -2195,6 +2234,14 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri * deliver bell notifications, which is the expected degradation. */ + /** + * The feed's writes this caller may be offered, read from the + * affordance-to-grant map (objectui#12082; `resolveFeedWriteGates` above). + * Booleans, so the handlers below key on what they read, not on an object. + */ + const feedGates = resolveFeedWriteGates(objects, perms); + const reactionRemoveGranted = feedGates.reactionRemove; + /** * A comment is on the panel only once the server has it (objectui#10899, * maintainer ruling on cloud#2431: a failed comment write is never rendered @@ -2383,6 +2430,13 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri if (reactionStore === 'records') { const userId = currentUser.id; const want = !holdsOwnReaction(row.reactions, emoji, userId); + // The control is offered on the add row (`feedReactionAdd`, where it is + // handed over below); taking a reaction back is the delete of the + // caller's own row, its own row of the map. A click that would take + // back with that row closed writes nothing and shows nothing changed, + // rather than showing a take-back the server then refuses + // (objectui#12082). + if (!want && !reactionRemoveGranted) return; const commentKey = JSON.stringify([key, String(itemId)]); const writeKey = JSON.stringify([key, String(itemId), emoji]); const ownRows = () => { @@ -2518,7 +2572,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri () => settle('failed'), ); }, - [currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, reactionStore, t], + [currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, reactionStore, reactionRemoveGranted, t], ); useEffect(() => { @@ -3193,9 +3247,21 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri loading={feedLoading} activityDenied={feedRefusal?.activity ?? false} commentsDenied={feedRefusal?.comments ?? false} - onAddComment={handleAddComment as any} - onAddReply={handleAddReply as any} - onToggleReaction={handleToggleReaction as any} + // objectui#12082: each write is handed over only where its row of + // the affordance-to-grant map allows it, and the panel draws the + // composer, the reply input and the reaction picker from the + // handler's presence. The reaction control is the add row on the + // records store (its take-back asks the remove row per click), and + // the column row where the deployment stores reactions in a column. + onAddComment={feedGates.comment ? (handleAddComment as any) : undefined} + onAddReply={feedGates.comment ? (handleAddReply as any) : undefined} + onToggleReaction={ + (reactionStore === 'records' + ? feedGates.reactionAdd + : reactionStore === 'column' && feedGates.reactionColumn) + ? (handleToggleReaction as any) + : undefined + } mentionSuggestions={mentionSuggestions} > {/* No `approval` handler in the set below (objectui#3055): the record diff --git a/packages/core/src/utils/affordanceGrants.ts b/packages/core/src/utils/affordanceGrants.ts index c2dd9917ec..9f5c95b9ff 100644 --- a/packages/core/src/utils/affordanceGrants.ts +++ b/packages/core/src/utils/affordanceGrants.ts @@ -189,6 +189,28 @@ 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' }, + /** + * The record feed's comment composer and its reply composer, asked of + * `sys_comment`: each creates a comment row. + */ + feedComment: { crud: 'create', grant: 'create' }, + /** + * The record feed's reaction control, adding the caller's reaction (its Add + * button, or an emoji the caller has not reacted with), asked of + * `sys_comment_reaction`: it creates the caller's own reaction row. + */ + feedReactionAdd: { crud: 'create', grant: 'create' }, + /** + * The same control taking the caller's reaction back, asked of + * `sys_comment_reaction`: it deletes the caller's own reaction row. + */ + feedReactionRemove: { crud: 'delete', grant: 'delete' }, + /** + * The same control on a deployment with no `sys_comment_reaction` (the + * reactions column store), asked of `sys_comment`: a click updates the + * comment's `reactions` column. + */ + feedReactionColumn: { crud: 'edit', grant: 'update' }, /** 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. */ diff --git a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index c604771835..64c099c905 100644 --- a/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -117,6 +117,13 @@ const EXPECTED_GRANT: Record = { calendarQuickCreate: 'create', calendarReschedule: 'update', kanbanCardMove: 'update', + // The record feed's writes (the census backlog's leg B), each asked of the + // object it writes: a comment or reply, a reaction added or taken back, and + // a reaction on the column store. + feedComment: 'create', + feedReactionAdd: 'create', + feedReactionRemove: 'delete', + feedReactionColumn: 'update', }; /** The field rows, and which grant their no-entry fallback reads. */ @@ -645,16 +652,20 @@ const WRITE_SITES: Record = { 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"), + // The record feed (objectui#12082, leg B): each write sits behind its own row, + // asked of the object it writes. Neither object is in the platform's + // `everyone` baseline — an application grants them — so a member could be + // offered a composer whose post the server refused. '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'), + mapped(['feedComment'], RECORD_PAGE), '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'), + mapped(['feedComment'], RECORD_PAGE), '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"), + mapped(['feedReactionColumn'], RECORD_PAGE), '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"), + mapped(['feedReactionAdd'], RECORD_PAGE), '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"), + mapped(['feedReactionRemove'], RECORD_PAGE), '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': @@ -823,11 +834,6 @@ const WRITE_SITES: Record = { * done-when is a census with no `unmapped` entry, and then this list is empty. */ const UNMAPPED_BACKLOG: ReadonlySet = new Set([ - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddComment :: create', - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleAddReply :: create', - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction :: update', - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: create', - 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleToggleReaction > write :: delete', 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > addUsers :: create', 'packages/app-shell/src/views/metadata-admin/AssignedUsersSection.tsx :: AssignedUsersSection > removeUser :: delete', 'packages/app-shell/src/views/metadata-admin/PositionHoldersSection.tsx :: PositionHoldersSection > addHolders :: create', From 376ac2ea9d76a8802fb1cc3c917c6f3fe16b7ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:18:26 +0000 Subject: [PATCH 3/5] fix(plugin-detail): a related list's Add existing and fallback remove read the affordance-to-grant map (objectui#12082) Leg B of the write census, the related list's three sites. Add existing (the `add` picker) read no grant. Measured where its write goes, it is one of two, each a row the map already has: with a `linkField` it creates a link row of the list's own object (relatedNew, create on the list object); without one it re-parents the picked record (relatedRowEdit, update on the picked object). RelatedList withholds the button and its dialog where that row is closed. The fallback remove (an `add` config, no host delete) deleted with no grant read, and on a record page it put back exactly the delete the host bridge had withheld for a closed relatedRowDelete. It now reads relatedRowDelete of the list object, with the object's registered schema, so it can never re-open what the host closed. No host and no permission provider: still offered, as before. The three census entries move to mapped and leave the frozen backlog. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- packages/plugin-detail/src/RelatedList.tsx | 35 ++++- ...tedListRenderer.removeGrant-12082.test.tsx | 119 ++++++++++++++ .../RelatedList.addGrant-12082.test.tsx | 145 ++++++++++++++++++ .../src/renderers/record-related-list.tsx | 25 ++- .../src/affordanceGrantMap-12082.test.tsx | 19 ++- 5 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 packages/plugin-detail/src/__tests__/RecordRelatedListRenderer.removeGrant-12082.test.tsx create mode 100644 packages/plugin-detail/src/__tests__/RelatedList.addGrant-12082.test.tsx diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 3c5bbfc1b8..6f79ffef74 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -65,6 +65,7 @@ import { filterRefusalSubject, toFilterNodeSafely, userActionPredicates, + resolveAffordance, type FilterNode, } from '@object-ui/core'; import { useSafeFieldLabel } from '@object-ui/react'; @@ -1344,6 +1345,32 @@ export const RelatedList: React.FC = ({ setDeleteTarget(null); }, [deleteTarget, onRowDelete]); + /** + * May the caller Add existing here? The write's row of the affordance-to-grant + * map (`resolveAffordance` in `@object-ui/core`, objectui#12082), asked of the + * object the write goes to: + * + * - with a `linkField` the picked records become link rows of THIS list's + * object (`api`, the junction) — a create of the child, `relatedNew`, the + * row the list's "+ New" reads; + * - without one the picked record itself is re-parented — an update of the + * picked object (`picker.object`), `relatedRowEdit`, the row a related + * list row's Edit reads. + * + * The button and its dialog are withheld when the row is closed: the server + * refuses that write for this caller. With no permission provider every + * grant reads open, as before. + */ + const addGranted = !add + ? false + : add.linkField + ? resolveAffordance('relatedNew', { objectSchema, objectName: api, perms }).allowed + : resolveAffordance('relatedRowEdit', { + objectSchema: add.picker?.object === api ? objectSchema : null, + objectName: add.picker?.object, + perms, + }).allowed; + // Add existing records via picker → create link rows (junction) or re-parent // (1:m). Server-side insert rules (e.g. the AI-seat cap) surface inline. const handleAddRecords = React.useCallback(async (records: any[]) => { @@ -2320,8 +2347,10 @@ export const RelatedList: React.FC = ({ the Studio designer preview and context-free embeds (`renderers/record-related-list.tsx`). Same principle as #3838 one condition further: an affordance is offered only where the - capability behind it exists (objectui#3895). */} - {add && pickerObject && dataSource && ( + capability behind it exists (objectui#3895) — and, since + objectui#12082, only where the caller's grant allows the write + it ends in (`addGranted`, above). */} + {add && pickerObject && dataSource && addGranted && (