Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/26346-reaction-unreact-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
'@object-ui/core': patch
---

fix(app-shell): a member takes back their own comment reaction through the unreact door, with no object delete grant

A member whose grants are the every-member ones could add a reaction on the record feed and never take it back. The reaction chip removed through the generic data door's `DELETE /api/v1/data/sys_comment_reaction/:id`, which needs an object-level `delete` grant that no every-member set may carry (ADR-0090 D5), and the `feedReactionRemove` row of the affordance-to-grant map read that grant, so the chip offered no take-back at all.

Clause-②: no
Metadata-protocol: none

- The record page now removes the caller's own reaction row through the unreact door, `DELETE /api/v1/comments/reactions/:id`, which `@objectstack/plugin-audit` mounts. The door removes only the caller's own row; a refused take-back puts the reaction back on screen and says it was not saved, as before.
- `feedReactionRemove` in `@object-ui/core`'s `AFFORDANCE_GRANTS` now reads `{ crud: null, grant: 'create' }`: its write no longer goes through the data door, so no CRUD bit is read, and it asks the grant the reaction was added under. Whose reaction a chip shows still decides which chips offer the take-back: a click on another member's reaction adds the caller's own.
- A member who holds the `delete` grant sees the same behaviour: the take-back goes through the same door.
5 changes: 4 additions & 1 deletion ui/content/docs/plugins/plugin-detail.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ Three sources, in precedence order:
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
`sys_comment_reaction`, and taking the caller's own reaction back needs the
same grant: it goes through the unreact door
(`DELETE /api/v1/comments/reactions/:id`), which removes only the caller's
own row and asks no **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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
*
* - 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;
* `sys_comment_reaction`, with a take-back asking `feedReactionRemove` per
* click: the same create grant, since the take-back goes through the
* unreact door (`DELETE /api/v1/comments/reactions/:id`), which removes the
* caller's own row with no `delete` grant;
* - on a deployment with no `sys_comment_reaction` (the column store), the
* reaction control: `feedReactionColumn`, the update grant on `sys_comment`.
*
Expand All @@ -36,10 +38,13 @@ import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/pe
import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail';
import { effectiveOperationsArray, resolveEffectiveApiMethods } from '@objectstack/spec/data';

/** The page's authenticated fetch: the unreact door is called through it. */
const authFetch = vi.hoisted(() => vi.fn());

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ user: { id: 'u1', name: 'u1', image: null }, activeOrganization: null }),
createAuthenticatedFetch: () => vi.fn(),
createAuthenticatedFetch: () => authFetch,
}));

vi.mock('@object-ui/collaboration', async (importOriginal) => ({
Expand Down Expand Up @@ -69,6 +74,7 @@ vi.mock('./MetadataInspector', () => ({
}));

import { RecordDetailView } from './RecordDetailView';
import { toast } from 'sonner';

const COMMENT = 'sys_comment';
const REACTION = 'sys_comment_reaction';
Expand Down Expand Up @@ -127,18 +133,21 @@ function answer(feed: { [COMMENT]?: Bits; [REACTION]?: Bits }): MePermissionsRes
} as MePermissionsResponse;
}

/** One comment by another member; the caller (`u1`) has already reacted 👍 on it. */
function makeDataSource() {
/**
* One comment by another member; `reactor` (the caller `u1` unless named) has
* already reacted 👍 on it.
*/
function makeDataSource(reactor = 'u1') {
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'] }),
reactions: JSON.stringify({ '👍': [reactor] }),
};
const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: 'u1', created_at: '2026-10-10T09:00:00.000Z' };
const ownReaction = { id: 'r1', comment_id: COMMENT_ID, emoji: '👍', user_id: reactor, created_at: '2026-10-10T09:00:00.000Z' };
return {
find: vi.fn(async (objectName: string) => {
if (objectName === COMMENT) return { data: [{ ...comment }] };
Expand Down Expand Up @@ -210,7 +219,23 @@ const thumbsChip = (view: View) =>
(b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '👍',
);

/** The unreact door's answer to a removal of the caller's own row. */
const unreacted = (id: string) =>
new Response(JSON.stringify({ success: true, data: { id, deleted: true } }), {
status: 200,
headers: { 'content-type': 'application/json' },
});

/** The calls that reached the unreact door: `[url, init]`. */
const unreactCalls = () =>
authFetch.mock.calls.filter(([url]) => String(url).includes('/api/v1/comments/reactions/'));

beforeEach(() => {
authFetch.mockImplementation(async (url: string) =>
String(url).includes('/api/v1/comments/reactions/')
? unreacted(String(url).split('/').pop() as string)
: new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }),
);
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Expand Down Expand Up @@ -267,19 +292,22 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio
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 () => {
it('a take-back reads no delete grant: with create but not delete, clicking the own reaction removes it through the unreact door', async () => {
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly }));
fireEvent.click(thumbsChip(view)!);
await act(async () => {});
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]);
expect(dataSource.delete).not.toHaveBeenCalled();
expect(thumbsChip(view)!.children[1].textContent).toBe('1');
expect(thumbsChip(view)).toBeUndefined();
});

it('CONTROL — with the delete grant the same click takes the reaction back by deleting the own row', async () => {
it('CONTROL — with the delete grant the same click takes the reaction back through the same door, never the data door', 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']]);
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/r1', { method: 'DELETE', credentials: 'include' }]]);
expect(dataSource.delete).not.toHaveBeenCalled();
expect(thumbsChip(view)).toBeUndefined();
});

it('adding through the picker creates the own row where create is held', async () => {
Expand All @@ -291,6 +319,51 @@ describe("the record feed's reaction control reads feedReactionAdd / feedReactio
});
});

describe("a take-back goes through the unreact door, and only on the caller's own reaction (objectstack#26346)", () => {
it('a member with no grant beyond create on sys_comment_reaction adds a reaction and takes it back', 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 () => {});
const party = () =>
(view.queryAllByRole('button') as HTMLElement[]).find(
(b) => !!b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === '🎉',
);
expect(party()).toBeDefined();
fireEvent.click(party()!);
await act(async () => {});
expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '🎉' }]]);
expect(unreactCalls()).toEqual([['/api/v1/comments/reactions/new-row', { method: 'DELETE', credentials: 'include' }]]);
expect(dataSource.delete).not.toHaveBeenCalled();
expect(party()).toBeUndefined();
});

it("another member's reaction offers no take-back: a click on their chip adds the caller's own row and reaches no door that removes", async () => {
const { view, dataSource } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.full }), makeDataSource('u2'));
fireEvent.click(thumbsChip(view)!);
await act(async () => {});
expect(dataSource.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '👍' }]]);
expect(unreactCalls()).toEqual([]);
expect(dataSource.delete).not.toHaveBeenCalled();
expect(thumbsChip(view)!.children[1].textContent).toBe('2');
});

it('a take-back the door refuses puts the reaction back and says it was not saved', async () => {
authFetch.mockImplementation(async () =>
new Response(JSON.stringify({ success: false, error: { code: 'PERMISSION_DENIED' } }), {
status: 403,
headers: { 'content-type': 'application/json' },
}),
);
const { view } = await mount(answer({ [COMMENT]: SHAPE.full, [REACTION]: SHAPE.createOnly }));
fireEvent.click(thumbsChip(view)!);
await act(async () => {});
expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']);
expect(thumbsChip(view)!.children[1].textContent).toBe('1');
expect(toast.error).toHaveBeenCalledTimes(1);
});
});

/**
* The two feed objects as objectstack registers them (`plugin-audit`): their
* policy keys (`managedBy`, `userActions`) and the `enable` block the server
Expand Down Expand Up @@ -336,7 +409,8 @@ describe('against the registered feed schemas in the page objects (objectui#1208
expect(addReaction(view)).not.toBeNull();
fireEvent.click(thumbsChip(view)!);
await act(async () => {});
expect(dataSource.delete.mock.calls).toEqual([[REACTION, 'r1']]);
expect(unreactCalls().map(([url]) => url)).toEqual(['/api/v1/comments/reactions/r1']);
expect(dataSource.delete).not.toHaveBeenCalled();
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
* left only the later write stored. Now, where the deployment has the object,
* the chatter reads every comment's reaction rows in one batched `comment_id`
* `$in` read, groups them into the shape the panel already renders, and a click
* creates or deletes the clicker's own row. Where the deployment has no such
* creates the clicker's own row, or removes it through the unreact door
* (`DELETE /api/v1/comments/reactions/:id`, objectstack#26346). Where the deployment has no such
* object (a framework that predates it), the column path stays, unchanged: the
* `objectui#11019` / `objectui#10899` / `objectui#11035` pins beside this file
* run on that path.
Expand All @@ -20,7 +21,8 @@
* source is a fake SERVER shared by every mounted view: each view's adapter is
* bound to its signed-in member, and the server keeps the object's declared
* rules: `user_id` stamped from that member, one row per (comment, emoji,
* user), and a member deletes only their own row. Which store the page uses is
* user), and a member removes only their own row, through the unreact door
* the page's authenticated fetch reaches. Which store the page uses is
* read from the object registry the page's metadata context serves.
*/

Expand All @@ -36,6 +38,9 @@ import { toast } from 'sonner';
/** The signed-in member is read from a context, so two views can be two members at once. */
const signedIn = vi.hoisted(() => ({ ctx: null as unknown as React.Context<string> }));

/** The unreact door of the member a view is being mounted as: the page's authenticated fetch reaches it. */
const mounting = vi.hoisted(() => ({ door: null as unknown as (url: string, init?: RequestInit) => Promise<Response> }));

vi.mock('@object-ui/auth', async (importOriginal) => {
const ReactModule = await import('react');
signedIn.ctx = ReactModule.createContext('u1');
Expand All @@ -45,7 +50,12 @@ vi.mock('@object-ui/auth', async (importOriginal) => {
const id = ReactModule.useContext(signedIn.ctx);
return { user: { id, name: id, image: null }, activeOrganization: null };
},
createAuthenticatedFetch: () => vi.fn(),
// Only the unreact door is answered; every other call answers nothing, as before.
createAuthenticatedFetch: () => {
const door = mounting.door;
return (url: string, init?: RequestInit) =>
String(url).includes('/api/v1/comments/reactions/') ? door(url, init) : undefined;
},
};
});

Expand Down Expand Up @@ -136,7 +146,8 @@ const refusal = (status: number, code: string) =>
/**
* One server shared by every view a case mounts. `as(member)` is that member's
* adapter: what they create is stamped as theirs, and they delete only rows
* that are theirs.
* that are theirs. Its `unreact` is that member's unreact door: it removes the
* member's own row and refuses anyone else's, as the server's door does.
*/
function makeServer(init: { comments?: CommentRow[]; reactions?: Omit<ReactionRow, 'id' | 'created_at'>[] } = {}) {
const comments: CommentRow[] = init.comments ?? [
Expand Down Expand Up @@ -192,6 +203,18 @@ function makeServer(init: { comments?: CommentRow[]; reactions?: Omit<ReactionRo
reactions.splice(at, 1);
return true;
}),
unreact: vi.fn(async (url: string, init?: RequestInit) => {
const answer = (status: number, body: unknown) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const id = decodeURIComponent(String(url).split('/api/v1/comments/reactions/')[1]);
if (init?.method !== 'DELETE') return answer(404, { success: false });
const at = reactions.findIndex((r) => r.id === id);
if (at < 0) return answer(404, { success: false, error: { code: 'RECORD_NOT_FOUND' } });
// The door's own rule: the row's reactor is the caller.
if (reactions[at].user_id !== member) return answer(403, { success: false, error: { code: 'PERMISSION_DENIED' } });
reactions.splice(at, 1);
return answer(200, { success: true, data: { id, deleted: true } });
}),
});

return { comments, reactions, as };
Expand Down Expand Up @@ -234,6 +257,7 @@ function tree(member: string, dataSource: any, metadata: any) {

/** Mounts the record page as `member` and waits for the comment to show. */
async function mountAs(member: string, dataSource: any, objects: unknown[] = REGISTRY.present) {
mounting.door = dataSource.unreact;
const view = render(tree(member, dataSource, metadataFor(objects)));
await within(view.container).findByText(COMMENT_BODY);
// Let the reaction read that follows the comment read land.
Expand Down Expand Up @@ -283,6 +307,11 @@ async function click(run: () => void) {
await act(async () => {});
}

/** The row ids a member's take-backs removed through the unreact door, in order. */
function unreacted(dataSource: any): string[] {
return dataSource.unreact.mock.calls.map(([url]: [string]) => decodeURIComponent(url.split('/').pop() as string));
}

/** Every `sys_comment_reaction` read a data source was asked, as its `$in` id lists. */
function reactionReads(dataSource: any): string[][] {
return dataSource.find.mock.calls
Expand Down Expand Up @@ -332,7 +361,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
expect(isOwn(again, '👍')).toBe(true);
});

it('a second click removes it: the clicker deletes their own row, by the id the create returned and by the id the read returned', async () => {
it('a second click removes it: the clicker removes their own row through the unreact door, by the id the create returned and by the id the read returned', async () => {
const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '👍', user_id: 'u2' }] });
const u1 = server.as('u1');
const view = await mountAs('u1', u1);
Expand All @@ -342,11 +371,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
expect(chips(view)).toEqual(['👍 1', '🎉 1']);

await click(() => fireEvent.click(chip(view, '🎉')));
expect(u1.delete.mock.calls).toEqual([[REACTION, created.id]]);
expect(unreacted(u1)).toEqual([created.id]);
expect(u1.delete).not.toHaveBeenCalled();
expect(server.reactions.map((r) => [r.emoji, r.user_id])).toEqual([['👍', 'u2']]);
expect(chips(view)).toEqual(['👍 1']);

// A row the read found: its id is the one the take-back deletes.
// A row the read found: its id is the one the take-back removes.
cleanup();
const before = makeServer({
reactions: [
Expand All @@ -359,7 +389,8 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
const read = await mountAs('u1', reader);
expect(chips(read)).toEqual(['👍 2']);
await click(() => fireEvent.click(chip(read, '👍')));
expect(reader.delete.mock.calls).toEqual([[REACTION, mine.id]]);
expect(unreacted(reader)).toEqual([mine.id]);
expect(reader.delete).not.toHaveBeenCalled();
expect(before.reactions.map((r) => r.user_id)).toEqual(['u2']);
expect(chips(read)).toEqual(['👍 1']);
expect(toast.error).not.toHaveBeenCalled();
Expand Down Expand Up @@ -452,7 +483,7 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
expect(server.reactions.map((r) => r.user_id)).toEqual(['u2']);
});

it('a take-back clicked before the create answers deletes the row that create made', async () => {
it('a take-back clicked before the create answers removes the row that create made', async () => {
const server = makeServer();
const u1 = server.as('u1');
let answer!: () => void;
Expand All @@ -471,11 +502,12 @@ describe("a comment's reactions are each member's own sys_comment_reaction recor
expect(chips(view)).toEqual([]);
await act(async () => {});
// The take-back waits for the row it takes back.
expect(u1.delete).not.toHaveBeenCalled();
expect(u1.unreact).not.toHaveBeenCalled();

await act(async () => answer());
await waitFor(() => expect(u1.delete).toHaveBeenCalledTimes(1));
expect(u1.delete.mock.calls[0]).toEqual([REACTION, 'r1']);
await waitFor(() => expect(u1.unreact).toHaveBeenCalledTimes(1));
expect(unreacted(u1)).toEqual(['r1']);
expect(u1.delete).not.toHaveBeenCalled();
expect(server.reactions).toEqual([]);
expect(chips(view)).toEqual([]);
expect(toast.error).not.toHaveBeenCalled();
Expand Down
Loading
Loading