Skip to content

Commit c4cb5ec

Browse files
fix(plugin-detail): the discussion header shows no count when a feed read was refused (#27844)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:记录页讨论区在评论(或活动)读取被拒时,标题不再显示 `(0)` 这样的条数,只显示「Discussion」;折叠按钮也不再写 `Show Discussion (0)`。 - 为什么改:没有权限看评论的成员看到「Discussion (0)」紧挨着「你没有权限查看评论」,会以为没人讨论过,而服务器只是不给看。 - 风险与回滚:只影响读取被拒这一种状态;可读的记录条数照旧(包括 0 条)。回滚即 revert 本 PR。 - 先看:`RecordActivityTimeline.tsx` 标题里的计数 span;`RecordChatterPanel.tsx` 的 `showLabel`;新测试文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx`,预期 7 条全部通过。 - 需要你做的:无,席位落地。 Fixes #27820 Clause-②: no Metadata-protocol: none ## What changes - `RecordActivityTimeline` draws its heading's `(N)` count only when neither of the feed's reads was refused. The signal is the existing `activityDenied` / `commentsDenied` props, the same ones that draw the refusal line; no second permission check. - `RecordChatterPanel`'s collapsed inline toggle reads `detail.openDiscussion` ("Open discussion panel", an existing key) instead of `Show Discussion (N)` under the same condition. - Changeset: `@object-ui/plugin-detail` patch. ## Tests `ui/packages/plugin-detail/src/__tests__/RecordChatterPanel.refusedReadCount-27820.test.tsx`, through the real `en` pack and the `record:chatter` block under a host `DiscussionContext` (the record page's mount): - refused comment read, no rows: heading is exactly `Discussion`, refusal line shows; - refused comment read beside a readable activity row: no count, row and refusal line both show; - refused activity read: no count; - controls: readable with 0 comments shows `(0)`, with 3 shows `(3)`; - collapsed toggle: refused shows no count; readable keeps `Show Discussion (0)`. <details><summary>Readings at head 64fcb00</summary> - `pnpm --dir ui exec vitest run --reporter=verbose packages/plugin-detail/` (under os-verify-lock): VERDICT command-exit 0, Test Files 339 passed / 1 skipped (340), Tests 3085 passed / 18 skipped (3103). Run on the fix commit 97c4beb; the later commit 64fcb00 changes only the new test's item type. - `pnpm --filter @object-ui/plugin-detail run type-check` (both the src and the test tsconfig): VERDICT command-exit 0 at 64fcb00; the new file: Tests 7 passed (7). - Ablation (fix committed first): `git restore --source=359e935d` on `RecordActivityTimeline.tsx` and `RecordChatterPanel.tsx`, mutation shown on disk (the count's `activity-count` marker 1 → 0, `showLabel` 3 → 0), new file run: VERDICT command-exit 1, Tests 4 failed / 3 passed (7). The four refused cases went red (first one: `expected 'Discussion(0)' to be 'Discussion'`), the three controls stayed green. Restored with `git checkout HEAD --`, `git diff HEAD` empty, `git status --porcelain` empty. </details> ## Acceptance notes - Measured repro on `main` (`359e935d`): the `(0)` is drawn by `RecordActivityTimeline`'s heading (`({filtered.length})`), which `RecordChatterPanel` mounts with `titleLabel={t('detail.discussion')}` on both its sidebar and inline paths. `DetailView`'s Discussion tab trigger draws no count. The new refused-read cases go red on `main`'s component (the ablation under Tests). - Same-class in-place fix, declared: the card names the comment refusal; a refused activity read leaves the count just as unknown, in the same heading, so it is withheld too. The collapsed toggle's `Show Discussion (0)` is the same claim in the same panel and uses an existing key, so `Clause-②: no` holds and no i18n key was added. - `RecordDetailView.tsx` (the `apiHandler` in #27793) is untouched. ## Verification <details><summary>Derived gates at head 64fcb00</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, each run and recorded as `command :: exit code` before any pipe; all 34 exit 0, including `node scripts/check-sdui-manifest.mjs`. Reconciliation with `--ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. `pnpm check:new-line-citations` (under `ui/`): 0 new citations. Every other family is left to this PR's CI check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6Cagj5WeiH9xGApkpYm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 637cfc6 commit c4cb5ec

4 files changed

Lines changed: 155 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): the discussion header shows no count when a feed read was refused
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.

‎ui/packages/plugin-detail/src/RecordActivityTimeline.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -778,9 +778,14 @@ export const RecordActivityTimeline: React.FC<RecordActivityTimelineProps> = ({
778778
<h2 className="flex items-center gap-2 text-base font-semibold leading-none tracking-tight">
779779
<Activity className="h-4 w-4" />
780780
{title}
781-
<span className="text-sm font-normal text-muted-foreground">
782-
({filtered.length})
783-
</span>
781+
{/* A refused read leaves the count unknown, not zero: the rows
782+
the reader may not see are not in `filtered`, so a number here
783+
would state what the server withheld. */}
784+
{!activityDenied && !commentsDenied && (
785+
<span className="text-sm font-normal text-muted-foreground" data-testid="activity-count">
786+
({filtered.length})
787+
</span>
788+
)}
784789
</h2>
785790
<div className="flex items-center gap-1">
786791
{isStream && (

‎ui/packages/plugin-detail/src/RecordChatterPanel.tsx‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
122122
*/
123123
const feedAria = useRecordAriaProps(config?.feed?.aria);
124124

125+
// A refused read leaves the count unknown, not zero (see the timeline's
126+
// header), so the collapsed toggle names the panel without one.
127+
const showLabel =
128+
activityDenied || commentsDenied
129+
? t('detail.openDiscussion')
130+
: t('detail.showDiscussion', { count: items.length });
131+
125132
const isSidebar = position === 'right' || position === 'left';
126133
const isInline = position === 'bottom';
127134

@@ -218,10 +225,10 @@ export const RecordChatterPanel: React.FC<RecordChatterPanelProps> = ({
218225
variant="ghost"
219226
className="w-full justify-start gap-2 text-muted-foreground"
220227
onClick={() => setCollapsed(false)}
221-
aria-label={t('detail.showDiscussion', { count: items.length })}
228+
aria-label={showLabel}
222229
>
223230
<MessageSquare className="h-4 w-4" />
224-
<span>{t('detail.showDiscussion', { count: items.length })}</span>
231+
<span>{showLabel}</span>
225232
</Button>
226233
) : (
227234
<div>
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* The discussion header shows no count when a feed read was refused
6+
* (objectstack#27820).
7+
*
8+
* The timeline's heading drew `(N)` from the rows it holds. A refused comment
9+
* read leaves no rows, so a member who may not read the comments saw
10+
* `Discussion (0)` right above "You don't have permission to view comments on
11+
* this record." — a count the server never gave. The host's refusal signal
12+
* (`commentsDenied` / `activityDenied`, the same one that draws the refusal
13+
* line) now withholds the count; a readable feed keeps it, `(0)` included.
14+
*
15+
* Read through the real `en` pack and the `record:chatter` block under a host
16+
* `DiscussionContext`, the way the record page mounts it.
17+
*/
18+
19+
import * as React from 'react';
20+
import { describe, it, expect, afterEach } from 'vitest';
21+
import { render, screen, cleanup } from '@testing-library/react';
22+
import { I18nProvider } from '@object-ui/i18n';
23+
import { DiscussionContextProvider, RecordContextProvider } from '@object-ui/react';
24+
import type { FeedItem } from '@object-ui/types';
25+
import { RecordChatterRenderer } from '../renderers/record-chatter';
26+
import { RecordChatterPanel } from '../RecordChatterPanel';
27+
28+
const COMMENTS_DENIED = "You don't have permission to view comments on this record.";
29+
const ACTIVITY_DENIED = "You don't have permission to view activity on this record.";
30+
31+
const comment = (n: number) =>
32+
({
33+
id: `c-${n}`,
34+
type: 'comment',
35+
actor: 'Ada',
36+
body: `Comment ${n}`,
37+
createdAt: `2026-01-0${n}T00:00:00.000Z`,
38+
}) satisfies FeedItem;
39+
40+
type HostItems = React.ComponentProps<typeof DiscussionContextProvider>['items'];
41+
42+
function mountChatter(ctx: { items: HostItems; commentsDenied?: boolean; activityDenied?: boolean }) {
43+
return render(
44+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
45+
<RecordContextProvider objectName="crm_account" recordId="rec-1" data={{ id: 'rec-1' }}>
46+
<DiscussionContextProvider loading={false} {...ctx}>
47+
<RecordChatterRenderer schema={{} as never} />
48+
</DiscussionContextProvider>
49+
</RecordContextProvider>
50+
</I18nProvider>,
51+
);
52+
}
53+
54+
const heading = () => screen.getByRole('heading', { level: 2 });
55+
56+
afterEach(() => cleanup());
57+
58+
describe('the discussion header withholds its count when a feed read was refused (objectstack#27820)', () => {
59+
it('a refused comment read: the header shows no count, the refusal line still shows', () => {
60+
mountChatter({ items: [], commentsDenied: true });
61+
62+
expect(heading().textContent?.trim()).toBe('Discussion');
63+
expect(heading().textContent).not.toMatch(/\(\d+\)/);
64+
expect(screen.queryByTestId('activity-count')).toBeNull();
65+
expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED);
66+
});
67+
68+
it('a refused comment read beside readable activity rows still shows no count', () => {
69+
const row = {
70+
id: 'a-1',
71+
type: 'field_change',
72+
actor: 'Grace',
73+
body: 'Stage moved to qualified',
74+
createdAt: '2026-01-02T00:00:00.000Z',
75+
} satisfies FeedItem;
76+
mountChatter({ items: [row], commentsDenied: true });
77+
78+
expect(screen.getByText('Stage moved to qualified')).toBeTruthy();
79+
expect(heading().textContent).not.toMatch(/\(\d+\)/);
80+
expect(screen.getByTestId('activity-access-denied').textContent).toContain(COMMENTS_DENIED);
81+
});
82+
83+
it('a refused activity read shows no count either', () => {
84+
mountChatter({ items: [], activityDenied: true });
85+
86+
expect(heading().textContent).not.toMatch(/\(\d+\)/);
87+
expect(screen.getByTestId('activity-access-denied').textContent).toContain(ACTIVITY_DENIED);
88+
});
89+
90+
it('control: a readable record with no comments shows (0)', () => {
91+
mountChatter({ items: [] });
92+
93+
expect(heading()).toHaveTextContent(/^Discussion\s*\(0\)$/);
94+
expect(screen.queryByTestId('activity-access-denied')).toBeNull();
95+
});
96+
97+
it('control: a readable record with 3 comments shows (3)', () => {
98+
mountChatter({ items: [comment(1), comment(2), comment(3)] });
99+
100+
expect(heading()).toHaveTextContent(/^Discussion\s*\(3\)$/);
101+
});
102+
});
103+
104+
describe('the collapsed discussion toggle withholds its count the same way (objectstack#27820)', () => {
105+
const mountCollapsed = (props: { commentsDenied?: boolean }) =>
106+
render(
107+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
108+
<RecordChatterPanel
109+
items={[]}
110+
config={{ position: 'bottom', collapsible: true, defaultCollapsed: true } as never}
111+
{...props}
112+
/>
113+
</I18nProvider>,
114+
);
115+
116+
it('refused: the toggle names the panel without a count', () => {
117+
mountCollapsed({ commentsDenied: true });
118+
119+
const toggle = screen.getByRole('button', { name: 'Open discussion panel' });
120+
expect(toggle.textContent).not.toMatch(/\(\d+\)/);
121+
});
122+
123+
it('control: readable, the toggle keeps its (0)', () => {
124+
mountCollapsed({});
125+
126+
expect(screen.getByRole('button', { name: 'Show Discussion (0)' })).toBeTruthy();
127+
});
128+
});

0 commit comments

Comments
 (0)