From 7a19eaa5cea2600c90c80d1cc5d71f7141261ce4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:57:06 +0000 Subject: [PATCH 1/4] test(app-shell): pin the record page's stored replies and reply input on read (objectui#12144) Red on the base: through the synthesized default record page, a stored reply shows nowhere and no comment read from the server offers the reply input. The threading-off control is green. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- ...DetailView.replyCountOnRead-12144.test.tsx | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx diff --git a/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx b/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx new file mode 100644 index 0000000000..271703ff48 --- /dev/null +++ b/packages/app-shell/src/views/RecordDetailView.replyCountOnRead-12144.test.tsx @@ -0,0 +1,301 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A stored reply shows under its parent, and a comment read from the server + * offers the reply input (objectui#12144). + * + * The record page reads the record's `sys_comment` rows and maps each one to a + * feed item carrying its `parentId`. With threading on (the default record + * page's `record:discussion` turns it on), the timeline takes every item with a + * `parentId` out of the top-level list and draws a comment's replies, and the + * reply input with them, only under an item whose `replyCount` is above zero. + * The read set no `replyCount`, so a stored reply was shown nowhere, and no + * comment read from the server offered a reply input: the first reply on a + * comment could not be posted from the page. + * + * `sys_comment` declares no reply count: the platform's object definition says + * to count `parent_id` children at read time. So the read counts each comment's + * children in the rows it read and sets `replyCount` beside `parentId`, and the + * timeline offers the reply input on every comment row the host hands + * `onAddReply` for, replies or not. Its reply LIST still shows only under a + * `replyCount` above zero, the one gate it had. + * + * Real subjects: `RecordDetailView` rendering the SYNTHESIZED default record + * page (no authored page, so `buildDefaultPageSchema` composes + * `record:discussion`), over a fake data source answering the `sys_comment` + * read, driven through the real panel. The grant cases run under the REAL + * `MePermissionsProvider`, the way objectui#12082's pins do. + */ + +import * as React from 'react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, within, act, waitFor } 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: 'Ada', 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 ACTIVITY = 'sys_activity'; +const REPLY_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.replyPlaceholder']; +const OBJECT_NAME = 'crm_customer'; +const RECORD_ID = 'rec-1'; +const THREAD = `${OBJECT_NAME}:${RECORD_ID}`; + +const PARENT_BODY = 'Parent comment on the renewal'; +const CHILD_BODY = 'Stored reply to the parent'; +const LONE_BODY = 'A comment nobody answered'; +const ACTIVITY_BODY = 'Stage changed to Negotiation'; +const NEW_REPLY = 'First reply typed on the page'; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Customer', + managedBy: 'platform', + fields: { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }, + }, +]; + +/** An authored page whose discussion turns threading OFF (the control). */ +const PAGE_THREADING_OFF = { + name: 'customer_record_page', + type: 'record', + object: OBJECT_NAME, + regions: [ + { + name: 'main', + components: [ + { type: 'page:header', title: 'Customer' }, + { type: 'record:discussion', feed: { enableThreading: false } }, + ], + }, + ], +}; + +const PARENT = { id: 'c-parent', thread_id: THREAD, author_name: 'Ada', body: PARENT_BODY, created_at: '2026-10-01T08:00:00.000Z' }; +const CHILD = { id: 'c-child', thread_id: THREAD, author_name: 'Grace', body: CHILD_BODY, parent_id: 'c-parent', created_at: '2026-10-01T09:00:00.000Z' }; +const LONE = { id: 'c-lone', thread_id: THREAD, author_name: 'Ada', body: LONE_BODY, created_at: '2026-10-01T10:00:00.000Z' }; +/** A field change from `sys_activity`: a feed row that is not a comment. */ +const UPDATED = { id: 'a-1', type: 'updated', summary: ACTIVITY_BODY, actor_name: 'Ada', timestamp: '2026-10-01T11:00:00.000Z' }; + +function makeDataSource(comments: Record[], activities: Record[] = []) { + return { + find: vi.fn(async (objectName: string) => { + if (objectName === COMMENT) return { data: comments.map((c) => ({ ...c })) }; + if (objectName === ACTIVITY) return { data: activities.map((a) => ({ ...a })) }; + return { data: [] }; + }), + findOne: vi.fn(async (_o: string, id: string) => ({ id, name: `Record ${id}` })), + create: vi.fn(async (_o: string, data: any) => ({ ...data })), + update: vi.fn(async (_o: string, id: string, patch: any) => ({ id, ...patch })), + delete: vi.fn(async () => true), + } as any; +} + +function metadataFor(pages: unknown[]) { + return { + objects: 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; +} + +const FULL = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; +const CREATE_ONLY = { allowRead: true, allowCreate: true, allowEdit: false, allowDelete: false }; +const READ_ONLY = { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }; + +/** The caller's `/me/permissions` answer: full on the record's object, `bits` on `sys_comment`. */ +function answer(bits: Record): MePermissionsResponse { + return { + authenticated: true, + userId: 'u1', + tenantId: null, + roles: ['member'], + permissionSets: ['member'], + objects: { [OBJECT_NAME]: FULL, [COMMENT]: bits }, + fields: {}, + } as MePermissionsResponse; +} + +interface MountOptions { + activities?: Record[]; + /** `null` mounts no permission provider: every grant reads open. */ + perms?: MePermissionsResponse | null; + /** No authored page by default: the synthesized default record page. */ + pages?: unknown[]; +} + +/** Mounts the record page and waits until its first comment shows. */ +async function mount(comments: Record[], options: MountOptions = {}) { + const dataSource = makeDataSource(comments, options.activities); + const page = ( + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + + ); + const perms = options.perms ?? null; + const rendered = render(perms ? {page} : page); + const view = within(rendered.container); + await view.findByText(String(comments[0].body)); + await waitFor(() => expect(view.queryByTestId('activity-loading')).toBeNull()); + await act(async () => {}); + return { view, dataSource }; +} + +type View = Awaited>['view']; + +const replyInputs = (view: View) => view.queryAllByPlaceholderText(REPLY_PLACEHOLDER); +/** The reply-count toggle `ThreadedReplies` draws above a comment's replies. */ +const replyToggles = (view: View) => view.queryAllByRole('button', { name: /\d+ repl/ }); + +/** + * The feed row a body sits in: the element the timeline keys per root item, + * which holds the item's own row and, after it, its `ThreadedReplies`. + */ +function rootRowOf(view: View, body: string): HTMLElement { + const rows = view.getByText(body).closest('.space-y-4')!.children; + const row = Array.from(rows).find((r) => r.textContent?.includes(body)); + if (!row) throw new Error(`no feed row holds ${JSON.stringify(body)}`); + return row as HTMLElement; +} + +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 stored reply shows under its parent on the default record page (objectui#12144)', () => { + it('a read with a parent and one child shows the child under the parent, not as a row of its own', async () => { + const { view } = await mount([PARENT, CHILD]); + const parentRow = rootRowOf(view, PARENT_BODY); + // Collapsed until asked: the reply is not on screen yet, and not as a root row either. + expect(view.queryByText(CHILD_BODY)).toBeNull(); + const toggle = within(parentRow).getByRole('button', { name: /1 reply/ }); + fireEvent.click(toggle); + expect(within(parentRow).getByText(CHILD_BODY)).toBeInTheDocument(); + expect(replyToggles(view)).toHaveLength(1); + }); + + it('the count is the children in the read: two stored replies read as two', async () => { + const second = { ...CHILD, id: 'c-child-2', body: 'Second stored reply', created_at: '2026-10-01T09:30:00.000Z' }; + const { view } = await mount([PARENT, CHILD, second]); + const parentRow = rootRowOf(view, PARENT_BODY); + fireEvent.click(within(parentRow).getByRole('button', { name: /2 replies/ })); + expect(within(parentRow).getByText(CHILD_BODY)).toBeInTheDocument(); + expect(within(parentRow).getByText('Second stored reply')).toBeInTheDocument(); + }); +}); + +describe('a comment read from the server offers the reply input where the caller may post one (objectui#12144)', () => { + it('under a create grant on sys_comment every comment row offers it, a comment with no replies included', async () => { + const { view } = await mount([PARENT, CHILD, LONE], { perms: answer(CREATE_ONLY) }); + expect(within(rootRowOf(view, LONE_BODY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).not.toBeNull(); + expect(within(rootRowOf(view, PARENT_BODY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).not.toBeNull(); + expect(replyInputs(view)).toHaveLength(2); + }); + + it('a row that is not a comment offers none: a reply is stored as a sys_comment under a comment', async () => { + const { view } = await mount([LONE], { activities: [UPDATED] }); + await view.findByText(ACTIVITY_BODY); + expect(within(rootRowOf(view, ACTIVITY_BODY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).toBeNull(); + expect(replyInputs(view)).toHaveLength(1); + }); + + it('the first reply on a comment posts from the page, under that comment, and then shows there', async () => { + const { view, dataSource } = await mount([LONE], { perms: answer(CREATE_ONLY) }); + const loneRow = rootRowOf(view, LONE_BODY); + fireEvent.change(within(loneRow).getByPlaceholderText(REPLY_PLACEHOLDER), { target: { value: NEW_REPLY } }); + await act(async () => { + fireEvent.click(within(loneRow).getByRole('button', { name: 'Send reply' })); + }); + expect(dataSource.create).toHaveBeenCalledTimes(1); + expect(dataSource.create.mock.calls[0][0]).toBe(COMMENT); + expect(dataSource.create.mock.calls[0][1]).toMatchObject({ thread_id: THREAD, parent_id: 'c-lone', body: NEW_REPLY }); + fireEvent.click(within(loneRow).getByRole('button', { name: /1 reply/ })); + expect(within(loneRow).getByText(NEW_REPLY)).toBeInTheDocument(); + }); + + it('without the create grant no row offers it, and a stored reply still shows under its parent', async () => { + const { view } = await mount([PARENT, CHILD, LONE], { perms: answer(READ_ONLY) }); + expect(replyInputs(view)).toHaveLength(0); + const parentRow = rootRowOf(view, PARENT_BODY); + fireEvent.click(within(parentRow).getByRole('button', { name: /1 reply/ })); + expect(within(parentRow).getByText(CHILD_BODY)).toBeInTheDocument(); + }); +}); + +describe('CONTROL: with threading off the list is unchanged (objectui#12144)', () => { + it('a reply is a row of its own in read order, with no reply toggle and no reply input', async () => { + const { view } = await mount([PARENT, CHILD, LONE], { pages: [PAGE_THREADING_OFF] }); + const list = view.getByText(PARENT_BODY).closest('.space-y-4')!; + const rows = Array.from(list.children).map((r) => r.textContent ?? ''); + expect(rows).toHaveLength(3); + expect(rows[0]).toContain(PARENT_BODY); + expect(rows[1]).toContain(CHILD_BODY); + expect(rows[2]).toContain(LONE_BODY); + expect(replyToggles(view)).toHaveLength(0); + expect(replyInputs(view)).toHaveLength(0); + }); +}); From 8d4ed86cdcdca7bc8fca0d08e282ae505648becf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 14:59:17 +0000 Subject: [PATCH 2/4] fix(app-shell,plugin-detail): the record page counts each comment's replies on read, and a comment with none offers the reply input (objectui#12144) The record page mapped each sys_comment row to a feed item with its parentId and never a replyCount, while the timeline takes every reply out of the top-level list and draws a comment's replies, and the reply input with them, only under a replyCount above zero. So a stored reply showed nowhere, and no comment read from the server offered the reply input. - RecordDetailView counts each comment's children in the rows its sys_comment read returned and sets replyCount beside parentId, as the sys_comment object definition prescribes (count parent_id children at read time). - RecordActivityTimeline offers the reply input on every comment row the host hands onAddReply for, replies or not, and on no other row. The reply list keeps its one replyCount gate. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .../app-shell/src/views/RecordDetailView.tsx | 13 ++ .../src/RecordActivityTimeline.tsx | 29 +++- ...vityTimeline.zeroReplyInput-12144.test.tsx | 126 ++++++++++++++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 packages/plugin-detail/src/__tests__/RecordActivityTimeline.zeroReplyInput-12144.test.tsx diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 173f1dd55d..764be97482 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -2110,6 +2110,18 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri } } } + // objectui#12144: each comment's reply count is its children in the + // rows this read returned. `sys_comment` stores no count: its object + // definition says to count `parent_id` children at read time. With + // threading on, the timeline shows a comment's replies only under a + // `replyCount` above zero, so without this a stored reply was shown + // nowhere. Counted the way the timeline groups replies: by the row's + // `parent_id`, when it has one. A reply whose parent is not among + // these rows counts toward no item here. + const replyCounts = new Map(); + for (const c of res.data as Array<{ parent_id?: unknown }>) { + if (c.parent_id) replyCounts.set(c.parent_id, (replyCounts.get(c.parent_id) ?? 0) + 1); + } const mapped: FeedItem[] = res.data.map((c: any) => ({ id: c.id, type: 'comment' as const, @@ -2119,6 +2131,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri createdAt: c.created_at, updatedAt: c.updated_at, parentId: c.parent_id ?? undefined, + replyCount: replyCounts.get(c.id), reactions: reactionStore === 'records' ? toReactions(records?.stored.get(String(c.id))) diff --git a/packages/plugin-detail/src/RecordActivityTimeline.tsx b/packages/plugin-detail/src/RecordActivityTimeline.tsx index d6bf6a5668..4f8f20e78f 100644 --- a/packages/plugin-detail/src/RecordActivityTimeline.tsx +++ b/packages/plugin-detail/src/RecordActivityTimeline.tsx @@ -198,6 +198,22 @@ function canStoreReaction(item: FeedItem): boolean { return item.type === 'comment'; } +/** + * Whether a feed row can take a reply (objectui#12144). + * + * A reply is a `sys_comment` row whose `parent_id` names the comment it + * answers, so only a `comment` row offers the reply input. A row read from + * `sys_activity` offers none: a reply there would store a comment whose parent + * is an activity, which no read shows under it. Same reasoning as + * {@link canStoreReaction}. + */ +function canTakeReply(item: FeedItem): boolean { + return item.type === 'comment'; +} + +/** The reply list a row shows while its `replyCount` is zero (objectui#12144). */ +const NO_REPLIES: FeedItem[] = []; + function filterItems(items: FeedItem[], mode: FeedFilterMode): FeedItem[] { switch (mode) { case 'comments_only': @@ -532,7 +548,14 @@ export const RecordActivityTimeline: React.FC = ({ const Icon = FEED_TYPE_ICONS[item.type] || Zap; const colorClass = FEED_TYPE_COLORS[item.type] || 'bg-gray-100 text-gray-600'; - const replies = repliesByParent.get(item.id) ?? []; + // Threading (objectui#12144). The reply LIST shows only under a + // `replyCount` above zero, the one threading gate: the host that + // read the rows sets it. The reply INPUT does not wait for a + // first reply, so a comment with none offers it wherever the + // host hands over `onAddReply`. + const replies = + (item.replyCount ?? 0) > 0 ? (repliesByParent.get(item.id) ?? NO_REPLIES) : NO_REPLIES; + const offersReply = !!onAddReply && canTakeReply(item); // Undefined on a row that cannot store a reaction: no Add // reaction button, and its chips render disabled (objectui#11035). const toggleReaction = @@ -631,12 +654,12 @@ export const RecordActivityTimeline: React.FC = ({ {/* Threading */} - {enableThreading && (item.replyCount ?? 0) > 0 && ( + {enableThreading && (replies.length > 0 || offersReply) && ( )} diff --git a/packages/plugin-detail/src/__tests__/RecordActivityTimeline.zeroReplyInput-12144.test.tsx b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.zeroReplyInput-12144.test.tsx new file mode 100644 index 0000000000..43e266c23f --- /dev/null +++ b/packages/plugin-detail/src/__tests__/RecordActivityTimeline.zeroReplyInput-12144.test.tsx @@ -0,0 +1,126 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The reply input does not wait for a first reply, and the reply list keeps its + * one `replyCount` gate (objectui#12144). + * + * With threading on, the timeline drew `ThreadedReplies`, the only place the + * reply input renders, only under an item whose `replyCount` was above zero. + * Nothing set it on a comment with no replies, so such a comment never offered + * the input and its first reply could not be posted. Now: + * + * - the INPUT is offered on every comment row the host hands `onAddReply` for, + * replies or not, and on no other row (a reply is a `sys_comment` whose + * parent is a comment); + * - the LIST still shows only under a `replyCount` above zero, so whether a + * comment's replies show never depends on whether the caller may post one. + * + * The record page's read is what sets `replyCount`; that half is pinned through + * the page in `@object-ui/app-shell` + * (`RecordDetailView.replyCountOnRead-12144.test.tsx`). This file pins the + * timeline's render condition alone. + * + * Real subjects: `RecordActivityTimeline` and `ThreadedReplies`; the activity + * row is built by `activityRowToFeedItem`, the reading the record page merges + * `sys_activity` rows through. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import type { FeedItem } from '@object-ui/types'; +import { RecordActivityTimeline } from '../RecordActivityTimeline'; +import { activityRowToFeedItem } from '../renderers/recordActivityFeed'; +import { DETAIL_DEFAULT_TRANSLATIONS } from '../useDetailTranslation'; + +const REPLY_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.replyPlaceholder']; +const PARENT_BODY = 'Parent comment'; +const REPLY_BODY = 'A reply to the parent'; +const ACTIVITY_SUMMARY = 'Stage changed'; + +const PARENT: FeedItem = { + id: 'c1', + type: 'comment', + actor: 'Grace', + body: PARENT_BODY, + createdAt: '2026-10-01T08:00:00.000Z', +}; + +const REPLY: FeedItem = { + id: 'c2', + type: 'comment', + actor: 'Ada', + body: REPLY_BODY, + createdAt: '2026-10-01T09:00:00.000Z', + parentId: 'c1', +}; + +const ACTIVITY = activityRowToFeedItem( + { id: 'a1', type: 'updated', summary: ACTIVITY_SUMMARY, actor_name: 'Ada', timestamp: '2026-10-01T07:00:00.000Z' }, + 'System', +) as FeedItem; + +function mount(items: FeedItem[], { threading = true, onAddReply }: { threading?: boolean; onAddReply?: () => void } = {}) { + render(); +} + +/** The feed row a body sits in: the keyed root element holding the row and its replies. */ +function rootRowOf(body: string): HTMLElement { + const rows = screen.getByText(body).closest('.space-y-4')!.children; + const row = Array.from(rows).find((r) => r.textContent?.includes(body)); + if (!row) throw new Error(`no feed row holds ${JSON.stringify(body)}`); + return row as HTMLElement; +} + +const replyInputs = () => screen.queryAllByPlaceholderText(REPLY_PLACEHOLDER); +const replyToggles = () => screen.queryAllByRole('button', { name: /\d+ repl/ }); + +afterEach(() => { + cleanup(); +}); + +describe('the reply input does not wait for a first reply (objectui#12144)', () => { + it('a comment with no replies offers it when the host hands over onAddReply', () => { + mount([PARENT], { onAddReply: vi.fn() }); + expect(within(rootRowOf(PARENT_BODY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).not.toBeNull(); + expect(replyToggles()).toHaveLength(0); + }); + + it('CONTROL: without onAddReply the same comment offers none', () => { + mount([PARENT]); + expect(replyInputs()).toHaveLength(0); + }); + + it('a row that is not a comment offers none, beside a comment that does', () => { + expect(ACTIVITY.type).not.toBe('comment'); + mount([ACTIVITY, PARENT], { onAddReply: vi.fn() }); + expect(within(rootRowOf(ACTIVITY_SUMMARY)).queryByPlaceholderText(REPLY_PLACEHOLDER)).toBeNull(); + expect(replyInputs()).toHaveLength(1); + }); + + it('CONTROL: with threading off no row offers it', () => { + mount([PARENT], { threading: false, onAddReply: vi.fn() }); + expect(replyInputs()).toHaveLength(0); + }); +}); + +describe("the reply list keeps its one replyCount gate (objectui#12144)", () => { + it('replies the host handed over without a replyCount stay hidden, whether or not the input is offered', () => { + mount([PARENT, REPLY], { onAddReply: vi.fn() }); + expect(replyToggles()).toHaveLength(0); + expect(screen.queryByText(REPLY_BODY)).toBeNull(); + expect(replyInputs()).toHaveLength(1); + }); + + it('with a replyCount above zero the list shows, and it shows without the input too', () => { + mount([{ ...PARENT, replyCount: 1 }, REPLY]); + expect(replyInputs()).toHaveLength(0); + expect(within(rootRowOf(PARENT_BODY)).getAllByRole('button', { name: /1 reply/ })).toHaveLength(1); + }); +}); From f7f477a3a70119efa9a3433fa23737eabf78462a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:01:15 +0000 Subject: [PATCH 3/4] chore(changeset): declare the record page's reply count on read (objectui#12144) Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .changeset/12144-reply-count-on-read.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/12144-reply-count-on-read.md diff --git a/.changeset/12144-reply-count-on-read.md b/.changeset/12144-reply-count-on-read.md new file mode 100644 index 0000000000..19520c1139 --- /dev/null +++ b/.changeset/12144-reply-count-on-read.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +--- + +The record page's discussion shows stored replies under their comment, and every comment offers the reply input where the caller may post one (objectui#12144). + +With threading on, which the default record page's discussion turns on, the timeline takes every reply out of the top-level list and shows a comment's replies only under a reply count above zero. The record page read each `sys_comment` row with its `parent_id` and never a reply count, so after a reload a stored reply was shown nowhere, and no comment read from the server offered the reply input: the first reply on a comment could not be posted from the page. + +- **`@object-ui/app-shell`:** the record page counts each comment's replies in the comments it read (the rows whose `parent_id` names it) and sets the feed item's `replyCount` from that count. `sys_comment` stores no reply count; its object definition says to count `parent_id` children at read time. +- **`@object-ui/plugin-detail`:** `RecordActivityTimeline` offers the reply input on every comment row it is handed `onAddReply` for, including a comment with no replies yet, and on no row that is not a comment, because a reply is stored as a comment under a comment. A comment's reply list still shows only when its `replyCount` is above zero, so whether replies show never depends on whether the caller may post one. On the record page the input still needs the create grant on `sys_comment`, as before. + +Nothing is added to either package's entry: no export, prop, type member or language-pack key. From dc1f3c0c50154da6c8ad0fb1fe33d01236e0d37c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:10:37 +0000 Subject: [PATCH 4/4] test(app-shell): pin the reply writer's failure path through the record page (objectui#12144) The header of RecordDetailView.commentWriteFailure-10899.test.tsx said the reply writer's input was not reachable from a loaded feed, because no read set replyCount. The record page now offers the reply input on every comment it reads, so the header says that, and the file pins the reply writer the way it pins the comment writer: a rejected sys_comment create adds no reply, raises a localized error and keeps the draft. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- ...ailView.commentWriteFailure-10899.test.tsx | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx b/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx index 453ea73a45..8b3445628a 100644 --- a/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx +++ b/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx @@ -16,11 +16,13 @@ * (`RecordActivityTimeline`), over a fake data source whose `sys_comment` * `create` is the varied axis. `sonner` is the observed channel for the error. * - * The reply writer (`handleAddReply`) takes the same shape, but its input is - * not reachable from a loaded feed — `ThreadedReplies` renders only under an - * item whose `replyCount > 0`, and no read sets that — so the composer-side - * half of the reply contract is pinned in plugin-detail - * (`composerKeepsDraftOnRejection-10899.test.tsx`) instead. + * The reply writer (`handleAddReply`) takes the same shape. Its input is + * reachable from a loaded feed: the timeline offers it on every comment row the + * page hands `onAddReply` for, a comment with no replies included + * (objectui#12144). So its failure path is pinned here too, through the reply + * input under a comment read from `sys_comment`. The input's own half (a + * rejection keeps the reply draft) is also pinned in plugin-detail + * (`composerKeepsDraftOnRejection-10899.test.tsx`). */ import * as React from 'react'; @@ -69,6 +71,7 @@ import { RecordDetailView } from './RecordDetailView'; const EMPTY_COMMENTS = DETAIL_DEFAULT_TRANSLATIONS['detail.noCommentsYet']; const COMMENT_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.leaveCommentPlaceholder']; const SUBMIT_COMMENT = DETAIL_DEFAULT_TRANSLATIONS['detail.submitComment']; +const REPLY_PLACEHOLDER = DETAIL_DEFAULT_TRANSLATIONS['detail.replyPlaceholder']; const OBJECT_NAME = 'crm_customer'; const RECORD_ID = 'rec-1'; @@ -213,4 +216,41 @@ describe('a failed comment write is never rendered as sent (objectui#10899)', () await waitFor(() => expect(box.value).toBe('')); expect(toast.error).not.toHaveBeenCalled(); }); + + it('a rejected reply create under a comment read from the server adds no reply, raises a localized error, and keeps the draft (objectui#12144)', async () => { + const dataSource = makeDataSource([ + { + id: 'c1', + thread_id: `${OBJECT_NAME}:${RECORD_ID}`, + author_name: 'Grace', + body: 'Signed the renewal', + created_at: '2026-10-01T08:00:00.000Z', + }, + ]); + mount(dataSource); + await screen.findByText('Signed the renewal'); + + // The comment has no replies; its reply input is offered all the same. + const input = (await screen.findByPlaceholderText(REPLY_PLACEHOLDER)) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Countersigned copy attached' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send reply' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledTimes(1)); + expect(dataSource.create).toHaveBeenCalledWith( + 'sys_comment', + expect.objectContaining({ + thread_id: `${OBJECT_NAME}:${RECORD_ID}`, + parent_id: 'c1', + body: 'Countersigned copy attached', + }), + ); + // The reply is not "sent": no reply row, and the parent shows no reply count. + expect(feedRow('Countersigned copy attached')).toBeNull(); + expect(screen.queryByRole('button', { name: /\d+ repl/ })).toBeNull(); + // The error is the localized one, not the transport's text. + const [message] = vi.mocked(toast.error).mock.calls[0]; + expect(String(message)).not.toContain('sys_comment'); + // The user's text survives for a retry. + await waitFor(() => expect(input.value).toBe('Countersigned copy attached')); + }); });