diff --git a/.changeset/12082-census-freeze-leg-b.md b/.changeset/12082-census-freeze-leg-b.md new file mode 100644 index 0000000000..5efec6f7c8 --- /dev/null +++ b/.changeset/12082-census-freeze-leg-b.md @@ -0,0 +1,35 @@ +--- +'@object-ui/core': minor +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +--- + +The record feed and a related list's Add existing and fallback remove read the +affordance-to-grant map, so a caller without the grant is no longer offered a +write the server then refuses (objectui#12082, leg B of the write-census +backlog). + +- **Four rows join `AFFORDANCE_GRANTS`** (`@object-ui/core`, a published value, + hence `minor`), each asked of the object the write goes to: `feedComment` (the + create grant on `sys_comment`), `feedReactionAdd` and `feedReactionRemove` + (the create and delete grants on `sys_comment_reaction`, the caller's own + reaction row) and `feedReactionColumn` (the update grant on `sys_comment`, + for a deployment that stores reactions in a column). +- **The record feed** (`@object-ui/app-shell`'s record page) hands its comment + composer, its reply input and its reaction control their handlers only where + the row allows the write; the panel draws each from its handler, so a closed + row withholds it. The platform's `everyone` baseline grants neither object; + an application does, so a member whose sets do not was offered a composer + whose post the server refused. On the reaction store, taking a reaction back + asks the delete grant: without it the click writes nothing. +- **A related list's Add existing** (`@object-ui/plugin-detail`) reads the row + of the write it ends in: with a `linkField`, a link row of the list's own + object (`relatedNew`, the create grant there); without one, a re-parent of the + picked record (`relatedRowEdit`, the update grant on `add.picker.object`). A + closed row withholds the button and its dialog. +- **The `record:related_list` fallback remove** (an `add` config with no host + delete) reads the list object's `relatedRowDelete` row with the object's + registered schema. On a record page the host withholds its delete exactly when + that row is closed, and the fallback used to put the delete back. + +With no permission provider mounted every one of these reads open, as before. diff --git a/content/docs/plugins/plugin-detail.mdx b/content/docs/plugins/plugin-detail.mdx index 1d2175bd65..6798bc078f 100644 --- a/content/docs/plugins/plugin-detail.mdx +++ b/content/docs/plugins/plugin-detail.mdx @@ -218,7 +218,16 @@ Three sources, in precedence order: 2. **A mounted discussion context** β€” the console's record page wraps the record body in one, carrying the merged `sys_comment` + `sys_activity` feed *and* the comment / reply / reaction handlers. When it is present the block - presents that feed instead of fetching a second, poorer copy. + presents that feed instead of fetching a second, poorer copy. The record page + hands each handler over only where the caller may make that write + (objectui#12082): the composer and the reply input need the **create** grant + on `sys_comment`; the reaction control needs the **create** grant on + `sys_comment_reaction`, and taking a reaction back its **delete** grant; on a + deployment with no `sys_comment_reaction` the control needs the **update** + grant on `sys_comment`. These are the `feedComment`, `feedReactionAdd`, + `feedReactionRemove` and `feedReactionColumn` rows of the affordance-to-grant + map in `@object-ui/core`, which also read each object's policy and effective + API operation set. With no permission provider mounted they read open. 3. **Self-fetch** β€” otherwise the block reads `sys_activity` scoped to the record it is bound to (`object_name` + `record_id`), newest first, `limit` rows per page. This is what makes it work on a hand-authored record page 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/README.md b/packages/core/README.md index ac0079497f..4cb5055b70 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -228,11 +228,19 @@ reads NO grant at all β€” the calendar's quick-create and drag-to-reschedule affordance that writes with the same verb on the same object reuses that row: the record overlay's inline edit reads `recordEdit`, a gantt's bar edits and a grid's bulk field update read `rowEdit`, a gantt row's Delete reads `rowDelete`, -and the AI build panel's spreadsheet import reads `listImport`. The map's +the AI build panel's spreadsheet import reads `listImport`, and a related list's +Add existing reads `relatedNew` (a link row) or `relatedRowEdit` (a re-parent). +The record feed's writes have rows of their own, each asked of the object it +writes: `feedComment` (a comment or reply, `sys_comment`), `feedReactionAdd` and +`feedReactionRemove` (the caller's own `sys_comment_reaction` row), and +`feedReactionColumn` (a reaction on a deployment that stores them in a +`sys_comment` column). The map's enumeration pin in `@object-ui/plugin-form` (`affordanceGrantMap-12082.test.tsx`) also counts every write call site in the console tree and holds each to the row it sits behind, or to the reason it has none β€” and, for a component whose write -its host gates (the import wizard), holds every mount to reading the row. +its host gates (the import wizard), holds every mount to reading the row. The +sites it found with no row yet form a frozen backlog: a new one cannot be added, +so a write affordance gets its row when it lands. ### Undo snapshot for an update (`captureUpdateUndoData`) 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-detail/README.md b/packages/plugin-detail/README.md index ee2c1794e9..c15b38bf97 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -568,6 +568,16 @@ add callback both required the adapter already, so without it the button used to render and do nothing at all when clicked β€” visible in hosts that supply no `RecordContext` (Studio designer previews, context-free embeds). +…and only where the caller may make the write it ends in (objectui#12082). With +a `linkField` the picked records become link rows of the list's own object: the +**create** grant there, the `relatedNew` row of the affordance-to-grant map in +`@object-ui/core`. Without one the picked record is re-parented: the **update** +grant on `add.picker.object`, the `relatedRowEdit` row. The remove that the +`record:related_list` renderer falls back to when an `add` config is present and +no host handed over a delete reads the list object's `relatedRowDelete` row, the +row the host's own delete reads, so it never puts back a delete the host +withheld. With no permission provider mounted these read open. + The `record:related_list` renderer is automatically gated on the current user's object-level `read` permission for the child object: when the permission system (`@object-ui/permissions`) is loaded and denies read, 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 && (