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. 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')); + }); }); 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); + }); +}); 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); + }); +});