diff --git a/src/sentry/replays/blueprints/api.md b/src/sentry/replays/blueprints/api.md index 3679cd7d15c89b..a1382f9a00da27 100644 --- a/src/sentry/replays/blueprints/api.md +++ b/src/sentry/replays/blueprints/api.md @@ -24,7 +24,7 @@ This document is structured by resource with each resource having actions that c Default: 7d Members: + s + m + h + d + w - start (optional, string) - ISO 8601 format (`YYYY-MM-DDTHH:mm:ss.sssZ`) - - end (optional, string) - ISO 8601 format. Required if `start` is set. + - end (optional, string) - ISO 8601 format. - per_page (optional, number) Default: 10 - offset (optional, number) diff --git a/static/app/components/replays/table/replayTable.tsx b/static/app/components/replays/table/replayTable.tsx index bc648a9ffa121a..8a5cf3967eba5b 100644 --- a/static/app/components/replays/table/replayTable.tsx +++ b/static/app/components/replays/table/replayTable.tsx @@ -8,9 +8,11 @@ import type {ReplayTableColumn} from 'sentry/components/replays/table/replayTabl import ReplayTableHeader from 'sentry/components/replays/table/replayTableHeader'; import {SimpleTable} from 'sentry/components/tables/simpleTable'; import {t} from 'sentry/locale'; +import EventView from 'sentry/utils/discover/eventView'; import type {Sort} from 'sentry/utils/discover/fields'; import type RequestError from 'sentry/utils/requestError/requestError'; import {ERROR_MAP} from 'sentry/utils/requestError/requestError'; +import {useLocation} from 'sentry/utils/useLocation'; import type {ReplayListRecord} from 'sentry/views/replays/types'; type SortProps = @@ -26,10 +28,12 @@ type Props = SortProps & { isPending: boolean; replays: ReplayListRecord[]; showDropdownFilters: boolean; + eventView?: EventView; ref?: RefObject; }; export default function ReplayTable({ + eventView, columns, error, isPending, @@ -41,6 +45,10 @@ export default function ReplayTable({ }: Props) { const gridTemplateColumns = columns.map(col => col.width ?? 'max-content').join(' '); const hasInteractiveColumn = columns.some(col => col.interactive); + const location = useLocation(); + if (!eventView) { + eventView = EventView.fromLocation(location); + } if (isPending) { return ( @@ -111,6 +119,7 @@ export default function ReplayTable({ ( { + Component: ({replay, eventView}) => { + const routes = useRoutes(); const organization = useOrganization(); + + const referrer = getRouteStringFromRoutes(routes); + let query = {referrer}; + + if (eventView) { + query = {...query, ...generateQueryStringObjectWithPlaylist(eventView)}; + } + + const replayDetailsPathname = makeReplaysPathname({ + path: `/${replay.id}/`, + organization, + }); + + const detailsTab = () => ({ + pathname: replayDetailsPathname, + query, + }); + return ( - + @@ -505,7 +540,7 @@ export const ReplaySessionColumn: ReplayTableColumn = { interactive: true, sortKey: 'started_at', width: 'minmax(150px, 1fr)', - Component: ({replay}) => { + Component: ({replay, eventView}) => { const routes = useRoutes(); const location = useLocation(); const organization = useOrganization(); @@ -521,7 +556,20 @@ export const ReplaySessionColumn: ReplayTableColumn = { ); const referrer = getRouteStringFromRoutes(routes); - const eventView = EventView.fromLocation(location); + + let query: Query = {referrer}; + + if (eventView) { + query = {...query, ...generateQueryStringObjectWithPlaylist(eventView)}; + } + + query.sort = location.query.sort ?? encodeSort(DEFAULT_SORT); + query.field = REPLAY_LIST_FIELDS; + + if (location.query.cursor) { + query.cursor = location.query.cursor; + } + const replayDetailsPathname = makeReplaysPathname({ path: `/${replay.id}/`, organization, @@ -529,10 +577,7 @@ export const ReplaySessionColumn: ReplayTableColumn = { const detailsTab = () => ({ pathname: replayDetailsPathname, - query: { - referrer, - ...eventView.generateQueryStringObject(), - }, + query, }); const trackNavigationEvent = () => trackAnalytics('replay.list-navigate-to-details', { diff --git a/static/app/components/replays/table/useReplayTableSort.tsx b/static/app/components/replays/table/useReplayTableSort.tsx index 438e2101797d2b..ad00da951f2db3 100644 --- a/static/app/components/replays/table/useReplayTableSort.tsx +++ b/static/app/components/replays/table/useReplayTableSort.tsx @@ -12,7 +12,7 @@ interface Props { queryParamKey?: string; } -const DEFAULT_SORT = {field: 'started_at', kind: 'desc'} as const; +export const DEFAULT_SORT = {field: 'started_at', kind: 'desc'} as const; export default function useReplayTableSort({ defaultSort = DEFAULT_SORT, diff --git a/static/app/utils/replays/fetchReplayList.tsx b/static/app/utils/replays/fetchReplayList.tsx index 25e9bcd4976163..1adde8fc346219 100644 --- a/static/app/utils/replays/fetchReplayList.tsx +++ b/static/app/utils/replays/fetchReplayList.tsx @@ -76,13 +76,7 @@ async function fetchReplayList({ return { fetchError: undefined, pageLinks, - replays: payload.query ? data.map(mapResponseToReplayRecord) : [], - // for the replay tab in transactions, if payload.query is undefined, - // this means the transaction has no related replays. - // but because we cannot query for an empty list of IDs (e.g. `id:[]` breaks our search endpoint), - // and leaving query empty results in ALL replays being returned for a specified project - // (which doesn't make sense as we want to show no replays), - // we essentially want to hardcode no replays being returned. + replays: data.map(mapResponseToReplayRecord), }; } catch (error: any) { if (error.responseJSON?.detail) { diff --git a/static/app/utils/replays/hooks/useReplayList.tsx b/static/app/utils/replays/hooks/useReplayList.tsx index 3264c9e7969feb..4edd5e27054ed6 100644 --- a/static/app/utils/replays/hooks/useReplayList.tsx +++ b/static/app/utils/replays/hooks/useReplayList.tsx @@ -15,6 +15,7 @@ type Options = { eventView: EventView; location: Location; organization: Organization; + enabled?: boolean; perPage?: number; queryReferrer?: ReplayListQueryReferrer; }; @@ -24,6 +25,7 @@ type State = Awaited> & {isFetching: boolean} type Result = State; function useReplayList({ + enabled = true, eventView, location, organization, @@ -44,8 +46,11 @@ function useReplayList({ api.clear(); setData(prev => ({ ...prev, - isFetching: true, + isFetching: enabled ? true : false, })); + if (!enabled) { + return; + } const response = await fetchReplayList({ api, organization, @@ -57,7 +62,16 @@ function useReplayList({ }); setData({...response, isFetching: false}); - }, [api, organization, location, eventView, queryReferrer, perPage, selection]); + }, [ + api, + organization, + location, + eventView, + queryReferrer, + perPage, + selection, + enabled, + ]); useEffect(() => { loadReplays(); diff --git a/static/app/utils/replays/playback/providers/replayPlaylistProvider.tsx b/static/app/utils/replays/playback/providers/replayPlaylistProvider.tsx new file mode 100644 index 00000000000000..389e52e02f766e --- /dev/null +++ b/static/app/utils/replays/playback/providers/replayPlaylistProvider.tsx @@ -0,0 +1,19 @@ +import type {ReactNode} from 'react'; +import {createContext, useContext} from 'react'; + +import type {ReplayListRecord} from 'sentry/views/replays/types'; + +interface Props { + children: ReactNode; + replays: ReplayListRecord[] | undefined; +} + +const Context = createContext(undefined); + +export function ReplayPlaylistProvider({children, replays}: Props) { + return {children}; +} + +export function useReplayPlaylist() { + return useContext(Context); +} diff --git a/static/app/views/issueDetails/groupReplays/groupReplays.spec.tsx b/static/app/views/issueDetails/groupReplays/groupReplays.spec.tsx index 0632527a18644e..5ad191735823fd 100644 --- a/static/app/views/issueDetails/groupReplays/groupReplays.spec.tsx +++ b/static/app/views/issueDetails/groupReplays/groupReplays.spec.tsx @@ -414,7 +414,7 @@ describe('GroupReplays', () => { expect(await screen.findAllByText('testDisplayName')).toHaveLength(2); const expectedQuery = - 'query=&referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&statsPeriod=14d&yAxis=count%28%29'; + 'field=activity&field=browser.name&field=browser.version&field=count_dead_clicks&field=count_errors&field=count_infos&field=count_rage_clicks&field=count_segments&field=count_urls&field=count_warnings&field=device.brand&field=device.family&field=device.model_id&field=device.name&field=dist&field=duration&field=environment&field=error_ids&field=finished_at&field=has_viewed&field=id&field=info_ids&field=is_archived&field=os.name&field=os.version&field=platform&field=project_id&field=releases&field=sdk.name&field=sdk.version&field=started_at&field=tags&field=trace_ids&field=urls&field=user&field=warning_ids&id=&name=&playlistEnd=2022-09-28T23%3A29%3A13&playlistStart=2022-06-30T23%3A29%3A13&query=id%3A%5B346789a703f6454384f1de473b8b9fcc%2Cb05dae9b6be54d21a4d5ad9f8f02b780%5D&referrer=%2Forganizations%2F%3AorgId%2Fissues%2F%3AgroupId%2Freplays%2F&sort=-started_at&yAxis=count%28%29'; // Expect the first row to have the correct href expect( diff --git a/static/app/views/issueDetails/groupReplays/groupReplays.tsx b/static/app/views/issueDetails/groupReplays/groupReplays.tsx index 6e7246fe1164ad..0dff121686bd7d 100644 --- a/static/app/views/issueDetails/groupReplays/groupReplays.tsx +++ b/static/app/views/issueDetails/groupReplays/groupReplays.tsx @@ -244,6 +244,7 @@ function GroupReplaysTable({ const replayTable = ( { let eventsMockApi: jest.Mock; - let replaysMockApi: jest.Mock; beforeEach(() => { MockApiClient.addMockResponse({ method: 'GET', @@ -97,13 +96,6 @@ describe('TransactionReplays', () => { }, statusCode: 200, }); - replaysMockApi = MockApiClient.addMockResponse({ - url: '/organizations/org-slug/replays/', - body: { - data: [], - }, - statusCode: 200, - }); }); afterEach(() => { @@ -140,29 +132,10 @@ describe('TransactionReplays', () => { }); }); - it('should snapshot empty state', async () => { - const mockApi = MockApiClient.addMockResponse({ - url: mockReplaysUrl, - body: { - data: [], - }, - statusCode: 200, - }); - - renderComponent(); - - await waitFor(() => { - expect(mockApi).toHaveBeenCalledTimes(1); - }); - }); - it('should show empty message when no replays are found', async () => { renderComponent(); - await waitFor(() => { - expect(replaysMockApi).toHaveBeenCalledTimes(1); - }); - expect(screen.getByText('No replays found')).toBeInTheDocument(); + await screen.findByText('No replays found'); }); it('should show loading indicator when loading replays', async () => { @@ -234,7 +207,8 @@ describe('TransactionReplays', () => { expect(screen.getAllByText('testDisplayName')).toHaveLength(2); const expectedQuery = - 'project=1&query=test&referrer=replays%2F&statsPeriod=14d&yAxis=count%28%29'; + 'field=activity&field=browser.name&field=browser.version&field=count_dead_clicks&field=count_errors&field=count_infos&field=count_rage_clicks&field=count_segments&field=count_urls&field=count_warnings&field=device.brand&field=device.family&field=device.model_id&field=device.name&field=dist&field=duration&field=environment&field=error_ids&field=finished_at&field=has_viewed&field=id&field=info_ids&field=is_archived&field=os.name&field=os.version&field=platform&field=project_id&field=releases&field=sdk.name&field=sdk.version&field=started_at&field=tags&field=trace_ids&field=urls&field=user&field=warning_ids&id=&name=&playlistEnd=2022-09-28T23%3A29%3A13&playlistStart=2022-09-14T23%3A29%3A13&query=test&referrer=replays%2F&sort=-started_at&yAxis=count%28%29'; + // Expect the first row to have the correct href expect( screen.getByRole('link', { diff --git a/static/app/views/performance/transactionSummary/transactionReplays/transactionReplays.tsx b/static/app/views/performance/transactionSummary/transactionReplays/transactionReplays.tsx index 75d5c78d52bc0f..cf151d0b7b863a 100644 --- a/static/app/views/performance/transactionSummary/transactionReplays/transactionReplays.tsx +++ b/static/app/views/performance/transactionSummary/transactionReplays/transactionReplays.tsx @@ -98,6 +98,13 @@ function ReplaysContent({ const hasRoomForColumns = useMedia(`(min-width: ${theme.breakpoints.sm})`); const {replays, isFetching, fetchError} = useReplayList({ + enabled: Boolean(eventView.query !== ''), + // for the replay tab in transactions, if payload.query is undefined, + // this means the transaction has no related replays. + // but because we cannot query for an empty list of IDs (e.g. `id:[]` breaks our search endpoint), + // and leaving query empty results in ALL replays being returned for a specified project + // (which doesn't make sense as we want to show no replays), + // we essentially want to hardcode no replays being returned. eventView, location: newLocation, organization, @@ -114,6 +121,7 @@ function ReplaysContent({ return ( { + return EventView.fromLocation(location); + }, [location]); + + const {replays} = useReplayList({ + eventView, + location, + organization, + queryReferrer: 'playlist', + }); + useLogReplayDataLoaded({projectId: replayRecord.project_id, replay}); return ( @@ -53,7 +70,9 @@ export default function ReplayDetailsProviders({children, replay, projectSlug}: replay={replay} > - {children} + + {children} + diff --git a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx index 626e803804b67f..0b5f9faf7585e3 100644 --- a/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx +++ b/static/app/views/replays/detail/header/replayDetailsPageBreadcrumbs.tsx @@ -1,19 +1,21 @@ -import {useState} from 'react'; +import {useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {Breadcrumbs} from 'sentry/components/breadcrumbs'; -import {Button} from 'sentry/components/core/button'; +import {Button, ButtonBar} from 'sentry/components/core/button'; +import {LinkButton} from 'sentry/components/core/button/linkButton'; import {Flex} from 'sentry/components/core/layout'; import {Tooltip} from 'sentry/components/core/tooltip'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import Placeholder from 'sentry/components/placeholder'; import {useReplayContext} from 'sentry/components/replays/replayContext'; -import {IconCopy} from 'sentry/icons'; +import {IconCopy, IconNext, IconPrevious} from 'sentry/icons'; import {t} from 'sentry/locale'; import {defined} from 'sentry/utils'; import EventView from 'sentry/utils/discover/eventView'; import {getShortEventId} from 'sentry/utils/events'; import type useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; +import {useReplayPlaylist} from 'sentry/utils/replays/playback/providers/replayPlaylistProvider'; import useCopyToClipboard from 'sentry/utils/useCopyToClipboard'; import {useLocation} from 'sentry/utils/useLocation'; import useOrganization from 'sentry/utils/useOrganization'; @@ -33,6 +35,24 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { const [isHovered, setIsHovered] = useState(false); const {currentTime} = useReplayContext(); + const replays = useReplayPlaylist(); + + const currentReplayIndex = useMemo( + () => replays?.findIndex(r => r.id === replayRecord?.id) ?? -1, + [replays, replayRecord] + ); + + const nextReplay = useMemo( + () => + currentReplayIndex >= 0 && currentReplayIndex < (replays?.length ?? 0) - 1 + ? replays?.[currentReplayIndex + 1] + : undefined, + [replays, currentReplayIndex] + ); + const previousReplay = useMemo( + () => (currentReplayIndex > 0 ? replays?.[currentReplayIndex - 1] : undefined), + [replays, currentReplayIndex] + ); // Create URL with current timestamp for copying const replayUrlWithTimestamp = replayRecord ? (() => { @@ -74,22 +94,53 @@ export default function ReplayDetailsPageBreadcrumbs({readerResult}: Props) { const replayCrumb = { label: replayRecord ? ( - setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- copy(replayUrlWithTimestamp, { - successMessage: t('Copied replay link to clipboard'), - }) - } + + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} > - {getShortEventId(replayRecord?.id)} -
- {isHovered && ( + {organization.features.includes('replay-playlist-view') && ( + + + } + disabled={!previousReplay} + to={{ + pathname: previousReplay + ? makeReplaysPathname({ + path: `/${previousReplay.id}/`, + organization, + }) + : undefined, + query: location.query, + }} + /> + } + disabled={!nextReplay} + to={{ + pathname: nextReplay + ? makeReplaysPathname({path: `/${nextReplay.id}/`, organization}) + : undefined, + query: location.query, + }} + /> + + + )} +
+ copy(replayUrlWithTimestamp, { + successMessage: t('Copied replay link to clipboard'), + }) + } + > + {getShortEventId(replayRecord?.id)} +