From b886ae32f7b021a243e572ded153b7798bea0639 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 08:42:30 +0000 Subject: [PATCH 1/2] fix(app-shell): the chatter reads and writes reactions as the member's own sys_comment_reaction records A reaction click wrote the comment's whole sys_comment.reactions set back with one update, so two members reacting at once left only the later write. Where the deployment has sys_comment_reaction, the chatter now reads the comments' reaction rows in one batched comment_id $in read (paged at 100 ids), groups them into the shape the panel renders, and a click creates or deletes the clicker's own row. Where the object registry earns "absent" (a framework that predates the object), the column path is kept unchanged. Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- .changeset/12078-reaction-records.md | 14 + ...dDetailView.reactionKeepIds-11019.test.tsx | 10 +- ...dDetailView.reactionRecords-12078.test.tsx | 533 ++++++++++++++++++ ...ilView.reactionWriteFailure-10899.test.tsx | 10 +- ...ew.reactionsCommentRowsOnly-11035.test.tsx | 10 +- .../app-shell/src/views/RecordDetailView.tsx | 287 +++++++++- 6 files changed, 845 insertions(+), 19 deletions(-) create mode 100644 .changeset/12078-reaction-records.md create mode 100644 packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx diff --git a/.changeset/12078-reaction-records.md b/.changeset/12078-reaction-records.md new file mode 100644 index 0000000000..222c8afdbe --- /dev/null +++ b/.changeset/12078-reaction-records.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': patch +--- + +The record chatter stores each reaction as the member's own `sys_comment_reaction` record, and stops writing the whole `sys_comment.reactions` column (objectui#12078; ruling A, amended, on objectstack-ai/objectstack#22505). + +A reaction click used to write the comment's whole reaction set back with one `sys_comment` update. Two members reacting at the same moment therefore left only the later write stored. A member who could read a record but not edit it could not react to another member's comment either, because that update is the comment author's (or a parent editor's) to make. + +- **Read.** After the comment read, the chatter reads the comments' reactions in one `sys_comment_reaction` read, `comment_id` `$in` the comment ids, and groups the rows into the reactions the panel already renders. It never reads per comment. A thread of more than 100 comments is read in pages of 100 ids, in parallel, which keeps each request URL near 4.6 KB. +- **Write.** A click creates the member's own reaction row (`comment_id`, `emoji`; the server stamps `user_id`), and a second click deletes that row. Nothing else is written, so another member's reaction can no longer be overwritten, and both of two simultaneous reactions stay. A refused write puts the reaction back and shows the same "Your reaction was not saved" error as before. +- **Reactions stored only in the column are not shown** where the object exists. The maintainer ruled that the column's reactions need no migration. +- **A deployment without `sys_comment_reaction` keeps the column path, unchanged.** This covers a framework that predates objectstack-ai/objectstack#22566, such as cloud's v17 pin. The chatter asks the object registry it already loads. Only an earned "absent" answer keeps the column path; a registry that lists nothing, or has not answered, is not read as absence. The comment read waits until the registry has answered. + +Nothing on the package entry changes: no export, prop, type member or language-pack key. `CommentThread` (`@object-ui/collaboration`) and `Reaction` (`@object-ui/types`) are untouched. diff --git a/packages/app-shell/src/views/RecordDetailView.reactionKeepIds-11019.test.tsx b/packages/app-shell/src/views/RecordDetailView.reactionKeepIds-11019.test.tsx index 521d140381..e12296da53 100644 --- a/packages/app-shell/src/views/RecordDetailView.reactionKeepIds-11019.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.reactionKeepIds-11019.test.tsx @@ -17,6 +17,11 @@ * data source whose `sys_comment` `update` payload is the observed channel. The * signed-in user is the varied axis, so one case can hand the row a first user * stored and read it back as a second user. + * + * Since objectui#12078 this is the column store, which the page uses only where + * the deployment has no `sys_comment_reaction` (the registry below lists none). + * Where it has one, a click writes the member's own reaction row instead: + * `RecordDetailView.reactionRecords-12078.test.tsx`. */ import * as React from 'react'; @@ -124,7 +129,10 @@ async function mountAs(userId: string, dataSource: any) { invalidate: () => {}, ensureType: async () => pages, getItem: async () => null, - getItemsByType: (type: string) => (type === 'page' ? pages : []), + // The registry lists this deployment's objects, and `sys_comment_reaction` + // is not one of them, so the page keeps reactions in the + // `sys_comment.reactions` column (objectui#12078). + getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? OBJECTS : []), } as any; render( diff --git a/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx b/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx new file mode 100644 index 0000000000..2705cc35eb --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx @@ -0,0 +1,533 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A comment's reactions are each member's own `sys_comment_reaction` records + * (objectui#12078; ruling A amended on objectstack-ai/objectstack#22505, the + * object landed by objectstack-ai/objectstack#22566). + * + * A reaction click used to write the comment's WHOLE `sys_comment.reactions` + * set back through one `update`, so two members reacting at the same moment + * 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 + * 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. + * + * Real subjects: `RecordDetailView` rendering a record page that composes + * `record:discussion`, driven through the real `ReactionPicker`. The data + * 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 + * read from the object registry the page's metadata context serves. + */ + +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, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { DETAIL_DEFAULT_TRANSLATIONS } from '@object-ui/plugin-detail'; +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 })); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const ReactModule = await import('react'); + signedIn.ctx = ReactModule.createContext('u1'); + return { + ...(await importOriginal>()), + useAuth: () => { + const id = ReactModule.useContext(signedIn.ctx); + return { user: { id, name: id, 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 REACTION = 'sys_comment_reaction'; +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'; +/** The picker's emoji set; the chips below are read against it. */ +const EMOJI = ['๐Ÿ‘', 'โค๏ธ', '๐ŸŽ‰', '๐Ÿ˜‚', '๐Ÿ˜ฎ', '๐Ÿ˜ข']; + +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 each case serves: with the reaction object, without it, or listing nothing. */ +const REGISTRY = { + present: [...OBJECTS, { name: REACTION, label: 'Comment Reaction' }], + absent: OBJECTS, + empty: [] as unknown[], +}; + +interface ReactionRow { + id: string; + comment_id: string; + emoji: string; + user_id: string; + created_at: string; +} + +interface CommentRow { + id: string; + thread_id: string; + author_id: string; + author_name: string; + body: string; + created_at: string; + reactions?: string; +} + +const refusal = (status: number, code: string) => + Object.assign(new Error(code), { httpStatus: status, status, code }); + +/** + * 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. + */ +function makeServer(init: { comments?: CommentRow[]; reactions?: Omit[] } = {}) { + const comments: CommentRow[] = init.comments ?? [ + { + id: COMMENT_ID, + thread_id: THREAD, + author_id: 'u3', + author_name: 'u3', + body: COMMENT_BODY, + created_at: '2026-10-10T08:00:00.000Z', + }, + ]; + let seq = 0; + const reactions: ReactionRow[] = []; + const insert = (row: Omit) => { + seq += 1; + const stored = { ...row, id: `r${seq}`, created_at: new Date(Date.UTC(2026, 9, 10, 9, 0, seq)).toISOString() }; + reactions.push(stored); + return stored; + }; + for (const row of init.reactions ?? []) insert(row); + + const as = (member: string) => ({ + find: vi.fn(async (objectName: string, params?: any) => { + if (objectName === 'sys_comment') return { data: comments.map((c) => ({ ...c })) }; + if (objectName === REACTION) { + const ids: string[] = params?.$filter?.comment_id?.$in ?? []; + return { data: reactions.filter((r) => ids.includes(r.comment_id)).map((r) => ({ ...r })) }; + } + return { data: [] }; + }), + findOne: vi.fn(async (_o: string, id: string) => ({ id, name: `Record ${id}` })), + create: vi.fn(async (objectName: string, data: any) => { + if (objectName !== REACTION) return data; + // The declared unique index, (comment_id, emoji, user_id). + if (reactions.some((r) => r.comment_id === data.comment_id && r.emoji === data.emoji && r.user_id === member)) { + throw refusal(409, 'UNIQUE_VIOLATION'); + } + // `user_id` is stamped from the session; a client value never wins. + return { ...insert({ comment_id: data.comment_id, emoji: data.emoji, user_id: member }) }; + }), + update: vi.fn(async (objectName: string, id: string, patch: any) => { + const comment = objectName === 'sys_comment' ? comments.find((c) => c.id === id) : undefined; + if (comment) Object.assign(comment, patch); + return { ...comment }; + }), + delete: vi.fn(async (objectName: string, id: string) => { + if (objectName !== REACTION) return true; + const at = reactions.findIndex((r) => r.id === id); + if (at < 0) throw refusal(404, 'RECORD_NOT_FOUND'); + // The platform's own-record delete floor. + if (reactions[at].user_id !== member) throw refusal(403, 'PERMISSION_DENIED'); + reactions.splice(at, 1); + return true; + }), + }); + + return { comments, reactions, as }; +} + +function metadataFor(objects: unknown[], status: 'loading' | 'ready' = 'ready') { + 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: () => status, + } as any; +} + +function tree(member: string, dataSource: any, metadata: any) { + return ( + + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + + + ); +} + +/** Mounts the record page as `member` and waits for the comment to show. */ +async function mountAs(member: string, dataSource: any, objects: unknown[] = REGISTRY.present) { + 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. + await act(async () => {}); + return within(view.container); +} + +type View = ReturnType; + +/** The comment's reaction chips, in order, each as `emoji count`. */ +function chips(view: View): string[] { + return view + .queryAllByRole('button') + .filter((b) => b.getAttribute('aria-label') && b.children.length === 2 && EMOJI.includes(b.children[0].textContent ?? '')) + .map((b) => `${b.children[0].textContent} ${b.children[1].textContent}`); +} + +function chip(view: View, emoji: string): HTMLElement { + const found = view + .queryAllByRole('button') + .find((b) => b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === emoji); + if (!found) throw new Error(`no ${emoji} chip on the panel (chips: ${chips(view).join(', ') || 'none'})`); + return found; +} + +/** Whether the panel shows `emoji` as the signed-in member's own reaction. */ +function isOwn(view: View, emoji: string): boolean { + const add = view.getByRole('button', { name: ADD_REACTION }); + fireEvent.click(add); + const option = within(screen.getByRole('listbox')).getByRole('option', { name: emoji }); + const own = option.getAttribute('aria-selected') === 'true'; + fireEvent.click(add); + return own; +} + +/** Toggles `emoji` through the emoji picker (for a reaction with no chip yet). */ +function pick(view: View, emoji: string) { + fireEvent.click(view.getByRole('button', { name: ADD_REACTION })); + fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: emoji })); +} + +/** Runs a click, then lets the writes it queued answer before going on. */ +async function click(run: () => void) { + run(); + await act(async () => {}); +} + +/** 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 + .filter(([objectName]: [string]) => objectName === REACTION) + .map(([, params]: [string, any]) => params.$filter.comment_id.$in); +} + +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("a comment's reactions are each member's own sys_comment_reaction records (objectui#12078)", () => { + it("a member's click on another member's comment creates their own reaction record, and the feed renders it grouped", async () => { + // The comment is u3's; u2 has already reacted. + const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }] }); + const u1 = server.as('u1'); + const view = await mountAs('u1', u1); + expect(chips(view)).toEqual(['๐Ÿ‘ 1']); + expect(isOwn(view, '๐Ÿ‘')).toBe(false); + + await click(() => fireEvent.click(chip(view, '๐Ÿ‘'))); + // u1's own row, and nothing else: no user id from the client, no comment write. + expect(u1.create.mock.calls).toEqual([[REACTION, { comment_id: COMMENT_ID, emoji: '๐Ÿ‘' }]]); + expect(u1.update).not.toHaveBeenCalled(); + expect(server.reactions.map((r) => [r.emoji, r.user_id])).toEqual([ + ['๐Ÿ‘', 'u2'], + ['๐Ÿ‘', 'u1'], + ]); + expect(chips(view)).toEqual(['๐Ÿ‘ 2']); + expect(isOwn(view, '๐Ÿ‘')).toBe(true); + + // A fresh read groups the two rows into one chip, the clicker's marked own. + cleanup(); + const again = await mountAs('u1', server.as('u1')); + expect(chips(again)).toEqual(['๐Ÿ‘ 2']); + 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 () => { + const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }] }); + const u1 = server.as('u1'); + const view = await mountAs('u1', u1); + + await click(() => pick(view, '๐ŸŽ‰')); + const [created] = server.reactions.filter((r) => r.user_id === 'u1'); + expect(chips(view)).toEqual(['๐Ÿ‘ 1', '๐ŸŽ‰ 1']); + + await click(() => fireEvent.click(chip(view, '๐ŸŽ‰'))); + expect(u1.delete.mock.calls).toEqual([[REACTION, created.id]]); + 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. + cleanup(); + const before = makeServer({ + reactions: [ + { comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }, + { comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u1' }, + ], + }); + const mine = before.reactions.find((r) => r.user_id === 'u1')!; + const reader = before.as('u1'); + 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(before.reactions.map((r) => r.user_id)).toEqual(['u2']); + expect(chips(read)).toEqual(['๐Ÿ‘ 1']); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('two members reacting at once both show', async () => { + const server = makeServer(); + const u1 = server.as('u1'); + const u2 = server.as('u2'); + // Both members have the comment open, and both read it with no reactions. + const first = await mountAs('u1', u1); + const second = await mountAs('u2', u2); + expect(chips(first)).toEqual([]); + expect(chips(second)).toEqual([]); + + // Both react before either write has answered. + pick(first, '๐Ÿ‘'); + pick(second, '๐Ÿ‘'); + await act(async () => {}); + + // The comment's author reads it afterwards and sees both reactions. + const author = await mountAs('u3', server.as('u3')); + expect(chips(author)).toEqual(['๐Ÿ‘ 2']); + expect(isOwn(author, '๐Ÿ‘')).toBe(false); + }); + + it('the feed reads every commentโ€™s reactions in one batched read, and pages it only past 100 comment ids', async () => { + const thread = (n: number): CommentRow[] => + Array.from({ length: n }, (_, i) => ({ + id: `c${i + 1}`, + thread_id: THREAD, + author_id: 'u3', + author_name: 'u3', + body: i === 0 ? COMMENT_BODY : `Comment ${i + 1}`, + created_at: new Date(Date.UTC(2026, 9, 10, 8, 0, i)).toISOString(), + })); + + const three = makeServer({ comments: thread(3), reactions: [{ comment_id: 'c3', emoji: '๐ŸŽ‰', user_id: 'u2' }] }); + const small = three.as('u1'); + const view = await mountAs('u1', small); + expect(reactionReads(small)).toEqual([['c1', 'c2', 'c3']]); + // Grouped onto the comment it is on, and only that one. + expect(chips(view)).toEqual(['๐ŸŽ‰ 1']); + + cleanup(); + const busy = makeServer({ comments: thread(250) }); + const large = busy.as('u1'); + render(tree('u1', large, metadataFor(REGISTRY.present))); + await waitFor(() => expect(reactionReads(large)).toHaveLength(3)); + const pages = reactionReads(large); + expect(pages.map((ids) => ids.length)).toEqual([100, 100, 50]); + expect(pages.flat()).toEqual(thread(250).map((c) => c.id)); + }); + + it('a reaction stored only in the retired sys_comment.reactions column is not shown on the records path', async () => { + // The maintainer ruled no migration of the column's reactions + // (objectstack-ai/objectstack#22505); this is what a member sees. + const server = makeServer({ + comments: [ + { + 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({ '๐Ÿ‘': ['u2'] }), + }, + ], + }); + const view = await mountAs('u1', server.as('u1')); + expect(chips(view)).toEqual([]); + }); + + it('a refused create puts the reaction back and raises the error once; nothing stored is lost', async () => { + const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }] }); + const u1 = server.as('u1'); + u1.create.mockImplementationOnce(async () => { + throw refusal(403, 'PERMISSION_DENIED'); + }); + const view = await mountAs('u1', u1); + + fireEvent.click(chip(view, '๐Ÿ‘')); + expect(chips(view)).toEqual(['๐Ÿ‘ 2']); + await act(async () => {}); + + await waitFor(() => expect(chips(view)).toEqual(['๐Ÿ‘ 1'])); + expect(isOwn(view, '๐Ÿ‘')).toBe(false); + expect(toast.error).toHaveBeenCalledTimes(1); + 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 () => { + const server = makeServer(); + const u1 = server.as('u1'); + let answer!: () => void; + const held = new Promise((resolve) => { + answer = resolve; + }); + const create = u1.create.getMockImplementation()!; + u1.create.mockImplementationOnce(async (...args: [string, any]) => { + await held; + return create(...args); + }); + const view = await mountAs('u1', u1); + + pick(view, '๐Ÿ‘'); + fireEvent.click(chip(view, '๐Ÿ‘')); + expect(chips(view)).toEqual([]); + await act(async () => {}); + // The take-back waits for the row it takes back. + expect(u1.delete).not.toHaveBeenCalled(); + + await act(async () => answer()); + await waitFor(() => expect(u1.delete).toHaveBeenCalledTimes(1)); + expect(u1.delete.mock.calls[0]).toEqual([REACTION, 'r1']); + expect(server.reactions).toEqual([]); + expect(chips(view)).toEqual([]); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); + +describe('which store the chatter uses is read from the object registry (objectui#12078)', () => { + it('a deployment without sys_comment_reaction keeps the column path: no reaction read, and the click writes sys_comment.reactions', async () => { + const server = makeServer({ + comments: [ + { + 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({ '๐Ÿ‘': ['u2'] }), + }, + ], + }); + const u1 = server.as('u1'); + const view = await mountAs('u1', u1, REGISTRY.absent); + expect(chips(view)).toEqual(['๐Ÿ‘ 1']); + expect(reactionReads(u1)).toEqual([]); + + await click(() => fireEvent.click(chip(view, '๐Ÿ‘'))); + expect(u1.create).not.toHaveBeenCalled(); + expect(u1.update).toHaveBeenCalledTimes(1); + const [objectName, id, patch] = u1.update.mock.calls[0]; + expect([objectName, id]).toEqual(['sys_comment', COMMENT_ID]); + expect(JSON.parse(patch.reactions)).toEqual({ '๐Ÿ‘': ['u2', 'u1'] }); + expect(chips(view)).toEqual(['๐Ÿ‘ 2']); + }); + + it('a registry that lists no objects is not evidence of absence: the records path', async () => { + const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }] }); + const u1 = server.as('u1'); + const view = await mountAs('u1', u1, REGISTRY.empty); + expect(reactionReads(u1)).toEqual([[COMMENT_ID]]); + expect(chips(view)).toEqual(['๐Ÿ‘ 1']); + }); + + it('the comment read waits until the registry has answered', async () => { + const server = makeServer({ reactions: [{ comment_id: COMMENT_ID, emoji: '๐Ÿ‘', user_id: 'u2' }] }); + const u1 = server.as('u1'); + const view = render(tree('u1', u1, metadataFor(REGISTRY.present, 'loading'))); + await act(async () => {}); + expect(u1.find.mock.calls.filter(([objectName]: [string]) => objectName === 'sys_comment')).toEqual([]); + + view.rerender(tree('u1', u1, metadataFor(REGISTRY.present, 'ready'))); + await within(view.container).findByText(COMMENT_BODY); + await act(async () => {}); + expect(reactionReads(u1)).toEqual([[COMMENT_ID]]); + expect(chips(within(view.container))).toEqual(['๐Ÿ‘ 1']); + }); +}); diff --git a/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx b/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx index 2679bbf86f..0a298cc322 100644 --- a/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.reactionWriteFailure-10899.test.tsx @@ -22,6 +22,11 @@ * The panel is read the way a user reads it: each chip's emoji and count from * its text, and whether a reaction is the signed-in user's own from the emoji * picker's `aria-selected` (the chip itself marks that only by colour). + * + * Since objectui#12078 this is the column store, which the page uses only where + * the deployment has no `sys_comment_reaction` (the registry below lists none). + * Where it has one, a click writes the member's own reaction row instead: + * `RecordDetailView.reactionRecords-12078.test.tsx`. */ import * as React from 'react'; @@ -139,7 +144,10 @@ function mount(dataSource: any, wrap: (tree: React.ReactElement) => React.ReactE invalidate: () => {}, ensureType: async () => pages, getItem: async () => null, - getItemsByType: (type: string) => (type === 'page' ? pages : []), + // The registry lists this deployment's objects, and `sys_comment_reaction` + // is not one of them, so the page keeps reactions in the + // `sys_comment.reactions` column (objectui#12078). + getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? OBJECTS : []), } as any; return render( wrap( diff --git a/packages/app-shell/src/views/RecordDetailView.reactionsCommentRowsOnly-11035.test.tsx b/packages/app-shell/src/views/RecordDetailView.reactionsCommentRowsOnly-11035.test.tsx index f848451dba..ceaf94c991 100644 --- a/packages/app-shell/src/views/RecordDetailView.reactionsCommentRowsOnly-11035.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.reactionsCommentRowsOnly-11035.test.tsx @@ -17,6 +17,11 @@ * `sys_activity` row. The observed channel is the data source's `update` calls. * A small probe mounted on the same page reads the page's `DiscussionContext`, * so the handler is also driven with an id no panel control hands it any more. + * + * Since objectui#12078 this runs on the column store, which the page uses only + * where the deployment has no `sys_comment_reaction` (the registry below lists + * none). The handler refuses a non-comment id before it picks a store, so the + * reaction-row store is refused the same id by the same check. */ import * as React from 'react'; @@ -157,7 +162,10 @@ async function mount(dataSource: any, { withProbe = false } = {}) { invalidate: () => {}, ensureType: async () => pages, getItem: async () => null, - getItemsByType: (type: string) => (type === 'page' ? pages : []), + // The registry lists this deployment's objects, and `sys_comment_reaction` + // is not one of them, so the page keeps reactions in the + // `sys_comment.reactions` column (objectui#12078). + getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? OBJECTS : []), } as any; render( diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 71b141ab98..271ae031ae 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -66,6 +66,7 @@ import { parseAuditValue, collectAuditChanges, collectLookupIds, formatAuditValu import { useFavorites } from '../hooks/useFavorites.js'; import { useActionModal } from '../hooks/useActionModal.js'; import { useRecentItems } from '../hooks/useRecentItems.js'; +import { useObjectPresence } from '../hooks/useObjectPresence.js'; interface RecordDetailViewProps { dataSource: any; @@ -292,6 +293,106 @@ interface ReactionLedger { shown: FeedItem['reactions']; } +/** + * The object that stores a comment's reactions, one row per (comment, emoji, + * user), each row the reactor's own record (objectstack-ai/objectstack#22566, + * ruling A amended on objectstack-ai/objectstack#22505; objectui#12078). + * + * A member adds a reaction by creating their row (`comment_id`, `emoji`; the + * server stamps `user_id` from the session) and takes it back by deleting that + * row. Nobody's write touches anyone else's reaction, so two members reacting + * at the same moment both stay stored, which a whole-set write to + * `sys_comment.reactions` could not do: the later write replaced the earlier. + * + * A deployment whose framework predates the object has no such object, and + * there the chatter keeps the `sys_comment.reactions` column path (see + * `reactionStore` in the view). That column is retired by + * objectstack-ai/objectstack#22573, after this. + */ +const COMMENT_REACTION_OBJECT = 'sys_comment_reaction'; + +/** + * 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 + * whole thread), so a busy thread is read in pages of this many ids, the pages + * in parallel. The bound is the URL: the data door's `find` is a GET carrying + * the filter in its query string, about 45 bytes per UUID-shaped id, so a page + * of 100 ids is a URL of about 4.6 KB, well under the request-line limits of + * Node's HTTP server (16 KB for all headers) and of common proxies (8 KB). + */ +const REACTION_READ_COMMENT_IDS = 100; + +/** + * The reaction rows of `commentIds`: one `sys_comment_reaction` read per page + * of {@link REACTION_READ_COMMENT_IDS} ids, never one read per comment. Rows + * come oldest first, so each comment's emoji keep the order they were first + * given in. + */ +async function readCommentReactionRows( + dataSource: { find: (resource: string, params?: any) => Promise }, + commentIds: readonly string[], +): Promise>> { + const pages: string[][] = []; + for (let i = 0; i < commentIds.length; i += REACTION_READ_COMMENT_IDS) { + pages.push(commentIds.slice(i, i + REACTION_READ_COMMENT_IDS)); + } + const answers = await Promise.all( + pages.map((ids) => + dataSource.find(COMMENT_REACTION_OBJECT, { + $filter: { comment_id: { $in: ids } }, + $orderby: { created_at: 'asc' }, + }), + ), + ); + return answers.flatMap((res) => (Array.isArray(res?.data) ? res.data : [])); +} + +/** + * Reaction rows grouped per comment into the `{ emoji: userIds[] }` shape the + * `sys_comment.reactions` column stores, so both stores reach the screen + * through one aggregator. `own` keeps the row id of each of `userId`'s own + * reactions, per comment and emoji: the id a second click deletes. + */ +function groupReactionRows( + rows: ReadonlyArray>, + userId: string, +): { stored: Map>; own: Map> } { + const stored = new Map>(); + const own = new Map>(); + for (const row of rows) { + const commentId = String(row.comment_id); + const emoji = String(row.emoji); + const reactor = String(row.user_id); + const byEmoji = stored.get(commentId) ?? {}; + (byEmoji[emoji] ??= []).push(reactor); + stored.set(commentId, byEmoji); + if (reactor === userId) { + const mine = own.get(commentId) ?? new Map(); + mine.set(emoji, String(row.id)); + own.set(commentId, mine); + } + } + return { stored, own }; +} + +/** Whether `userId`'s id is among the stored ids of a row's `emoji` reaction. */ +function holdsOwnReaction(reactions: readonly Reaction[] | undefined, emoji: string, userId: string): boolean { + return (reactions ?? []).some((r) => r.emoji === emoji && storedUserIds(r).includes(userId)); +} + +/** + * One (comment, emoji)'s reaction-record writes by the signed-in user, run one + * after another: a click that takes a reaction back needs the id of the row an + * earlier click is still creating. + */ +interface ReactionRecordQueue { + tail: Promise; + /** Writes queued and not yet answered. */ + pending: number; + /** Whether the newest click on this key showed the signed-in user's reaction. */ + shown: boolean; +} + /** * Which system record-header affordances the record page may offer for this * object โ€” the primary `sys_edit` CTA (which also gates the record-body @@ -1844,6 +1945,33 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const [settledFeedKey, setSettledFeedKey] = useState(null); const feedLoading = feedFetchKey !== null && settledFeedKey !== feedFetchKey; + // โ”€โ”€ Where a comment's reactions are stored (objectui#12078) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // + // `records`: one `sys_comment_reaction` row per member, emoji and comment, + // read in one batched `$in` read and written as the member's own row + // (COMMENT_REACTION_OBJECT). `column`: the `sys_comment.reactions` JSON + // column, read off each comment and written back whole โ€” kept ONLY where the + // deployment has no `sys_comment_reaction` (a framework that predates + // objectstack-ai/objectstack#22566, such as cloud's v17 pin), so reactions + // there keep working exactly as before rather than reading empty and failing + // every write. The object registry answers which, through the shell's one + // presence reading; per its contract only an earned `absent` changes the + // path, and every uncertain answer takes the records path. `null` until the + // registry has answered, and the feed read waits for it: comments and their + // reactions are read as one answer. + const reactionPresence = useObjectPresence(COMMENT_REACTION_OBJECT); + const reactionStore: 'records' | 'column' | null = !reactionPresence.settled + ? null + : reactionPresence.presence === 'absent' + ? 'column' + : 'records'; + /** + * The signed-in user's own reaction rows, per comment (`[thread, comment]`) + * and emoji: the row id a click that takes the reaction back deletes. Filled + * by the read, and by each create and delete as it answers. + */ + const ownReactionRowsRef = useRef(new Map>()); + // Fetch comments from API. // // NOTE: Record-level presence ("who else is viewing this record") used to @@ -1855,6 +1983,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // realtime / OCC plan. useEffect(() => { if (!dataSource || !objectName || !pureRecordId) return; + // The comment read needs to know where reactions are stored; until the + // registry says, nothing is read and the panel stays in its loading state. + if (feedsEnabled && reactionStore === null) return; let cancelled = false; const threadId = `${objectName}:${pureRecordId}`; // The two reads below run in PARALLEL and are collected here so the @@ -1888,11 +2019,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // thread_id, author_id, author_name, author_avatar_url, body, // reactions (JSON string), parent_id, created_at, updated_at. // - // Reactions are stored as a JSON object of `{ emoji: string[] }` - // (one array of user-ids per emoji). The aggregator below counts - // entries and flags the currently-signed-in user, and keeps each - // emoji's stored ids as `userIds`: a reaction click writes the row - // back from them, changing only the clicker's own id (objectui#11019). + // Either store reaches the screen as `{ emoji: string[] }` (one array of + // user-ids per emoji). The aggregator below counts entries and flags the + // currently-signed-in user, and keeps each emoji's stored ids as + // `userIds`: a reaction click changes only the clicker's own id + // (objectui#11019). + const toReactions = (stored: Record | undefined): FeedItem['reactions'] => { + if (!stored) return undefined; + return Object.entries(stored).map(([emoji, ids]) => { + const userIds = Array.isArray(ids) ? ids : []; + return { emoji, count: userIds.length, reacted: userIds.includes(currentUser.id), userIds }; + }); + }; + // The `column` store: the JSON in `sys_comment.reactions`. const parseReactions = (raw: unknown): FeedItem['reactions'] => { if (!raw) return undefined; let parsed: Record | undefined; @@ -1901,17 +2040,37 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } else if (typeof raw === 'object') { parsed = raw as Record; } - if (!parsed) return undefined; - return Object.entries(parsed).map(([emoji, stored]) => { - const userIds = Array.isArray(stored) ? stored : []; - return { emoji, count: userIds.length, reacted: userIds.includes(currentUser.id), userIds }; - }); + return toReactions(parsed); }; if (feedsEnabled) inFlight.push(dataSource.find('sys_comment', { $filter: { thread_id: threadId }, $orderby: { created_at: 'asc' } }) - .then((res: any) => { + .then(async (res: any) => { recordRefusal('comments', false); if (!res?.data?.length) return; + // The `records` store (objectui#12078): ONE batched read of the + // comments' reaction rows (paged only past REACTION_READ_COMMENT_IDS + // ids), never one read per comment, grouped here into the column's + // shape. The column is not read on this path: a reaction stored only + // there is not shown (the maintainer ruled no migration of it, + // objectstack-ai/objectstack#22505). A failed reaction read does not + // take the comments down with it; they show without reactions. + let records: ReturnType | null = null; + if (reactionStore === 'records') { + const commentIds = res.data.map((c: any) => String(c.id)); + try { + records = groupReactionRows(await readCommentReactionRows(dataSource, commentIds), currentUser.id); + } catch { + records = null; + } + if (records) { + for (const commentId of commentIds) { + ownReactionRowsRef.current.set( + JSON.stringify([threadId, commentId]), + records.own.get(commentId) ?? new Map(), + ); + } + } + } const mapped: FeedItem[] = res.data.map((c: any) => ({ id: c.id, type: 'comment' as const, @@ -1921,7 +2080,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri createdAt: c.created_at, updatedAt: c.updated_at, parentId: c.parent_id ?? undefined, - reactions: parseReactions(c.reactions), + reactions: + reactionStore === 'records' + ? toReactions(records?.stored.get(String(c.id))) + : parseReactions(c.reactions), })); // Into THIS record's slice โ€” `threadId` is the key the effect closed // over, so a response that arrives after the user navigated away @@ -2020,7 +2182,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }); return () => { cancelled = true; }; - }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey]); + }, [dataSource, objectName, pureRecordId, currentUser, feedsEnabled, activitiesEnabled, feedFetchKey, reactionStore]); /** * Note: comment-mention โ†’ notification fan-out lives on the server @@ -2187,8 +2349,21 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri * (`toggleOwnReaction`, objectui#11019), so every other user's reaction is * written back as it was read. Two people reacting at the same moment still * means the later write wins. + * + * All of the above is the `column` store, which runs only where the + * deployment has no `sys_comment_reaction`. On the `records` store + * (objectui#12078) a click writes nothing but the clicker's own row: it + * creates that row, or deletes it, so no write can replace another member's + * reaction and two members reacting at once both stay stored. The writes of + * one comment and emoji run one after another (a take-back deletes the row an + * earlier click is still creating). Once the last of them answers, the row + * must show the clicker's reaction exactly when the server holds their row; + * if it does not, a write was refused, and the clicker's own id on that emoji + * is put back as stored and the same error is raised. As on the column store, + * that is skipped when a re-read already replaced what the click showed. */ const reactionLedgersRef = useRef(new Map()); + const reactionQueuesRef = useRef(new Map()); const handleToggleReaction = useCallback( (itemId: string | number, emoji: string) => { @@ -2199,11 +2374,91 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // with a row cached for another record cannot reach across. const key = feedRecordKey; const row = (feedItemsByRecord[key] ?? EMPTY_FEED).find(item => item.id === itemId); - // Only a `sys_comment` row stores reactions, and those are the feed's + // Only a `sys_comment` row carries reactions, and those are the feed's // `comment` rows. The panel offers the control on comment rows only; // this refuses any other id without a write, so a `sys_activity` id can - // never key a `sys_comment` update (objectui#11035). + // never key a `sys_comment` update or a reaction row (objectui#11035). if (!row || row.type !== 'comment') return; + + if (reactionStore === 'records') { + const userId = currentUser.id; + const want = !holdsOwnReaction(row.reactions, emoji, userId); + const commentKey = JSON.stringify([key, String(itemId)]); + const writeKey = JSON.stringify([key, String(itemId), emoji]); + const ownRows = () => { + let mine = ownReactionRowsRef.current.get(commentKey); + if (!mine) { + mine = new Map(); + ownReactionRowsRef.current.set(commentKey, mine); + } + return mine; + }; + const shown = toggleOwnReaction(row.reactions, emoji, userId); + setFeedItemsByRecord(prev => ({ + ...prev, + [key]: (prev[key] ?? EMPTY_FEED).map(item => (item.id === itemId ? { ...item, reactions: shown } : item)), + })); + + let queue = reactionQueuesRef.current.get(writeKey); + if (!queue) { + queue = { tail: Promise.resolve(), pending: 0, shown: want }; + reactionQueuesRef.current.set(writeKey, queue); + } + const q = queue; + q.pending += 1; + q.shown = want; + // Each write decides when its turn comes, from the row the server + // holds by then: a click whose wish is already stored writes nothing. + const write = async () => { + const rowId = ownRows().get(emoji); + if (want && rowId === undefined) { + const created: any = await dataSource.create(COMMENT_REACTION_OBJECT, { + comment_id: String(itemId), + emoji, + }); + if (created?.id == null || created.id === '') { + throw new Error(`The ${emoji} reaction was stored without an id, so it cannot be taken back.`); + } + ownRows().set(emoji, String(created.id)); + } else if (!want && rowId !== undefined) { + if ((await dataSource.delete(COMMENT_REACTION_OBJECT, rowId)) === false) { + throw new Error(`The ${emoji} reaction was not deleted.`); + } + ownRows().delete(emoji); + } + }; + q.tail = q.tail + .then(write) + // A refused write leaves the server's row as it was; the check + // below reads that, so the rejection itself carries nothing more. + .catch(() => {}) + .then(() => { + q.pending -= 1; + if (q.pending > 0) return; + reactionQueuesRef.current.delete(writeKey); + const stored = ownRows().has(emoji); + if (stored === q.shown) return; + setFeedItemsByRecord(prev => { + const rows = prev[key]; + const current = rows?.find(item => item.id === itemId); + if (!rows || !current || holdsOwnReaction(current.reactions, emoji, userId) !== q.shown) return prev; + return { + ...prev, + [key]: rows.map(item => + item === current ? { ...item, reactions: toggleOwnReaction(item.reactions, emoji, userId) } : item, + ), + }; + }); + toast.error( + t('detail.reactionFailed', { + defaultValue: 'Your reaction was not saved. Please try again.', + }), + ); + }); + return; + } + if (reactionStore !== 'column') return; + const rowKey = JSON.stringify([key, String(itemId)]); let ledger = reactionLedgersRef.current.get(rowKey); if (!ledger || ledger.shown !== row.reactions) { @@ -2263,7 +2518,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri () => settle('failed'), ); }, - [currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, t], + [currentUser.id, dataSource, feedRecordKey, feedItemsByRecord, reactionStore, t], ); useEffect(() => { From 086db35b0226fd217a85901d2405eac2d0174763 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 09:14:31 +0000 Subject: [PATCH 2/2] fix(app-shell): type the reaction-record read and the new pins precisely Claude-Session: https://claude.ai/code/session_01B1gHb9baeX7oioD5sHVm7z Co-authored-by: Claude --- ...rdDetailView.reactionRecords-12078.test.tsx | 18 ++++++++++-------- .../app-shell/src/views/RecordDetailView.tsx | 8 ++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx b/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx index 2705cc35eb..6bc86b1013 100644 --- a/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.reactionRecords-12078.test.tsx @@ -243,18 +243,20 @@ async function mountAs(member: string, dataSource: any, objects: unknown[] = REG type View = ReturnType; +/** The view's reaction chips: a labelled button holding an emoji and a count. */ +function chipButtons(view: View): HTMLElement[] { + return (view.queryAllByRole('button') as HTMLElement[]).filter( + (b) => !!b.getAttribute('aria-label') && b.children.length === 2 && EMOJI.includes(b.children[0].textContent ?? ''), + ); +} + /** The comment's reaction chips, in order, each as `emoji count`. */ function chips(view: View): string[] { - return view - .queryAllByRole('button') - .filter((b) => b.getAttribute('aria-label') && b.children.length === 2 && EMOJI.includes(b.children[0].textContent ?? '')) - .map((b) => `${b.children[0].textContent} ${b.children[1].textContent}`); + return chipButtons(view).map((b) => `${b.children[0].textContent} ${b.children[1].textContent}`); } function chip(view: View, emoji: string): HTMLElement { - const found = view - .queryAllByRole('button') - .find((b) => b.getAttribute('aria-label') && b.children.length === 2 && b.children[0].textContent === emoji); + const found = chipButtons(view).find((b) => b.children[0].textContent === emoji); if (!found) throw new Error(`no ${emoji} chip on the panel (chips: ${chips(view).join(', ') || 'none'})`); return found; } @@ -522,7 +524,7 @@ describe('which store the chatter uses is read from the object registry (objectu const u1 = server.as('u1'); const view = render(tree('u1', u1, metadataFor(REGISTRY.present, 'loading'))); await act(async () => {}); - expect(u1.find.mock.calls.filter(([objectName]: [string]) => objectName === 'sys_comment')).toEqual([]); + expect(u1.find.mock.calls.filter((call) => call[0] === 'sys_comment')).toEqual([]); view.rerender(tree('u1', u1, metadataFor(REGISTRY.present, 'ready'))); await within(view.container).findByText(COMMENT_BODY); diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 271ae031ae..eba397e802 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -43,7 +43,7 @@ import { useRecordBreadcrumbTitle } from '../context/NavigationContext.js'; // framework-injected bookkeeping columns are hidden from the body outright. // Both sets are derived, not restated โ€” see record-detail-system-fields.ts. import { AUDIT_FIELD_NAMES, HIDDEN_SYSTEM_FIELD_NAMES } from './record-detail-system-fields.js'; -import type { FeedItem, Reaction } from '@object-ui/types'; +import type { DataSource, FeedItem, Reaction } from '@object-ui/types'; import type { ActionDef, ActionParamDef, ConfirmationHandler } from '@object-ui/core'; import type { ConsoleActionDispatch } from '../consoleActionDispatch.js'; import { useRecordApprovals, recordLockedByApproval, isSubmitterOf } from '../hooks/useRecordApprovals.js'; @@ -329,7 +329,7 @@ const REACTION_READ_COMMENT_IDS = 100; * given in. */ async function readCommentReactionRows( - dataSource: { find: (resource: string, params?: any) => Promise }, + dataSource: Pick>, 'find'>, commentIds: readonly string[], ): Promise>> { const pages: string[][] = []; @@ -2056,7 +2056,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // take the comments down with it; they show without reactions. let records: ReturnType | null = null; if (reactionStore === 'records') { - const commentIds = res.data.map((c: any) => String(c.id)); + const commentIds: string[] = res.data.map((c: { id: unknown }) => String(c.id)); try { records = groupReactionRows(await readCommentReactionRows(dataSource, commentIds), currentUser.id); } catch { @@ -2412,7 +2412,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri const write = async () => { const rowId = ownRows().get(emoji); if (want && rowId === undefined) { - const created: any = await dataSource.create(COMMENT_REACTION_OBJECT, { + const created: { id?: unknown } | undefined = await dataSource.create(COMMENT_REACTION_OBJECT, { comment_id: String(itemId), emoji, });