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
Expand Up @@ -14,7 +14,7 @@ export type ClaimRecordPageProps = {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

/** The shared server guard and page shell behind the claim's three record routes. */
/** The shared server guard and page shell behind the claim's record routes. */
export async function ClaimRecordPage(props: ClaimRecordPageProps) {
const params = await props.params;
const searchParams = await props.searchParams;
Expand Down
5 changes: 5 additions & 0 deletions apps/web/app/space/(entity)/[id]/[entityId]/topics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page';

export default function ClaimTopicsPage(props: ClaimRecordPageProps) {
return <ClaimRecordPage {...props} />;
}
196 changes: 170 additions & 26 deletions apps/web/core/claims/browse/claim-page-view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ import { ClaimPageView, resolveClaimTab } from './claim-page-view';

const mocks = vi.hoisted(() => ({
entity: null as Record<string, unknown> | null,
/** How many responses the claim has; zero means the hero draws no verdict column. */
responseTotal: 11,
/** Whether the response counts are still out, which is what the hero reserves its column for. */
summaryLoading: false,
/** Whether the counts actually answered. False after a terminal failure, not just while loading. */
hasCounts: true,
/** Drives the one strip that can occupy the hero's first grid row. */
isControversial: false,
/** Props the description's clamp received, or null if it rendered no clamp at all. */
clamp: null as Record<string, unknown> | null,
/** Props the chip section received, or null if the page rendered none. */
chipSection: null as Record<string, unknown> | null,
/** Props the Topics tab received, or null if the page rendered none. */
topicsTab: null as Record<string, unknown> | null,
tabs: null as Record<string, unknown> | null,
activity: null as Record<string, unknown> | null,
recordTab: null as Record<string, unknown> | null,
Expand Down Expand Up @@ -80,7 +90,8 @@ vi.mock('~/design-system/clamped-text', () => ({
},
}));

// Its own suite covers the chips and the expander; here we only need to see what it was handed.
// Only `META_CHIP_CLASS` is still read from here — the hero's switched-off topics row borrows the
// chips' shape. The section itself no longer renders on this page at all.
vi.mock('~/partials/entity-page/relation-chip-section', () => ({
META_CHIP_CLASS: 'meta-chip',
RelationChipSection: (props: Record<string, unknown>) => {
Expand All @@ -89,6 +100,15 @@ vi.mock('~/partials/entity-page/relation-chip-section', () => ({
},
}));

// The Topics tab is a feed of its own, with its own counts and ordering covered in its own suite.
// Here we only need to see which topics reached it.
vi.mock('./claim-topics-tab', () => ({
ClaimTopicsTab: (props: Record<string, unknown>) => {
mocks.topicsTab = props;
return <div data-testid="topics-tab" />;
},
}));

vi.mock('~/core/sync/use-store', () => ({
useQueryEntity: () => ({ entity: mocks.entity, isLoading: false }),
}));
Expand All @@ -101,7 +121,10 @@ vi.mock('./use-claim-response-state', () => ({
useClaimResponseState: () => ({
responseKind: 'stance',
summary: {
isControversial: false,
isLoading: mocks.summaryLoading,
hasCounts: mocks.hasCounts,
total: mocks.responseTotal,
isControversial: mocks.isControversial,
viewerDirection: 'positive',
viewerSpaceId: 'viewer-space',
isViewerResponseLoading: true,
Expand Down Expand Up @@ -138,10 +161,20 @@ vi.mock('./claim-comment-position', () => ({
},
}));
vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => {} }));
// The prefetching link reaches for the sync engine; the topic chips only need to be links.
vi.mock('~/design-system/prefetch-link', () => ({
PrefetchLink: ({ href, children, className }: { href: string; children: React.ReactNode; className?: string }) => (
<a href={href} className={className}>
{children}
</a>
),
}));
vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({
useBackfillReadinessForHeldPosition: () => {},
}));
vi.mock('./claim-verdict', () => ({ ClaimVerdict: () => <div data-testid="verdict" /> }));
vi.mock('~/partials/explore/claim-explore-feed-card', () => ({
ClaimVerdictColumn: () => <div data-testid="verdict" />,
}));
vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () => <div data-testid="sources" /> }));
vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null }));
vi.mock('./claim-record-tab', () => ({
Expand Down Expand Up @@ -185,9 +218,14 @@ function claimEntity(description: string | null) {
}

beforeEach(() => {
mocks.responseTotal = 11;
mocks.summaryLoading = false;
mocks.hasCounts = true;
mocks.isControversial = false;
mocks.entity = claimEntity('A description long enough that the page has something to collapse.');
mocks.clamp = null;
mocks.chipSection = null;
mocks.topicsTab = null;
mocks.tabs = null;
mocks.activity = null;
mocks.recordTab = null;
Expand Down Expand Up @@ -261,16 +299,114 @@ describe('ClaimPageView record', () => {
]);
});

it('orders Overview as response summary, position, activity, then comments', () => {
it('heads the page like an Explore claim card: the claim, the pills, then the verdict column', () => {
mocks.entity = {
...mocks.entity,
types: [{ id: 'claim-type', name: 'Claim' }],
relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }],
};
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

const heading = screen.getByRole('heading', { level: 1 });
const position = screen.getByTestId('position');
const verdict = screen.getByTestId('verdict');
const tabs = screen.getByTestId('tabs');

// Topics are on their own tab for now, not above the claim.
expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull();
expect(heading.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(position.compareDocumentPosition(verdict) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(verdict.compareDocumentPosition(tabs) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// No type chip: every claim on this page is a Claim.
expect(screen.queryByText('Claim')).toBeNull();
});

it('draws no verdict column on a claim nobody has answered, as Explore does', () => {
mocks.responseTotal = 0;
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.queryByTestId('verdict')).toBeNull();
expect(screen.getByTestId('position')).toBeInTheDocument();
});

it('keeps both hero tracks while the counts are still out', () => {
// `hasVerdict` cannot be true until they answer, so a template derived from it alone painted
// one column and then re-wrapped the claim when the second appeared — a shift at the top of
// the page on every load.
mocks.summaryLoading = true;
const { container } = render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

const grid = container.querySelector('header > div');
expect(grid?.className).toContain('grid-cols-[minmax(0,1fr)_220px]');
// Reserved, not filled: nothing has said what the verdict is yet.
expect(screen.queryByTestId('verdict')).toBeNull();
});

it('keeps the track when the counts fail rather than reading the failure as a zero', () => {
// A counts query that exhausts its retries leaves `total` at zero with nothing loading any
// more — the shape of an unanswered claim, which is exactly what it is not. Keying the track
// off `isLoading` gave it back on that failure and re-wrapped the title anyway.
mocks.summaryLoading = false;
mocks.hasCounts = false;
mocks.responseTotal = 0;
const { container } = render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(container.querySelector('header > div')?.className).toContain('grid-cols-[minmax(0,1fr)_220px]');
// Reserved, not filled: there is still no verdict to draw.
expect(screen.queryByTestId('verdict')).toBeNull();
});

it('gives the column back once the counts settle on nobody having answered', () => {
mocks.responseTotal = 0;
const { container } = render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(container.querySelector('header > div')?.className).toContain('grid-cols-1');
});

it('leaves no empty row above the claim when nothing is drawn there', () => {
// The hero pins its parts to explicit rows so the verdict can start on the title's. With the
// chips row empty, row 1 is a `gap-y-4` above the claim belonging to a row nothing occupies —
// visible in the side panel and at phone widths, where that gap is set.
const { container } = render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-1');
expect(container.querySelector('[data-testid="position"]')?.closest('.col-start-1')?.className).toContain(
'row-start-2'
);
});

it('moves the rows down again when the claim is controversial', () => {
mocks.isControversial = true;
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.getByRole('heading', { level: 1 }).closest('div')?.className).toContain('row-start-2');
});

it('announces no Topics landmark over a row that is only the Controversial chip', () => {
// `SHOW_HERO_TOPICS` is off, so being controversial is the only thing that puts this row on
// the page — and it holds one status chip and no links. A navigation landmark named "Topics"
// over that is both empty and misnamed to anyone moving through the page by landmark.
mocks.isControversial = true;
// With topics on the claim, so this is about the row having no *links* rather than the claim
// having no topics.
mocks.entity = {
...claimEntity('Anything'),
relations: [{ id: 'relation-1', type: { id: TOPICS_PROPERTY_ID }, toEntity: { id: 'topic-1', name: 'Ethics' } }],
};
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.getByText('Controversial')).toBeInTheDocument();
expect(screen.queryByRole('navigation', { name: 'Topics' })).toBeNull();
});

it('orders Overview as activity, then comments', () => {
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

const tabs = screen.getByTestId('tabs');
const activity = screen.getByTestId('activity');
const comments = screen.getByTestId('comments');

expect(verdict.compareDocumentPosition(position) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(position.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(tabs.compareDocumentPosition(activity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});

Expand Down Expand Up @@ -388,38 +524,46 @@ describe('ClaimPageView comments', () => {
});
});

// GEO-2781. Topics used to be a run of chips crammed into the header's meta row, capped at three
// and with a `+N` that only counted. It is now the topic view's Subtopics section, which is the
// same question asked of the reader and so should not be a second thing that merely looks like it.
// Topics live on their own tab for now; the hero's row is switched off (`SHOW_HERO_TOPICS`). The
// Overview no longer repeats them either.
describe('ClaimPageView topics', () => {
const topicRelation = {
id: 'relation-1',
const topic = (n: number) => ({
id: `relation-${n}`,
type: { id: TOPICS_PROPERTY_ID },
toEntity: { id: 'topic-1', name: 'Ethics' },
};
toEntity: { id: `topic-${n}`, name: `Topic ${n}` },
});
const tagRelation = { id: 'relation-tag', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } };

it('draws every topic, and only topics, on the Topics tab', () => {
mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() };
mocks.entity = { ...claimEntity('Anything'), relations: [topic(1), topic(2), tagRelation] };
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.getByTestId('topics-tab')).toBeInTheDocument();
expect(mocks.topicsTab?.topics).toEqual([topic(1), topic(2)]);
expect(mocks.topicsTab?.spaceId).toBe('space-1');
});

it('draws them with the shared chip section, under the label Topics', () => {
mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] };
it('no longer draws them as chips anywhere on the page', () => {
mocks.sidePanel = { activeTabId: null, activeSystemTab: 'topics', setActiveSystemTab: vi.fn() };
mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(screen.getByTestId('chip-section')).toHaveAttribute('data-label', 'Topics');
expect(screen.getByTestId('activity').nextElementSibling).toBe(screen.getByTestId('chip-section'));
expect(screen.queryByTestId('chip-section')).toBeNull();
});

it('hands the section the topic relations, scoped to the viewing space', () => {
mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation] };
it('no longer repeats them on the Overview', () => {
mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(mocks.chipSection?.relations).toEqual([topicRelation]);
expect(mocks.chipSection?.spaceId).toBe('space-1');
expect(screen.queryByTestId('chip-section')).toBeNull();
expect(screen.queryByTestId('topics-tab')).toBeNull();
});

// Tags share the header row with the type and are a different relation; only Topics moved.
it('passes only topic relations, not the tags beside the type', () => {
const tagRelation = { id: 'relation-2', type: { id: TAG_PROPERTY_ID }, toEntity: { id: 'tag-1', name: 'Draft' } };
mocks.entity = { ...claimEntity('Anything'), relations: [topicRelation, tagRelation] };
it('offers a Topics tab only when the claim has topics', () => {
mocks.entity = { ...claimEntity('Anything'), relations: [topic(1)] };
render(<ClaimPageView entityId="claim-1" spaceId="space-1" />);

expect(mocks.chipSection?.relations).toEqual([topicRelation]);
expect((mocks.tabs?.systemTabsBefore as Array<{ label: string }>).map(tab => tab.label)).toContain('Topics');
});
});
Loading
Loading