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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/27820-discussion-count-refused-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/plugin-detail': patch
---

fix(plugin-detail): the discussion header shows no count when a feed read was refused

Clause-②: no
Metadata-protocol: none

A member who may not read a record's comments saw `Discussion (0)` directly above "You don't have permission to view comments on this record." The timeline's heading counted the rows it holds, and a refused read holds none, so the header claimed the record had no comments when the server only withheld them. When the host reports a refused comment or activity read, the timeline heading now shows its title with no count, and the collapsed discussion toggle reads "Open discussion panel" instead of `Show Discussion (0)`. A readable feed keeps its count, `(0)` included.
11 changes: 8 additions & 3 deletions ui/packages/plugin-detail/src/RecordActivityTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -778,9 +778,14 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
<h2 className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight">
<Activity className="h-4 w-4" />
{title}
<span className="text-sm font-normal text-muted-foreground">
({filtered.length})
</span>
{/* A refused read leaves the count unknown, not zero: the rows
the reader may not see are not in `filtered`, so a number here
would state what the server withheld. */}
{!activityDenied && !commentsDenied && (
<span className="text-sm font-normal text-muted-foreground" data-testid="activity-count">
({filtered.length})
</span>
)}
</h2>
<div className="flex items-center gap-1">
{isStream && (
Expand Down
11 changes: 9 additions & 2 deletions ui/packages/plugin-detail/src/RecordChatterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
*/
const feedAria = useRecordAriaProps(config?.feed?.aria);

// A refused read leaves the count unknown, not zero (see the timeline's
// header), so the collapsed toggle names the panel without one.
const showLabel =
activityDenied || commentsDenied
? t('detail.openDiscussion')
: t('detail.showDiscussion', { count: items.length });

const isSidebar = position === 'right' || position === 'left';
const isInline = position === 'bottom';

Expand Down Expand Up @@ -218,10 +225,10 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
variant="ghost"
className="w-full justify-start gap-2 text-muted-foreground"
onClick={() => setCollapsed(false)}
aria-label={t('detail.showDiscussion', { count: items.length })}
aria-label={showLabel}
>
<MessageSquare className="h-4 w-4" />
<span>{t('detail.showDiscussion', { count: items.length })}</span>
<span>{showLabel}</span>
</Button>
) : (
<div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* The discussion header shows no count when a feed read was refused
* (objectstack#27820).
*
* The timeline's heading drew `(N)` from the rows it holds. A refused comment
* read leaves no rows, so a member who may not read the comments saw
* `Discussion (0)` right above "You don't have permission to view comments on
* this record." — a count the server never gave. The host's refusal signal
* (`commentsDenied` / `activityDenied`, the same one that draws the refusal
* line) now withholds the count; a readable feed keeps it, `(0)` included.
*
* Read through the real `en` pack and the `record:chatter` block under a host
* `DiscussionContext`, the way the record page mounts it.
*/

import * as React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { I18nProvider } from '@object-ui/i18n';
import { DiscussionContextProvider, RecordContextProvider } from '@object-ui/react';
import type { FeedItem } from '@object-ui/types';
import { RecordChatterRenderer } from '../renderers/record-chatter';
import { RecordChatterPanel } from '../RecordChatterPanel';

const COMMENTS_DENIED = "You don't have permission to view comments on this record.";
const ACTIVITY_DENIED = "You don't have permission to view activity on this record.";

const comment = (n: number) =>
({
id: `c-${n}`,
type: 'comment',
actor: 'Ada',
body: `Comment ${n}`,
createdAt: `2026-01-0${n}T00:00:00.000Z`,
}) satisfies FeedItem;

type HostItems = React.ComponentProps<typeof DiscussionContextProvider>['items'];

function mountChatter(ctx: { items: HostItems; commentsDenied?: boolean; activityDenied?: boolean }) {
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<RecordContextProvider objectName="crm_account" recordId="rec-1" data={{ id: 'rec-1' }}>
<DiscussionContextProvider loading={false} {...ctx}>
<RecordChatterRenderer schema={{} as never} />
</DiscussionContextProvider>
</RecordContextProvider>
</I18nProvider>,
);
}

const heading = () => screen.getByRole('heading', { level: 2 });

afterEach(() => cleanup());

describe('the discussion header withholds its count when a feed read was refused (objectstack#27820)', () => {
it('a refused comment read: the header shows no count, the refusal line still shows', () => {
mountChatter({ items: [], commentsDenied: true });

expect(heading().textContent?.trim()).toBe('Discussion');
expect(heading().textContent).not.toMatch(/\(\d+\)/);
expect(screen.queryByTestId('activity-count')).toBeNull();
expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED);
});

it('a refused comment read beside readable activity rows still shows no count', () => {
const row = {
id: 'a-1',
type: 'field_change',
actor: 'Grace',
body: 'Stage moved to qualified',
createdAt: '2026-01-02T00:00:00.000Z',
} satisfies FeedItem;
mountChatter({ items: [row], commentsDenied: true });

expect(screen.getByText('Stage moved to qualified')).toBeTruthy();
expect(heading().textContent).not.toMatch(/\(\d+\)/);
expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED);
});

it('a refused activity read shows no count either', () => {
mountChatter({ items: [], activityDenied: true });

expect(heading().textContent).not.toMatch(/\(\d+\)/);
expect(screen.getByTestId('activity-access-denied').textContent).toContain(ACTIVITY_DENIED);
});

it('control: a readable record with no comments shows (0)', () => {
mountChatter({ items: [] });

expect(heading()).toHaveTextContent(/^Discussion\s*\(0\)$/);
expect(screen.queryByTestId('activity-access-denied')).toBeNull();
});

it('control: a readable record with 3 comments shows (3)', () => {
mountChatter({ items: [comment(1), comment(2), comment(3)] });

expect(heading()).toHaveTextContent(/^Discussion\s*\(3\)$/);
});
});

describe('the collapsed discussion toggle withholds its count the same way (objectstack#27820)', () => {
const mountCollapsed = (props: { commentsDenied?: boolean }) =>
render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<RecordChatterPanel
items={[]}
config={{ position: 'bottom', collapsible: true, defaultCollapsed: true } as never}
{...props}
/>
</I18nProvider>,
);

it('refused: the toggle names the panel without a count', () => {
mountCollapsed({ commentsDenied: true });

const toggle = screen.getByRole('button', { name: 'Open discussion panel' });
expect(toggle.textContent).not.toMatch(/\(\d+\)/);
});

it('control: readable, the toggle keeps its (0)', () => {
mountCollapsed({});

expect(screen.getByRole('button', { name: 'Show Discussion (0)' })).toBeTruthy();
});
});
Loading