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
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { createElement, Fragment, type ReactNode } from 'react';
import { type ComponentProps, createElement, Fragment, type ReactNode } from 'react';
import { act, TestRenderer } from '@/test/renderer';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { type DiscussionListItem } from '@/lib/pr-review/discussion/review-discussion-types';
import {
type ConversationComment,
type DiscussionListItem,
type ReviewThread,
} from '@/lib/pr-review/discussion/review-discussion-types';
import { ProviderPrScopeProvider } from '@/lib/pr-review/provider-pr-ref';
import { PrReviewDiscussionList } from './pr-review-discussion-list';

type TaggedOptions = { tag: string; input?: unknown };

const observed = vi.hoisted(() => ({ options: [] as TaggedOptions[] }));
const observed = vi.hoisted(() => ({
options: [] as TaggedOptions[],
blockedLogins: [] as string[],
mutedLogins: [] as string[],
}));

// Records every query the list mounts and answers the overview with a viewer
// login, so a test can assert BOTH which namespace was asked and what the
Expand All @@ -20,7 +28,7 @@ vi.mock('@tanstack/react-query', () => ({
useQuery: (options: TaggedOptions) => {
observed.options.push(options);
if (options.tag === 'moderation') {
return { data: { blockedLogins: [], mutedLogins: [] } };
return { data: { blockedLogins: observed.blockedLogins, mutedLogins: observed.mutedLogins } };
}
return { data: { repo: { viewerLogin: 'octocat' } } };
},
Expand All @@ -46,16 +54,23 @@ vi.mock('@shopify/flash-list', () => ({
FlashList: ({
data,
renderItem,
ListEmptyComponent,
ListFooterComponent,
}: {
data: readonly unknown[];
renderItem: (args: { item: unknown; index: number }) => ReactNode;
ListEmptyComponent?: ReactNode;
ListFooterComponent?: ReactNode;
}) =>
createElement(
Fragment,
null,
data.map((item, index) =>
createElement(Fragment, { key: index }, renderItem({ item, index }))
)
data.length === 0
? ListEmptyComponent
: data.map((item, index) =>
createElement(Fragment, { key: index }, renderItem({ item, index }))
),
ListFooterComponent
),
}));
vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' }));
Expand All @@ -64,30 +79,54 @@ vi.mock('@/components/pr-review/discussion/discussion-thread', () => ({
}));
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' }));
vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus' }));
vi.mock('@/lib/screen-insets', () => ({ useDetailScreenBottomPadding: () => 0 }));

function noop(): void {
// The viewer query is what this file asserts; the list callbacks are inert.
// Callbacks not exercised by a test are inert.
}

function makeComment(id: number, login: string | null = 'octocat'): ConversationComment {
return {
commentId: id,
nodeId: `c${id}`,
author: login === null ? null : { login, avatarUrl: null },
bodyMarkdown: 'hello',
createdAt: '2026-01-01T00:00:00Z',
reactions: [],
};
}

const listItems: readonly DiscussionListItem[] = [
{
kind: 'comment',
comment: {
commentId: 1,
nodeId: 'c1',
author: { login: 'octocat', avatarUrl: null },
bodyMarkdown: 'hello',
createdAt: '2026-01-01T00:00:00Z',
reactions: [],
function makeThread(comments: ReviewThread['comments']): DiscussionListItem {
return {
kind: 'thread',
thread: {
threadId: 't1',
isResolved: false,
isOutdated: false,
subjectType: 'LINE',
path: 'src/index.ts',
line: 10,
startLine: null,
originalLine: null,
originalStartLine: null,
diffSide: 'RIGHT',
diffHunk: null,
comments,
},
},
];
};
}

const listItems: readonly DiscussionListItem[] = [{ kind: 'comment', comment: makeComment(1) }];

function mountList(scope?: {
ref: { platform: 'gitlab'; projectPath: string; mrIid: number };
organizationId: string | null;
}) {
function mountList(
scope?: {
ref: { platform: 'gitlab'; projectPath: string; mrIid: number };
organizationId: string | null;
},
overrides: Partial<ComponentProps<typeof PrReviewDiscussionList>> = {}
) {
const list = (
<PrReviewDiscussionList
owner="group/sub"
Expand All @@ -104,6 +143,7 @@ function mountList(scope?: {
laterPageError={false}
onLoadMore={noop}
onRetryLoadMore={noop}
{...overrides}
/>
);
const created: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
Expand All @@ -119,11 +159,120 @@ function mountList(scope?: {
return renderer;
}

describe('PrReviewDiscussionList viewer query', () => {
describe('PrReviewDiscussionList visible content', () => {
beforeEach(() => {
observed.options = [];
observed.blockedLogins = [];
observed.mutedLogins = [];
});

it.each(['blockedLogins', 'mutedLogins'] as const)(
'explains an empty visible discussion when every author is in %s',
hiddenUsers => {
observed[hiddenUsers] = ['OCTOCAT'];
const renderer = mountList(undefined, {
listItems: Array.from({ length: 5 }, (_, index) => ({
kind: 'comment',
comment: makeComment(index + 1),
})),
});

expect(renderer.root.findAll(node => String(node.type) === 'CommentRow')).toHaveLength(0);
const emptyStates = renderer.root.findAll(node => String(node.type) === 'EmptyState');
expect(emptyStates).toHaveLength(1);
expect(emptyStates[0]?.props.title).toBe('prReview.discussion.noDiscussion');
expect(emptyStates[0]?.props.description).toBe('prReview.discussion.noDiscussionDescription');
// The FlashList owns scrolling; do not nest the centered state's ScrollView.
expect(emptyStates[0]?.props.placement).toBe('top');
// The same empty state names a merge request under a GitLab scope.
const gitlab = mountList(
{
ref: { platform: 'gitlab', projectPath: 'group/sub/repo', mrIid: 12 },
organizationId: null,
},
{ listItems: [{ kind: 'comment', comment: makeComment(1) }] }
);
expect(gitlab.root.findByType('EmptyState').props.description).toBe(
'prReview.terms.noDiscussionDescription'
);
gitlab.unmount();

renderer.unmount();
}
);

it.each([{ comments: [] }, { comments: [makeComment(1)] }])(
'explains threads with no visible comments ($comments)',
({ comments }) => {
observed.blockedLogins = ['octocat'];
const renderer = mountList(undefined, { listItems: [makeThread(comments)] });
expect(renderer.root.findAllByType('DiscussionThread')).toHaveLength(0);
expect(renderer.root.findAllByType('EmptyState')).toHaveLength(1);
renderer.unmount();
}
);

it('keeps visible replies and deleted-author comments without an empty state', () => {
observed.mutedLogins = ['octocat'];
const visibleReply = makeComment(2, 'alice');
const renderer = mountList(undefined, {
listItems: [
...listItems,
{ kind: 'comment', comment: makeComment(3, null) },
makeThread([makeComment(1), visibleReply]),
],
});
expect(renderer.root.findAllByType('CommentRow')).toHaveLength(1);
expect(renderer.root.findByType('DiscussionThread').props.thread).toMatchObject({
comments: [visibleReply],
});
expect(renderer.root.findAllByType('EmptyState')).toHaveLength(0);
renderer.unmount();
});

it.each(['load-more', 'loading', 'retry'] as const)(
'keeps the %s footer usable when all loaded comments are hidden',
state => {
observed.mutedLogins = ['octocat'];
const onLoadMore = vi.fn<() => void>();
const onRetryLoadMore = vi.fn<() => void>();
const renderer = mountList(undefined, {
hasNextPage: true,
isFetchingNextPage: state === 'loading',
laterPageError: state === 'retry',
onLoadMore,
onRetryLoadMore,
});
expect(renderer.root.findAllByType('EmptyState')).toHaveLength(1);
const button = renderer.root.findByType('Button');
if (state === 'loading') {
expect(button.props.loading).toBe(true);
} else {
act(() => {
(button.props.onPress as () => void)();
});
expect(state === 'retry' ? onRetryLoadMore : onLoadMore).toHaveBeenCalledOnce();
}
// A later page can reveal a visible comment without remounting the list surface.
const props = renderer.root.findByType(PrReviewDiscussionList).props as ComponentProps<
typeof PrReviewDiscussionList
>;
act(() => {
renderer.update(
<PrReviewDiscussionList
{...props}
listItems={[...listItems, { kind: 'comment', comment: makeComment(2, 'alice') }]}
isFetchingNextPage={false}
laterPageError={false}
/>
);
});
expect(renderer.root.findAllByType('EmptyState')).toHaveLength(0);
expect(renderer.root.findAllByType('CommentRow')).toHaveLength(1);
renderer.unmount();
}
);

it('reads the viewer login from the GitHub procedure on a GitHub scope', () => {
const renderer = mountList();

Expand All @@ -135,6 +284,7 @@ describe('PrReviewDiscussionList viewer query', () => {
expect(
renderer.root.findAll(node => String(node.type) === 'CommentRow')[0]?.props.viewerLogin
).toBe('octocat');
expect(renderer.root.findAllByType('EmptyState')).toHaveLength(0);

act(() => {
renderer.unmount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ vi.mock('@/lib/trpc', () => ({
}));
vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
vi.mock('@/components/empty-state', () => ({ EmptyState: 'EmptyState' }));
vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus' }));
vi.mock('@/components/pr-review/discussion/comment-row', () => ({ CommentRow: 'CommentRow' }));
vi.mock('@/components/pr-review/discussion/discussion-thread', () => ({
DiscussionThread: 'DiscussionThread',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import { useTranslation } from 'react-i18next';
import { View, type ViewStyle } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { EmptyState } from '@/components/empty-state';
import { CommentRow } from '@/components/pr-review/discussion/comment-row';
import { DiscussionThread } from '@/components/pr-review/discussion/discussion-thread';
import { Button } from '@/components/ui/button';
import { MessageSquarePlus } from '@/components/ui/icons';
import { Text } from '@/components/ui/text';
import {
type DiscussionListItem,
type ReviewThread,
} from '@/lib/pr-review/discussion/review-discussion-types';
import { expandedForThread } from '@/lib/pr-review/discussion/thread-expansion';
import { useProviderPrQueries } from '@/lib/pr-review/provider-pr-queries';
import { useProviderPrScope } from '@/lib/pr-review/provider-pr-ref';
import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
import { useTRPC } from '@/lib/trpc';

Expand Down Expand Up @@ -74,7 +77,12 @@ export function PrReviewDiscussionList({
onReplyInputFocus,
onViewportLayout,
}: Readonly<PrReviewDiscussionListProps>) {
const { t } = useTranslation();
const trpc = useTRPC();
// Provider noun for the empty message below (GitLab calls it a merge
// request); the same scope the tab reads for its own copy.
const { ref } = useProviderPrScope({ owner, repo, number });
const isMergeRequest = ref.platform === 'gitlab';
// Account-local hidden users (blocked + muted GitHub logins) filter rows.
const hiddenUsers = useQuery(trpc.moderation.listHiddenUsers.queryOptions());
// Viewer login for self-target gating on the comment overflow menu. The
Expand Down Expand Up @@ -190,6 +198,23 @@ export function PrReviewDiscussionList({
onLayout={event => {
onViewportLayout?.(event.nativeEvent.layout.height);
}}
ListEmptyComponent={
// Every loaded row can be hidden (blocked/muted authors) while the
// tab still counts the discussion as content. Without this the list
// renders an empty body under the pinned Comment bar: no rows, no
// loading, no message (spot check e7). The copy is the discussion's
// existing empty copy — new copy belongs to the translation slice.
<EmptyState
placement="top"
icon={MessageSquarePlus}
title={t('prReview.discussion.noDiscussion')}
description={
isMergeRequest
? t('prReview.terms.noDiscussionDescription')
: t('prReview.discussion.noDiscussionDescription')
}
/>
}
ListFooterComponent={
<ListFooter
hasNextPage={hasNextPage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,31 @@ describe('PrReviewDiscussionTab full-body states', () => {
expect(renderer.root.findAll(node => String(node.type) === 'QueryError')).toHaveLength(0);
});

it.each(['load-more', 'loading', 'retry'] as const)(
'keeps the %s footer reachable when normalization leaves no discussion rows',
state => {
discussionState.query.hasNextPage = state !== 'retry';
discussionState.query.isFetchingNextPage = state === 'loading';
discussionState.laterPageError = state === 'retry';
const renderer = mountTab();
const list = renderer.root.find(node => String(node.type) === 'PrReviewDiscussionList');
expect(list.props.listItems).toEqual([]);
expect(list.props.hasNextPage).toBe(state !== 'retry');
expect(list.props.isFetchingNextPage).toBe(state === 'loading');
expect(list.props.laterPageError).toBe(state === 'retry');
expectCtaPresence(renderer, true);
if (state !== 'loading') {
act(() => {
(list.props[state === 'retry' ? 'onRetryLoadMore' : 'onLoadMore'] as () => void)();
});
expect(
state === 'retry' ? discussionState.query.refetch : discussionState.query.fetchNextPage
).toHaveBeenCalledOnce();
}
renderer.unmount();
}
);

it('keeps permission denial ahead of retained comments', () => {
discussionState.conversation = [{ nodeId: 'c1', createdAt: null }];
discussionState.firstPageErrorState = { kind: 'permission' };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,8 +368,9 @@ export function PrReviewDiscussionTab({
);
}

// ── Empty (neither threads nor conversation comments) ──────────────
if (view.kind === 'empty') {
// An empty normalized page can still have more discussion to load. Keep
// the list's empty message and pagination footer reachable in that case.
if (view.kind === 'empty' && !query.hasNextPage && !query.isFetchingNextPage && !laterPageError) {
return withCommentCta(
<EmptyState
icon={MessageSquarePlus}
Expand Down
Loading
Loading