diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx new file mode 100644 index 0000000000..869a5707b8 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claim-record-page.tsx @@ -0,0 +1,30 @@ +import { IdUtils } from '@geoprotocol/geo-sdk/lite'; + +import { notFound } from 'next/navigation'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { ID } from '~/core/id'; +import { isHiddenEntity } from '~/core/moderation/hidden'; + +import { cachedFetchEntityPage } from './cached-fetch-entity'; +import DefaultEntityPage from './default-entity-page'; + +export type ClaimRecordPageProps = { + params: Promise<{ id: string; entityId: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +/** The shared server guard and page shell behind the claim's three record routes. */ +export async function ClaimRecordPage(props: ClaimRecordPageProps) { + const params = await props.params; + const searchParams = await props.searchParams; + + if (!IdUtils.isValid(params.id) || !IdUtils.isValid(params.entityId)) notFound(); + + const result = await cachedFetchEntityPage(params.entityId, params.id); + if (isHiddenEntity(result?.entity) || !result?.entity?.types.some(type => ID.equals(type.id, CLAIM_TYPE_ID))) { + notFound(); + } + + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx new file mode 100644 index 0000000000..57aaff8f7f --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/claims/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimClaimsPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx new file mode 100644 index 0000000000..99a34a0fa1 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/debates/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimDebatesPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx index fdfecab73e..0a4cd52c86 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/layout.tsx @@ -15,6 +15,7 @@ import { EntityStoreProvider } from '~/core/state/entity-page-store/entity-store import { TabEntity } from '~/core/types'; import { Entity, Relation } from '~/core/types'; import { Entities } from '~/core/utils/entity'; +import { entityBrowseViewFromTypes } from '~/core/utils/entity-browse-view'; import { sortRelations } from '~/core/utils/utils'; import { Spacer } from '~/design-system/spacer'; @@ -77,9 +78,9 @@ export default async function ProfileLayout(props: Props) { const { children } = props; const result = await cachedFetchEntityPage(entityId, spaceId); - const typeIds = result?.entity?.types.map(t => t.id) ?? []; + const entityTypes = result?.entity?.types ?? []; - if (!typeIds.includes(SystemIds.PERSON_TYPE)) { + if (entityBrowseViewFromTypes(entityTypes) !== 'person') { return <>{children}; } diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/page.tsx index bcd6d6d06f..4fb1253d04 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/page.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/page.tsx @@ -1,4 +1,4 @@ -import { IdUtils, SystemIds } from '@geoprotocol/geo-sdk/lite'; +import { IdUtils } from '@geoprotocol/geo-sdk/lite'; import { notFound } from 'next/navigation'; @@ -8,6 +8,7 @@ import { getRecordingUrls } from '~/core/community-calls/recordings'; import { DebateEntityView } from '~/core/debates/browse/debate-entity-view'; import { isDebateEntity } from '~/core/debates/is-debate-entity'; import { isHiddenEntity } from '~/core/moderation/hidden'; +import { entityBrowseViewFromTypes } from '~/core/utils/entity-browse-view'; import { entityHasOnlyPostType } from '~/core/utils/entity/entities'; import { BountyDetailHeader } from '~/partials/bounties/bounty-detail-header'; @@ -43,7 +44,7 @@ export default async function EntityTemplateStrategy(props: Props) { notFound(); } - if (result?.entity?.types.map(t => t.id).includes(SystemIds.PERSON_TYPE)) { + if (result?.entity && entityBrowseViewFromTypes(result.entity.types) === 'person') { return ; } diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/profile-layout.test.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/profile-layout.test.tsx index 21860a1a6f..b25c642da5 100644 --- a/apps/web/app/space/(entity)/[id]/[entityId]/profile-layout.test.tsx +++ b/apps/web/app/space/(entity)/[id]/[entityId]/profile-layout.test.tsx @@ -1,3 +1,4 @@ +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; import '@testing-library/jest-dom/vitest'; import { cleanup, render } from '@testing-library/react'; @@ -5,6 +6,13 @@ import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; + +// Statically, below the mocks vitest hoists above it. Importing this graph inside a test body ran +// it against the 5s test timeout instead, which the route's module tree exceeds under a parallel +// run — it is the layout, so it pulls in most of the entity page. +import ProfileLayout from './layout'; + // The layout 404s on anything `IdUtils.isValid` rejects, so these are real ids rather than labels. /** The route params. The fetched person's id is deliberately a different one. */ const ROUTE_ENTITY_ID = 'a1b2c3d4e5f6427a8b9c0d1e2f3a4b5c'; @@ -17,14 +25,16 @@ const mocks = vi.hoisted(() => ({ heading: null as Record | null, storeProvider: null as Record | null, metadataHeader: null as Record | null, + entityTypes: [] as { id: string }[], })); -vi.mock('./cached-fetch-entity', async () => { - const { SystemIds } = await import('@geoprotocol/geo-sdk/lite'); +vi.mock('./cached-fetch-entity', () => { const person = { id: 'c3d4e5f6a7b8429c0d1e2f3a4b5c6d7e', name: 'Ada Lovelace', - types: [{ id: SystemIds.PERSON_TYPE }], + get types() { + return mocks.entityTypes; + }, relations: [], values: [], spaces: [], @@ -88,12 +98,8 @@ vi.mock('~/partials/entity-page/personal-profile-suggested-task-sync', () => ({ PersonalProfileSuggestedTaskSync: () => null, })); -// Statically, below the mocks vitest hoists above it. Importing this graph inside a test body ran -// it against the 5s test timeout instead, which the route's module tree exceeds under a parallel -// run — it is the layout, so it pulls in most of the entity page. -import ProfileLayout from './layout'; - beforeEach(() => { + mocks.entityTypes = [{ id: SystemIds.PERSON_TYPE }]; mocks.actions = null; mocks.heading = null; mocks.storeProvider = null; @@ -140,4 +146,14 @@ describe('profile ProfileLayout', () => { // above is what covers it — the space it scopes by is the one assertable here. expect(mocks.metadataHeader).toMatchObject({ spaceId: SPACE_ID }); }); + + it('does not wrap a mixed Claim and Person entity in the profile shell', async () => { + mocks.entityTypes = [{ id: SystemIds.PERSON_TYPE }, { id: CLAIM_TYPE_ID }]; + + const view = await renderProfileLayout(); + + expect(view.getByTestId('page')).toBeInTheDocument(); + expect(mocks.storeProvider).toBeNull(); + expect(mocks.actions).toBeNull(); + }); }); diff --git a/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx b/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx new file mode 100644 index 0000000000..35ee6dfdd2 --- /dev/null +++ b/apps/web/app/space/(entity)/[id]/[entityId]/sources/page.tsx @@ -0,0 +1,5 @@ +import { ClaimRecordPage, type ClaimRecordPageProps } from '../claim-record-page'; + +export default function ClaimSourcesPage(props: ClaimRecordPageProps) { + return ; +} diff --git a/apps/web/core/claims/browse/claim-page-view.test.tsx b/apps/web/core/claims/browse/claim-page-view.test.tsx index ceae054f44..71efb965b3 100644 --- a/apps/web/core/claims/browse/claim-page-view.test.tsx +++ b/apps/web/core/claims/browse/claim-page-view.test.tsx @@ -7,8 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; import { TAG_PROPERTY_ID } from '~/core/constants'; +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; -import { ClaimPageView } from './claim-page-view'; +import { ClaimPageView, resolveClaimTab } from './claim-page-view'; const mocks = vi.hoisted(() => ({ entity: null as Record | null, @@ -16,6 +17,33 @@ const mocks = vi.hoisted(() => ({ clamp: null as Record | null, /** Props the chip section received, or null if the page rendered none. */ chipSection: null as Record | null, + tabs: null as Record | null, + activity: null as Record | null, + recordTab: null as Record | null, + sidePanel: null as { + activeTabId: string | null; + activeSystemTab: string | null; + setActiveSystemTab: ReturnType; + } | null, + record: { + relatedClaimIds: [], + claimRows: [], + debateRows: [], + claimsTotal: 0, + debatesTotal: 0, + claimsLoading: false, + debatesLoading: false, + claimsError: false, + debatesError: false, + claimsCountUnavailable: false, + debatesCountUnavailable: false, + claimsFetchingNextPage: false, + debatesFetchingNextPage: false, + claimsHasNextPage: false, + debatesHasNextPage: false, + fetchNextClaimsPage: () => {}, + fetchNextDebatesPage: () => {}, + }, /** Claim response context supplied to the otherwise generic comment thread. */ commentPosition: null as Record | null, /** @@ -29,8 +57,17 @@ const mocks = vi.hoisted(() => ({ maxLines: 5, })); +vi.mock('next/navigation', () => ({ + usePathname: () => '/space/space-1/claim-1', +})); +vi.mock('~/core/state/editor/editor-provider', () => ({ useActiveTabIdForEditor: () => null })); + vi.mock('~/partials/entity-page/entity-page-inline-description', () => ({ ENTITY_DESCRIPTION_MAX_LINES: mocks.maxLines, + EntityPageInlineDescription: () =>
, +})); +vi.mock('~/partials/entity-page/editable-entity-header', () => ({ + EditableHeading: () =>
, })); // jsdom has no layout, so the real clamp can never measure an overflow. What this file is about is @@ -81,7 +118,6 @@ vi.mock('./use-claim-response-state', () => ({ // The page's modules each reach for the sync engine, geo-chat or Privy. None of them is what this // file is asserting, and the hero renders above all of them. vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ - PositionRow: () => null, useClaimPositionControl: () => ({ optimisticPositions: [], viewerPosition: null, @@ -89,9 +125,12 @@ vi.mock('~/core/debates/matchmaking/matchmaking-claim-card', () => ({ canRespond: false, actionTitle: () => undefined, responseError: null, + isConnected: false, }), })); -vi.mock('./claim-position-comment', () => ({ ClaimPositionCommentControl: () => null })); +vi.mock('./claim-position-comment', () => ({ + ClaimPositionCommentControl: () =>
, +})); vi.mock('./claim-comment-position', () => ({ ClaimCommentPositionProvider: (props: Record) => { mocks.commentPosition = props; @@ -102,13 +141,38 @@ vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => () => { vi.mock('~/core/debates/backfill-readiness-for-held-position', () => ({ useBackfillReadinessForHeldPosition: () => {}, })); -vi.mock('./claim-verdict', () => ({ ClaimVerdict: () => null })); -vi.mock('./claim-debates', () => ({ ClaimDebates: () => null })); -vi.mock('./claim-provenance', () => ({ ClaimProvenance: () => null })); -vi.mock('./claim-related-claims', () => ({ ClaimRelatedClaims: () => null })); +vi.mock('./claim-verdict', () => ({ ClaimVerdict: () =>
})); +vi.mock('./claim-sources-tab', () => ({ ClaimSourcesTab: () =>
})); vi.mock('./claim-end-slot', () => ({ ClaimEndSlot: () => null })); +vi.mock('./claim-record-tab', () => ({ + ClaimRecordTab: (props: Record) => { + mocks.recordTab = props; + return
; + }, +})); vi.mock('./claim-summary', () => ({ ControversialTag: () => null })); -vi.mock('~/partials/comments/comments-section', () => ({ CommentSection: () => null })); +vi.mock('./use-claim-record', () => ({ + useClaimRecord: () => mocks.record, +})); +vi.mock('~/core/state/entity-side-panel-active-tab', () => ({ + useEntitySidePanelActiveTab: () => mocks.sidePanel, +})); +vi.mock('~/partials/entity-page/entity-tabs', () => ({ + EntityTabs: (props: Record) => { + mocks.tabs = props; + return
; + }, +})); +vi.mock('~/partials/profile/profile-activity-section', () => ({ + ProfileActivitySection: (props: Record) => { + mocks.activity = props; + return
; + }, +})); +vi.mock('~/partials/editor/editor', () => ({ Editor: () =>
})); +vi.mock('~/partials/comments/comments-section', () => ({ + CommentSection: () =>
, +})); function claimEntity(description: string | null) { return { @@ -124,9 +188,161 @@ beforeEach(() => { mocks.entity = claimEntity('A description long enough that the page has something to collapse.'); mocks.clamp = null; mocks.chipSection = null; + mocks.tabs = null; + mocks.activity = null; + mocks.recordTab = null; + mocks.sidePanel = null; + mocks.record.claimsTotal = 0; + mocks.record.claimsLoading = false; + mocks.record.claimsError = false; + mocks.record.claimsCountUnavailable = false; + mocks.record.claimsFetchingNextPage = false; + mocks.record.claimsHasNextPage = false; + mocks.record.fetchNextClaimsPage = () => {}; + mocks.record.debatesTotal = 0; + mocks.record.debatesLoading = false; + mocks.record.debatesError = false; + mocks.record.debatesCountUnavailable = false; + mocks.record.debatesFetchingNextPage = false; + mocks.record.debatesHasNextPage = false; + mocks.record.fetchNextDebatesPage = () => {}; mocks.commentPosition = null; }); +describe('ClaimPageView record', () => { + it('offers product tabs before authored claim tabs', () => { + mocks.record.claimsTotal = 1; + + render(); + + expect(mocks.tabs).toMatchObject({ + reservedSystemLabels: ['Overview', 'Related claims'], + divideBeforeAuthored: true, + }); + expect(mocks.tabs?.systemTabsBefore).toEqual([ + expect.objectContaining({ label: 'Overview', sidePanelKey: 'overview' }), + expect.objectContaining({ label: 'Related claims', sidePanelKey: 'claims' }), + ]); + }); + + it('exposes the shared tabs anchor on routes but not inside side panels', () => { + const route = render(); + + expect(document.getElementById('space-tabs')).toContainElement(screen.getByTestId('tabs')); + + route.unmount(); + mocks.sidePanel = { activeTabId: null, activeSystemTab: null, setActiveSystemTab: vi.fn() }; + render(); + + expect(document.getElementById('space-tabs')).not.toBeInTheDocument(); + }); + + it('hides empty system tabs and shows them once they have content', () => { + render(); + + expect((mocks.tabs?.systemTabsBefore as Array<{ label: string }>).map(tab => tab.label)).toEqual(['Overview']); + expect(mocks.tabs?.reservedSystemLabels).toEqual(['Overview']); + + cleanup(); + mocks.record.claimsTotal = 1; + mocks.record.debatesTotal = 1; + mocks.entity = { + ...claimEntity('Anything'), + relations: [{ id: 'source-relation', type: { id: SOURCES_PROPERTY_ID }, toEntity: { id: 'source-1' } }], + }; + + render(); + + expect((mocks.tabs?.systemTabsBefore as Array<{ label: string }>).map(tab => tab.label)).toEqual([ + 'Overview', + 'Debates', + 'Related claims', + 'Sources', + ]); + }); + + it('orders Overview as response summary, position, activity, then comments', () => { + render(); + + const position = screen.getByTestId('position'); + const verdict = screen.getByTestId('verdict'); + 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(activity.compareDocumentPosition(comments) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('keeps the claim heading and description editable on the custom surface', () => { + render(); + + expect(screen.getByTestId('editable-heading')).toBeInTheDocument(); + expect(screen.getByTestId('editable-description')).toBeInTheDocument(); + }); + + it('makes Activity select claim record tabs inside a side panel', () => { + const setActiveSystemTab = vi.fn(); + mocks.sidePanel = { activeTabId: null, activeSystemTab: null, setActiveSystemTab }; + + render(); + + const kinds = mocks.activity?.kinds as Array<{ key: string; onSeeAll?: () => void }>; + kinds.find(kind => kind.key === 'debates')?.onSeeAll?.(); + kinds.find(kind => kind.key === 'claims')?.onSeeAll?.(); + + expect(setActiveSystemTab).toHaveBeenNthCalledWith(1, 'debates'); + expect(setActiveSystemTab).toHaveBeenNthCalledWith(2, 'claims'); + }); + + it('marks only failed record counts unavailable in Activity', () => { + mocks.record.claimsError = true; + mocks.record.claimsCountUnavailable = true; + mocks.record.debatesError = true; + mocks.record.debatesCountUnavailable = false; + + render(); + + const kinds = mocks.activity?.kinds as Array<{ key: string; isCountUnavailable?: boolean }>; + expect(kinds.find(kind => kind.key === 'claims')?.isCountUnavailable).toBe(true); + expect(kinds.find(kind => kind.key === 'debates')?.isCountUnavailable).toBe(false); + }); + + it('hands the full side-panel tab the claim record scope', () => { + mocks.sidePanel = { activeTabId: null, activeSystemTab: 'claims', setActiveSystemTab: vi.fn() }; + + render(); + + expect(mocks.recordTab).toMatchObject({ + kind: 'claims', + claimId: 'claim-1', + spaceId: 'space-1', + }); + }); +}); + +describe('resolveClaimTab', () => { + it('does not borrow the underlying route or authored query for a side panel', () => { + expect( + resolveClaimTab({ + pathname: '/space/space-1/claim-1/debates', + authoredTabId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + panel: { activeTabId: null, activeSystemTab: null }, + }) + ).toBe('overview'); + }); + + it('uses the panel selection when one is present', () => { + expect( + resolveClaimTab({ + pathname: '/space/space-1/claim-1', + authoredTabId: null, + panel: { activeTabId: null, activeSystemTab: 'sources' }, + }) + ).toBe('sources'); + }); +}); + afterEach(cleanup); describe('ClaimPageView description', () => { @@ -187,6 +403,7 @@ describe('ClaimPageView topics', () => { render(); expect(screen.getByTestId('chip-section')).toHaveAttribute('data-label', 'Topics'); + expect(screen.getByTestId('activity').nextElementSibling).toBe(screen.getByTestId('chip-section')); }); it('hands the section the topic relations, scoped to the viewing space', () => { diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 990c40df4b..cc70698fc5 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; +import { usePathname } from 'next/navigation'; + import { ClaimCommentPositionProvider } from '~/core/claims/browse/claim-comment-position'; import { ClaimPositionCommentControl } from '~/core/claims/browse/claim-position-comment'; import { TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; @@ -12,26 +14,72 @@ import { useDebateClaims } from '~/core/debates/hooks'; import { useClaimPositionControl } from '~/core/debates/matchmaking/matchmaking-claim-card'; import { usePrivySignIn } from '~/core/hooks/use-privy-sign-in'; import { ID } from '~/core/id'; +import { hasRecordToShow } from '~/core/profile/profile-proposer'; +import { useActiveTabIdForEditor } from '~/core/state/editor/editor-provider'; +import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-active-tab'; import { useQueryEntity } from '~/core/sync/use-store'; -import type { Relation } from '~/core/types'; +import type { Relation, TabEntity } from '~/core/types'; +import { NavUtils } from '~/core/utils/utils'; import { ClampedText } from '~/design-system/clamped-text'; import { Skeleton } from '~/design-system/skeleton'; import { Text } from '~/design-system/text'; import { CommentSection } from '~/partials/comments/comments-section'; -import { ENTITY_DESCRIPTION_MAX_LINES } from '~/partials/entity-page/entity-page-inline-description'; +import { Editor } from '~/partials/editor/editor'; +import { EditableHeading } from '~/partials/entity-page/editable-entity-header'; +import { + ENTITY_DESCRIPTION_MAX_LINES, + EntityPageInlineDescription, +} from '~/partials/entity-page/entity-page-inline-description'; +import { EntityTabs } from '~/partials/entity-page/entity-tabs'; import { META_CHIP_CLASS, RelationChipSection } from '~/partials/entity-page/relation-chip-section'; import { SectionTitle } from '~/partials/entity-page/section-title'; +import { type ActivityKind, ProfileActivitySection } from '~/partials/profile/profile-activity-section'; +import { SPACE_TABS_ANCHOR } from '~/partials/space-page/space-tabs-anchor'; -import { ClaimDebates } from './claim-debates'; import { ClaimEndSlot } from './claim-end-slot'; -import { ClaimProvenance } from './claim-provenance'; -import { ClaimRelatedClaims } from './claim-related-claims'; +import { ClaimRecordTab } from './claim-record-tab'; +import { getClaimSources } from './claim-sources'; +import { ClaimSourcesTab } from './claim-sources-tab'; import { ControversialTag } from './claim-summary'; import { ClaimVerdict } from './claim-verdict'; +import { useClaimRecord } from './use-claim-record'; import { type ClaimResponseState, useClaimResponseState } from './use-claim-response-state'; +type ClaimTab = 'overview' | 'debates' | 'claims' | 'sources' | 'custom'; +type ClaimSystemTab = Exclude; + +/** Shared with the cover/avatar header so its left edge stays aligned with the claim column. */ +export const CLAIM_PAGE_CONTENT_MAX_WIDTH = 720; +export const CLAIM_PAGE_CONTENT_INSET_CLASS = 'px-4 @[560px]:px-5'; + +export function resolveClaimTab({ + pathname, + authoredTabId, + panel, +}: { + pathname: string; + authoredTabId: string | null; + panel: { activeTabId: string | null; activeSystemTab: string | null } | null; +}): ClaimTab { + // A side panel owns its navigation. The page behind it may itself be on /debates or carry a + // custom tab query, and borrowing either would make a newly opened panel start on the wrong tab. + if (panel) { + if (panel.activeTabId) return 'custom'; + if (panel.activeSystemTab === 'debates') return 'debates'; + if (panel.activeSystemTab === 'claims') return 'claims'; + if (panel.activeSystemTab === 'sources') return 'sources'; + return 'overview'; + } + + if (authoredTabId) return 'custom'; + if (pathname.endsWith('/debates')) return 'debates'; + if (pathname.endsWith('/claims')) return 'claims'; + if (pathname.endsWith('/sources')) return 'sources'; + return 'overview'; +} + /** * The browse-mode read view for a Claim. * @@ -48,8 +96,25 @@ import { type ClaimResponseState, useClaimResponseState } from './use-claim-resp * never been debated, that carries no topics and was authored by hand shows its text, its space, * and the controls to act on it — and nothing else. */ -export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId: string }) { +export function ClaimPageView({ + entityId, + spaceId, + initialTabRelations = [], + tabEntities = [], + footer, + isEditing = false, +}: { + entityId: string; + spaceId: string; + initialTabRelations?: Relation[]; + tabEntities?: TabEntity[]; + footer?: React.ReactNode; + isEditing?: boolean; +}) { const { entity, isLoading } = useQueryEntity({ id: entityId, spaceId }); + const pathname = usePathname(); + const activeAuthoredTabId = useActiveTabIdForEditor(); + const sidePanelTab = useEntitySidePanelActiveTab(); // Hoisted so one lookup answers for the whole page. geo-chat's row and the graph's `Is factual` // are two copies of the same fact and can disagree — while an edit to the flag indexes, most @@ -67,12 +132,47 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId const topics = React.useMemo(() => relationsOfType(entity?.relations, TOPICS_PROPERTY_ID), [entity?.relations]); const tags = React.useMemo(() => relationsOfType(entity?.relations, TAG_PROPERTY_ID), [entity?.relations]); const topicIds = React.useMemo(() => topics.map(topic => topic.toEntity.id), [topics]); + const sources = React.useMemo(() => getClaimSources(entity?.relations ?? []), [entity?.relations]); // Named types only: an unnamed one would render as a raw id, which says less than no chip. const typeName = entity?.types.find(type => type.name)?.name ?? null; + const requestedTab = resolveClaimTab({ + pathname, + authoredTabId: activeAuthoredTabId, + panel: sidePanelTab, + }); + const record = useClaimRecord({ + claimId: entityId, + spaceId, + topicIds, + }); + + const overviewHref = NavUtils.toEntity(spaceId, entityId); + const hrefs = { + debates: `${overviewHref}/debates`, + claims: `${overviewHref}/claims`, + sources: `${overviewHref}/sources`, + }; + // Matches profile record tabs: unknown/error stays reachable, while a settled zero disappears. + const hasDebates = hasRecordToShow( + record.debatesLoading || record.debatesError || record.debatesCountUnavailable + ? undefined + : record.debatesTotal + ); + const hasClaims = hasRecordToShow( + record.claimsLoading || record.claimsError || record.claimsCountUnavailable ? undefined : record.claimsTotal + ); + const hasSources = sources.length > 0; + const systemTabs = [ + { label: 'Overview', href: overviewHref, sidePanelKey: 'overview' }, + ...(hasDebates ? [{ label: 'Debates', href: hrefs.debates, sidePanelKey: 'debates' }] : []), + ...(hasClaims ? [{ label: 'Related claims', href: hrefs.claims, sidePanelKey: 'claims' }] : []), + ...(hasSources ? [{ label: 'Sources', href: hrefs.sources, sidePanelKey: 'sources' }] : []), + ]; + if (isLoading && !entity) { return ( -
+
@@ -84,16 +184,23 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId return (
-
+
{/* Hero */}
{/* `text-pretty`, not `text-balance`. Balancing evens every line to the same length, which on a claim — a full sentence running to three or four lines — leaves each one breaking well short of the measure and reads as wrapping early. Pretty only avoids a stranded last word, so the lines fill. */} -

- {entity.name ?? entity.id} -

+ {isEditing ? ( + + ) : ( +

+ {entity.name ?? entity.id} +

+ )} {/* Clamped, like entity pages and the side panel (GEO-2772). What is shared is the line budget, not the cut: wrapping differs with width, so the route, the side panel and a @@ -105,13 +212,21 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId `ClampedText` measures an unclamped clone, so the toggle appears only when something is genuinely hidden, and it is unaffected by the naive-overflow bug GEO-2756 fixed in the feed's own title. */} - {entity.description && ( - + ) : ( + entity.description && ( + + ) )} {/* What this is. Topics — what it is *about* — used to sit opposite these, pushed to the @@ -130,41 +245,153 @@ export function ClaimPageView({ entityId, spaceId }: { entityId: string; spaceId
- {/* The topic view's Subtopics, drawing a claim's Topics (GEO-2781) — same question for the - reader, so the same answer rather than two that look alike until one of them changes. - Directly under the header, where the topic view puts its own: on a claim the thing worth - offering before the argument itself is somewhere else to take it. */} - - - - - - - - - - - +
+ tab.label)} + divideBeforeAuthored + /> +
- {/* Last, and in the same `page` variant a regular entity uses — the entity body renders it - this way for both the route and the side panel, and only the dedicated comments panel - asks for the `panel` variant. Unlike the modules above, this one always renders: an - empty thread is an invitation to start it, not an absence to hide. */} - - - + summary={summary} + state={state} + row={row} + record={record} + topics={topics} + availableSpaceIds={entity.spaces} + hrefs={{ debates: hrefs.debates, claims: hrefs.claims }} + onSelectSystemTab={sidePanelTab?.setActiveSystemTab} + /> + {footer}
); } +function ClaimTabPanel({ + activeTab, + entityId, + spaceId, + entityRelations, + responseKind, + summary, + state, + row, + record, + topics, + availableSpaceIds, + hrefs, + onSelectSystemTab, +}: { + activeTab: ClaimTab; + entityId: string; + spaceId: string; + entityRelations: Relation[]; + responseKind: ClaimResponseState['responseKind']; + summary: ClaimResponseState['summary']; + state: ClaimResponseState; + row: DebateClaim | null; + record: ReturnType; + topics: Relation[]; + availableSpaceIds: string[]; + hrefs: { debates: string; claims: string }; + onSelectSystemTab?: (tab: ClaimSystemTab) => void; +}) { + if (activeTab === 'custom') return ; + + if (activeTab === 'debates') { + return ( + ({ id: topic.toEntity.id, name: topic.toEntity.name }))} + /> + ); + } + + if (activeTab === 'claims') { + return ( + ({ id: topic.toEntity.id, name: topic.toEntity.name }))} + /> + ); + } + + if (activeTab === 'sources') { + return ; + } + + const kinds: ActivityKind[] = [ + { + key: 'debates', + label: 'Debates', + rows: record.debateRows, + total: record.debatesTotal, + isLoading: record.debatesLoading, + isError: record.debatesError, + isCountUnavailable: record.debatesCountUnavailable, + href: hrefs.debates, + seeAllLabel: 'See all debates', + onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('debates') : undefined, + }, + { + key: 'claims', + label: 'Claims', + rows: record.claimRows, + total: record.claimsTotal, + isLoading: record.claimsLoading, + isError: record.claimsError, + isCountUnavailable: record.claimsCountUnavailable, + href: hrefs.claims, + seeAllLabel: 'See all claims', + onSeeAll: onSelectSystemTab ? () => onSelectSystemTab('claims') : undefined, + }, + ]; + + return ( + <> + + {/* The response control follows the aggregate result, so a reader understands the current + split before being asked to add their own position. */} +
+ +
+ + {/* The topic view's Subtopics, drawing a claim's Topics (GEO-2781) — same question for the + reader, so the same shared section rather than two implementations. On a claim, Topics + belong to the Overview's supporting context and follow its Activity record. */} + + {/* Last, like the ordinary entity page. An empty thread is an invitation, not absence. */} + + + + + ); +} + /** * Taking a side, and standing ready to argue it. * diff --git a/apps/web/core/claims/browse/claim-provenance.tsx b/apps/web/core/claims/browse/claim-provenance.tsx index 803813eb41..217054ba44 100644 --- a/apps/web/core/claims/browse/claim-provenance.tsx +++ b/apps/web/core/claims/browse/claim-provenance.tsx @@ -12,13 +12,14 @@ import { useProfilesBySpaceIds } from '~/core/hooks/use-profiles-by-space-ids'; import { ID } from '~/core/id'; import { useQueryEntities, useQueryEntity } from '~/core/sync/use-store'; import type { Relation } from '~/core/types'; -import { dedupeRelationsByToEntityId } from '~/core/utils/dedupe-relations'; import { NavUtils } from '~/core/utils/utils'; import { Avatar } from '~/design-system/avatar'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; import { Text } from '~/design-system/text'; +import { getClaimSources } from './claim-sources'; + /** * Where a claim came from. * @@ -49,15 +50,7 @@ export function ClaimProvenance({ // already exists in the space is linked to the existing entity, which then collects one `Sources` // per debate that stated it. The first is rendered as the primary source and the rest listed, so // no debate is hidden behind another. - const sources = React.useMemo( - () => - dedupeRelationsByToEntityId( - claimRelations.filter( - relation => relation.isDeleted !== true && ID.equals(relation.type.id, SOURCES_PROPERTY_ID) - ) - ).map(relation => ({ id: relation.toEntity.id, name: relation.toEntity.name })), - [claimRelations] - ); + const sources = React.useMemo(() => getClaimSources(claimRelations), [claimRelations]); const source = sources[0] ?? null; const otherSources = sources.slice(1); diff --git a/apps/web/core/claims/browse/claim-record-query.test.ts b/apps/web/core/claims/browse/claim-record-query.test.ts new file mode 100644 index 0000000000..a33a66df28 --- /dev/null +++ b/apps/web/core/claims/browse/claim-record-query.test.ts @@ -0,0 +1,435 @@ +import { print } from 'graphql'; +import { describe, expect, it } from 'vitest'; + +import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { EntitiesOrderBy } from '~/core/gql/graphql'; + +import { + claimRecordClaimsDocument, + claimRecordCountsDocument, + claimRecordDebatesDocument, + claimRecordFilters, + claimRecordOrderBy, + decodeClaimRecordClaims, + decodeClaimRecordCounts, + mergeSortedRecordEntities, + nextClaimRecordClaimsPageParam, +} from './claim-record-query'; + +const CLAIM_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const SPACE_ID = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const TOPIC_ID = 'cccccccccccccccccccccccccccccccc'; + +describe('claim record GraphQL', () => { + it('uses bounded, server-ranked entity pages and distinct union counts', () => { + const claims = print(claimRecordClaimsDocument); + const debates = print(claimRecordDebatesDocument); + const counts = print(claimRecordCountsDocument); + + expect(claims).toContain('topicClaims: entitiesConnection'); + expect(claims).toContain('extractedClaims: entitiesConnection'); + expect(claims).toContain('first: $first'); + expect(claims).toContain('after: $topicAfter'); + expect(claims).toContain('after: $extractedAfter'); + expect(claims).toContain('orderBy: $orderBy'); + expect(claims).toContain('relatedClaimsTop: entitiesOrderedByPropertyConnection'); + expect(claims).toMatch(/relatedClaimsTop: entitiesOrderedByPropertyConnection\([\s\S]*?filter: \$relatedFilter/); + expect(claims).not.toContain('topicClaimsTop: entitiesOrderedByPropertyConnection'); + expect(claims).not.toContain('extractedClaimsTop: entitiesOrderedByPropertyConnection'); + expect(claims).toContain('@skip(if: $skipTopicClaims)'); + expect(claims).toContain('@skip(if: $skipExtractedClaims)'); + expect(claims.match(/matchingRelations: relationsList/g)).toHaveLength(3); + expect(debates).toContain('debates: entitiesConnection'); + expect(debates).toContain('orderBy: $orderBy'); + expect(debates).toContain('debatesTop: entitiesOrderedByPropertyConnection'); + expect(claims).not.toContain('scoreValues'); + expect(debates).not.toContain('scoreValues'); + expect(debates.match(/matchingRelations: relationsList/g)).toHaveLength(2); + expect(counts.match(/distinctCount/g)).toHaveLength(2); + expect(counts.match(/fromEntityId/g)).toHaveLength(2); + }); + + it('uses Best by default and exposes the exact server orders for Best and New', () => { + expect(claimRecordOrderBy('best')).toEqual([ + EntitiesOrderBy.RankingScoreDesc, + EntitiesOrderBy.UpdatedAtDesc, + EntitiesOrderBy.IdAsc, + ]); + expect(claimRecordOrderBy('new')).toEqual([EntitiesOrderBy.CreatedAtDesc, EntitiesOrderBy.IdAsc]); + }); + + it('scopes both related-claim paths to the space and excludes the current claim', () => { + const filters = claimRecordFilters({ + claimId: CLAIM_ID, + spaceIds: [SPACE_ID], + topicIds: [TOPIC_ID], + filterTopicIds: [], + }); + + expect(filters.topicClaims).toEqual({ + or: [ + { + id: { isNot: CLAIM_ID }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + and: [ + { + relations: { + some: { + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { in: [TOPIC_ID] }, + }, + }, + }, + ], + }, + ], + }); + expect(filters.extractedClaims).toEqual({ + or: [ + { + id: { isNot: CLAIM_ID }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + and: [ + { + relations: { + some: { + typeId: { is: SOURCES_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntity: { + typeIds: { overlaps: [DEBATE_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + relations: { + some: { + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { is: CLAIM_ID }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }); + expect(filters.relatedClaims).toEqual({ + or: [ + { + id: { isNot: CLAIM_ID }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + or: [ + { + relations: { + some: { + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { in: [TOPIC_ID] }, + }, + }, + }, + { + relations: { + some: { + typeId: { is: SOURCES_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntity: { + typeIds: { overlaps: [DEBATE_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + relations: { + some: { + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { is: CLAIM_ID }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }); + + expect(filters.claimRelations.or).toHaveLength(2); + expect(filters.claimRelations.or?.[0]).toMatchObject({ + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { in: [TOPIC_ID] }, + fromEntity: { + id: { isNot: CLAIM_ID }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + }, + }); + expect(filters.claimRelations.or?.[1]).toMatchObject({ + typeId: { is: SOURCES_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + fromEntity: { id: { isNot: CLAIM_ID } }, + }); + expect(filters.claimTopicRelations).toEqual({ + or: [ + { + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + fromEntity: { + id: { isNot: CLAIM_ID }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + or: [ + { + relations: { + some: { + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { in: [TOPIC_ID] }, + }, + }, + }, + { + relations: { + some: { + typeId: { is: SOURCES_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntity: { + typeIds: { overlaps: [DEBATE_TYPE_ID] }, + spaceIds: { overlaps: [SPACE_ID] }, + relations: { + some: { + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + toEntityId: { is: CLAIM_ID }, + }, + }, + }, + }, + }, + }, + ], + }, + }, + ], + }); + }); + + it('omits only the topic branch when there are no topics', () => { + const filters = claimRecordFilters({ + claimId: CLAIM_ID, + spaceIds: [SPACE_ID], + topicIds: [], + filterTopicIds: [], + }); + + expect(filters.hasTopics).toBe(false); + expect(filters.relatedClaims.or?.[0]?.or).toHaveLength(1); + expect(filters.relatedClaims.or?.[0]?.or?.[0]).toMatchObject({ + relations: { some: { typeId: { is: SOURCES_PROPERTY_ID } } }, + }); + expect(filters.claimRelations.or).toHaveLength(1); + expect(filters.claimRelations.or?.[0]).toMatchObject({ typeId: { is: SOURCES_PROPERTY_ID } }); + expect(filters.debates.relations?.some?.toEntity).toEqual({ id: { is: CLAIM_ID } }); + expect(filters.debateRelations.toEntity).toEqual({ id: { is: CLAIM_ID } }); + }); + + it('keeps Debates scoped to the viewed claim even when Related claims has topics', () => { + const filters = claimRecordFilters({ + claimId: CLAIM_ID, + spaceIds: [SPACE_ID], + topicIds: [TOPIC_ID], + filterTopicIds: [], + }); + + expect(filters.debates.relations?.some?.toEntity).toEqual({ id: { is: CLAIM_ID } }); + expect(filters.debateRelations.toEntity).toEqual({ id: { is: CLAIM_ID } }); + }); + + it('decodes the spaces whose relations actually matched the record filter', () => { + const secondSpace = 'dddddddddddddddddddddddddddddddd'; + const page = decodeClaimRecordClaims({ + topicClaims: { + nodes: [ + { + id: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + name: 'A related claim', + description: null, + spaceIds: [SPACE_ID, secondSpace], + createdAt: '1788802463', + backlinks: { totalCount: 0 }, + types: [], + valuesList: [], + relationsList: [], + matchingRelations: [{ spaceId: secondSpace }, { spaceId: secondSpace }], + rankingScore: 10, + scoreValues: [], + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + pageInfo: { endCursor: null, hasNextPage: false }, + }, + }); + + expect(page.topicClaims.entities[0]?.matchingSpaceIds).toEqual([secondSpace]); + }); + + it('decodes the combined server-ranked Top connection as the Related claims page', () => { + const page = decodeClaimRecordClaims({ + relatedClaimsTop: { + nodes: [ + { + id: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + name: 'A top related claim', + description: null, + spaceIds: [SPACE_ID], + createdAt: '1788802463', + backlinks: { totalCount: 0 }, + types: [], + valuesList: [], + relationsList: [], + matchingRelations: [{ spaceId: SPACE_ID }], + rankingScore: 10, + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + pageInfo: { endCursor: 'next', hasNextPage: true }, + }, + }); + + expect(page.topicClaims.entities.map(entity => entity.id)).toEqual(['eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee']); + expect(page.topicClaims).toMatchObject({ endCursor: 'next', hasNextPage: true }); + expect(page.extractedClaims.entities).toEqual([]); + }); + + it('requires every selected topic in the same selected space', () => { + const secondSpace = 'dddddddddddddddddddddddddddddddd'; + const secondTopic = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; + const filters = claimRecordFilters({ + claimId: CLAIM_ID, + spaceIds: [SPACE_ID, secondSpace], + topicIds: [TOPIC_ID], + filterTopicIds: [TOPIC_ID, secondTopic], + }); + + const branches = filters.topicClaims.or ?? []; + expect(branches).toHaveLength(2); + expect(branches[0]).toMatchObject({ + spaceIds: { overlaps: [SPACE_ID] }, + and: [ + { relations: { some: { spaceId: { is: SPACE_ID }, toEntityId: { in: [TOPIC_ID] } } } }, + { relations: { some: { spaceId: { is: SPACE_ID }, toEntityId: { is: TOPIC_ID } } } }, + { relations: { some: { spaceId: { is: SPACE_ID }, toEntityId: { is: secondTopic } } } }, + ], + }); + expect(branches[1]).toMatchObject({ spaceIds: { overlaps: [secondSpace] } }); + + // The topic facet uses the same-space union population and includes every active topic, so + // each count is the number of complete Related claims matches rather than a loaded-page tally. + expect(filters.claimTopicRelations.or).toHaveLength(2); + expect(filters.claimTopicRelations.or?.[0]).toMatchObject({ + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: SPACE_ID }, + fromEntity: { + spaceIds: { overlaps: [SPACE_ID] }, + and: [ + { relations: { some: { spaceId: { is: SPACE_ID }, toEntityId: { is: TOPIC_ID } } } }, + { relations: { some: { spaceId: { is: SPACE_ID }, toEntityId: { is: secondTopic } } } }, + ], + }, + }); + }); + + it('decodes exact distinct counts without mistaking malformed data for a result', () => { + expect( + decodeClaimRecordCounts({ + claims: { aggregates: { distinctCount: { fromEntityId: '42' } } }, + debates: { aggregates: { distinctCount: { fromEntityId: '7' } } }, + }) + ).toEqual({ claims: 42, debates: 7 }); + + expect(() => + decodeClaimRecordCounts({ claims: { aggregates: { distinctCount: { fromEntityId: 'not-a-count' } } } }) + ).toThrow('claim record count'); + }); +}); + +describe('claim record ranked union', () => { + it('dedupes claims found through both paths before applying global Best order', () => { + const topic = [ + { id: '11111111111111111111111111111111', rankingScore: 4, updatedAt: '2026-01-01T00:00:00Z' }, + { id: '22222222222222222222222222222222', rankingScore: 2, updatedAt: '2026-03-01T00:00:00Z' }, + ]; + const extracted = [ + { id: '22222222-2222-2222-2222-222222222222', rankingScore: 2, updatedAt: '2026-03-01T00:00:00Z' }, + { id: '33333333333333333333333333333333', rankingScore: 9, updatedAt: '2025-01-01T00:00:00Z' }, + ]; + + expect(mergeSortedRecordEntities('best', topic, extracted).map(entity => entity.id)).toEqual([ + '33333333333333333333333333333333', + '11111111111111111111111111111111', + '22222222222222222222222222222222', + ]); + }); + + it('puts unscored claims after scored claims and gives ties a deterministic order', () => { + const rows = [ + { id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', rankingScore: null, updatedAt: '2026-01-01T00:00:00Z' }, + { id: 'cccccccccccccccccccccccccccccccc', rankingScore: 1, updatedAt: '2026-01-01T00:00:00Z' }, + { id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', rankingScore: null, updatedAt: '2026-02-01T00:00:00Z' }, + ]; + + expect(mergeSortedRecordEntities('best', rows).map(entity => entity.id)).toEqual([ + 'cccccccccccccccccccccccccccccccc', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ]); + }); + + it('preserves server-ranked Top order and merges New by its server sort values', () => { + const rows = [ + { + id: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + rankingScore: 100, + score: 1, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + rankingScore: 1, + score: 9, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ]; + + expect(mergeSortedRecordEntities('top', rows).map(entity => entity.id)).toEqual([ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + ]); + expect(mergeSortedRecordEntities('new', rows).map(entity => entity.id)).toEqual([ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + ]); + }); + + it('continues each source with its own cursor and stops completed sources', () => { + expect( + nextClaimRecordClaimsPageParam({ + topicClaims: { entities: [], endCursor: 'topic-next', hasNextPage: true }, + extractedClaims: { entities: [], endCursor: null, hasNextPage: false }, + }) + ).toEqual({ topicAfter: 'topic-next', extractedAfter: null, skipTopicClaims: false, skipExtractedClaims: true }); + + expect( + nextClaimRecordClaimsPageParam({ + topicClaims: { entities: [], endCursor: null, hasNextPage: false }, + extractedClaims: { entities: [], endCursor: null, hasNextPage: false }, + }) + ).toBeUndefined(); + }); +}); diff --git a/apps/web/core/claims/browse/claim-record-query.ts b/apps/web/core/claims/browse/claim-record-query.ts new file mode 100644 index 0000000000..30695eeb79 --- /dev/null +++ b/apps/web/core/claims/browse/claim-record-query.ts @@ -0,0 +1,617 @@ +import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; + +import { Effect } from 'effect'; +import { parse } from 'graphql'; + +import { CLAIM_TYPE_ID, TOPICS_PROPERTY_ID } from '~/core/claims/ontology'; +import { SCORE_SYSTEM_PROPERTY } from '~/core/constants'; +import { DEBATE_CLAIMS_PROPERTY_ID, DEBATE_TYPE_ID, SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { + type ExploreCardEntity, + decodeExploreCardEntity, +} from '~/core/explore/explore-card-item'; +import { exploreCardNodeFields, exploreCardPropertyFragment } from '~/core/explore/explore-card-selection'; +import { EntitiesOrderBy, type EntityFilter, type RelationFilter } from '~/core/gql/graphql'; +import { graphql } from '~/core/io/graphql-client'; +import { normId } from '~/core/utils/norm-id'; + +export const CLAIM_RECORD_PAGE_SIZE = 20; + +const CLAIM_FRAGMENT = 'ClaimRecordClaimPropertyFragment'; +const DEBATE_FRAGMENT = 'ClaimRecordDebatePropertyFragment'; + +const CLAIMS_SOURCE = /* GraphQL */ ` + ${exploreCardPropertyFragment(CLAIM_FRAGMENT)} + + query ClaimRecordClaims( + $claimTypeId: UUID! + $spaceIds: [UUID!]! + $spaceIdsForLists: [UUID!]! + $topicFilter: EntityFilter! + $extractedFilter: EntityFilter! + $relatedFilter: EntityFilter! + $matchingFilter: RelationFilter! + $orderBy: [EntitiesOrderBy!]! + $scorePropertyId: UUID! + $first: Int! + $topicAfter: Cursor + $extractedAfter: Cursor + $skipTopicClaims: Boolean! + $skipExtractedClaims: Boolean! + $fetchRelatedClaimsTop: Boolean! + ) { + topicClaims: entitiesConnection( + first: $first + after: $topicAfter + typeId: $claimTypeId + filter: $topicFilter + orderBy: $orderBy + ) @skip(if: $skipTopicClaims) { + pageInfo { + hasNextPage + endCursor + } + nodes { + rankingScore + updatedAt + matchingRelations: relationsList(filter: $matchingFilter) { + spaceId + } + ${exploreCardNodeFields(CLAIM_FRAGMENT)} + } + } + + extractedClaims: entitiesConnection( + first: $first + after: $extractedAfter + typeId: $claimTypeId + filter: $extractedFilter + orderBy: $orderBy + ) @skip(if: $skipExtractedClaims) { + pageInfo { + hasNextPage + endCursor + } + nodes { + rankingScore + updatedAt + matchingRelations: relationsList(filter: $matchingFilter) { + spaceId + } + ${exploreCardNodeFields(CLAIM_FRAGMENT)} + } + } + + relatedClaimsTop: entitiesOrderedByPropertyConnection( + first: $first + after: $topicAfter + filter: $relatedFilter + propertyId: $scorePropertyId + dataType: "integer" + sortDirection: DESC + includeWithoutValue: true + spaceIds: $spaceIds + typeIds: [$claimTypeId] + ) @include(if: $fetchRelatedClaimsTop) { + pageInfo { + hasNextPage + endCursor + } + nodes { + rankingScore + updatedAt + matchingRelations: relationsList(filter: $matchingFilter) { + spaceId + } + ${exploreCardNodeFields(CLAIM_FRAGMENT)} + } + } + } +`; + +const DEBATES_SOURCE = /* GraphQL */ ` + ${exploreCardPropertyFragment(DEBATE_FRAGMENT)} + + query ClaimRecordDebates( + $debateTypeId: UUID! + $spaceIds: [UUID!]! + $spaceIdsForLists: [UUID!]! + $filter: EntityFilter! + $matchingFilter: RelationFilter! + $orderBy: [EntitiesOrderBy!]! + $scorePropertyId: UUID! + $first: Int! + $after: Cursor + $isTop: Boolean! + ) { + debates: entitiesConnection( + first: $first + after: $after + typeId: $debateTypeId + filter: $filter + orderBy: $orderBy + ) @skip(if: $isTop) { + pageInfo { + hasNextPage + endCursor + } + nodes { + rankingScore + updatedAt + matchingRelations: relationsList(filter: $matchingFilter) { + spaceId + } + ${exploreCardNodeFields(DEBATE_FRAGMENT)} + } + } + + debatesTop: entitiesOrderedByPropertyConnection( + first: $first + after: $after + filter: $filter + propertyId: $scorePropertyId + dataType: "integer" + sortDirection: DESC + includeWithoutValue: true + spaceIds: $spaceIds + typeIds: [$debateTypeId] + ) @include(if: $isTop) { + pageInfo { + hasNextPage + endCursor + } + nodes { + rankingScore + updatedAt + matchingRelations: relationsList(filter: $matchingFilter) { + spaceId + } + ${exploreCardNodeFields(DEBATE_FRAGMENT)} + } + } + } +`; + +const COUNTS_SOURCE = /* GraphQL */ ` + query ClaimRecordCounts($claimFilter: RelationFilter!, $debateFilter: RelationFilter!) { + claims: relationsConnection(filter: $claimFilter) { + aggregates { + distinctCount { + fromEntityId + } + } + } + debates: relationsConnection(filter: $debateFilter) { + aggregates { + distinctCount { + fromEntityId + } + } + } + } +`; + +export const claimRecordClaimsDocument = parse(CLAIMS_SOURCE) as TypedDocumentNode; +export const claimRecordDebatesDocument = parse(DEBATES_SOURCE) as TypedDocumentNode; +export const claimRecordCountsDocument = parse(COUNTS_SOURCE) as TypedDocumentNode; + +export type ClaimRecordSort = 'best' | 'top' | 'new'; + +export function claimRecordOrderBy(sort: ClaimRecordSort): EntitiesOrderBy[] { + if (sort === 'new') return [EntitiesOrderBy.CreatedAtDesc, EntitiesOrderBy.IdAsc]; + return [EntitiesOrderBy.RankingScoreDesc, EntitiesOrderBy.UpdatedAtDesc, EntitiesOrderBy.IdAsc]; +} + +export type ClaimRecordFilters = { + hasTopics: boolean; + topicClaims: EntityFilter; + extractedClaims: EntityFilter; + relatedClaims: EntityFilter; + debates: EntityFilter; + claimRelations: RelationFilter; + /** Topics carried by the exact Related claims union, grouped for the Topics menu. */ + claimTopicRelations: RelationFilter; + debateRelations: RelationFilter; +}; + +/** + * One definition of the record shared by the result pages and their exact counts. + * + * Related claims are the union of claims sharing a space-scoped topic and claims whose Sources + * relation points at a direct debate on this claim. Relation counts use the same predicates and + * count distinct source entities, so overlap between those paths never inflates the tab count. + */ +export function claimRecordFilters({ + claimId, + spaceIds, + topicIds, + filterTopicIds, +}: { + claimId: string; + spaceIds: string[]; + topicIds: string[]; + filterTopicIds: string[]; +}): ClaimRecordFilters { + const scopes = [...new Map(spaceIds.map(id => [normId(id), id])).values()]; + if (scopes.length === 0) throw new Error('Claim records require at least one space'); + + const selectedTopicConditions = (spaceId: string): EntityFilter[] => + filterTopicIds.map(topicId => ({ + relations: { + some: { + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: spaceId }, + toEntityId: { is: topicId }, + }, + }, + })); + + const claimScope = (spaceId: string): EntityFilter => ({ + id: { isNot: claimId }, + typeIds: { overlaps: [CLAIM_TYPE_ID] }, + spaceIds: { overlaps: [spaceId] }, + }); + const narrowedClaimScope = (spaceId: string): EntityFilter => ({ + ...claimScope(spaceId), + ...(filterTopicIds.length > 0 ? { and: selectedTopicConditions(spaceId) } : {}), + }); + const debateScope = (spaceId: string): EntityFilter => ({ + typeIds: { overlaps: [DEBATE_TYPE_ID] }, + spaceIds: { overlaps: [spaceId] }, + }); + const directClaimRelation = (spaceId: string): RelationFilter => ({ + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: spaceId }, + toEntityId: { is: claimId }, + }); + const directDebate = (spaceId: string): EntityFilter => ({ + ...debateScope(spaceId), + relations: { some: directClaimRelation(spaceId) }, + }); + const extractedSourceRelation = (spaceId: string): RelationFilter => ({ + typeId: { is: SOURCES_PROPERTY_ID }, + spaceId: { is: spaceId }, + toEntity: directDebate(spaceId), + }); + const topicRelation = (spaceId: string): RelationFilter => ({ + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: spaceId }, + toEntityId: { in: topicIds }, + }); + const topicClaim = (spaceId: string): EntityFilter => ({ + ...claimScope(spaceId), + and: [{ relations: { some: topicRelation(spaceId) } }, ...selectedTopicConditions(spaceId)], + }); + const extractedClaim = (spaceId: string): EntityFilter => ({ + ...claimScope(spaceId), + and: [{ relations: { some: extractedSourceRelation(spaceId) } }, ...selectedTopicConditions(spaceId)], + }); + const relatedClaim = (spaceId: string): EntityFilter => ({ + ...narrowedClaimScope(spaceId), + or: [ + ...(topicIds.length > 0 ? [{ relations: { some: topicRelation(spaceId) } }] : []), + { relations: { some: extractedSourceRelation(spaceId) } }, + ], + }); + + const debateBranches: EntityFilter[] = scopes.map(spaceId => ({ + ...debateScope(spaceId), + relations: { + some: { + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: spaceId }, + toEntity: { id: { is: claimId } }, + }, + }, + })); + const claimRelationBranches: RelationFilter[] = scopes.flatMap(spaceId => [ + ...(topicIds.length > 0 + ? [ + { + ...topicRelation(spaceId), + fromEntity: narrowedClaimScope(spaceId), + }, + ] + : []), + { + ...extractedSourceRelation(spaceId), + fromEntity: narrowedClaimScope(spaceId), + }, + ]); + const claimTopicRelationBranches: RelationFilter[] = scopes.map(spaceId => ({ + typeId: { is: TOPICS_PROPERTY_ID }, + spaceId: { is: spaceId }, + fromEntity: relatedClaim(spaceId), + })); + const debateRelationBranches: RelationFilter[] = scopes.map(spaceId => ({ + typeId: { is: DEBATE_CLAIMS_PROPERTY_ID }, + spaceId: { is: spaceId }, + fromEntity: debateScope(spaceId), + toEntity: { id: { is: claimId } }, + })); + + return { + hasTopics: topicIds.length > 0, + topicClaims: { or: scopes.map(topicClaim) }, + extractedClaims: { or: scopes.map(extractedClaim) }, + relatedClaims: { or: scopes.map(relatedClaim) }, + debates: debateBranches.length === 1 ? debateBranches[0] : { or: debateBranches }, + claimRelations: { or: claimRelationBranches }, + claimTopicRelations: { or: claimTopicRelationBranches }, + debateRelations: + debateRelationBranches.length === 1 ? debateRelationBranches[0] : { or: debateRelationBranches }, + }; +} + +export type RankedClaimRecordEntity = ExploreCardEntity & { + /** Spaces where this entity satisfied the complete record relation predicate. */ + matchingSpaceIds: string[]; + rankingScore: number | null; + updatedAt: string; +}; + +export type ClaimRecordConnectionPage = { + entities: RankedClaimRecordEntity[]; + endCursor: string | null; + hasNextPage: boolean; +}; + +export type ClaimRecordClaimsPage = { + topicClaims: ClaimRecordConnectionPage; + extractedClaims: ClaimRecordConnectionPage; +}; + +type ConnectionShape = { + nodes?: unknown[] | null; + pageInfo?: { endCursor?: string | null; hasNextPage?: boolean | null } | null; +} | null; + +const EMPTY_CONNECTION_PAGE: ClaimRecordConnectionPage = { + entities: [], + endCursor: null, + hasNextPage: false, +}; + +function decodeConnection(connection: ConnectionShape): ClaimRecordConnectionPage { + if (!connection) return EMPTY_CONNECTION_PAGE; + + const entities: RankedClaimRecordEntity[] = []; + for (const node of connection.nodes ?? []) { + const decoded = decodeExploreCardEntity(node); + if (!decoded || !node || typeof node !== 'object') continue; + + const raw = node as { + rankingScore?: string | number | null; + updatedAt?: string | null; + matchingRelations?: Array<{ spaceId?: string | null }> | null; + }; + const parsedScore = raw.rankingScore == null ? null : Number(raw.rankingScore); + entities.push({ + ...decoded, + matchingSpaceIds: [ + ...new Map( + (raw.matchingRelations ?? []) + .map(relation => relation.spaceId) + .filter((spaceId): spaceId is string => typeof spaceId === 'string' && spaceId.length > 0) + .map(spaceId => [normId(spaceId), spaceId]) + ).values(), + ], + rankingScore: parsedScore !== null && Number.isFinite(parsedScore) ? parsedScore : null, + updatedAt: raw.updatedAt ?? '', + }); + } + + return { + entities, + endCursor: connection.pageInfo?.endCursor ?? null, + hasNextPage: connection.pageInfo?.hasNextPage ?? false, + }; +} + +export function decodeClaimRecordClaims(data: { + topicClaims?: ConnectionShape; + extractedClaims?: ConnectionShape; + relatedClaimsTop?: ConnectionShape; +}): ClaimRecordClaimsPage { + return { + topicClaims: decodeConnection(data.topicClaims ?? data.relatedClaimsTop ?? null), + extractedClaims: decodeConnection(data.extractedClaims ?? null), + }; +} + +export function decodeClaimRecordDebates(data: { + debates?: ConnectionShape; + debatesTop?: ConnectionShape; +}): ClaimRecordConnectionPage { + return decodeConnection(data.debates ?? data.debatesTop ?? null); +} + +type CountConnection = { + aggregates?: { distinctCount?: { fromEntityId?: string | number | null } | null } | null; +} | null; + +function decodeCount(connection: CountConnection): number { + const raw = connection?.aggregates?.distinctCount?.fromEntityId; + const count = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN; + if (!Number.isSafeInteger(count) || count < 0) throw new Error('Invalid claim record count response'); + return count; +} + +export function decodeClaimRecordCounts(data: { + claims?: CountConnection; + debates?: CountConnection; +}): { claims: number; debates: number } { + return { claims: decodeCount(data.claims ?? null), debates: decodeCount(data.debates ?? null) }; +} + +function updatedAtMillis(value: string | null | undefined): number { + if (!value) return 0; + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +/** Merge independently paged relation branches into one deterministic record order. */ +export function mergeSortedRecordEntities< + T extends { + id: string; + rankingScore: number | null; + createdAt?: string | null; + updatedAt: string | null; + }, +>( + sort: ClaimRecordSort, + ...groups: readonly T[][] +): T[] { + const byId = new Map(); + for (const entity of groups.flat()) { + const key = normId(entity.id); + if (!byId.has(key)) byId.set(key, entity); + } + + const entities = [...byId.values()]; + // Top connections already own the score aggregation, tie-breaking, and cursor order. Re-sorting + // their nodes from entity values is both redundant and wrong for entities scored in many spaces. + if (sort === 'top') return entities; + + return entities.sort((a, b) => { + if (sort === 'new') { + const created = updatedAtMillis(b.createdAt) - updatedAtMillis(a.createdAt); + return created || normId(a.id).localeCompare(normId(b.id)); + } + + const left = a.rankingScore; + const right = b.rankingScore; + if (left !== right) { + if (left === null) return 1; + if (right === null) return -1; + return right - left; + } + + const updated = updatedAtMillis(b.updatedAt) - updatedAtMillis(a.updatedAt); + return updated || normId(a.id).localeCompare(normId(b.id)); + }); +} + +export type ClaimRecordClaimsPageParam = { + topicAfter: string | null; + extractedAfter: string | null; + skipTopicClaims: boolean; + skipExtractedClaims: boolean; +}; + +export function firstClaimRecordClaimsPageParam(hasTopics: boolean): ClaimRecordClaimsPageParam { + return { + topicAfter: null, + extractedAfter: null, + skipTopicClaims: !hasTopics, + skipExtractedClaims: false, + }; +} + +export function nextClaimRecordClaimsPageParam( + page: ClaimRecordClaimsPage +): ClaimRecordClaimsPageParam | undefined { + const topicHasNext = page.topicClaims.hasNextPage && page.topicClaims.endCursor !== null; + const extractedHasNext = page.extractedClaims.hasNextPage && page.extractedClaims.endCursor !== null; + if (!topicHasNext && !extractedHasNext) return undefined; + + return { + topicAfter: topicHasNext ? page.topicClaims.endCursor : null, + extractedAfter: extractedHasNext ? page.extractedClaims.endCursor : null, + skipTopicClaims: !topicHasNext, + skipExtractedClaims: !extractedHasNext, + }; +} + +export async function fetchClaimRecordClaimsPage({ + filters, + spaceIds, + sort, + pageParam, + signal, +}: { + filters: ClaimRecordFilters; + spaceIds: string[]; + sort: ClaimRecordSort; + pageParam: ClaimRecordClaimsPageParam; + signal?: AbortSignal; +}): Promise { + const isTop = sort === 'top'; + return Effect.runPromise( + graphql({ + query: claimRecordClaimsDocument, + decoder: decodeClaimRecordClaims, + variables: { + claimTypeId: CLAIM_TYPE_ID, + spaceIds, + spaceIdsForLists: spaceIds, + topicFilter: filters.topicClaims, + extractedFilter: filters.extractedClaims, + relatedFilter: filters.relatedClaims, + matchingFilter: filters.claimRelations, + orderBy: claimRecordOrderBy(sort), + scorePropertyId: SCORE_SYSTEM_PROPERTY, + first: CLAIM_RECORD_PAGE_SIZE, + topicAfter: pageParam.topicAfter, + extractedAfter: pageParam.extractedAfter, + skipTopicClaims: pageParam.skipTopicClaims || isTop, + skipExtractedClaims: pageParam.skipExtractedClaims || isTop, + fetchRelatedClaimsTop: isTop, + }, + signal, + }) + ); +} + +export async function fetchClaimRecordDebatesPage({ + filters, + spaceIds, + sort, + after, + signal, +}: { + filters: ClaimRecordFilters; + spaceIds: string[]; + sort: ClaimRecordSort; + after: string | null; + signal?: AbortSignal; +}): Promise { + return Effect.runPromise( + graphql({ + query: claimRecordDebatesDocument, + decoder: decodeClaimRecordDebates, + variables: { + debateTypeId: DEBATE_TYPE_ID, + spaceIds, + spaceIdsForLists: spaceIds, + filter: filters.debates, + matchingFilter: filters.debateRelations, + orderBy: claimRecordOrderBy(sort), + scorePropertyId: SCORE_SYSTEM_PROPERTY, + first: CLAIM_RECORD_PAGE_SIZE, + after, + isTop: sort === 'top', + }, + signal, + }) + ); +} + +export async function fetchClaimRecordCounts({ + filters, + signal, +}: { + filters: ClaimRecordFilters; + signal?: AbortSignal; +}): Promise<{ claims: number; debates: number }> { + return Effect.runPromise( + graphql({ + query: claimRecordCountsDocument, + decoder: decodeClaimRecordCounts, + variables: { claimFilter: filters.claimRelations, debateFilter: filters.debateRelations }, + signal, + }) + ); +} diff --git a/apps/web/core/claims/browse/claim-record-tab.test.tsx b/apps/web/core/claims/browse/claim-record-tab.test.tsx new file mode 100644 index 0000000000..54dcc0a6bb --- /dev/null +++ b/apps/web/core/claims/browse/claim-record-tab.test.tsx @@ -0,0 +1,228 @@ +import '@testing-library/jest-dom/vitest'; +import { act, cleanup, render } from '@testing-library/react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ClaimRecordTab } from './claim-record-tab'; + +const mocks = vi.hoisted(() => ({ + hookCalls: [] as Array>, + facetHookCalls: [] as Array>, + facetError: false, + filterProps: null as Record | null, + feedProps: null as Record | null, +})); + +vi.mock('./use-claim-record', () => ({ + useClaimRecord: (args: Record) => { + mocks.hookCalls.push(args); + return { + claimRows: [], + debateRows: [], + claimsLoading: false, + debatesLoading: false, + claimsError: false, + debatesError: false, + claimsFetchingNextPage: false, + debatesFetchingNextPage: false, + claimsHasNextPage: false, + debatesHasNextPage: false, + fetchNextClaimsPage: vi.fn(), + fetchNextDebatesPage: vi.fn(), + }; + }, +})); +vi.mock('./use-claim-record-facets', () => ({ + useClaimRecordFacets: (args: Record) => { + mocks.facetHookCalls.push(args); + return { + claimSpaces: [ + { id: 'space-1', count: 8 }, + { id: 'space-2', count: 3 }, + ], + claimTopics: [ + { id: 'topic-3', name: 'Economics', count: 5 }, + { id: 'topic-1', name: 'Ethics', count: 2 }, + ], + debateSpaces: [ + { id: 'space-1', count: 4 }, + { id: 'space-2', count: 1 }, + ], + countsPending: false, + facetsSettled: true, + isError: mocks.facetError, + }; + }, +})); +vi.mock('~/core/hooks/use-space-labels', () => ({ + spaceLabel: (labels: Map, id: string) => labels.get(id), + useSpaceLabels: (ids: string[]) => ({ + labelsById: new Map(ids.map(id => [id, { name: id === 'space-1' ? 'Relationships' : 'Technology' }])), + isLoading: false, + }), +})); +vi.mock('~/partials/profile/record-filter-row', () => ({ + RecordFilterRow: (props: Record) => { + mocks.filterProps = props; + return
; + }, +})); +vi.mock('~/partials/profile/person-record-feed', () => ({ + PersonRecordFeed: (props: Record) => { + mocks.feedProps = props; + return
; + }, +})); + +const common = { + claimId: 'claim-1', + spaceId: 'space-1', + availableSpaceIds: ['space-1', 'space-2'], + sourceTopics: [ + { id: 'topic-1', name: 'Ethics' }, + { id: 'topic-2', name: 'Technology' }, + ], +}; + +beforeEach(() => { + mocks.hookCalls = []; + mocks.facetHookCalls = []; + mocks.facetError = false; + mocks.filterProps = null; + mocks.feedProps = null; +}); + +afterEach(cleanup); + +describe('ClaimRecordTab', () => { + it('gives Related claims Best, Top, New plus Spaces and Topics controls', () => { + render(); + + expect(mocks.filterProps?.sort.value).toBe('best'); + expect(mocks.filterProps?.sort.options.map((option: { label: string }) => option.label)).toEqual([ + 'Best', + 'Top', + 'New', + ]); + expect(mocks.filterProps?.dimensions.map((dimension: { key: string }) => dimension.key)).toEqual([ + 'spaces', + 'topics', + ]); + expect(mocks.filterProps?.dimensions[0].options).toEqual([ + { value: 'space-1', label: 'Relationships', count: 8, pending: false }, + { value: 'space-2', label: 'Technology', count: 3, pending: false }, + ]); + // Facets describe the complete Related claims corpus, not only the source claim's topics. + expect(mocks.filterProps?.dimensions[1].options).toEqual([ + { value: 'topic-3', label: 'Economics', count: 5 }, + { value: 'topic-1', label: 'Ethics', count: 2 }, + ]); + expect(mocks.hookCalls.at(-1)).toMatchObject({ + claimSort: 'best', + spaceIds: ['space-1'], + filterTopicIds: [], + claimsEnabled: true, + debatesEnabled: false, + countsEnabled: false, + }); + expect(mocks.facetHookCalls.at(-1)).toMatchObject({ + kind: 'claims', + allSpaceIds: ['space-1', 'space-2'], + selectedSpaceIds: ['space-1'], + selectedTopicIds: [], + sourceTopicIds: ['topic-1', 'topic-2'], + }); + expect(mocks.feedProps?.emptyLabel).toBe('No related claims yet.'); + }); + + it('updates the server query when a sort or Related claims filter changes', () => { + render(); + + act(() => mocks.filterProps?.sort.onChange('top')); + expect(mocks.hookCalls.at(-1)).toMatchObject({ claimSort: 'top' }); + + const dimensions = mocks.filterProps?.dimensions as Array>; + act(() => dimensions.find(dimension => dimension.key === 'spaces')?.onClear()); + expect(mocks.hookCalls.at(-1)).toMatchObject({ spaceIds: ['space-1', 'space-2'] }); + expect(mocks.facetHookCalls.at(-1)).toMatchObject({ selectedSpaceIds: ['space-1', 'space-2'] }); + + act(() => + (mocks.filterProps?.dimensions as Array>) + .find(dimension => dimension.key === 'topics') + ?.onToggle('topic-1') + ); + expect(mocks.hookCalls.at(-1)).toMatchObject({ filterTopicIds: ['topic-1'] }); + expect(mocks.facetHookCalls.at(-1)).toMatchObject({ selectedTopicIds: ['topic-1'] }); + }); + + it('keeps active filters visible and clearable after a terminal facet error', () => { + const view = render(); + + act(() => + (mocks.filterProps?.dimensions as Array>) + .find(dimension => dimension.key === 'topics') + ?.onToggle('topic-1') + ); + mocks.facetError = true; + view.rerender(); + + const dimensions = mocks.filterProps?.dimensions as Array>; + expect(dimensions.map(dimension => dimension.key)).toEqual(['spaces', 'topics']); + expect(dimensions.find(dimension => dimension.key === 'spaces')?.values).toEqual(['space-1']); + expect(dimensions.find(dimension => dimension.key === 'topics')?.values).toEqual(['topic-1']); + + act(() => dimensions.find(dimension => dimension.key === 'topics')?.onClear()); + expect(mocks.hookCalls.at(-1)).toMatchObject({ filterTopicIds: [] }); + }); + + it('resets sort and filters before rendering a different claim or space', () => { + const view = render(); + + act(() => mocks.filterProps?.sort.onChange('top')); + act(() => + (mocks.filterProps?.dimensions as Array>) + .find(dimension => dimension.key === 'topics') + ?.onToggle('topic-1') + ); + + view.rerender( + + ); + + expect(mocks.hookCalls.at(-1)).toMatchObject({ + claimId: 'claim-2', + claimSort: 'best', + spaceIds: ['space-2'], + filterTopicIds: [], + }); + }); + + it('gives Debates the same sort order and only its Spaces filter', () => { + render(); + + expect(mocks.filterProps?.sort.value).toBe('best'); + expect(mocks.filterProps?.sort.options.map((option: { label: string }) => option.label)).toEqual([ + 'Best', + 'Top', + 'New', + ]); + expect(mocks.filterProps?.dimensions.map((dimension: { key: string }) => dimension.key)).toEqual(['spaces']); + expect(mocks.filterProps?.dimensions[0].options).toEqual([ + { value: 'space-1', label: 'Relationships', count: 4, pending: false }, + { value: 'space-2', label: 'Technology', count: 1, pending: false }, + ]); + expect(mocks.hookCalls.at(-1)).toMatchObject({ + debateSort: 'best', + spaceIds: ['space-1'], + claimsEnabled: false, + debatesEnabled: true, + countsEnabled: false, + }); + }); +}); diff --git a/apps/web/core/claims/browse/claim-record-tab.tsx b/apps/web/core/claims/browse/claim-record-tab.tsx new file mode 100644 index 0000000000..8fc500444e --- /dev/null +++ b/apps/web/core/claims/browse/claim-record-tab.tsx @@ -0,0 +1,202 @@ +'use client'; + +import * as React from 'react'; + +import type { HubFilterOption } from '~/core/debates/matchmaking/hub-filter-menu'; +import { keepSelectableTopics, orderFacetOptions } from '~/core/debates/matchmaking/topic-facets'; +import { spaceLabel, useSpaceLabels } from '~/core/hooks/use-space-labels'; +import { normId } from '~/core/utils/norm-id'; + +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; +import { RecordFilterRow } from '~/partials/profile/record-filter-row'; +import { useRecordSelection } from '~/partials/profile/use-record-selection'; + +import type { ClaimRecordSort } from './claim-record-query'; +import { useClaimRecord } from './use-claim-record'; +import { useClaimRecordFacets } from './use-claim-record-facets'; + +const SORT_OPTIONS: HubFilterOption[] = [ + { value: 'best', label: 'Best' }, + { value: 'top', label: 'Top' }, + { value: 'new', label: 'New' }, +]; + +type ClaimRecordTabProps = { + kind: 'claims' | 'debates'; + claimId: string; + /** The space whose claim page the reader opened; it is the initial Spaces selection. */ + spaceId: string; + /** Every space carrying this claim, and therefore meaningful as a record scope. */ + availableSpaceIds: string[]; + /** The claim topics that define Related claims and can further narrow that union. */ + sourceTopics: Array<{ id: string; name: string | null }>; +}; + +/** + * Controlled record feed shared by the claim page's Related claims and Debates tabs. + * + * It deliberately reuses the personal-space control row and selection hooks. The only contextual + * difference is the initial space: a claim page opens scoped to the space in its URL, while + * clearing Spaces broadens the tab to every space carrying that same claim. + */ +export function ClaimRecordTab({ + kind, + claimId, + spaceId, + availableSpaceIds, + sourceTopics, +}: ClaimRecordTabProps) { + return ( + + ); +} + +function ClaimRecordTabContent({ + kind, + claimId, + spaceId, + availableSpaceIds, + sourceTopics, +}: ClaimRecordTabProps) { + const [sort, setSort] = React.useState('best'); + const spaces = useRecordSelection([spaceId]); + const topics = useRecordSelection(); + + const allSpaceIds = React.useMemo( + () => [...new Map([spaceId, ...availableSpaceIds].map(id => [normId(id), id])).values()], + [availableSpaceIds, spaceId] + ); + // No selection is the menu's “Any space”: every space that carries this claim. Starting with the + // URL space selected preserves the existing space-scoped result until the reader broadens it. + const selectedSpaceIds = spaces.values.length > 0 ? spaces.values : allSpaceIds; + const sourceTopicIds = React.useMemo(() => sourceTopics.map(topic => topic.id), [sourceTopics]); + + const record = useClaimRecord({ + claimId, + spaceId, + topicIds: sourceTopicIds, + spaceIds: selectedSpaceIds, + filterTopicIds: kind === 'claims' ? topics.values : [], + claimSort: kind === 'claims' ? sort : 'best', + debateSort: kind === 'debates' ? sort : 'best', + claimsEnabled: kind === 'claims', + debatesEnabled: kind === 'debates', + countsEnabled: false, + }); + const facets = useClaimRecordFacets({ + kind, + claimId, + allSpaceIds, + selectedSpaceIds, + sourceTopicIds, + selectedTopicIds: kind === 'claims' ? topics.values : [], + }); + + const facetSpaces = kind === 'claims' ? facets.claimSpaces : facets.debateSpaces; + const countsBySpace = React.useMemo( + () => new Map(facetSpaces.map(facet => [normId(facet.id), facet.count])), + [facetSpaces] + ); + + const { labelsById, isLoading: spaceLabelsLoading } = useSpaceLabels(allSpaceIds); + const spaceOptions = React.useMemo( + () => + allSpaceIds + .map(id => ({ + value: id, + label: spaceLabel(labelsById, id)?.name ?? `Space ${id.slice(0, 6)}`, + pending: spaceLabelsLoading && !spaceLabel(labelsById, id), + count: countsBySpace.get(normId(id)), + })) + // Keep every option while the facet is arriving, then only spaces that lead somewhere — + // plus a selected zero, which has to stay visible so it can be un-picked. + .filter(option => + facets.facetsSettled + ? (option.count ?? 0) > 0 || spaces.values.some(id => normId(id) === normId(option.value)) + : true + ) + .sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.value.localeCompare(b.value)), + [allSpaceIds, countsBySpace, facets.facetsSettled, labelsById, spaceLabelsLoading, spaces.values] + ); + const topicOptions = React.useMemo( + () => + orderFacetOptions(facets.claimTopics, topics.values).map(topic => ({ + value: topic.id, + label: topic.name?.trim() || 'Topic', + count: topic.count, + ...(facets.topicNamesPending && !topic.name ? { pending: true } : {}), + })), + [facets.claimTopics, facets.topicNamesPending, topics.values] + ); + + const replaceTopics = topics.replace; + React.useEffect(() => { + if (kind !== 'claims' || !facets.facetsSettled) return; + replaceTopics(current => keepSelectableTopics(current, facets.claimTopics, true)); + }, [facets.claimTopics, facets.facetsSettled, kind, replaceTopics]); + + const dimensions = [ + { + key: 'spaces', + options: spaceOptions, + values: spaces.values, + onToggle: spaces.toggle, + onClear: spaces.clear, + anyLabel: 'Any space', + noun: ['space', 'spaces'] as const, + isPending: facets.countsPending, + }, + ...(kind === 'claims' + ? [ + { + key: 'topics', + options: topicOptions, + values: topics.values, + onToggle: topics.toggle, + onClear: topics.clear, + anyLabel: 'Any topic', + noun: ['topic', 'topics'] as const, + isPending: facets.countsPending, + }, + ] + : []), + ]; + + const isFiltered = spaces.values.length !== 1 || !spaces.values.includes(spaceId) || topics.values.length > 0; + const isClaims = kind === 'claims'; + + return ( +
+ setSort(value as ClaimRecordSort) }} + dimensions={dimensions} + /> + + +
+ ); +} diff --git a/apps/web/core/claims/browse/claim-related-claims.tsx b/apps/web/core/claims/browse/claim-related-claims.tsx index b00273dc97..aa16a4542f 100644 --- a/apps/web/core/claims/browse/claim-related-claims.tsx +++ b/apps/web/core/claims/browse/claim-related-claims.tsx @@ -86,10 +86,7 @@ export function ClaimRelatedClaims({ enabled: topicIds.length > 0, }); - const candidates = React.useMemo( - () => page.filter(entity => !ID.equals(entity.id, claimId) && entity.name), - [claimId, page] - ); + const candidates = React.useMemo(() => page.filter(entity => !ID.equals(entity.id, claimId)), [claimId, page]); const candidateIds = React.useMemo(() => candidates.map(entity => entity.id), [candidates]); const { rankByClaimId, isReady: isRankReady } = useClaimsBestOrder(candidateIds, spaceId); diff --git a/apps/web/core/claims/browse/claim-sources-tab.test.tsx b/apps/web/core/claims/browse/claim-sources-tab.test.tsx new file mode 100644 index 0000000000..466b822788 --- /dev/null +++ b/apps/web/core/claims/browse/claim-sources-tab.test.tsx @@ -0,0 +1,77 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render } from '@testing-library/react'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; +import type { Relation } from '~/core/types'; + +import { ClaimSourcesTab } from './claim-sources-tab'; + +const mocks = vi.hoisted(() => ({ + feed: null as Record | null, + refetch: vi.fn(), + rows: [{ entityId: 'source-1', spaceId: 'space-1' }] as ExploreFeedRow[], + isError: true, + isFetching: false, +})); + +vi.mock('./use-claim-explore-rows', () => ({ + useClaimExploreRows: () => ({ + data: mocks.rows, + isLoading: false, + isError: mocks.isError, + isFetching: mocks.isFetching, + refetch: mocks.refetch, + }), +})); + +vi.mock('./claim-provenance', () => ({ ClaimProvenance: () =>
})); +vi.mock('~/partials/profile/person-record-feed', () => ({ + PersonRecordFeed: (props: Record) => { + mocks.feed = props; + return
; + }, +})); + +const sourceRelation = { + id: 'source-relation', + type: { id: SOURCES_PROPERTY_ID }, + toEntity: { id: 'source-1', name: 'Source' }, +} as Relation; + +describe('ClaimSourcesTab', () => { + beforeEach(() => { + mocks.feed = null; + mocks.refetch.mockReset(); + mocks.rows = [{ entityId: 'source-1', spaceId: 'space-1' }] as ExploreFeedRow[]; + mocks.isError = true; + mocks.isFetching = false; + }); + + afterEach(cleanup); + + it('exposes partial hydration failures through the shared feed retry', () => { + render(); + + expect(mocks.feed).toMatchObject({ + rows: mocks.rows, + isError: true, + isFetchingNextPage: false, + onRetry: mocks.refetch, + }); + expect(mocks.feed?.fetchNextPage).toBeUndefined(); + + (mocks.feed?.onRetry as () => void)(); + expect(mocks.refetch).toHaveBeenCalledTimes(1); + }); + + it('does not report a background retry as next-page loading', () => { + mocks.isFetching = true; + + render(); + + expect(mocks.feed).toMatchObject({ isFetchingNextPage: false }); + }); +}); diff --git a/apps/web/core/claims/browse/claim-sources-tab.tsx b/apps/web/core/claims/browse/claim-sources-tab.tsx new file mode 100644 index 0000000000..fd1561017c --- /dev/null +++ b/apps/web/core/claims/browse/claim-sources-tab.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; + +import type { Relation } from '~/core/types'; + +import { PersonRecordFeed } from '~/partials/profile/person-record-feed'; + +import { ClaimProvenance } from './claim-provenance'; +import { getClaimSources } from './claim-sources'; +import { useClaimExploreRows } from './use-claim-explore-rows'; + +/** Provenance plus the source entities themselves, rendered as the explore feed cards they use elsewhere. */ +export function ClaimSourcesTab({ + claimId, + claimRelations, + spaceId, +}: { + claimId: string; + claimRelations: Relation[]; + spaceId: string; +}) { + const sourceIds = React.useMemo(() => getClaimSources(claimRelations).map(source => source.id), [claimRelations]); + const sources = useClaimExploreRows(sourceIds, spaceId); + + // The parent removes the Sources tab when this is empty. The route remains addressable directly, + // matching the profile record-tab pattern, so a stale bookmark still gets an honest empty state. + if (sourceIds.length === 0) { + return

No sources have been linked to this claim yet.

; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/core/claims/browse/claim-sources.test.ts b/apps/web/core/claims/browse/claim-sources.test.ts new file mode 100644 index 0000000000..d36aa5ab65 --- /dev/null +++ b/apps/web/core/claims/browse/claim-sources.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import type { Relation } from '~/core/types'; + +import { getClaimSources } from './claim-sources'; + +function relation({ id, sourceId, deleted = false }: { id: string; sourceId: string; deleted?: boolean }) { + return { + id, + isDeleted: deleted, + type: { id: SOURCES_PROPERTY_ID }, + toEntity: { id: sourceId, name: `Source ${sourceId}` }, + } as unknown as Relation; +} + +describe('getClaimSources', () => { + it('drops deleted sources and dedupes live targets in relation order', () => { + const sources = getClaimSources([ + relation({ id: 'r1', sourceId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' }), + relation({ id: 'r2', sourceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }), + relation({ id: 'r3', sourceId: 'source-2', deleted: true }), + relation({ id: 'r4', sourceId: 'source-3' }), + ]); + + expect(sources).toEqual([ + { id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', name: 'Source aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' }, + { id: 'source-3', name: 'Source source-3' }, + ]); + }); +}); diff --git a/apps/web/core/claims/browse/claim-sources.ts b/apps/web/core/claims/browse/claim-sources.ts new file mode 100644 index 0000000000..52666b42a0 --- /dev/null +++ b/apps/web/core/claims/browse/claim-sources.ts @@ -0,0 +1,22 @@ +import { SOURCES_PROPERTY_ID } from '~/core/debates/ontology'; +import { ID } from '~/core/id'; +import type { Relation } from '~/core/types'; +import { dedupeRelationsByToEntityId } from '~/core/utils/dedupe-relations'; + +export type ClaimSource = { + id: string; + name: string | null; +}; + +/** + * Live, distinct source targets in relation order. + * + * Availability, provenance, and the Sources feed all depend on this exact set. Keeping the + * extraction here prevents one surface from counting a deleted or duplicate source that another + * surface does not render. + */ +export function getClaimSources(relations: Relation[]): ClaimSource[] { + return dedupeRelationsByToEntityId( + relations.filter(relation => relation.isDeleted !== true && ID.equals(relation.type.id, SOURCES_PROPERTY_ID)) + ).map(relation => ({ id: relation.toEntity.id, name: relation.toEntity.name })); +} diff --git a/apps/web/core/claims/browse/use-claim-explore-rows.hook.test.tsx b/apps/web/core/claims/browse/use-claim-explore-rows.hook.test.tsx new file mode 100644 index 0000000000..d35ed09cf5 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-explore-rows.hook.test.tsx @@ -0,0 +1,45 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; + +import { useClaimExploreRows } from './use-claim-explore-rows'; + +const mocks = vi.hoisted(() => ({ fetchExploreRowsByIds: vi.fn() })); + +vi.mock('~/core/profile/explore-rows-by-ids', () => ({ + fetchExploreRowsByIds: mocks.fetchExploreRowsByIds, +})); + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +describe('useClaimExploreRows result stability', () => { + beforeEach(() => { + mocks.fetchExploreRowsByIds.mockReset(); + mocks.fetchExploreRowsByIds.mockResolvedValue([ + { entityId: 'entity-1', spaceId: 'space-1' } as ExploreFeedRow, + ]); + }); + + it('keeps rows and retry callbacks stable across an unrelated rerender', async () => { + const { result, rerender } = renderHook( + ({ ids }: { ids: string[] }) => useClaimExploreRows(ids, 'space-1'), + { initialProps: { ids: ['entity-1'] }, wrapper } + ); + await waitFor(() => expect(result.current.data).toHaveLength(1)); + const firstData = result.current.data; + const firstRefetch = result.current.refetch; + + rerender({ ids: ['entity-1'] }); + + expect(result.current.data).toBe(firstData); + expect(result.current.refetch).toBe(firstRefetch); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-explore-rows.test.ts b/apps/web/core/claims/browse/use-claim-explore-rows.test.ts new file mode 100644 index 0000000000..03435ea154 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-explore-rows.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { CLAIM_RECORD_PAGE_SIZE, claimExploreRowPages } from './use-claim-explore-rows'; + +describe('claimExploreRowPages', () => { + const ids = Array.from({ length: CLAIM_RECORD_PAGE_SIZE * 2 + 1 }, (_, index) => `id-${index}`); + + it('bounds every hydration request while preserving order', () => { + const pages = claimExploreRowPages(ids); + + expect(pages.map(page => page.length)).toEqual([CLAIM_RECORD_PAGE_SIZE, CLAIM_RECORD_PAGE_SIZE, 1]); + expect(pages.flat()).toEqual(ids); + }); + + it('returns no requests for an empty record', () => { + expect(claimExploreRowPages([])).toEqual([]); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-explore-rows.ts b/apps/web/core/claims/browse/use-claim-explore-rows.ts new file mode 100644 index 0000000000..9e4f1314a1 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-explore-rows.ts @@ -0,0 +1,67 @@ +'use client'; + +import { useQueries } from '@tanstack/react-query'; + +import * as React from 'react'; + +import type { ExploreFeedRow } from '~/core/explore/explore-card-item'; +import { fetchExploreRowsByIds } from '~/core/profile/explore-rows-by-ids'; +import { normId } from '~/core/utils/norm-id'; + +export const CLAIM_RECORD_PAGE_SIZE = 20; + +/** Stable request-sized chunks, preserving the complete Best-ranked id order. */ +export function claimExploreRowPages(ids: readonly string[]): string[][] { + const pages: string[][] = []; + for (let start = 0; start < ids.length; start += CLAIM_RECORD_PAGE_SIZE) { + pages.push(ids.slice(start, start + CLAIM_RECORD_PAGE_SIZE)); + } + return pages; +} + +type ClaimExploreRowsQueryResult = { + data?: ExploreFeedRow[]; + isLoading: boolean; + isError: boolean; + isFetching: boolean; + refetch: () => Promise; +}; + +function combineClaimExploreRows(queries: ClaimExploreRowsQueryResult[]) { + return { + data: queries.flatMap(query => query.data ?? []), + isLoading: queries.some(query => query.isLoading), + isError: queries.some(query => query.isError), + isFetching: queries.some(query => query.isFetching), + refetch: async () => void (await Promise.all(queries.filter(query => query.isError).map(query => query.refetch()))), + }; +} + +/** + * Hydrates a claim-scoped id list through the shared Explore card projection. + * + * Related claims, their debates, and source entities all use the same display space and card + * model. Stable request-page keys preserve already loaded pages during infinite scroll without + * ever asking `fetchExploreRowsByIds` to materialize an unbounded id list. + */ +export function useClaimExploreRows(ids: string[], spaceId: string, enabled = true) { + const normalizedSpaceId = normId(spaceId); + const pages = React.useMemo(() => claimExploreRowPages(ids), [ids]); + + return useQueries({ + queries: pages.map(page => { + const normalizedIds = page.map(normId); + + return { + queryKey: ['claim', 'explore-rows', normalizedSpaceId, normalizedIds], + queryFn: ({ signal }: { signal: AbortSignal }) => { + const preferredSpaces = new Map(normalizedIds.map(id => [id, [spaceId]])); + return fetchExploreRowsByIds(page, signal, preferredSpaces); + }, + enabled, + staleTime: 30_000, + }; + }), + combine: combineClaimExploreRows, + }); +} diff --git a/apps/web/core/claims/browse/use-claim-record-facets.test.tsx b/apps/web/core/claims/browse/use-claim-record-facets.test.tsx new file mode 100644 index 0000000000..fe38c7ce7a --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record-facets.test.tsx @@ -0,0 +1,121 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { Effect } from 'effect'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { claimRecordFilters } from './claim-record-query'; +import { useClaimRecordFacets } from './use-claim-record-facets'; + +const mocks = vi.hoisted(() => ({ graphql: vi.fn(), getEntityNames: vi.fn() })); + +vi.mock('~/core/io/graphql-client', () => ({ graphql: mocks.graphql })); +vi.mock('~/core/io/queries', () => ({ getEntityNames: mocks.getEntityNames })); + +const CLAIM = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const SPACE = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const OTHER_SPACE = 'cccccccccccccccccccccccccccccccc'; +const SOURCE_TOPIC = 'dddddddddddddddddddddddddddddddd'; +const SELECTED_TOPIC = 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'; +const FACET_TOPIC = 'ffffffffffffffffffffffffffffffff'; + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +beforeEach(() => { + mocks.graphql.mockReset(); + mocks.getEntityNames.mockReset(); + mocks.graphql.mockImplementation(({ decoder, variables }) => { + const isTopic = variables.groupBy[0] === 'TO_ENTITY_ID'; + return Effect.succeed( + decoder({ + relationsConnection: { + groupedAggregates: [ + { + keys: [isTopic ? FACET_TOPIC : SPACE], + distinctCount: { fromEntityId: isTopic ? '3' : '7' }, + }, + ], + }, + }) + ); + }); + mocks.getEntityNames.mockReturnValue(Effect.succeed([{ id: FACET_TOPIC, name: 'A corpus topic' }])); +}); + +describe('useClaimRecordFacets', () => { + it('asks separate full-corpus space and topic aggregates for Related claims', async () => { + const { result } = renderHook( + () => + useClaimRecordFacets({ + kind: 'claims', + claimId: CLAIM, + allSpaceIds: [SPACE, OTHER_SPACE], + selectedSpaceIds: [SPACE], + sourceTopicIds: [SOURCE_TOPIC], + selectedTopicIds: [SELECTED_TOPIC], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.facetsSettled).toBe(true)); + await waitFor(() => expect(result.current.claimTopics[0]?.name).toBe('A corpus topic')); + + expect(result.current.claimSpaces).toEqual([{ id: SPACE, count: 7 }]); + expect(result.current.claimTopics).toEqual([{ id: FACET_TOPIC, count: 3, name: 'A corpus topic' }]); + + const calls = mocks.graphql.mock.calls.map(([options]) => options); + const spaceCall = calls.find(options => options.variables.groupBy[0] === 'SPACE_ID'); + const topicCall = calls.find(options => options.variables.groupBy[0] === 'TO_ENTITY_ID'); + expect(spaceCall.variables.filter).toEqual( + claimRecordFilters({ + claimId: CLAIM, + spaceIds: [SPACE, OTHER_SPACE], + topicIds: [SOURCE_TOPIC], + filterTopicIds: [SELECTED_TOPIC], + }).claimRelations + ); + expect(topicCall.variables.filter).toEqual( + claimRecordFilters({ + claimId: CLAIM, + spaceIds: [SPACE], + topicIds: [SOURCE_TOPIC], + filterTopicIds: [SELECTED_TOPIC], + }).claimTopicRelations + ); + }); + + it('asks only for the complete Debates space facet', async () => { + const { result } = renderHook( + () => + useClaimRecordFacets({ + kind: 'debates', + claimId: CLAIM, + allSpaceIds: [SPACE, OTHER_SPACE], + selectedSpaceIds: [SPACE], + sourceTopicIds: [SOURCE_TOPIC], + selectedTopicIds: [], + }), + { wrapper } + ); + + await waitFor(() => expect(result.current.facetsSettled).toBe(true)); + + expect(result.current.debateSpaces).toEqual([{ id: SPACE, count: 7 }]); + expect(mocks.graphql).toHaveBeenCalledTimes(1); + expect(mocks.graphql.mock.calls[0][0].variables).toEqual({ + filter: claimRecordFilters({ + claimId: CLAIM, + spaceIds: [SPACE, OTHER_SPACE], + topicIds: [SOURCE_TOPIC], + filterTopicIds: [], + }).debateRelations, + groupBy: ['SPACE_ID'], + }); + expect(mocks.getEntityNames).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-record-facets.ts b/apps/web/core/claims/browse/use-claim-record-facets.ts new file mode 100644 index 0000000000..dbb72d8718 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record-facets.ts @@ -0,0 +1,169 @@ +'use client'; + +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { Effect } from 'effect'; + +import type { RelationFilter } from '~/core/gql/graphql'; +import { graphql } from '~/core/io/graphql-client'; +import { getEntityNames } from '~/core/io/queries'; +import { + decodeRelationFacet, + relationFacetByFilterDocument, + type RelationFacetCount, + type RelationFacetGroupBy, +} from '~/core/io/relation-facet'; +import { normId } from '~/core/utils/norm-id'; + +import { claimRecordFilters } from './claim-record-query'; + +const FACET_STALE_TIME = 60_000; +const NAME_STALE_TIME = 30 * 60_000; +const NO_FACETS: RelationFacetCount[] = []; +const NO_NAMES = new Map(); + +export function fetchClaimRecordFacet({ + filter, + groupBy, + signal, +}: { + filter: RelationFilter; + groupBy: RelationFacetGroupBy; + signal?: AbortSignal; +}) { + return Effect.runPromise( + graphql({ + query: relationFacetByFilterDocument, + decoder: decodeRelationFacet, + variables: { filter, groupBy: [groupBy] }, + signal, + }) + ); +} + +/** + * Complete server facets for the claim page's Related claims and Debates records. + * + * The row queries stay paged; counts never come from those pages. Spaces are counted over every + * available space without applying the current space selection to themselves, while Topics are + * counted over the already-selected spaces and topics. This is the same asymmetric facet contract + * used by the debates panel and personal-space Positions tab: spaces are OR, topics are AND. + */ +export function useClaimRecordFacets({ + kind, + claimId, + allSpaceIds, + selectedSpaceIds, + sourceTopicIds, + selectedTopicIds, +}: { + kind: 'claims' | 'debates'; + claimId: string; + allSpaceIds: string[]; + selectedSpaceIds: string[]; + sourceTopicIds: string[]; + selectedTopicIds: string[]; +}) { + const allSpaceKey = allSpaceIds.map(normId).sort().join(','); + const selectedSpaceKey = selectedSpaceIds.map(normId).sort().join(','); + const sourceTopicKey = sourceTopicIds.map(normId).sort().join(','); + const selectedTopicKey = selectedTopicIds.map(normId).sort().join(','); + + const allSpaceFilters = React.useMemo( + () => + claimRecordFilters({ + claimId, + spaceIds: allSpaceIds, + topicIds: sourceTopicIds, + filterTopicIds: kind === 'claims' ? selectedTopicIds : [], + }), + // The sorted keys are the semantic identity; callers can rebuild and reorder the arrays. + // eslint-disable-next-line react-hooks/exhaustive-deps + [claimId, kind, allSpaceKey, sourceTopicKey, selectedTopicKey] + ); + const selectedSpaceFilters = React.useMemo( + () => + claimRecordFilters({ + claimId, + spaceIds: selectedSpaceIds, + topicIds: sourceTopicIds, + filterTopicIds: selectedTopicIds, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [claimId, selectedSpaceKey, sourceTopicKey, selectedTopicKey] + ); + + const claimSpacesQuery = useQuery({ + queryKey: ['claim-record', 'facets', 'claim-spaces', claimId, allSpaceKey, sourceTopicKey, selectedTopicKey], + enabled: kind === 'claims', + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + fetchClaimRecordFacet({ filter: allSpaceFilters.claimRelations, groupBy: 'SPACE_ID', signal }), + staleTime: FACET_STALE_TIME, + }); + const claimTopicsQuery = useQuery({ + queryKey: [ + 'claim-record', + 'facets', + 'claim-topics', + claimId, + selectedSpaceKey, + sourceTopicKey, + selectedTopicKey, + ], + enabled: kind === 'claims', + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + fetchClaimRecordFacet({ filter: selectedSpaceFilters.claimTopicRelations, groupBy: 'TO_ENTITY_ID', signal }), + staleTime: FACET_STALE_TIME, + }); + const debateSpacesQuery = useQuery({ + queryKey: ['claim-record', 'facets', 'debate-spaces', claimId, allSpaceKey, sourceTopicKey], + enabled: kind === 'debates', + placeholderData: keepPreviousData, + queryFn: ({ signal }) => + fetchClaimRecordFacet({ filter: allSpaceFilters.debateRelations, groupBy: 'SPACE_ID', signal }), + staleTime: FACET_STALE_TIME, + }); + + const topicIds = React.useMemo( + () => (claimTopicsQuery.data ?? NO_FACETS).map(facet => facet.id), + [claimTopicsQuery.data] + ); + const topicNamesQuery = useQuery({ + queryKey: ['claim-record', 'facet-topic-names', [...topicIds].sort().join(',')], + enabled: kind === 'claims' && topicIds.length > 0, + placeholderData: keepPreviousData, + queryFn: async ({ signal }) => { + const rows = await Effect.runPromise(getEntityNames(topicIds, signal)); + return new Map(rows.map(row => [normId(row.id), row.name])); + }, + staleTime: NAME_STALE_TIME, + }); + + const claimTopics = React.useMemo(() => { + const names = topicNamesQuery.data ?? NO_NAMES; + return (claimTopicsQuery.data ?? NO_FACETS).map(facet => ({ + ...facet, + name: names.get(normId(facet.id)) ?? null, + })); + }, [claimTopicsQuery.data, topicNamesQuery.data]); + + const activeQueries = + kind === 'claims' ? [claimSpacesQuery, claimTopicsQuery] : [debateSpacesQuery]; + const countsPending = activeQueries.some(query => query.isLoading || query.isPlaceholderData); + const facetsSettled = activeQueries.every(query => !query.isLoading && !query.isPlaceholderData && !query.error); + + return { + claimSpaces: claimSpacesQuery.data ?? NO_FACETS, + claimTopics, + debateSpaces: debateSpacesQuery.data ?? NO_FACETS, + countsPending, + facetsSettled, + topicNamesPending: + kind === 'claims' && topicIds.length > 0 && (topicNamesQuery.isLoading || topicNamesQuery.isPlaceholderData), + isError: activeQueries.some(query => Boolean(query.error)), + }; +} diff --git a/apps/web/core/claims/browse/use-claim-record.cached-data.test.tsx b/apps/web/core/claims/browse/use-claim-record.cached-data.test.tsx new file mode 100644 index 0000000000..9f9c5ad989 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record.cached-data.test.tsx @@ -0,0 +1,175 @@ +import { act, renderHook } from '@testing-library/react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useClaimRecord } from './use-claim-record'; + +const claimEntity = { + id: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + rankingScore: 4, + updatedAt: '2026-01-01T00:00:00Z', +}; +const claimRow = { entityId: claimEntity.id, spaceId: 'space-1' }; + +const mocks = vi.hoisted(() => ({ + countLoading: true, + claimsError: false, + useQuery: vi.fn(), + useInfiniteQuery: vi.fn(), + buildExploreFeedRows: vi.fn(), + claimData: undefined as Record | undefined, + claimsHasNextPage: false, + claimFetchNextPage: vi.fn(), + allowedSets: [] as Set[], +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: mocks.useQuery, + useInfiniteQuery: mocks.useInfiniteQuery, +})); +vi.mock('~/core/explore/explore-card-item', async importOriginal => ({ + ...(await importOriginal()), + buildExploreFeedRows: mocks.buildExploreFeedRows, +})); + +const emptyConnection = { entities: [], endCursor: null, hasNextPage: false }; + +describe('useClaimRecord independent loading and cached data', () => { + beforeEach(() => { + mocks.countLoading = true; + mocks.claimsError = false; + mocks.useQuery.mockReset(); + mocks.useInfiniteQuery.mockReset(); + mocks.buildExploreFeedRows.mockReset(); + mocks.claimFetchNextPage.mockReset(); + mocks.allowedSets = []; + mocks.claimsHasNextPage = false; + mocks.claimFetchNextPage.mockResolvedValue({ isError: false }); + mocks.claimData = { + pages: [ + { + topicClaims: { entities: [claimEntity], endCursor: null, hasNextPage: false }, + extractedClaims: emptyConnection, + }, + ], + }; + + mocks.useQuery.mockImplementation(() => ({ + data: undefined, + isLoading: mocks.countLoading, + isError: false, + error: null, + })); + mocks.useInfiniteQuery.mockImplementation((options: { queryKey: string[] }) => { + if (options.queryKey[1] === 'claims') { + return { + data: mocks.claimData, + isLoading: false, + isError: mocks.claimsError, + isFetchingNextPage: false, + hasNextPage: mocks.claimsHasNextPage, + fetchNextPage: mocks.claimFetchNextPage, + refetch: vi.fn(), + }; + } + + return { + data: undefined, + isLoading: true, + isError: false, + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: vi.fn(), + refetch: vi.fn(), + }; + }); + mocks.buildExploreFeedRows.mockImplementation((entities: Array<{ id: string }>, allowed: Set) => { + mocks.allowedSets.push(allowed); + return entities.map(entity => ({ ...claimRow, entityId: entity.id })); + }); + + }); + + it('publishes claim rows before the independent exact count and debate requests finish', () => { + const { result } = renderHook(() => + useClaimRecord({ claimId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', spaceId: 'space-1', topicIds: ['topic-1'] }) + ); + + expect(result.current.claimRows).toEqual([claimRow]); + expect(result.current.claimsLoading).toBe(false); + expect(result.current.claimsTotal).toBe(1); + expect(result.current.debatesLoading).toBe(true); + }); + + it('keeps cached claim cards visible when their background page refresh fails', () => { + mocks.claimsError = true; + + const { result } = renderHook(() => + useClaimRecord({ claimId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', spaceId: 'space-1', topicIds: ['topic-1'] }) + ); + + expect(result.current.claimRows).toEqual([claimRow]); + expect(result.current.claimsError).toBe(true); + }); + + it('uses each entity’s matching spaces rather than every selected space', () => { + mocks.claimData = { + pages: [ + { + topicClaims: { + entities: [{ ...claimEntity, matchingSpaceIds: ['space-2'] }], + endCursor: null, + hasNextPage: false, + }, + extractedClaims: emptyConnection, + }, + ], + }; + + renderHook(() => + useClaimRecord({ + claimId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spaceId: 'space-1', + spaceIds: ['space-1', 'space-2'], + topicIds: ['topic-1'], + }) + ); + + expect([...mocks.allowedSets[0]]).toEqual(['space2']); + }); + + it('fetches every open union branch before revealing the next ranked slice', async () => { + let resolveNextPage!: (value: { isError: boolean }) => void; + mocks.claimFetchNextPage.mockReturnValue( + new Promise<{ isError: boolean }>(resolve => { + resolveNextPage = resolve; + }) + ); + mocks.claimsHasNextPage = true; + const entities = Array.from({ length: 40 }, (_, index) => ({ + ...claimEntity, + id: `${index + 1}`.padStart(32, '0'), + rankingScore: 40 - index, + })); + mocks.claimData = { + pages: [ + { + topicClaims: { entities: entities.slice(0, 20), endCursor: 'topic-next', hasNextPage: true }, + extractedClaims: { entities: entities.slice(20), endCursor: 'extracted-next', hasNextPage: true }, + }, + ], + }; + + const { result } = renderHook(() => + useClaimRecord({ claimId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', spaceId: 'space-1', topicIds: ['topic-1'] }) + ); + expect(result.current.claimRows).toHaveLength(20); + + act(() => result.current.fetchNextClaimsPage()); + expect(mocks.claimFetchNextPage).toHaveBeenCalledOnce(); + expect(result.current.claimRows).toHaveLength(20); + + await act(async () => resolveNextPage({ isError: false })); + expect(result.current.claimRows).toHaveLength(40); + }); +}); diff --git a/apps/web/core/claims/browse/use-claim-record.ts b/apps/web/core/claims/browse/use-claim-record.ts new file mode 100644 index 0000000000..b48c73c971 --- /dev/null +++ b/apps/web/core/claims/browse/use-claim-record.ts @@ -0,0 +1,236 @@ +'use client'; + +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; + +import * as React from 'react'; + +import { buildExploreFeedRows, type ExploreFeedRow } from '~/core/explore/explore-card-item'; +import { normId } from '~/core/utils/norm-id'; + +import { + CLAIM_RECORD_PAGE_SIZE, + claimRecordFilters, + fetchClaimRecordClaimsPage, + fetchClaimRecordCounts, + fetchClaimRecordDebatesPage, + firstClaimRecordClaimsPageParam, + mergeSortedRecordEntities, + nextClaimRecordClaimsPageParam, + type ClaimRecordSort, + type RankedClaimRecordEntity, +} from './claim-record-query'; + +export { CLAIM_RECORD_PAGE_SIZE } from './claim-record-query'; + +const CLAIM_RECORD_STALE_TIME = 60_000; +const NO_ROWS: ExploreFeedRow[] = []; + +function useVisibleRecordPage({ + recordKey, + entityCount, + queryHasNextPage, + queryIsFetchingNextPage, + queryIsError, + refetch, + fetchNextPage, +}: { + recordKey: string; + entityCount: number; + queryHasNextPage: boolean; + queryIsFetchingNextPage: boolean; + queryIsError: boolean; + refetch: () => Promise; + fetchNextPage: () => Promise<{ isError: boolean }>; +}) { + const [page, setPage] = React.useState({ key: recordKey, visibleCount: CLAIM_RECORD_PAGE_SIZE }); + const visibleCount = page.key === recordKey ? page.visibleCount : CLAIM_RECORD_PAGE_SIZE; + const hasNextPage = entityCount > visibleCount || queryHasNextPage; + + const revealNextPage = React.useCallback(() => { + setPage(current => ({ + key: recordKey, + visibleCount: + (current.key === recordKey ? current.visibleCount : CLAIM_RECORD_PAGE_SIZE) + CLAIM_RECORD_PAGE_SIZE, + })); + }, [recordKey]); + + const fetchNext = React.useCallback(() => { + if (queryIsError) { + void refetch(); + return; + } + if (queryIsFetchingNextPage) return; + + // Every still-open branch must advance before another union slice is visible. A buffered 20 + // from each branch cannot prove the union's top 40: row 21 in either branch may outrank a row + // already buffered from the other. Once each branch has K rows, its unseen rows cannot enter + // the union's top K because that branch alone already has K rows ahead of them. + if (queryHasNextPage) { + void fetchNextPage().then(result => { + if (!result.isError) revealNextPage(); + }); + return; + } + + revealNextPage(); + }, [fetchNextPage, queryHasNextPage, queryIsError, queryIsFetchingNextPage, refetch, revealNextPage]); + + return { visibleCount, hasNextPage, fetchNextPage: fetchNext }; +} + +function rowsForEntities( + entities: RankedClaimRecordEntity[], + visibleCount: number, + spaceIds: string[] +): ExploreFeedRow[] { + if (entities.length === 0) return NO_ROWS; + return entities.slice(0, visibleCount).flatMap(entity => { + const matchingSpaceIds = entity.matchingSpaceIds?.length ? entity.matchingSpaceIds : spaceIds; + return buildExploreFeedRows( + [entity], + new Set(matchingSpaceIds.map(normId)), + // These records do not render a Join button, so there is no membership state to resolve. + new Set() + ); + }); +} + +/** + * The claim page's Debates and Related claims record. + * + * The old path discovered every matching entity, fetched a score for every id, sorted them in the + * browser, and then fetched the visible cards. This version asks the graph for Best-ranked card + * pages directly. Topic matches and claims extracted from a direct debate remain separate indexed + * branches; their pages are merged and deduped in Best order, while an aggregate relation query + * counts the exact union independently. That separation lets Activity draw whichever list arrives + * first instead of waiting for the slowest prerequisite. + */ +export function useClaimRecord({ + claimId, + spaceId, + topicIds, + spaceIds, + filterTopicIds = [], + claimSort = 'best', + debateSort = 'best', + claimsEnabled = true, + debatesEnabled = true, + countsEnabled = true, +}: { + claimId: string; + spaceId: string; + topicIds: string[]; + spaceIds?: string[]; + filterTopicIds?: string[]; + claimSort?: ClaimRecordSort; + debateSort?: ClaimRecordSort; + claimsEnabled?: boolean; + debatesEnabled?: boolean; + countsEnabled?: boolean; +}) { + const selectedSpaceIds = spaceIds && spaceIds.length > 0 ? spaceIds : [spaceId]; + const topicKey = topicIds.map(normId).sort().join(','); + const filterTopicKey = filterTopicIds.map(normId).sort().join(','); + const spaceKey = selectedSpaceIds.map(normId).sort().join(','); + const recordKey = `${normId(claimId)}:${spaceKey}:${topicKey}:${filterTopicKey}`; + const filters = React.useMemo( + () => claimRecordFilters({ claimId, spaceIds: selectedSpaceIds, topicIds, filterTopicIds }), + // Topic/space order and UUID formatting do not change the query's meaning. + // eslint-disable-next-line react-hooks/exhaustive-deps + [claimId, spaceKey, topicKey, filterTopicKey] + ); + + const counts = useQuery({ + queryKey: ['claim-record', 'counts', recordKey], + enabled: countsEnabled, + queryFn: ({ signal }) => fetchClaimRecordCounts({ filters, signal }), + staleTime: CLAIM_RECORD_STALE_TIME, + }); + + const claims = useInfiniteQuery({ + queryKey: ['claim-record', 'claims', recordKey, claimSort], + enabled: claimsEnabled, + initialPageParam: firstClaimRecordClaimsPageParam(filters.hasTopics), + queryFn: ({ pageParam, signal }) => + fetchClaimRecordClaimsPage({ filters, spaceIds: selectedSpaceIds, sort: claimSort, pageParam, signal }), + getNextPageParam: nextClaimRecordClaimsPageParam, + staleTime: CLAIM_RECORD_STALE_TIME, + }); + + const debates = useInfiniteQuery({ + queryKey: ['claim-record', 'debates', recordKey, debateSort], + enabled: debatesEnabled, + initialPageParam: null as string | null, + queryFn: ({ pageParam, signal }) => + fetchClaimRecordDebatesPage({ filters, spaceIds: selectedSpaceIds, sort: debateSort, after: pageParam, signal }), + getNextPageParam: page => (page.hasNextPage && page.endCursor !== null ? page.endCursor : undefined), + staleTime: CLAIM_RECORD_STALE_TIME, + }); + + const claimEntities = React.useMemo(() => { + const pages = claims.data?.pages ?? []; + return mergeSortedRecordEntities( + claimSort, + pages.flatMap(page => page.topicClaims.entities), + pages.flatMap(page => page.extractedClaims.entities) + ); + }, [claimSort, claims.data?.pages]); + const debateEntities = React.useMemo( + () => mergeSortedRecordEntities(debateSort, ...(debates.data?.pages ?? []).map(page => page.entities)), + [debateSort, debates.data?.pages] + ); + + const claimsPage = useVisibleRecordPage({ + recordKey: `${recordKey}:${claimSort}`, + entityCount: claimEntities.length, + queryHasNextPage: Boolean(claims.hasNextPage), + queryIsFetchingNextPage: claims.isFetchingNextPage, + queryIsError: claims.isError, + refetch: claims.refetch, + fetchNextPage: claims.fetchNextPage, + }); + const debatesPage = useVisibleRecordPage({ + recordKey: `${recordKey}:${debateSort}`, + entityCount: debateEntities.length, + queryHasNextPage: Boolean(debates.hasNextPage), + queryIsFetchingNextPage: debates.isFetchingNextPage, + queryIsError: debates.isError, + refetch: debates.refetch, + fetchNextPage: debates.fetchNextPage, + }); + + const claimRows = React.useMemo( + () => rowsForEntities(claimEntities, claimsPage.visibleCount, selectedSpaceIds), + // `spaceKey` is the semantic identity; callers may rebuild the array around it. + // eslint-disable-next-line react-hooks/exhaustive-deps + [claimEntities, claimsPage.visibleCount, spaceKey] + ); + const debateRows = React.useMemo( + () => rowsForEntities(debateEntities, debatesPage.visibleCount, selectedSpaceIds), + // eslint-disable-next-line react-hooks/exhaustive-deps + [debateEntities, debatesPage.visibleCount, spaceKey] + ); + + return { + // Kept for the public hook shape; it now describes the bounded pages already fetched rather + // than forcing an exhaustive id walk before Activity can appear. + relatedClaimIds: claimEntities.map(entity => entity.id), + claimRows, + debateRows, + // Rows are a truthful lower bound while the independent exact aggregate is still in flight. + claimsTotal: counts.data?.claims ?? claimEntities.length, + debatesTotal: counts.data?.debates ?? debateEntities.length, + claimsCountUnavailable: counts.isError, + debatesCountUnavailable: counts.isError, + claimsLoading: claims.isLoading, + debatesLoading: debates.isLoading, + claimsError: claims.isError, + debatesError: debates.isError, + claimsFetchingNextPage: claims.isFetchingNextPage, + debatesFetchingNextPage: debates.isFetchingNextPage, + claimsHasNextPage: claimsPage.hasNextPage, + debatesHasNextPage: debatesPage.hasNextPage, + fetchNextClaimsPage: claimsPage.fetchNextPage, + fetchNextDebatesPage: debatesPage.fetchNextPage, + }; +} diff --git a/apps/web/core/io/relation-facet.test.ts b/apps/web/core/io/relation-facet.test.ts index c0765deb10..a6712fa52e 100644 --- a/apps/web/core/io/relation-facet.test.ts +++ b/apps/web/core/io/relation-facet.test.ts @@ -1,6 +1,19 @@ +import { print } from 'graphql'; import { describe, expect, it } from 'vitest'; -import { decodeRelationFacet } from './relation-facet'; +import { decodeRelationFacet, relationFacetByFilterDocument } from './relation-facet'; + +describe('relationFacetByFilterDocument', () => { + it('accepts a complete relation filter so union populations can be grouped exactly', () => { + const source = print(relationFacetByFilterDocument); + + expect(source).toContain('$filter: RelationFilter!'); + expect(source).toContain('relationsConnection(filter: $filter)'); + expect(source).toContain('groupedAggregates(groupBy: $groupBy)'); + expect(source).toContain('distinctCount'); + expect(source).toContain('fromEntityId'); + }); +}); describe('decodeRelationFacet', () => { it('normalizes dashed group keys to the dashless spelling and coerces bigint-string counts', () => { diff --git a/apps/web/core/io/relation-facet.ts b/apps/web/core/io/relation-facet.ts index 7d8ad39404..0fc0c85186 100644 --- a/apps/web/core/io/relation-facet.ts +++ b/apps/web/core/io/relation-facet.ts @@ -2,7 +2,7 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { parse } from 'graphql'; -import type { EntityFilter } from '~/core/gql/graphql'; +import type { EntityFilter, RelationFilter } from '~/core/gql/graphql'; import { uuidToHex } from '~/core/id/normalize'; /** @@ -39,6 +39,28 @@ const RELATION_FACET_SOURCE = /* GraphQL */ ` } `; +/** + * The same grouped aggregate, accepting the complete relation predicate. + * + * Most callers facet one property over an entity population, which is the simpler document above. + * A population can instead be a union of relation paths — Related claims are either topic matches + * or claims extracted from a debate — and reducing that to one `typeId` plus `fromEntity` loses the + * path and space that matched. Keeping the full relation filter lets the server dedupe that union + * by `fromEntityId` before grouping it, rather than forcing a page-local client approximation. + */ +const RELATION_FACET_BY_FILTER_SOURCE = /* GraphQL */ ` + query RelationFacetByFilter($filter: RelationFilter!, $groupBy: [RelationsGroupBy!]!) { + relationsConnection(filter: $filter) { + groupedAggregates(groupBy: $groupBy) { + keys + distinctCount { + fromEntityId + } + } + } + } +`; + export type RelationFacetResult = { relationsConnection: { groupedAggregates: Array<{ @@ -63,6 +85,11 @@ export const relationFacetDocument = parse(RELATION_FACET_SOURCE) as TypedDocume RelationFacetVariables >; +export const relationFacetByFilterDocument = parse(RELATION_FACET_BY_FILTER_SOURCE) as TypedDocumentNode< + RelationFacetResult, + { filter: RelationFilter; groupBy: RelationFacetGroupBy[] } +>; + /** One facet row: a group's id and how many distinct population rows fall in it. */ export type RelationFacetCount = { id: string; count: number }; diff --git a/apps/web/core/profile/activity-gallery.ts b/apps/web/core/profile/activity-gallery.ts new file mode 100644 index 0000000000..5e24f9eadf --- /dev/null +++ b/apps/web/core/profile/activity-gallery.ts @@ -0,0 +1,2 @@ +/** Maximum cards rendered by an Overview activity gallery. */ +export const ACTIVITY_GALLERY_CARD_LIMIT = 6; diff --git a/apps/web/core/profile/use-entity-scores.test.tsx b/apps/web/core/profile/use-entity-scores.test.tsx new file mode 100644 index 0000000000..6eeca034e3 --- /dev/null +++ b/apps/web/core/profile/use-entity-scores.test.tsx @@ -0,0 +1,97 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { Effect } from 'effect'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useEntityScores } from './use-entity-scores'; + +const mocks = vi.hoisted(() => ({ graphql: vi.fn(), failLastChunk: true })); + +vi.mock('~/core/io/graphql-client', () => ({ graphql: mocks.graphql })); + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +function automaticRetryWrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: 1, retryDelay: 0 } } }); + return {children}; +} + +describe('useEntityScores chunk retries', () => { + beforeEach(() => { + mocks.graphql.mockReset(); + mocks.failLastChunk = true; + mocks.graphql.mockImplementation(({ variables }: { variables: { ids: string[] } }) => { + if (variables.ids.includes('id-100') && mocks.failLastChunk) { + return Effect.fail(new Error('last score chunk failed')); + } + + return Effect.succeed({ scores: new Map(), rankings: new Map() }); + }); + }); + + it('starts a manual retry from the first score chunk', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `id-${index}`); + const { result } = renderHook(() => useEntityScores({ ids }), { wrapper }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(mocks.graphql).toHaveBeenCalledTimes(2); + + mocks.failLastChunk = false; + await act(async () => void (await result.current.refetch())); + await waitFor(() => expect(result.current.isError).toBe(false)); + + const firstChunkCalls = mocks.graphql.mock.calls.filter( + ([options]) => !(options as { variables: { ids: string[] } }).variables.ids.includes('id-100') + ); + const lastChunkCalls = mocks.graphql.mock.calls.filter(([options]) => + (options as { variables: { ids: string[] } }).variables.ids.includes('id-100') + ); + expect(firstChunkCalls).toHaveLength(2); + expect(lastChunkCalls).toHaveLength(2); + }); + + it('reports cached rankings as available after a background refresh fails', async () => { + mocks.failLastChunk = false; + mocks.graphql.mockReturnValueOnce( + Effect.succeed({ scores: new Map(), rankings: new Map([['id-1', 42]]) }) + ); + const { result } = renderHook(() => useEntityScores({ ids: ['id-1'] }), { wrapper }); + await waitFor(() => expect(result.current.rankings.get('id-1')).toBe(42)); + + mocks.graphql.mockReturnValueOnce(Effect.fail(new Error('refresh failed'))); + await act(async () => void (await result.current.refetch())); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.rankings.get('id-1')).toBe(42); + expect(result.current.dataAvailable).toBe(true); + }); + + it('resumes an automatic retry at the failed score chunk', async () => { + const ids = Array.from({ length: 101 }, (_, index) => `id-${index}`); + let lastChunkAttempts = 0; + mocks.graphql.mockImplementation(({ variables }: { variables: { ids: string[] } }) => { + if (variables.ids.includes('id-100') && lastChunkAttempts++ === 0) { + return Effect.fail(new Error('transient score failure')); + } + return Effect.succeed({ scores: new Map(), rankings: new Map() }); + }); + + const { result } = renderHook(() => useEntityScores({ ids }), { wrapper: automaticRetryWrapper }); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const firstChunkCalls = mocks.graphql.mock.calls.filter( + ([options]) => !(options as { variables: { ids: string[] } }).variables.ids.includes('id-100') + ); + const lastChunkCalls = mocks.graphql.mock.calls.filter(([options]) => + (options as { variables: { ids: string[] } }).variables.ids.includes('id-100') + ); + expect(firstChunkCalls).toHaveLength(1); + expect(lastChunkCalls).toHaveLength(2); + }); +}); diff --git a/apps/web/core/profile/use-entity-scores.ts b/apps/web/core/profile/use-entity-scores.ts index 80c96d273e..0eab4a9e69 100644 --- a/apps/web/core/profile/use-entity-scores.ts +++ b/apps/web/core/profile/use-entity-scores.ts @@ -3,6 +3,8 @@ import type { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { useQuery } from '@tanstack/react-query'; +import * as React from 'react'; + import { Effect } from 'effect'; import { parse } from 'graphql'; @@ -117,40 +119,90 @@ export function entityScoresQueryKey(ids: readonly string[]) { const NO_SCORES: EntityScores = { scores: new Map(), rankings: new Map() }; +type EntityScoreCheckpoint = EntityScores & { + ids: string[]; + nextStart: number; +}; + +function createEntityScoreCheckpoint(ids: readonly string[]): EntityScoreCheckpoint { + return { ids: [...ids], nextStart: 0, scores: new Map(), rankings: new Map() }; +} + +function cloneEntityScoreCheckpoint(checkpoint: EntityScoreCheckpoint): EntityScoreCheckpoint { + return { + ids: [...checkpoint.ids], + nextStart: checkpoint.nextStart, + scores: new Map(checkpoint.scores), + rankings: new Map(checkpoint.rankings), + }; +} + export function useEntityScores({ ids, enabled = true }: { ids: readonly string[]; enabled?: boolean }) { - const { data, isLoading, isError } = useQuery({ - queryKey: entityScoresQueryKey(ids), + const queryKey = entityScoresQueryKey(ids); + const signature = queryKey[1]; + const progressRef = React.useRef({ signature, checkpoint: createEntityScoreCheckpoint(ids) }); + const resetProgress = React.useCallback(() => { + progressRef.current = { signature, checkpoint: createEntityScoreCheckpoint(ids) }; + }, [ids, signature]); + const { data, isLoading, isError, isFetching, refetch: refetchQuery } = useQuery({ + queryKey, enabled: enabled && ids.length > 0, staleTime: 5 * 60_000, queryFn: async ({ signal }): Promise => { - const scores = new Map(); - const rankings = new Map(); - - for (let start = 0; start < ids.length; start += ID_BATCH_SIZE) { - const chunk = ids.slice(start, start + ID_BATCH_SIZE); - - const page = await Effect.runPromise( - graphql({ - query: entityScoresDocument, - decoder: decodeScores, - variables: { ids: chunk, propertyId: SCORE_SYSTEM_PROPERTY }, - signal, - }) - ); - - for (const [id, score] of page.scores) scores.set(id, score); - for (const [id, rank] of page.rankings) rankings.set(id, rank); + if (progressRef.current.signature !== signature) { + resetProgress(); } - return { scores, rankings }; + const checkpoint = cloneEntityScoreCheckpoint(progressRef.current.checkpoint); + try { + while (checkpoint.nextStart < checkpoint.ids.length) { + const chunk = checkpoint.ids.slice(checkpoint.nextStart, checkpoint.nextStart + ID_BATCH_SIZE); + const page = await Effect.runPromise( + graphql({ + query: entityScoresDocument, + decoder: decodeScores, + variables: { ids: chunk, propertyId: SCORE_SYSTEM_PROPERTY }, + signal, + }) + ); + + for (const [id, score] of page.scores) checkpoint.scores.set(id, score); + for (const [id, rank] of page.rankings) checkpoint.rankings.set(id, rank); + checkpoint.nextStart += chunk.length; + } + } catch (cause) { + if (!signal.aborted) progressRef.current = { signature, checkpoint }; + throw cause; + } + + const result = { scores: new Map(checkpoint.scores), rankings: new Map(checkpoint.rankings) }; + if (!signal.aborted) resetProgress(); + return result; }, }); + React.useEffect(() => { + if (isError) resetProgress(); + }, [isError, resetProgress]); + const refetch = React.useCallback(() => { + resetProgress(); + return refetchQuery(); + }, [refetchQuery, resetProgress]); + // `isError` matters because the empty map is indistinguishable from the // loading one, and `sortRows` treats "no scores" as "keep the incoming order". // A caller that ignores it therefore shows the New order under a menu that // says Top or Best, permanently and silently. const { scores, rankings } = data ?? NO_SCORES; - return { scores, rankings, isLoading, isError }; + return { + scores, + rankings, + /** Cached rankings remain usable when a later background refresh fails. */ + dataAvailable: ids.length === 0 || data !== undefined, + isLoading, + isError, + isFetching, + refetch, + }; } diff --git a/apps/web/core/state/entity-side-panel-active-tab.test.tsx b/apps/web/core/state/entity-side-panel-active-tab.test.tsx new file mode 100644 index 0000000000..6cec709857 --- /dev/null +++ b/apps/web/core/state/entity-side-panel-active-tab.test.tsx @@ -0,0 +1,67 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { EntitySidePanelActiveTabProvider, useEntitySidePanelActiveTab } from './entity-side-panel-active-tab'; + +const AUTHORED_TAB_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +function Probe() { + const tabs = useEntitySidePanelActiveTab(); + if (!tabs) throw new Error('Probe requires an active-tab provider'); + + return ( + <> + {tabs.activeTabId ?? 'none'} + {tabs.activeSystemTab ?? 'none'} + + + + ); +} + +function ScopedProbe({ entityId, spaceId }: { entityId: string; spaceId: string }) { + return ( + + + + ); +} + +describe('EntitySidePanelActiveTabProvider', () => { + afterEach(cleanup); + + it('retains the selection while the entity and effective space stay the same', () => { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Select authored' })); + + view.rerender(); + + expect(screen.getByTestId('authored-tab')).toHaveTextContent(AUTHORED_TAB_ID); + }); + + it('clears authored state before rendering the same entity in a different effective space', () => { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Select authored' })); + + view.rerender(); + + expect(screen.getByTestId('authored-tab')).toHaveTextContent('none'); + expect(screen.getByTestId('system-tab')).toHaveTextContent('none'); + }); + + it('clears system state before rendering a different entity', () => { + const view = render(); + fireEvent.click(screen.getByRole('button', { name: 'Select claims' })); + + view.rerender(); + + expect(screen.getByTestId('authored-tab')).toHaveTextContent('none'); + expect(screen.getByTestId('system-tab')).toHaveTextContent('none'); + }); +}); diff --git a/apps/web/core/state/entity-side-panel-active-tab.tsx b/apps/web/core/state/entity-side-panel-active-tab.tsx index 64b7a35220..28155bf786 100644 --- a/apps/web/core/state/entity-side-panel-active-tab.tsx +++ b/apps/web/core/state/entity-side-panel-active-tab.tsx @@ -6,32 +6,53 @@ import { validateEntityId } from '~/core/utils/utils'; export type EntitySidePanelActiveTabContextValue = { activeTabId: string | null; + activeSystemTab: string | null; setActiveTabId: (tabId: string | null) => void; + setActiveSystemTab: (tab: string) => void; }; export const EntitySidePanelActiveTabContext = React.createContext(null); export function EntitySidePanelActiveTabProvider({ entityId, + spaceId, children, }: { entityId: string; + spaceId: string; children: React.ReactNode; }) { - const [activeTabId, setActiveTabId] = React.useState(null); + return ( + + {children} + + ); +} - React.useEffect(() => { - setActiveTabId(null); - }, [entityId]); +/** A keyed boundary synchronously clears selections before children render in a new entity scope. */ +function ScopedEntitySidePanelActiveTabProvider({ children }: { children: React.ReactNode }) { + const [activeTabId, setActiveTabId] = React.useState(null); + const [activeSystemTab, setActiveSystemTab] = React.useState(null); const setActiveTabIdValidated = React.useCallback((tabId: string | null) => { if (tabId !== null && !validateEntityId(tabId)) return; setActiveTabId(tabId); + setActiveSystemTab(null); + }, []); + + const selectSystemTab = React.useCallback((tab: string) => { + setActiveTabId(null); + setActiveSystemTab(tab); }, []); const value = React.useMemo( - () => ({ activeTabId, setActiveTabId: setActiveTabIdValidated }), - [activeTabId, setActiveTabIdValidated] + () => ({ + activeTabId, + activeSystemTab, + setActiveTabId: setActiveTabIdValidated, + setActiveSystemTab: selectSystemTab, + }), + [activeSystemTab, activeTabId, selectSystemTab, setActiveTabIdValidated] ); return {children}; diff --git a/apps/web/core/sync/collect-cursor-pages.test.ts b/apps/web/core/sync/collect-cursor-pages.test.ts new file mode 100644 index 0000000000..d9337d2e52 --- /dev/null +++ b/apps/web/core/sync/collect-cursor-pages.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { cloneCursorPageCheckpoint, collectCursorPages, createCursorPageCheckpoint } from './collect-cursor-pages'; + +describe('collectCursorPages', () => { + it('collects every page in cursor order', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: ['a', 'b'], endCursor: 'cursor-b', hasNextPage: true }) + .mockResolvedValueOnce({ items: ['c'], endCursor: 'cursor-c', hasNextPage: true }) + .mockResolvedValueOnce({ items: ['d'], endCursor: null, hasNextPage: false }); + + await expect(collectCursorPages(fetchPage)).resolves.toEqual(['a', 'b', 'c', 'd']); + expect(fetchPage.mock.calls).toEqual([[undefined], ['cursor-b'], ['cursor-c']]); + }); + + it('rejects a next page without a usable cursor', async () => { + const fetchPage = vi.fn().mockResolvedValue({ items: ['a'], endCursor: null, hasNextPage: true }); + + await expect(collectCursorPages(fetchPage)).rejects.toThrow('no end cursor'); + }); + + it('rejects a repeated cursor instead of looping forever', async () => { + const fetchPage = vi.fn().mockResolvedValue({ items: ['a'], endCursor: 'same', hasNextPage: true }); + + await expect(collectCursorPages(fetchPage)).rejects.toThrow('repeated its end cursor'); + expect(fetchPage).toHaveBeenCalledTimes(2); + }); + + it('resumes at the failed cursor without replaying successful pages', async () => { + const checkpoint = createCursorPageCheckpoint(); + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: ['a'], endCursor: 'cursor-a', hasNextPage: true }) + .mockRejectedValueOnce(new Error('page two failed')) + .mockResolvedValueOnce({ items: ['b'], endCursor: null, hasNextPage: false }); + + await expect(collectCursorPages(fetchPage, checkpoint)).rejects.toThrow('page two failed'); + await expect(collectCursorPages(fetchPage, checkpoint)).resolves.toEqual(['a', 'b']); + expect(fetchPage.mock.calls).toEqual([[undefined], ['cursor-a'], ['cursor-a']]); + }); + + it('does not checkpoint a page whose cursor contract is invalid', async () => { + const checkpoint = createCursorPageCheckpoint(); + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ items: ['invalid'], endCursor: null, hasNextPage: true }) + .mockResolvedValueOnce({ items: ['valid'], endCursor: null, hasNextPage: false }); + + await expect(collectCursorPages(fetchPage, checkpoint)).rejects.toThrow('no end cursor'); + await expect(collectCursorPages(fetchPage, checkpoint)).resolves.toEqual(['valid']); + }); + + it('clones mutable retry progress for one query execution', () => { + const retained = { + items: ['a'], + seenCursors: new Set(['cursor-a']), + after: 'cursor-a', + }; + const execution = cloneCursorPageCheckpoint(retained); + + execution.items.push('b'); + execution.seenCursors.add('cursor-b'); + execution.after = 'cursor-b'; + + expect(retained).toEqual({ + items: ['a'], + seenCursors: new Set(['cursor-a']), + after: 'cursor-a', + }); + }); +}); diff --git a/apps/web/core/sync/collect-cursor-pages.ts b/apps/web/core/sync/collect-cursor-pages.ts new file mode 100644 index 0000000000..ffc1e9c0d7 --- /dev/null +++ b/apps/web/core/sync/collect-cursor-pages.ts @@ -0,0 +1,54 @@ +export type CursorPage = { + items: readonly T[]; + endCursor: string | null; + hasNextPage: boolean; +}; + +export type CursorPageCheckpoint = { + items: T[]; + seenCursors: Set; + after: string | undefined; +}; + +/** Mutable progress that lets a retried cursor walk resume at its failed page. */ +export function createCursorPageCheckpoint(): CursorPageCheckpoint { + return { items: [], seenCursors: new Set(), after: undefined }; +} + +/** Give each query execution private mutable progress while retaining the last safe retry point. */ +export function cloneCursorPageCheckpoint(checkpoint: CursorPageCheckpoint): CursorPageCheckpoint { + return { + items: [...checkpoint.items], + seenCursors: new Set(checkpoint.seenCursors), + after: checkpoint.after, + }; +} + +/** + * Exhaust a forward-only cursor connection without silently accepting a broken cursor chain. + * + * A connection that says another page exists must provide a new cursor. Throwing on a missing or + * repeated cursor turns an incomplete record into a visible query error instead of an infinite + * request loop or a plausible-looking truncated total. + */ +export async function collectCursorPages( + fetchPage: (after: string | undefined) => Promise>, + checkpoint = createCursorPageCheckpoint() +): Promise { + while (true) { + const page = await fetchPage(checkpoint.after); + + if (!page.hasNextPage) { + checkpoint.items.push(...page.items); + return checkpoint.items; + } + + const nextCursor = page.endCursor; + if (!nextCursor) throw new Error('Cursor connection has a next page but no end cursor'); + if (checkpoint.seenCursors.has(nextCursor)) throw new Error('Cursor connection repeated its end cursor'); + + checkpoint.items.push(...page.items); + checkpoint.seenCursors.add(nextCursor); + checkpoint.after = nextCursor; + } +} diff --git a/apps/web/core/sync/orm.sync-many.test.ts b/apps/web/core/sync/orm.sync-many.test.ts index dbce404b67..53b7b85981 100644 --- a/apps/web/core/sync/orm.sync-many.test.ts +++ b/apps/web/core/sync/orm.sync-many.test.ts @@ -107,6 +107,37 @@ describe('E.syncMany pagination', () => { expect(result.merged.map(e => e.id)).toEqual(['entity-c']); }); + it('forwards empty-name inclusion and cancellation to exhaustive page reads', async () => { + const signal = new AbortController().signal; + vi.mocked(getAllEntities).mockReturnValue(Effect.succeed({ entities: [], endCursor: null, hasNextPage: false })); + + await E.syncMany({ store, cache, where: {}, first: 9, includeEmptyNames: true, signal }); + + expect(vi.mocked(getAllEntities)).toHaveBeenCalledWith(expect.objectContaining({ filter: {} }), signal); + }); + + it('forwards empty-name inclusion and cancellation to sorted page reads', async () => { + const signal = new AbortController().signal; + vi.mocked(getEntitiesOrderedByPropertyConnection).mockReturnValue( + Effect.succeed({ entities: [], endCursor: null, hasNextPage: false }) + ); + + await E.syncMany({ + store, + cache, + where: {}, + first: 9, + includeEmptyNames: true, + signal, + sort: { propertyId: 'score-property', direction: 'desc' }, + }); + + expect(vi.mocked(getEntitiesOrderedByPropertyConnection)).toHaveBeenCalledWith( + expect.objectContaining({ filter: {} }), + signal + ); + }); + it('hydrates large id.in queries in bounded batches while preserving requested order', async () => { const ids = Array.from({ length: 117 }, (_, index) => `entity-${index}`); vi.mocked(getBatchEntities).mockImplementation((batchIds: string[]) => @@ -147,7 +178,8 @@ describe('E.syncMany pagination', () => { }); expect(vi.mocked(getEntitiesOrderedByPropertyConnection)).toHaveBeenCalledWith( - expect.objectContaining({ includeWithoutValue: true }) + expect.objectContaining({ includeWithoutValue: true }), + undefined ); }); }); diff --git a/apps/web/core/sync/orm.ts b/apps/web/core/sync/orm.ts index d506616792..a19164f653 100644 --- a/apps/web/core/sync/orm.ts +++ b/apps/web/core/sync/orm.ts @@ -386,6 +386,8 @@ export class E { spaceId, sort, orderBy, + includeEmptyNames, + signal, }: { store: GeoStore; cache: QueryClient; @@ -396,24 +398,29 @@ export class E { spaceId?: string; sort?: { propertyId: string; direction: 'asc' | 'desc'; dataType?: string; includeWithoutValue?: boolean }; orderBy?: EntitiesOrderBy[]; + includeEmptyNames?: boolean; + signal?: AbortController['signal']; }): Promise<{ merged: Entity[]; remote: Entity[]; endCursor: string | null; hasNextPage: boolean }> { if (where?.id?.in) { const entityIds = where.id.in.filter(id => id !== ''); if (sort) { - const filter = convertWhereConditionToEntityFilter(where); + const filter = convertWhereConditionToEntityFilter(where, { includeEmptyNames }); const page = await Effect.runPromise( - getEntitiesOrderedByPropertyConnection({ - propertyId: sort.propertyId, - sortDirection: sort.direction === 'asc' ? SortOrder.Asc : SortOrder.Desc, - dataType: sort.dataType, - includeWithoutValue: sort.includeWithoutValue, - spaceId, - limit: first, - after, - offset, - filter, - }) + getEntitiesOrderedByPropertyConnection( + { + propertyId: sort.propertyId, + sortDirection: sort.direction === 'asc' ? SortOrder.Asc : SortOrder.Desc, + dataType: sort.dataType, + includeWithoutValue: sort.includeWithoutValue, + spaceId, + limit: first, + after, + offset, + filter, + }, + signal + ) ); const remoteEntities = page.entities; @@ -453,32 +460,38 @@ export class E { return { merged: nonNullEntities, remote: remoteEntities, endCursor: null, hasNextPage: false }; } - const filter = convertWhereConditionToEntityFilter(where); + const filter = convertWhereConditionToEntityFilter(where, { includeEmptyNames }); const typeIds = extractTypeIdsFromWhere(where); const page = sort ? await Effect.runPromise( - getEntitiesOrderedByPropertyConnection({ - propertyId: sort.propertyId, - sortDirection: sort.direction === 'asc' ? SortOrder.Asc : SortOrder.Desc, - dataType: sort.dataType, - includeWithoutValue: sort.includeWithoutValue, - spaceId, - limit: first, - after, - offset, - filter, - }) + getEntitiesOrderedByPropertyConnection( + { + propertyId: sort.propertyId, + sortDirection: sort.direction === 'asc' ? SortOrder.Asc : SortOrder.Desc, + dataType: sort.dataType, + includeWithoutValue: sort.includeWithoutValue, + spaceId, + limit: first, + after, + offset, + filter, + }, + signal + ) ) : await Effect.runPromise( - getAllEntities({ - limit: first, - after, - offset, - filter, - typeIds, - orderBy, - }) + getAllEntities( + { + limit: first, + after, + offset, + filter, + typeIds, + orderBy, + }, + signal + ) ); const remoteEntities = page.entities; diff --git a/apps/web/core/sync/use-query-all-entities.test.tsx b/apps/web/core/sync/use-query-all-entities.test.tsx new file mode 100644 index 0000000000..64cfdf26ad --- /dev/null +++ b/apps/web/core/sync/use-query-all-entities.test.tsx @@ -0,0 +1,145 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; + +import type { ReactNode } from 'react'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Entity } from '../types'; +import { useQueryAllEntities } from './use-store'; + +const entity = { + id: 'entity-1', + name: null, + description: null, + spaces: ['space-1'], + types: [], + relations: [], + values: [], +} as Entity; + +const mocks = vi.hoisted(() => ({ + syncMany: vi.fn(), + getEntity: vi.fn(), + emit: vi.fn(), +})); + +vi.mock('./orm', async importOriginal => { + const original = await importOriginal(); + return { ...original, E: { ...original.E, syncMany: mocks.syncMany } }; +}); +vi.mock('./use-sync-engine', () => ({ + useSyncEngine: () => ({ store: { getEntity: mocks.getEntity }, stream: { emit: mocks.emit } }), +})); + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return {children}; +} + +function automaticRetryWrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ defaultOptions: { queries: { retry: 1, retryDelay: 0 } } }); + return {children}; +} + +describe('useQueryAllEntities', () => { + beforeEach(() => { + mocks.syncMany.mockReset(); + mocks.getEntity.mockReset(); + mocks.emit.mockReset(); + mocks.getEntity.mockReturnValue(entity); + }); + + it('passes empty-name inclusion and the React Query cancellation signal through every page', async () => { + mocks.syncMany.mockResolvedValue({ + merged: [entity], + remote: [entity], + endCursor: null, + hasNextPage: false, + }); + + const { result } = renderHook(() => useQueryAllEntities({ where: {}, includeEmptyNames: true }), { wrapper }); + await waitFor(() => expect(result.current.isFetched).toBe(true)); + + expect(mocks.syncMany).toHaveBeenCalledWith( + expect.objectContaining({ + includeEmptyNames: true, + signal: expect.any(AbortSignal), + }) + ); + expect(result.current.entities).toEqual([entity]); + }); + + it('aborts an exhaustive walk when its last observer unmounts', async () => { + let signal: AbortSignal | undefined; + mocks.syncMany.mockImplementation( + ({ signal: querySignal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + signal = querySignal; + querySignal.addEventListener('abort', () => reject(querySignal.reason)); + }) + ); + + const hook = renderHook(() => useQueryAllEntities({ where: {} }), { wrapper }); + await waitFor(() => expect(signal).toBeDefined()); + hook.unmount(); + + expect(signal?.aborted).toBe(true); + }); + + it('reports cached entities as available after a background refresh fails', async () => { + mocks.syncMany.mockResolvedValueOnce({ + merged: [entity], + remote: [entity], + endCursor: null, + hasNextPage: false, + }); + + const { result } = renderHook(() => useQueryAllEntities({ where: {} }), { wrapper }); + await waitFor(() => expect(result.current.entities).toEqual([entity])); + + mocks.syncMany.mockRejectedValueOnce(new Error('refresh failed')); + await act(async () => void (await result.current.refetch())); + + await waitFor(() => expect(result.current.error).toEqual(new Error('refresh failed'))); + expect(result.current.entities).toEqual([entity]); + expect(result.current.dataAvailable).toBe(true); + }); + + it('starts a manual retry from page one instead of reusing an old cursor', async () => { + mocks.syncMany + .mockResolvedValueOnce({ merged: [entity], remote: [entity], endCursor: 'cursor-a', hasNextPage: true }) + .mockRejectedValueOnce(new Error('page two failed')) + .mockResolvedValueOnce({ merged: [entity], remote: [entity], endCursor: 'fresh-a', hasNextPage: true }) + .mockResolvedValueOnce({ merged: [entity], remote: [entity], endCursor: null, hasNextPage: false }); + + const { result } = renderHook(() => useQueryAllEntities({ where: {} }), { wrapper }); + await waitFor(() => expect(result.current.error).toEqual(new Error('page two failed'))); + + await act(async () => void (await result.current.refetch())); + await waitFor(() => expect(result.current.error).toBeNull()); + + expect(mocks.syncMany.mock.calls.map(([options]) => options.after)).toEqual([ + undefined, + 'cursor-a', + undefined, + 'fresh-a', + ]); + }); + + it('resumes an automatic retry at the failed cursor within the same attempt', async () => { + mocks.syncMany + .mockResolvedValueOnce({ merged: [entity], remote: [entity], endCursor: 'cursor-a', hasNextPage: true }) + .mockRejectedValueOnce(new Error('transient page failure')) + .mockResolvedValueOnce({ merged: [entity], remote: [entity], endCursor: null, hasNextPage: false }); + + const { result } = renderHook(() => useQueryAllEntities({ where: {} }), { wrapper: automaticRetryWrapper }); + await waitFor(() => expect(result.current.isFetched).toBe(true)); + + expect(mocks.syncMany.mock.calls.map(([options]) => options.after)).toEqual([ + undefined, + 'cursor-a', + 'cursor-a', + ]); + }); +}); diff --git a/apps/web/core/sync/use-store.tsx b/apps/web/core/sync/use-store.tsx index bef63e614d..d443e9e3c8 100644 --- a/apps/web/core/sync/use-store.tsx +++ b/apps/web/core/sync/use-store.tsx @@ -16,6 +16,7 @@ import { Entity, Property, Relation, Value } from '../types'; import { Properties } from '../utils/property'; // @TODO replace with Values.merge() import { merge } from '../utils/value/values'; +import { cloneCursorPageCheckpoint, collectCursorPages, createCursorPageCheckpoint } from './collect-cursor-pages'; import { EntityQuery, WhereCondition } from './experimental_query-layer'; import { hydrateEntityBatched } from './hydrate-entity-batcher'; import { E, mergeRelations } from './orm'; @@ -84,6 +85,11 @@ function localEntityLatestTimestamp(entity: Entity): string { return latest; } +/** Store lookups can miss with either nullish spelling; never narrow only one of them. */ +function isMaterializedEntity(entity: Entity | null | undefined): entity is Entity { + return entity != null; +} + /** * Prepend unpublished local entities that match the query filter but are not yet in the server page */ @@ -97,7 +103,7 @@ function mergeUnpublishedLocalEntities( const localIds = getUnpublishedLocalEntityIds().filter(id => !serverIdSet.has(id)); if (localIds.length === 0) return serverEntities; - const localEntities = localIds.map(id => store.getEntity(id)).filter((e): e is Entity => e !== null); + const localEntities = localIds.map(id => store.getEntity(id)).filter(isMaterializedEntity); if (localEntities.length === 0) return serverEntities; @@ -429,7 +435,7 @@ export function useQueryEntities({ // must fall through to the server-filtered `data.ids`. if (where?.id?.in && !sort && Object.keys(where).length === 1) { const ids = where.id.in; - const entities = ids.map(id => store.getEntity(id)).filter((e): e is Entity => e != null); + const entities = ids.map(id => store.getEntity(id)).filter(isMaterializedEntity); return first !== undefined ? entities.slice(0, first) : entities; } @@ -437,7 +443,7 @@ export function useQueryEntities({ // from the server-returned ids; store.getEntity still picks up local // edits. Falls through to a local EntityQuery only before first fetch. if (data?.ids) { - const serverEntities = data.ids.map(id => store.getEntity(id)).filter((e): e is Entity => e !== null); + const serverEntities = data.ids.map(id => store.getEntity(id)).filter(isMaterializedEntity); // Cursor-anchored fetches of later pages arrive as (after, offset: // undefined), so a missing offset alone does not mean page one. @@ -486,6 +492,110 @@ export function useQueryEntities({ }; } +type QueryAllEntitiesOptions = { + where: WhereCondition; + pageSize?: number; + enabled?: boolean; + orderBy?: EntitiesOrderBy[]; + /** Skip the query layer's default non-empty-name filter when unnamed entities are meaningful. */ + includeEmptyNames?: boolean; +}; + +/** + * Fetches a complete cursor connection before exposing its entities. + * + * Use this only when the caller explicitly needs the whole connection and can activate it lazily. + * Summary/list screens should use a server count plus a bounded page, or `useQueryEntities` with + * pagination; exhausting a broad relation filter during initial render scales with the graph. + */ +export function useQueryAllEntities({ + where, + pageSize = 100, + enabled = true, + orderBy, + includeEmptyNames = false, +}: QueryAllEntitiesOptions) { + const cache = useQueryClient(); + const { store, stream } = useSyncEngine(); + const querySignature = `${stableStringify(where)}:${pageSize}:${stableStringify(orderBy ?? null)}:${includeEmptyNames}`; + const progressRef = React.useRef({ signature: querySignature, checkpoint: createCursorPageCheckpoint() }); + const resetProgress = React.useCallback(() => { + progressRef.current = { signature: querySignature, checkpoint: createCursorPageCheckpoint() }; + }, [querySignature]); + + const { data, isFetched, isLoading, isFetching, error, refetch: refetchQuery } = useQuery({ + enabled, + queryKey: ['store', 'all-entities', stableStringify(where), pageSize, orderBy ?? null, includeEmptyNames], + queryFn: async ({ signal }) => { + if (progressRef.current.signature !== querySignature) { + resetProgress(); + } + + const checkpoint = cloneCursorPageCheckpoint(progressRef.current.checkpoint); + let ids: string[]; + try { + ids = await collectCursorPages(async after => { + const page = await E.syncMany({ + store, + cache, + where, + first: pageSize, + after, + orderBy, + includeEmptyNames, + signal, + }); + stream.emit({ type: GeoEventStream.ENTITIES_SYNCED, entities: page.merged, remoteEntities: page.remote }); + + return { + items: page.merged.map(entity => entity.id), + endCursor: page.endCursor, + hasNextPage: page.hasNextPage, + }; + }, checkpoint); + } catch (cause) { + // Automatic retries resume this attempt. An execution cancelled by a newer refetch cannot + // overwrite that newer execution's progress with its stale checkpoint. + if (!signal.aborted) progressRef.current = { signature: querySignature, checkpoint }; + throw cause; + } + + if (!signal.aborted) resetProgress(); + + return { ids: [...new Set(ids)] }; + }, + }); + + // A user retry or a later background refetch is a new snapshot, not another automatic attempt. + React.useEffect(() => { + if (error) resetProgress(); + }, [error, resetProgress]); + const refetch = React.useCallback(() => { + resetProgress(); + return refetchQuery(); + }, [refetchQuery, resetProgress]); + + const entities = useSelector( + reactive, + () => { + if (!enabled || !data) return []; + return data.ids.map(id => store.getEntity(id)).filter(isMaterializedEntity); + }, + equal + ); + + return { + entities, + /** Cached results remain usable when a later background refresh fails. */ + dataAvailable: !enabled || data !== undefined, + isLoading: !isFetched && enabled && isLoading, + isFetching: enabled && isFetching, + isFetched: isFetched && enabled, + error, + refetch, + }; +} + export function useQueryProperty({ id, spaceId, enabled = true }: QueryEntityOptions) { const { store } = useSyncEngine(); diff --git a/apps/web/core/utils/dedupe-relations.test.ts b/apps/web/core/utils/dedupe-relations.test.ts new file mode 100644 index 0000000000..63ea699a1c --- /dev/null +++ b/apps/web/core/utils/dedupe-relations.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import type { Relation } from '~/core/types'; + +import { dedupeRelationsByToEntityId } from './dedupe-relations'; + +function relation(id: string, targetId: string) { + return { id, toEntity: { id: targetId } } as Pick; +} + +describe('dedupeRelationsByToEntityId', () => { + it('keeps the first target when UUID spellings differ only by formatting', () => { + const first = relation('relation-1', 'AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA'); + const duplicate = relation('relation-2', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + const other = relation('relation-3', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + + expect(dedupeRelationsByToEntityId([first, duplicate, other])).toEqual([first, other]); + }); +}); diff --git a/apps/web/core/utils/dedupe-relations.ts b/apps/web/core/utils/dedupe-relations.ts index 124d6d490f..09f7ee86db 100644 --- a/apps/web/core/utils/dedupe-relations.ts +++ b/apps/web/core/utils/dedupe-relations.ts @@ -1,14 +1,16 @@ import { Relation } from '~/core/types'; +import { normId } from '~/core/utils/norm-id'; export function dedupeRelationsByToEntityId>(relations: T[]): T[] { const seen = new Set(); return relations.filter(relation => { - if (seen.has(relation.toEntity.id)) { + const key = normId(relation.toEntity.id); + if (seen.has(key)) { return false; } - seen.add(relation.toEntity.id); + seen.add(key); return true; }); } diff --git a/apps/web/core/utils/entity-browse-view.test.ts b/apps/web/core/utils/entity-browse-view.test.ts new file mode 100644 index 0000000000..5c75d61b04 --- /dev/null +++ b/apps/web/core/utils/entity-browse-view.test.ts @@ -0,0 +1,28 @@ +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; + +import { describe, expect, it } from 'vitest'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { TOPIC_TYPE_ID } from '~/core/constants'; + +import { entityBrowseViewFromTypes } from './entity-browse-view'; + +describe('entityBrowseViewFromTypes', () => { + it('gives Claim precedence over Topic and Person', () => { + expect( + entityBrowseViewFromTypes([{ id: SystemIds.PERSON_TYPE }, { id: TOPIC_TYPE_ID }, { id: CLAIM_TYPE_ID }]) + ).toBe('claim'); + }); + + it('gives Topic precedence over Person', () => { + expect(entityBrowseViewFromTypes([{ id: SystemIds.PERSON_TYPE }, { id: TOPIC_TYPE_ID }])).toBe('topic'); + }); + + it('recognizes equivalent UUID formatting', () => { + expect(entityBrowseViewFromTypes([{ id: CLAIM_TYPE_ID.replaceAll('-', '') }])).toBe('claim'); + }); + + it('returns null when no custom view applies', () => { + expect(entityBrowseViewFromTypes([{ id: SystemIds.PAGE_TYPE }])).toBeNull(); + }); +}); diff --git a/apps/web/core/utils/entity-browse-view.ts b/apps/web/core/utils/entity-browse-view.ts new file mode 100644 index 0000000000..e248172577 --- /dev/null +++ b/apps/web/core/utils/entity-browse-view.ts @@ -0,0 +1,22 @@ +import { SystemIds } from '@geoprotocol/geo-sdk/lite'; + +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { TOPIC_TYPE_ID } from '~/core/constants'; +import { ID } from '~/core/id'; + +export type EntityBrowseView = 'claim' | 'topic' | 'person' | null; + +/** + * The custom read surface an entity's types put it in line for. + * + * This precedence is shared by the client body and the server route/layout. Keeping it here avoids + * a mixed-type entity being classified as a Claim in one layer and wrapped in a Person profile in + * another. Claim and Topic are narrower record types, so both take precedence over Person. + */ +export function entityBrowseViewFromTypes(types: readonly { id: string }[]): EntityBrowseView { + if (types.some(type => ID.equals(type.id, CLAIM_TYPE_ID))) return 'claim'; + if (types.some(type => ID.equals(type.id, TOPIC_TYPE_ID))) return 'topic'; + if (types.some(type => ID.equals(type.id, SystemIds.PERSON_TYPE))) return 'person'; + + return null; +} diff --git a/apps/web/core/utils/entity-tab-navigation.test.ts b/apps/web/core/utils/entity-tab-navigation.test.ts new file mode 100644 index 0000000000..79f8749544 --- /dev/null +++ b/apps/web/core/utils/entity-tab-navigation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { entityTabIdFromHref, isEntityTabActive } from './entity-tab-navigation'; + +const TAB_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +describe('entity tab navigation', () => { + it('reads valid authored tab ids and rejects unrelated query strings', () => { + expect(entityTabIdFromHref(`/claim?tabId=${TAB_ID}`)).toBe(TAB_ID); + expect(entityTabIdFromHref('/claim?filter=recent')).toBeNull(); + expect(entityTabIdFromHref('/claim?tabId=not-an-id')).toBeNull(); + }); + + it('matches the exact tabId parameter instead of text in another key or value', () => { + expect(entityTabIdFromHref(`/claim?notabId=${TAB_ID}`)).toBeNull(); + expect(entityTabIdFromHref(`/claim?redirect=tabId%3D${TAB_ID}`)).toBeNull(); + }); + + it('parses tabId in any query position without including the fragment', () => { + expect(entityTabIdFromHref(`/claim?filter=recent&tabId=${TAB_ID}#activity`)).toBe(TAB_ID); + }); + + it('uses the first tabId value when the parameter is repeated', () => { + const secondTabId = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + + expect(entityTabIdFromHref(`/claim?tabId=${TAB_ID}&tabId=${secondTabId}`)).toBe(TAB_ID); + }); + + it('uses the route path outside the side panel', () => { + expect( + isEntityTabActive({ + href: '/claim/debates', + activeTabId: null, + fullPath: '/claim/debates', + sidePanel: false, + }) + ).toBe(true); + }); + + it('keeps system and authored side-panel tabs mutually exclusive', () => { + expect( + isEntityTabActive({ + href: '/claim/sources', + activeTabId: null, + fullPath: '/claim', + sidePanel: true, + sidePanelKey: 'sources', + activeSystemTab: 'sources', + }) + ).toBe(true); + + expect( + isEntityTabActive({ + href: `/claim?tabId=${TAB_ID}`, + activeTabId: TAB_ID, + fullPath: '/claim', + sidePanel: true, + activeSystemTab: null, + }) + ).toBe(true); + + expect( + isEntityTabActive({ + href: '/claim', + activeTabId: TAB_ID, + fullPath: '/claim', + sidePanel: true, + sidePanelKey: 'overview', + activeSystemTab: null, + }) + ).toBe(false); + }); +}); diff --git a/apps/web/core/utils/entity-tab-navigation.ts b/apps/web/core/utils/entity-tab-navigation.ts new file mode 100644 index 0000000000..caaa3bcfa9 --- /dev/null +++ b/apps/web/core/utils/entity-tab-navigation.ts @@ -0,0 +1,39 @@ +import { validateEntityId } from './utils'; + +export function entityTabIdFromHref(href: string): string | null { + const queryStart = href.indexOf('?'); + if (queryStart === -1) return null; + + const fragmentStart = href.indexOf('#', queryStart); + const query = href.slice(queryStart + 1, fragmentStart === -1 ? undefined : fragmentStart); + const tabId = new URLSearchParams(query).get('tabId'); + return tabId && validateEntityId(tabId) ? tabId : null; +} + +/** One active-tab rule for read and edit tab bars, on routes and in the entity side panel. */ +export function isEntityTabActive({ + href, + activeTabId, + fullPath, + sidePanel, + sidePanelKey, + activeSystemTab, +}: { + href: string; + activeTabId: string | null; + fullPath: string; + sidePanel: boolean; + sidePanelKey?: string; + activeSystemTab?: string | null; +}): boolean { + if (!sidePanel) return href === fullPath; + if (sidePanelKey) { + // A null system tab means Overview only when no authored tab owns the selection. Treating every + // null as Overview made both markers active while an authored side-panel tab was selected. + const selectedSystemTab = activeSystemTab ?? (activeTabId === null ? 'overview' : null); + return selectedSystemTab === sidePanelKey; + } + + const hrefTabId = entityTabIdFromHref(href); + return hrefTabId === null ? activeTabId === null : activeTabId === hrefTabId; +} diff --git a/apps/web/design-system/tab-group.tsx b/apps/web/design-system/tab-group.tsx index d226c41ee6..242c7764bc 100644 --- a/apps/web/design-system/tab-group.tsx +++ b/apps/web/design-system/tab-group.tsx @@ -10,31 +10,107 @@ import { usePathname } from 'next/navigation'; import { useEditable } from '~/core/state/editable-store'; import { useActiveTabIdForEditor } from '~/core/state/editor/editor-provider'; import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-active-tab'; -import { validateEntityId } from '~/core/utils/utils'; +import { entityTabIdFromHref, isEntityTabActive } from '~/core/utils/entity-tab-navigation'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +export type TabGroupTab = { + href: string; + label: string; + badge?: string; + disabled?: boolean; + hidden?: boolean; + /** In-place product tab key used when this tab group renders in an entity side panel. */ + sidePanelKey?: string; + /** Draws a rule before this tab, marking where one group of tabs ends and another begins. */ + dividerBefore?: boolean; + /** + * Only shown where the side rail is not. + * + * Breakpoints here are desktop-first (`lg` is max-width 1023px), and + * `StickySideRail` drops itself at exactly that width — so a tab reaching + * the rail's content appears precisely when the rail stops being there. + */ + onlyWhenNarrow?: boolean; +}; + interface TabGroupProps { - tabs: Array<{ - href: string; - label: string; - badge?: string; - disabled?: boolean; - hidden?: boolean; - /** Draws a rule before this tab, marking where one group of tabs ends and another begins. */ - dividerBefore?: boolean; - /** - * Only shown where the side rail is not. - * - * Breakpoints here are desktop-first (`lg` is max-width 1023px), and - * `StickySideRail` drops itself at exactly that width — so a tab reaching - * the rail's content appears precisely when the rail stops being there. - */ - onlyWhenNarrow?: boolean; - }>; + tabs: TabGroupTab[]; className?: string; } +export type ActiveTabIndicatorPosition = { left: number; width: number }; + +/** + * Measures one active tab for a row-owned indicator. + * + * Keeping the marker outside the tab links means route changes only animate its horizontal + * position and width. A layout marker inside each link can also interpolate the page's vertical + * scroll offset and travel through the labels when Next mounts the destination route. + */ +export function useActiveTabIndicator(layoutKey: unknown) { + const activeTabElement = useRef(null); + const activeTabObserver = useRef(null); + const [indicator, setIndicator] = useState(null); + + const measureActiveTab = React.useCallback(() => { + const element = activeTabElement.current; + if (!element) { + setIndicator(null); + return; + } + + setIndicator(previous => { + const next = { left: element.offsetLeft, width: element.offsetWidth }; + return previous?.left === next.left && previous.width === next.width ? previous : next; + }); + }, []); + + const registerActiveTab = React.useCallback( + (element: HTMLElement | null) => { + activeTabObserver.current?.disconnect(); + activeTabObserver.current = null; + activeTabElement.current = element; + measureActiveTab(); + + if (element && typeof ResizeObserver !== 'undefined') { + activeTabObserver.current = new ResizeObserver(measureActiveTab); + activeTabObserver.current.observe(element); + } + }, + [measureActiveTab] + ); + + // Re-measure when the row's tabs settle or responsive tabs appear. The active element itself is + // observed from its ref callback, so switching active tabs also moves the observer immediately. + React.useLayoutEffect(() => measureActiveTab(), [layoutKey, measureActiveTab]); + + useEffect(() => { + window.addEventListener('resize', measureActiveTab); + return () => { + window.removeEventListener('resize', measureActiveTab); + activeTabObserver.current?.disconnect(); + }; + }, [measureActiveTab]); + + return { indicator, registerActiveTab }; +} + +export function ActiveTabIndicator({ indicator }: { indicator: ActiveTabIndicatorPosition | null }) { + if (!indicator) return null; + + return ( + + ); +} + export function TabGroup({ tabs, className = '' }: TabGroupProps) { const scrollRef = useRef(null); const [scrollPosition, setScrollPosition] = useState<'start' | 'middle' | 'end'>('start'); @@ -43,6 +119,7 @@ export function TabGroup({ tabs, className = '' }: TabGroupProps) { const dragStartX = useRef(0); const scrollStartLeft = useRef(0); const pointerUpHandler = useRef<((e: PointerEvent) => void) | null>(null); + const { indicator, registerActiveTab } = useActiveTabIndicator(tabs); useEffect(() => { const checkScroll = () => { @@ -136,13 +213,30 @@ export function TabGroup({ tabs, className = '' }: TabGroupProps) { {t.dividerBefore && } {t.onlyWhenNarrow ? ( - ) : ( -
@@ -162,6 +256,8 @@ interface TabProps { badge?: React.ReactNode; disabled?: boolean; hidden?: boolean; + sidePanelKey?: string; + activeRef: (element: HTMLElement | null) => void; } /** Shared with entity/space `TabGroup` and governance home tab rows (same underline behavior). */ @@ -184,14 +280,7 @@ export const tabGroupTabLinkStyles = cva( } ); -function tabIdFromEntityTabHref(href: string): string | null { - const idx = href.indexOf('tabId='); - if (idx === -1) return null; - const raw = href.slice(idx + 6).split('&')[0]; - return validateEntityId(raw) ? raw : null; -} - -function Tab({ href, label, badge, disabled, hidden }: TabProps) { +function Tab({ href, label, badge, disabled, hidden, sidePanelKey, activeRef }: TabProps) { const { editable } = useEditable(); const path = usePathname(); @@ -199,11 +288,14 @@ function Tab({ href, label, badge, disabled, hidden }: TabProps) { const sidePanelTab = useEntitySidePanelActiveTab(); const fullPath = activeTabId ? `${path}?tabId=${activeTabId}` : `${path}`; - const active = sidePanelTab - ? tabIdFromEntityTabHref(href) === null - ? activeTabId === null - : activeTabId === tabIdFromEntityTabHref(href) - : href === fullPath; + const active = isEntityTabActive({ + href, + activeTabId, + fullPath, + sidePanel: Boolean(sidePanelTab), + sidePanelKey, + activeSystemTab: sidePanelTab?.activeSystemTab, + }); if (!editable && hidden) { return null; @@ -211,33 +303,27 @@ function Tab({ href, label, badge, disabled, hidden }: TabProps) { if (disabled) { return ( -
+
{label} {badge && {badge}}
); } - const hrefTabId = tabIdFromEntityTabHref(href); + const hrefTabId = entityTabIdFromHref(href); if (sidePanelTab) { return ( ); } @@ -248,24 +334,16 @@ function Tab({ href, label, badge, disabled, hidden }: TabProps) { // preserving the offset for all of them lands a reader who switched tabs // near the bottom of a long list somewhere past the end of a shorter one. // - // What it was added for is real — Next's jump to the top makes the underline - // fly up through the label, because the shared-layout animation measures the - // marker before and after and animates through the scroll delta. That wants - // scrolling *to the tab bar* rather than to the top or not at all, which is - // a behaviour to design alongside the sticky bar in GEO-2923 rather than a - // flag to set here. - + // The underline is one sibling owned by `TabGroup`, animated with x + width only. A shared + // layout marker measured the page's vertical scroll between routes and flew through the label. + {label} {badge && {badge}} - {active && ( - - )} ); } diff --git a/apps/web/partials/entity-page/custom-browse-view.test.ts b/apps/web/partials/entity-page/custom-browse-view.test.ts index 926dfc65bd..5f46f532f4 100644 --- a/apps/web/partials/entity-page/custom-browse-view.test.ts +++ b/apps/web/partials/entity-page/custom-browse-view.test.ts @@ -107,10 +107,13 @@ describe('customBrowseView', () => { expect(view({ entity: { types: [PERSON, CLAIM_TYPE] } })).toBe('claim'); }); - it('falls through to the generic page while editing', () => { - // These are read surfaces with no property editor behind them, so an editor - // who lost the value sheet would have no way to change the entity. + it('keeps the claim surface while editing', () => { + expect(view({ entity: { types: [CLAIM_TYPE] }, isEditing: true })).toBe('claim'); + }); + + it('falls through to the generic page for other custom views while editing', () => { expect(view({ isEditing: true })).toBe('generic'); + expect(view({ entity: { types: [TOPIC_TYPE] }, isEditing: true })).toBe('generic'); }); it('draws nothing until the entity itself is known', () => { diff --git a/apps/web/partials/entity-page/custom-browse-view.ts b/apps/web/partials/entity-page/custom-browse-view.ts index 64b7eb98a7..c47e594e5b 100644 --- a/apps/web/partials/entity-page/custom-browse-view.ts +++ b/apps/web/partials/entity-page/custom-browse-view.ts @@ -31,7 +31,6 @@ export function customBrowseView({ isLoadingSpace: boolean; isEditing: boolean; }): CustomBrowseView { - if (isEditing) return 'generic'; // The types decide which page this is, so until they are known there is no page to draw. Falling // through to the generic one meanwhile rendered the value sheet for a claim or a topic and then // replaced it a moment later, which read as the page loading twice. @@ -40,6 +39,7 @@ export function customBrowseView({ const byType = viewFromTypes(entity); if (byType === 'claim') return 'claim'; + if (isEditing) return 'generic'; if (byType === 'topic') return 'topic'; /* diff --git a/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx b/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx index 89ab2aa603..4a7befa912 100644 --- a/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx +++ b/apps/web/partials/entity-page/editable-entity-cover-avatar-header.tsx @@ -57,6 +57,7 @@ export const EditableCoverAvatarHeader = ({ coverUrl, compact = false, withAvatar = false, + contentInsetClassName, }: { avatarUrl: string | null; /** @@ -65,6 +66,8 @@ export const EditableCoverAvatarHeader = ({ * — a profile — passes the wider with-sidebar width instead. */ contentMaxWidth?: number; + /** Responsive inline padding inside the aligned content column. */ + contentInsetClassName?: string; coverUrl: string | null; compact?: boolean; /** Whether the compact header shows the avatar too — see the `compact` branch. */ @@ -106,7 +109,7 @@ export const EditableCoverAvatarHeader = ({ if (!hasCoverImage && !(withAvatar && hasAvatar)) return null; return ( -
+
{hasCoverImage && (
@@ -150,7 +153,7 @@ export const EditableCoverAvatarHeader = ({ initial={false} animate={layout} transition={TRANSITION} - className={`relative mx-auto w-full ${mobileCoverHeightClass} ${mobileCoverAvatarMarginClass}`} + className={`@container relative mx-auto w-full ${mobileCoverHeightClass} ${mobileCoverAvatarMarginClass}`} > {/* Cover — fixed size, fades in/out. The inner div clips it via overflow-hidden so during the height animation the cover is revealed, not scaled. */} @@ -201,6 +204,7 @@ export const EditableCoverAvatarHeader = ({ // one band of widths. className={cx( 'absolute right-0 left-0 mx-auto flex justify-start', + contentInsetClassName, isWideColumn && 'max-w-[var(--entity-page-with-sidebar-max-width)] lg:max-w-[var(--entity-page-content-max-width)]' )} diff --git a/apps/web/partials/entity-page/editable-entity-page.tsx b/apps/web/partials/entity-page/editable-entity-page.tsx index 8d031d9d33..d89a4932d6 100644 --- a/apps/web/partials/entity-page/editable-entity-page.tsx +++ b/apps/web/partials/entity-page/editable-entity-page.tsx @@ -44,7 +44,6 @@ import { Checkbox, getChecked } from '~/design-system/checkbox'; import { LinkableMediaChip } from '~/design-system/chip'; import { DateField } from '~/design-system/editable-fields/date-field'; import { - PageImageField, PageStringField, PageVideoField, diff --git a/apps/web/partials/entity-page/editable-tab-group.test.tsx b/apps/web/partials/entity-page/editable-tab-group.test.tsx new file mode 100644 index 0000000000..a25dbb89dc --- /dev/null +++ b/apps/web/partials/entity-page/editable-tab-group.test.tsx @@ -0,0 +1,96 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; + +import React from 'react'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { type EditableTab, EditableTabGroup } from './editable-tab-group'; + +const mocks = vi.hoisted(() => ({ + activeTabId: 'tab-1' as string | null, + router: { + push: vi.fn(), + replace: vi.fn(), + prefetch: vi.fn(), + }, +})); + +vi.mock('next/navigation', () => ({ + usePathname: () => '/claim', + useRouter: () => mocks.router, +})); + +vi.mock('~/core/state/editor/editor-provider', () => ({ + useActiveTabIdForEditor: () => mocks.activeTabId, +})); + +vi.mock('~/core/state/entity-side-panel-active-tab', () => ({ + useEntitySidePanelActiveTab: () => null, +})); + +vi.mock('~/core/sync/use-mutate', () => ({ + useMutate: () => ({ + storage: { + entities: { name: { set: vi.fn() } }, + relations: { set: vi.fn(), update: vi.fn(), deleteMany: vi.fn() }, + values: { deleteMany: vi.fn() }, + }, + }), +})); + +vi.mock('~/core/sync/use-store', () => ({ + getRelations: () => [], + getValues: () => [], +})); + +vi.mock('~/design-system/prefetch-link', async () => { + const React = await import('react'); + + return { + PrefetchLink: React.forwardRef< + HTMLAnchorElement, + React.AnchorHTMLAttributes & { prefetch?: boolean } + >(function MockPrefetchLink({ children, href, prefetch, ...props }, ref) { + return ( + + {children} + + ); + }), + }; +}); + +afterEach(cleanup); + +describe('EditableTabGroup active indicator', () => { + it('keeps one row-owned indicator outside the active sortable tab', async () => { + const editableTabs: EditableTab[] = [ + { + relation: { + id: 'relation-1', + entityId: 'relation-entity-1', + spaceId: 'space-1', + position: '1', + } as EditableTab['relation'], + entityId: 'tab-1', + name: 'Authored tab', + href: '/claim?tabId=tab-1', + }, + ]; + + render(); + + // dnd-kit's sortable attributes intentionally give this anchor the rendered role `button` so + // keyboard users can pick it up. Assert both halves of that contract: its accessible role and + // its real navigation target. Querying `link` would not match the DOM rendered in production. + const activeLink = screen.getByRole('button', { name: 'Authored tab' }); + expect(activeLink).toHaveAttribute('href', '/claim?tabId=tab-1'); + + await waitFor(() => expect(document.querySelectorAll('[data-active-tab-indicator]')).toHaveLength(1)); + + const indicator = document.querySelector('[data-active-tab-indicator]'); + expect(activeLink).not.toContainElement(indicator); + expect(indicator?.parentElement).toBe(activeLink.parentElement?.parentElement); + }); +}); diff --git a/apps/web/partials/entity-page/editable-tab-group.tsx b/apps/web/partials/entity-page/editable-tab-group.tsx index a8a37c9d3e..f3c3aa5aad 100644 --- a/apps/web/partials/entity-page/editable-tab-group.tsx +++ b/apps/web/partials/entity-page/editable-tab-group.tsx @@ -17,7 +17,6 @@ import * as Popover from '@radix-ui/react-popover'; import React, { useEffect, useRef, useState } from 'react'; -import { cva } from 'class-variance-authority'; import cx from 'classnames'; import { usePathname, useRouter } from 'next/navigation'; @@ -27,47 +26,22 @@ import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-acti import { useMutate } from '~/core/sync/use-mutate'; import { getRelations, getValues } from '~/core/sync/use-store'; import type { Relation } from '~/core/types'; -import { NavUtils, validateEntityId } from '~/core/utils/utils'; +import { entityTabIdFromHref, isEntityTabActive } from '~/core/utils/entity-tab-navigation'; +import { NavUtils } from '~/core/utils/utils'; import { EditSmall } from '~/design-system/icons/edit-small'; import { ExpandSmall } from '~/design-system/icons/expand-small'; import { Menu } from '~/design-system/icons/menu'; import { Trash } from '~/design-system/icons/trash'; import { PrefetchLink as Link } from '~/design-system/prefetch-link'; +import { + ActiveTabIndicator, + type TabGroupTab, + tabGroupTabLinkStyles, + useActiveTabIndicator, +} from '~/design-system/tab-group'; -function tabIdFromEntityTabHref(href: string): string | null { - const idx = href.indexOf('tabId='); - if (idx === -1) return null; - const raw = href.slice(idx + 6).split('&')[0]; - return validateEntityId(raw) ? raw : null; -} - -function isEntityTabHrefActive( - href: string, - activeTabId: string | null, - sidePanel: boolean, - fullPath: string -): boolean { - if (!sidePanel) return href === fullPath; - const hrefTabId = tabIdFromEntityTabHref(href); - if (hrefTabId === null) return activeTabId === null; - return activeTabId === hrefTabId; -} - -export type SystemTab = { - label: string; - href: string; - /** - * Only shown where the side rail is not. - * - * Desktop-first breakpoints, so `lg:` applies at 1023px and below — - * `StickySideRail` drops itself at exactly that width. A tab whose page only - * repeats the rail has to disappear when the rail comes back, in edit mode as - * well as out of it: `about/page.tsx` hides its body on a wide screen, so - * leaving the tab there is a link to an empty column. - */ - onlyWhenNarrow?: boolean; -}; +export type SystemTab = Pick; export type EditableTab = { relation: Relation; @@ -86,21 +60,6 @@ type EditableTabGroupProps = { className?: string; }; -const tabStyles = cva( - 'relative z-10 flex items-center gap-1.5 text-quoteMedium whitespace-nowrap transition-colors duration-100', - { - variants: { - active: { - true: 'text-text', - false: 'text-grey-04 hover:text-text', - }, - }, - defaultVariants: { - active: false, - }, - } -); - export function EditableTabGroup({ entityId, spaceId, @@ -303,6 +262,12 @@ export function EditableTabGroup({ // Key the memo on a joined string so we only allocate a new array when the id set actually changes. const sortableIdsKey = editableTabs.map(t => t.relation.id).join(','); const sortableIds = React.useMemo(() => (sortableIdsKey === '' ? [] : sortableIdsKey.split(',')), [sortableIdsKey]); + const indicatorLayoutKey = [ + ...systemTabsBefore.map(tab => `${tab.href}:${tab.label}`), + ...editableTabs.map(tab => `${tab.relation.id}:${tab.name}`), + ...systemTabsAfter.map(tab => `${tab.href}:${tab.label}`), + ].join('|'); + const { indicator, registerActiveTab } = useActiveTabIndicator(indicatorLayoutKey); return (
@@ -328,10 +293,23 @@ export function EditableTabGroup({ href={tab.href} label={tab.label} onlyWhenNarrow={tab.onlyWhenNarrow} - active={isEntityTabHrefActive(tab.href, activeTabId, Boolean(sidePanelTab), fullPath)} + active={isEntityTabActive({ + href: tab.href, + activeTabId, + sidePanel: Boolean(sidePanelTab), + fullPath, + sidePanelKey: tab.sidePanelKey, + activeSystemTab: sidePanelTab?.activeSystemTab, + })} onSelect={ - sidePanelTab ? () => sidePanelTab.setActiveTabId(tabIdFromEntityTabHref(tab.href)) : undefined + sidePanelTab + ? () => + tab.sidePanelKey + ? sidePanelTab.setActiveSystemTab(tab.sidePanelKey) + : sidePanelTab.setActiveTabId(entityTabIdFromHref(tab.href)) + : undefined } + activeRef={registerActiveTab} /> ))} @@ -349,6 +327,7 @@ export function EditableTabGroup({ onDelete={() => handleDeleteTab(tab)} onOpen={() => router.push(NavUtils.toEntity(tab.relation.spaceId, tab.entityId))} onSelect={sidePanelTab ? () => sidePanelTab.setActiveTabId(tab.entityId) : undefined} + activeRef={registerActiveTab} /> ))} @@ -359,10 +338,23 @@ export function EditableTabGroup({ href={tab.href} label={tab.label} onlyWhenNarrow={tab.onlyWhenNarrow} - active={isEntityTabHrefActive(tab.href, activeTabId, Boolean(sidePanelTab), fullPath)} + active={isEntityTabActive({ + href: tab.href, + activeTabId, + sidePanel: Boolean(sidePanelTab), + fullPath, + sidePanelKey: tab.sidePanelKey, + activeSystemTab: sidePanelTab?.activeSystemTab, + })} onSelect={ - sidePanelTab ? () => sidePanelTab.setActiveTabId(tabIdFromEntityTabHref(tab.href)) : undefined + sidePanelTab + ? () => + tab.sidePanelKey + ? sidePanelTab.setActiveSystemTab(tab.sidePanelKey) + : sidePanelTab.setActiveTabId(entityTabIdFromHref(tab.href)) + : undefined } + activeRef={registerActiveTab} /> ))} @@ -377,13 +369,14 @@ export function EditableTabGroup({ +
{activeId && activeTab ? ( -
+
{activeTab.name || 'Untitled'}
) : null} @@ -399,12 +392,14 @@ function StaticTab({ active, onSelect, onlyWhenNarrow, + activeRef, }: { href: string; label: string; active: boolean; onSelect?: () => void; onlyWhenNarrow?: boolean; + activeRef: (element: HTMLElement | null) => void; }) { // `contents` rather than `block`, so the wrapper does not become a flex item // between the tabs and pull them apart. The same shape `TabGroup` uses. @@ -412,17 +407,20 @@ function StaticTab({ if (onSelect) { return wrap( - ); } return wrap( - + {label} - {active &&
} ); } @@ -438,6 +436,7 @@ type SortableTabProps = { onDelete: () => void; onOpen: () => void; onSelect?: () => void; + activeRef: (element: HTMLElement | null) => void; }; function SortableTab({ @@ -451,6 +450,7 @@ function SortableTab({ onDelete, onOpen, onSelect, + activeRef, }: SortableTabProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.relation.id, @@ -547,14 +547,21 @@ function SortableTab({ // tab.name already includes the live name via EntityTabs' liveNameMap, so no per-tab subscription needed here. const displayName = tab.name; + const setTabNodeRef = React.useCallback( + (element: HTMLDivElement | null) => { + setNodeRef(element); + if (active) activeRef(element); + }, + [active, activeRef, setNodeRef] + ); return ( -
+
{isEditing ? ( ) : ( {displayName || 'Untitled'} - {active &&
} )} diff --git a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx index b0b70d1592..2dba23d303 100644 --- a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx +++ b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx @@ -6,17 +6,25 @@ import * as React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; +import { TOPIC_TYPE_ID } from '~/core/constants'; + import { EntityPageBody } from './entity-page-body'; const mocks = vi.hoisted(() => ({ actions: null as Record | null, + cover: null as Record | null, entity: { id: 'entity-1', types: [] as { id: string }[] }, heading: null as Record | null, + editing: false, isLoadingSpace: false, + claimPage: null as Record | null, + entityMediaUrl: null as string | null, + previewImageUrl: null as string | null, space: null as { type: string; entity: { id: string; types: { id: string }[] } } | null, })); -vi.mock('~/core/hooks/use-user-is-editing', () => ({ useUserIsEditing: () => false })); +vi.mock('~/core/hooks/use-user-is-editing', () => ({ useUserIsEditing: () => mocks.editing })); // Node's built-in localStorage shim can shadow jsdom with a partial object. This // layout test only reaches the pending-space atom through transitive UI imports. vi.mock('~/core/state/pending-personal-space', () => ({ @@ -35,8 +43,8 @@ vi.mock('~/core/hooks/use-space', () => ({ useSpace: () => ({ space: mocks.space, isLoading: mocks.isLoadingSpace }), })); vi.mock('~/core/utils/use-entity-media', () => ({ - useEntityMediaUrl: () => null, - useImageUrlFromEntity: () => null, + useEntityMediaUrl: () => mocks.entityMediaUrl, + useImageUrlFromEntity: () => mocks.previewImageUrl, })); vi.mock('~/partials/entity-page/entity-page-actions', () => ({ @@ -67,20 +75,38 @@ vi.mock('~/partials/profile/personal-space-profile', () => ({ // Everything below the header row. Each reaches for the sync engine, the editor or geo-chat, and // none of it is what this file asserts. -vi.mock('~/partials/entity-page/entity-page-cover', () => ({ EntityPageCover: () => null })); +vi.mock('~/partials/entity-page/entity-page-cover', () => ({ + EntityPageCover: (props: Record) => { + mocks.cover = props; + return
; + }, +})); vi.mock('~/partials/entity-page/entity-page-content-container', () => ({ EntityPageContentContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, })); vi.mock('~/partials/entity-page/entity-tabs', () => ({ EntityTabs: () => null })); -vi.mock('~/partials/entity-page/toggle-entity-page', () => ({ ToggleEntityPage: () => null })); -vi.mock('~/partials/entity-page/automatic-mode-toggle', () => ({ AutomaticModeToggle: () => null })); +vi.mock('~/partials/entity-page/toggle-entity-page', () => ({ + ToggleEntityPage: () =>
, +})); +vi.mock('~/partials/entity-page/automatic-mode-toggle', () => ({ + AutomaticModeToggle: () =>
, +})); vi.mock('~/partials/entity-page/backlinks-client-container', () => ({ BacklinksClientContainer: () => null })); vi.mock('~/partials/entity-page/type-schema-inline', () => ({ TypeSchemaInline: () => null })); vi.mock('~/partials/entity-page/entity-page-header', () => ({ EntityPageHeader: () => null })); vi.mock('~/partials/editor/editor', () => ({ Editor: () => null })); vi.mock('~/partials/comments/comments-section', () => ({ CommentSection: () => null })); -vi.mock('~/core/claims/browse/claim-page-view', () => ({ ClaimPageView: () => null })); -vi.mock('~/core/topics/browse/topic-page-view', () => ({ TopicPageView: () => null })); +vi.mock('~/core/claims/browse/claim-page-view', () => ({ + CLAIM_PAGE_CONTENT_INSET_CLASS: 'claim-content-inset', + CLAIM_PAGE_CONTENT_MAX_WIDTH: 720, + ClaimPageView: (props: Record) => { + mocks.claimPage = props; + return
{props.footer as React.ReactNode}
; + }, +})); +vi.mock('~/core/topics/browse/topic-page-view', () => ({ + TopicPageView: () =>
, +})); const SHARED = { entityId: 'entity-1', @@ -97,8 +123,13 @@ function renderPanel(overrides?: { isRelationPage?: boolean; previewName?: strin beforeEach(() => { mocks.actions = null; + mocks.cover = null; mocks.entity = { id: 'entity-1', types: [] }; mocks.heading = null; + mocks.editing = false; + mocks.claimPage = null; + mocks.entityMediaUrl = null; + mocks.previewImageUrl = null; mocks.isLoadingSpace = false; mocks.space = null; }); @@ -188,3 +219,112 @@ describe('EntityPageBody relation side panel', () => { expect(mocks.heading).toMatchObject({ entityId: 'entity-1', spaceId: 'space-1', fallbackName: 'Preview name' }); }); }); + +describe('EntityPageBody claim side panel', () => { + it('shows both configured claim images above the custom view', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + + render( + + ); + + expect(screen.getByTestId('cover')).toBeInTheDocument(); + expect(screen.getByTestId('claim-page')).toBeInTheDocument(); + expect(mocks.cover).toMatchObject({ + avatarUrl: 'https://example.com/avatar.png', + coverUrl: 'https://example.com/cover.png', + compact: true, + withAvatar: true, + contentMaxWidth: 720, + contentInsetClassName: 'claim-content-inset', + }); + }); + + it('does not render a cover or preview fallback as the claim avatar', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + mocks.entityMediaUrl = 'https://example.com/cover-fallback.png'; + mocks.previewImageUrl = 'https://example.com/preview-fallback.png'; + + render( + + ); + + expect(mocks.cover).toMatchObject({ + avatarUrl: null, + coverUrl: 'https://example.com/cover.png', + withAvatar: true, + }); + }); + + it('does not render a cover or preview fallback as a person avatar either', () => { + const personType = { id: SystemIds.PERSON_TYPE }; + mocks.entity = { id: 'entity-1', types: [personType] }; + mocks.space = { type: 'PERSONAL', entity: { id: 'entity-1', types: [personType] } }; + mocks.entityMediaUrl = 'https://example.com/cover-fallback.png'; + mocks.previewImageUrl = 'https://example.com/preview-fallback.png'; + + render( + + ); + + expect(mocks.cover).toMatchObject({ + avatarUrl: null, + coverUrl: 'https://example.com/cover.png', + withAvatar: true, + }); + }); + + it('keeps the custom claim view and appends properties while editing', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + mocks.editing = true; + + render(); + + expect(screen.getByTestId('claim-page')).toBeInTheDocument(); + expect(screen.getByTestId('properties')).toBeInTheDocument(); + expect(mocks.claimPage?.isEditing).toBe(true); + expect(mocks.claimPage?.footer).toBeTruthy(); + }); + + it('mounts route edit initialization before the claim is already editing', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + + render(); + + expect(screen.getByTestId('automatic-mode-toggle')).toBeInTheDocument(); + expect(mocks.claimPage?.isEditing).toBe(false); + expect(screen.queryByTestId('properties')).not.toBeInTheDocument(); + }); + + it('mounts the same route edit initialization for the Topic custom surface', () => { + mocks.entity = { id: 'entity-1', types: [{ id: TOPIC_TYPE_ID }] }; + + render(); + + expect(screen.getByTestId('topic-page')).toBeInTheDocument(); + expect(screen.getByTestId('automatic-mode-toggle')).toBeInTheDocument(); + }); + + it('does not mount route edit initialization in the side panel', () => { + mocks.entity = { id: 'entity-1', types: [{ id: CLAIM_TYPE_ID }] }; + + render(); + + expect(screen.queryByTestId('automatic-mode-toggle')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/partials/entity-page/entity-page-body.tsx b/apps/web/partials/entity-page/entity-page-body.tsx index dadfb22e84..77a8b1dc4c 100644 --- a/apps/web/partials/entity-page/entity-page-body.tsx +++ b/apps/web/partials/entity-page/entity-page-body.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; -import { ClaimPageView } from '~/core/claims/browse/claim-page-view'; +import { + CLAIM_PAGE_CONTENT_INSET_CLASS, + CLAIM_PAGE_CONTENT_MAX_WIDTH, + ClaimPageView, +} from '~/core/claims/browse/claim-page-view'; import { useSpace } from '~/core/hooks/use-space'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { useQueryEntity } from '~/core/sync/use-store'; @@ -105,7 +109,6 @@ function EditorFooter({ <> {hideProperties ? null : } - ) : hideProperties ? null : ( @@ -126,15 +129,16 @@ function EditorFooter({ /** * Which custom read view this entity gets, if any. * - * Editing always falls through to the generic page: these are read surfaces with no property editor - * behind them, so an editor who lost the value sheet would have no way to change the entity. + * Claims keep their custom surface while editing, matching personal-space profiles: product-owned + * tabs stay visible and fixed while authored tabs can be managed. `EntityPageBody` appends the + * property editor beneath the claim surface, so keeping the custom UI does not hide raw fields. + * Other custom views still fall through to the generic editor. * * Unscoped, matching how `EntityVoteButtons` reads the same flag. `types` is * derived across every space either way, so this is about consistency with the controls the pages * render rather than about reaching a type a scoped read would miss. */ -function useCustomBrowseView(entityId: string, spaceId: string): CustomBrowseView { - const isEditing = useUserIsEditing(spaceId); +function useCustomBrowseView(entityId: string, spaceId: string, isEditing: boolean): CustomBrowseView { const { entity, isLoading } = useQueryEntity({ id: entityId }); /* @@ -163,7 +167,8 @@ function useCustomBrowseView(entityId: string, spaceId: string): CustomBrowseVie export function EntityPageBody(props: EntityPageBodyProps) { const { entityId, spaceId, initialTabRelations, tabEntities } = props; - const customView = useCustomBrowseView(entityId, spaceId); + const isEditing = useUserIsEditing(spaceId); + const customView = useCustomBrowseView(entityId, spaceId, isEditing); const previewImageUrl = props.variant === 'sidePanel' ? props.previewImageUrl : undefined; const entityMediaUrl = useEntityMediaUrl(entityId, spaceId); @@ -187,12 +192,55 @@ export function EntityPageBody(props: EntityPageBodyProps) { // generic value sheet and swapping it out from under the reader. if (customView === 'pending') return null; + // Mounted independently of edit-only content so a direct `?edit=true` route can turn editing on. + // Claim and Topic both return before the generic EditorFooter, so keeping initialization there + // makes both custom routes self-blocking. Side panels own their edit intent and do not use this. + const routeEditInitializer = props.variant === 'route' ? : null; + if (customView === 'claim') { - return ; + const showClaimMedia = props.variant === 'sidePanel' || props.showCover !== false; + + return ( + <> + {showClaimMedia ? ( + props.variant === 'route' && props.coverSlot ? ( + props.coverSlot + ) : ( + + ) + ) : null} + {routeEditInitializer} + : undefined} + /> + + ); } if (customView === 'topic') { - return ; + return ( + <> + {routeEditInitializer} + + + ); } /* @@ -271,7 +319,14 @@ export function EntityPageBody(props: EntityPageBodyProps) { {/* A profile brings its avatar: it is the person's face, and the panel opened on a cover with nobody in it. Everything else keeps the cover-only header — see `EditableCoverAvatarHeader`. */} - +
@@ -348,6 +403,7 @@ export function EntityPageBody(props: EntityPageBodyProps) { return ( <> + {routeEditInitializer} {showCover && (coverSlot ?? )} { const { id, spaceId } = useEntityStoreInstance(); @@ -86,6 +89,7 @@ export const EntityPageCover = ({ compact={compact} withAvatar={withAvatar} contentMaxWidth={contentMaxWidth} + contentInsetClassName={contentInsetClassName} /> ); }; diff --git a/apps/web/partials/entity-page/entity-side-panel.tsx b/apps/web/partials/entity-page/entity-side-panel.tsx index 2547cc730c..20cf9791aa 100644 --- a/apps/web/partials/entity-page/entity-side-panel.tsx +++ b/apps/web/partials/entity-page/entity-side-panel.tsx @@ -309,7 +309,7 @@ export function EntitySidePanelSurface({ data-entity-side-panel-scroll data-mobile-sheet-scroll > - + ({ + canEdit: false, + editable: false, + editingSpace: null as string | null, + readTabs: null as Array<{ label: string; href: string }> | null, + sidePanel: null as null | { + activeTabId: string | null; + activeSystemTab: string | null; + setActiveTabId: ReturnType; + setActiveSystemTab: ReturnType; + }, +})); + +vi.mock('~/core/hooks/use-user-is-editing', () => ({ + useCanUserEdit: () => mocks.canEdit, + useUserIsEditing: (spaceId: string) => { + mocks.editingSpace = spaceId; + return mocks.editable; + }, +})); + +vi.mock('~/core/sync/use-store', () => ({ + useQueryEntity: () => ({ entity: { id: 'claim-1', types: [] } }), + useRelations: ({ mergeWith }: { mergeWith: unknown[] }) => mergeWith, + useValues: () => [], +})); + +vi.mock('~/core/state/entity-side-panel-active-tab', () => ({ + useEntitySidePanelActiveTab: () => mocks.sidePanel, +})); + +vi.mock('~/design-system/tab-group', () => ({ + TabGroup: ({ tabs }: { tabs: Array<{ label: string; href: string }> }) => { + mocks.readTabs = tabs; + return
; + }, +})); + +vi.mock('./editable-tab-group', () => ({ + EditableTabGroup: () =>
, +})); + +const RELATIONS = [ + { + id: 'relation-1', + fromEntity: { id: 'claim-1' }, + toEntity: { id: 'tab-1', name: 'Debates' }, + type: { id: 'tabs' }, + spaceId: 'space-1', + }, +]; + +beforeEach(() => { + mocks.canEdit = false; + mocks.editable = false; + mocks.editingSpace = null; + mocks.readTabs = null; + mocks.sidePanel = null; +}); + +afterEach(cleanup); + +describe('EntityTabs access and system tabs', () => { + it('uses the access-controlled editing state for the current space', () => { + mocks.canEdit = true; + mocks.editable = true; + + render( + + ); + + expect(mocks.editingSpace).toBe('space-1'); + expect(screen.getByTestId('editable-tabs')).toBeInTheDocument(); + }); + + it('keeps edit controls hidden until current-space access resolves', () => { + mocks.editable = true; + + render( + + ); + + expect(screen.queryByTestId('editable-tabs')).not.toBeInTheDocument(); + expect(screen.getByTestId('read-tabs')).toBeInTheDocument(); + }); + + it('keeps product tabs and hides colliding authored labels in browse mode', () => { + render( + + ); + + expect(mocks.readTabs?.map(tab => tab.label)).toEqual(['Overview', 'Debates']); + }); + + it('keeps an authored label when the matching product tab is not available', () => { + render( + + ); + + expect(mocks.readTabs?.map(tab => tab.label)).toEqual(['Overview', 'Debates']); + }); + + it('returns a side panel to Overview when its selected system tab disappears', () => { + const setActiveSystemTab = vi.fn(); + mocks.sidePanel = { + activeTabId: null, + activeSystemTab: 'sources', + setActiveTabId: vi.fn(), + setActiveSystemTab, + }; + + render( + + ); + + expect(setActiveSystemTab).toHaveBeenCalledWith('overview'); + }); +}); diff --git a/apps/web/partials/entity-page/entity-tabs.tsx b/apps/web/partials/entity-page/entity-tabs.tsx index d44f47c2d5..5a55168bbc 100644 --- a/apps/web/partials/entity-page/entity-tabs.tsx +++ b/apps/web/partials/entity-page/entity-tabs.tsx @@ -4,8 +4,8 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; import * as React from 'react'; -import { useEditable } from '~/core/state/editable-store'; -import { EntitySidePanelEditContext } from '~/core/state/entity-side-panel-edit-context'; +import { useCanUserEdit, useUserIsEditing } from '~/core/hooks/use-user-is-editing'; +import { useEntitySidePanelActiveTab } from '~/core/state/entity-side-panel-active-tab'; import { useQueryEntity, useRelations, useValues } from '~/core/sync/use-store'; import { TabEntity } from '~/core/types'; import { Relation } from '~/core/types'; @@ -14,27 +14,38 @@ import { NavUtils, sortRelations } from '~/core/utils/utils'; import { TabGroup } from '~/design-system/tab-group'; -import { EditableTabGroup } from './editable-tab-group'; +import { EditableTabGroup, type SystemTab } from './editable-tab-group'; type EntityTabsProps = { entityId: string; spaceId: string; initialTabRelations: Relation[]; tabEntities: TabEntity[]; + /** Product-owned tabs that lead the entity's authored tabs. Defaults to Overview. */ + systemTabsBefore?: SystemTab[]; + /** Authored labels hidden in browse mode because a product-owned tab already uses the name. */ + reservedSystemLabels?: string[]; + /** Visually separates the first authored tab from the product-owned record tabs. */ + divideBeforeAuthored?: boolean; }; -export function EntityTabs({ entityId, spaceId, initialTabRelations, tabEntities }: EntityTabsProps) { - const { editable } = useEditable(); +export function EntityTabs({ + entityId, + spaceId, + initialTabRelations, + tabEntities, + systemTabsBefore, + reservedSystemLabels = [], + divideBeforeAuthored = false, +}: EntityTabsProps) { + // `useUserIsEditing` preserves edit intent while access is loading to avoid whole-page hydration + // flicker. Tab mutation controls must be stricter: do not expose them until this space's access + // has positively resolved. + const editingIntent = useUserIsEditing(spaceId); + const canEdit = useCanUserEdit(spaceId); + const effectiveEditable = editingIntent && canEdit; + const sidePanelTab = useEntitySidePanelActiveTab(); const { entity } = useQueryEntity({ id: entityId, spaceId }); - const sidePanelEdit = React.useContext(EntitySidePanelEditContext); - - /** - * Full entity page: same as before — only global edit toggle (`editable`). - * Side panel: only `panelWantsEdit` (how the panel was opened + toggle). Do **not** OR with - * global `editable`, or a leftover edit mode elsewhere forces EditableTabGroup and tabs show - * even when the panel is in view mode. - */ - const effectiveEditable = sidePanelEdit != null ? sidePanelEdit.panelWantsEdit : editable; const initialTabRelationIds = React.useMemo(() => new Set(initialTabRelations.map(r => r.id)), [initialTabRelations]); @@ -70,6 +81,25 @@ export function EntityTabs({ entityId, spaceId, initialTabRelations, tabEntities return map; }, [liveNameValues]); + const overviewHref = NavUtils.toEntity(spaceId, entityId); + const leadingSystemTabs = systemTabsBefore ?? [{ label: 'Overview', href: overviewHref }]; + const systemTabKey = leadingSystemTabs.flatMap(tab => (tab.sidePanelKey ? [tab.sidePanelKey] : [])).join('\u0000'); + const activeSystemTab = sidePanelTab?.activeSystemTab ?? null; + const setActiveSystemTab = sidePanelTab?.setActiveSystemTab; + const clearToOverview = sidePanelTab?.setActiveTabId; + + // Counts can settle after the panel opens. If the selected product tab disappears at zero, + // reconcile the shared panel state instead of leaving an unreachable empty panel selected. + // This belongs at the shared entity-tabs boundary so browse and edit bars follow one rule. + React.useEffect(() => { + const systemTabKeys = systemTabKey === '' ? [] : systemTabKey.split('\u0000'); + if (!activeSystemTab || systemTabKeys.includes(activeSystemTab)) return; + + const fallback = systemTabKeys.includes('overview') ? 'overview' : systemTabKeys[0]; + if (fallback && setActiveSystemTab) setActiveSystemTab(fallback); + else clearToOverview?.(null); + }, [activeSystemTab, clearToOverview, setActiveSystemTab, systemTabKey]); + if (entityHasOnlyPostType(entity)) { return null; } @@ -80,8 +110,6 @@ export function EntityTabs({ entityId, spaceId, initialTabRelations, tabEntities return liveName !== undefined ? { ...base, name: liveName } : base; }); - const overviewHref = NavUtils.toEntity(spaceId, entityId); - if (effectiveEditable) { const editableTabs = sortedTabRelations.map((relation, i) => ({ relation, @@ -95,25 +123,24 @@ export function EntityTabs({ entityId, spaceId, initialTabRelations, tabEntities entityId={entityId} spaceId={spaceId} editableTabs={editableTabs} - systemTabsBefore={[{ label: 'Overview', href: overviewHref }]} + systemTabsBefore={leadingSystemTabs} overviewHref={overviewHref} /> ); } // Build tabs in the correct order - const tabs = sortedTabEntities.map(entity => ({ - label: entity.name ?? '', - href: `${overviewHref}?tabId=${entity.id}`, - })); + const reserved = new Set(reservedSystemLabels); + const tabs = sortedTabEntities + .filter(entity => !reserved.has(entity.name ?? '')) + .map(entity => ({ + label: entity.name ?? '', + href: `${overviewHref}?tabId=${entity.id}`, + })); - // Add Overview tab at the beginning const allTabs = [ - { - label: 'Overview', - href: overviewHref, - }, - ...tabs, + ...leadingSystemTabs, + ...tabs.map((tab, index) => ({ ...tab, dividerBefore: divideBeforeAuthored && index === 0 })), ]; if (allTabs.length <= 1) { diff --git a/apps/web/partials/profile/partial-load-error.tsx b/apps/web/partials/profile/partial-load-error.tsx index 7cde212d22..a2d6d18181 100644 --- a/apps/web/partials/profile/partial-load-error.tsx +++ b/apps/web/partials/profile/partial-load-error.tsx @@ -4,28 +4,22 @@ import * as React from 'react'; import { SmallButton } from '~/design-system/button'; -/** - * A page that failed, after the pages that did not (GEO-2859). - * - * The record tabs stop their sentinel on `isError`, because the 8000px margin - * keeps it permanently in view and one failing page otherwise becomes a loop - * that asks forever and never grows the list. Stopping was right; stopping - * *silently* was not — a 208-claim record that failed on page two looked like a - * 20-claim record, complete, with nothing to press. - * - * So: keep the rows that arrived, say the rest did not, and offer the retry the - * sentinel can no longer perform. `fetchNextPage` retries the failed page, and - * a success clears `isError` and hands the sentinel back its job. - * - * Shared because the same hole existed on Positions and on Proposals, in two - * different render paths, and a second copy of this wording would drift from - * the first. - */ -export function PartialLoadError({ noun, onRetry }: { noun: string; onRetry: () => void }) { +/** Shared record error presentation for both initial and later-page retries. */ +export function RecordLoadError({ message, onRetry }: { message: string; onRetry: () => void }) { return (
-

Couldn’t load more {noun}.

+

{message}

Try again
); } + +/** + * A page that failed after earlier pages succeeded (GEO-2859). + * + * Sentinels stop on errors to avoid an automatic retry loop. Keep the rows that arrived, say the + * rest did not, and offer the same explicit retry used by initial record failures. + */ +export function PartialLoadError({ noun, onRetry }: { noun: string; onRetry: () => void }) { + return ; +} diff --git a/apps/web/partials/profile/person-record-feed.test.tsx b/apps/web/partials/profile/person-record-feed.test.tsx index 8446cd8f2b..8f41d1c13b 100644 --- a/apps/web/partials/profile/person-record-feed.test.tsx +++ b/apps/web/partials/profile/person-record-feed.test.tsx @@ -69,6 +69,16 @@ describe('PersonRecordFeed', () => { expect(screen.queryByText('No positions on claims yet.')).not.toBeInTheDocument(); }); + it('offers the upstream retry when the initial request failed', () => { + const fetchNextPage = vi.fn(); + renderFeed({ isError: true, fetchNextPage }); + + screen.getByRole('button', { name: 'Try again' }).click(); + + expect(screen.getByText('Couldn’t load positions.')).toBeInTheDocument(); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + // A failure on page four is not a reason to throw away pages one to three. it('keeps the rows it already has when a later page fails', () => { renderFeed({ rows: [row('claim-1'), row('claim-2')], isError: true }); @@ -96,6 +106,17 @@ describe('PersonRecordFeed', () => { expect(fetchNextPage).toHaveBeenCalledTimes(1); }); + it('uses the record error label when bounded hydration partially fails', () => { + const onRetry = vi.fn(); + renderFeed({ rows: [row('source-1')], isError: true, onRetry }); + + expect(screen.getByText('Couldn’t load positions.')).toBeInTheDocument(); + expect(screen.queryByText('Couldn’t load more positions.')).not.toBeInTheDocument(); + + screen.getByRole('button', { name: 'Try again' }).click(); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + it('says nothing about a later page while one is still in flight', () => { renderFeed({ rows: [row('claim-1')], isError: true, isFetchingNextPage: true, fetchNextPage: () => {} }); diff --git a/apps/web/partials/profile/person-record-feed.tsx b/apps/web/partials/profile/person-record-feed.tsx index 3870aa1792..f0fdbfcecd 100644 --- a/apps/web/partials/profile/person-record-feed.tsx +++ b/apps/web/partials/profile/person-record-feed.tsx @@ -13,7 +13,7 @@ import { Skeleton } from '~/design-system/skeleton'; import { ExploreFeedCard } from '~/partials/explore/explore-feed-card'; import { ClaimResponseTag } from './claim-response-tag'; -import { PartialLoadError } from './partial-load-error'; +import { PartialLoadError, RecordLoadError } from './partial-load-error'; /** * A person's record, rendered as explore cards (GEO-2859). @@ -34,6 +34,7 @@ export function PersonRecordFeed({ isFetchingNextPage = false, hasNextPage = false, fetchNextPage, + onRetry, loadingLabel, emptyLabel, errorLabel, @@ -52,7 +53,10 @@ export function PersonRecordFeed({ isError?: boolean; isFetchingNextPage?: boolean; hasNextPage?: boolean; + /** Loads another page and uses the partial-page error message when existing rows remain. */ fetchNextPage?: () => void; + /** Retries a bounded record and keeps `errorLabel` even when some rows were hydrated. */ + onRetry?: () => void; loadingLabel: string; emptyLabel: string; /** Said instead of `emptyLabel` when the list could not be read at all. */ @@ -90,6 +94,7 @@ export function PersonRecordFeed({ ); const noop = React.useCallback(() => {}, []); + const retry = onRetry ?? fetchNextPage; const sentinelRef = useInfiniteSentinel({ hasNextPage, isFetchingNextPage, @@ -105,6 +110,7 @@ export function PersonRecordFeed({ // failed leaves the rows that did arrive on screen, where the sentinel has // already stopped asking for more. if (isError && rows.length === 0) { + if (retry) return ; return

{errorLabel}

; } @@ -166,7 +172,10 @@ export function PersonRecordFeed({ * asking — deliberately — so without this the list simply ends early and * reads as complete. */} - {isError && !isFetchingNextPage && fetchNextPage && } + {isError && !isFetchingNextPage && onRetry && } + {isError && !isFetchingNextPage && !onRetry && fetchNextPage && ( + + )}
); } diff --git a/apps/web/partials/profile/profile-activity-section.test.tsx b/apps/web/partials/profile/profile-activity-section.test.tsx index f98989865a..98790a86b5 100644 --- a/apps/web/partials/profile/profile-activity-section.test.tsx +++ b/apps/web/partials/profile/profile-activity-section.test.tsx @@ -235,6 +235,67 @@ describe('ProfileActivitySection', () => { expect(container).toBeEmptyDOMElement(); }); + it('reserves the Activity section with a skeleton while both kinds are loading', () => { + render( + + ); + + expect(screen.getByRole('region', { name: 'Loading activity' })).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByRole('heading', { name: 'Activity' })).toBeInTheDocument(); + }); + + it('shows a completed kind without waiting for the other kind', () => { + render( + + ); + + expect(screen.getByTestId('card')).toHaveTextContent('d1'); + expect(screen.queryByRole('region', { name: 'Loading activity' })).not.toBeInTheDocument(); + }); + + it('keeps the first available kind selected when an earlier kind finishes later', () => { + const claims = kind({ key: 'claims', label: 'Claims', rows: [row('c1')] }); + const { rerender } = render( + + ); + + expect(screen.getByTestId('card')).toHaveTextContent('c1'); + + rerender(); + + expect(screen.getByTestId('card')).toHaveTextContent('c1'); + expect(screen.getByRole('button', { name: /Claims/ })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('keeps the visible fallback selected when the previous kind later returns', () => { + const debates = kind({ rows: [row('d1')] }); + const claims = kind({ key: 'claims', label: 'Claims', rows: [row('c1')] }); + const { rerender } = render(); + + fireEvent.click(screen.getByRole('button', { name: /Claims/ })); + expect(screen.getByTestId('card')).toHaveTextContent('c1'); + + rerender(); + expect(screen.getByTestId('card')).toHaveTextContent('d1'); + + rerender(); + expect(screen.getByTestId('card')).toHaveTextContent('d1'); + expect(screen.getByRole('button', { name: /Debates/ })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('renders only the shared gallery card limit', () => { + render( + row(`d${index + 1}`)) })]} /> + ); + + expect(screen.getAllByTestId('card')).toHaveLength(6); + }); + it('leaves out a kind that is empty but fine', () => { render(); @@ -490,6 +551,18 @@ describe('ProfileActivitySection', () => { expect(screen.getByRole('button', { name: /Debates/ })).toHaveTextContent('—'); }); + it('selects an in-place record tab instead of navigating when given an action', () => { + const onSeeAll = vi.fn(); + render(); + + const seeAll = screen.getByRole('button', { name: 'See all debates' }); + expect(screen.queryByRole('link', { name: 'See all debates' })).not.toBeInTheDocument(); + + fireEvent.click(seeAll); + + expect(onSeeAll).toHaveBeenCalledOnce(); + }); + it('sends See all to the tab bar rather than the top of the page', () => { render( void; }; /** @@ -88,16 +92,25 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { const available = React.useMemo(() => kinds.filter(kind => kind.rows.length > 0 || kind.isError), [kinds]); const [selectedKey, setSelectedKey] = React.useState(null); + React.useEffect(() => { + if (available[0] && !available.some(kind => kind.key === selectedKey)) setSelectedKey(available[0].key); + }, [available, selectedKey]); + // Whichever the reader picked, or the first with anything in it. Held as a key // rather than an index so a kind arriving late — the two load separately — // cannot shift the selection out from under them. const selected = available.find(kind => kind.key === selectedKey) ?? available[0]; const { sectionRef, reserveRef, prepareSwitch } = useMobileActivityHeightReserve(selected?.key); + const isLoading = kinds.some(kind => kind.isLoading); + + // Reserve the section while its first usable record is on the way. Once either kind resolves, + // draw it immediately rather than holding the whole card behind the slower request. + if (available.length === 0 && isLoading) return ; - // Nothing at all rather than an empty card. A heading over a blank space reads - // as a page that failed to load, and most accounts have never been in a debate. - if (kinds.some(kind => kind.isLoading) || available.length === 0 || !selected) return null; + // Nothing at all once both kinds have settled empty. Most accounts have never been in a debate, + // and a permanent heading over blank space would imply that content failed to render. + if (available.length === 0 || !selected) return null; return (
@@ -175,21 +188,7 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { personName={selected.personName} /> )} - {/* - * Lands on the tab bar, not the page top. - * - * A navigation lands at the top of the page, which on a phone is a screenful of cover, - * avatar, name, roles and bio — none of it what "See all debates" was clicked for. The - * fragment puts the tab row under the navbar instead, so the list opens at the top of the - * screen with the underlined tab above it saying where the reader has been sent. - */} - - {selected.seeAllLabel} - - + {/* @@ -209,6 +208,56 @@ export function ProfileActivitySection({ kinds }: { kinds: ActivityKind[] }) { ); } +function ProfileActivitySkeleton() { + return ( +
+
+

Activity

+ +
+
+ +
+
+ +
+
+ ); +} + +function ActivitySeeAll({ kind }: { kind: ActivityKind }) { + const content = ( + <> + {kind.seeAllLabel} + + + ); + + if (kind.onSeeAll) { + return ( + + ); + } + + // A route navigation lands at the top of the page, which on a phone is a screenful of profile + // chrome. The fragment puts the tab row under the navbar instead. Side panels use `onSeeAll` + // above because their tabs are selected in place and have no route fragment to follow. + return ( + + {content} + + ); +} + /** * Keep the reader where they were while Activity views of different heights are swapped. * @@ -400,7 +449,7 @@ function ActivityGallery({ responseByClaimId?: Record; personName?: string | null; }) { - const shown = React.useMemo(() => rows.slice(0, SHOWN), [rows]); + const shown = React.useMemo(() => rows.slice(0, ACTIVITY_GALLERY_CARD_LIMIT), [rows]); // Looked up once for the gallery. These are routinely spaces the viewer has // never opened, which the browse sidebar cannot name.