Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {useState} from 'react';
import {ClassNames} from '@emotion/react';

import {Flex} from 'sentry/components/core/layout/flex';
import {Hovercard} from 'sentry/components/hovercard';
import ReplayList from 'sentry/components/replays/list/__stories__/replayList';
import EnvironmentPicker from 'sentry/components/replays/player/__stories__/environmentPicker';
import ProjectPicker from 'sentry/components/replays/player/__stories__/projectPicker';
import * as Storybook from 'sentry/stories';
import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
import useReplayListQueryKey from 'sentry/utils/replays/hooks/useReplayListQueryKey';
import useOrganization from 'sentry/utils/useOrganization';
import type {ReplayListRecord} from 'sentry/views/replays/types';

export default Storybook.story('ReplayList', story => {
story('Rendered', () => {
const organization = useOrganization();
const [project, setProject] = useState<string | undefined>();
const [environment, setEnvironment] = useState<string | undefined>();
const [replayId, setReplayId] = useState<string | undefined>();

const query = {
environment: environment ? [environment] : undefined,
project: project ? [project] : undefined,
sort: '-started_at',
statsPeriod: '90d',
};

const listQueryKey = useReplayListQueryKey({
options: {query},
organization,
queryReferrer: 'replayList',
});
const queryResult = useInfiniteApiQuery<{data: ReplayListRecord[]}>({
queryKey: ['infinite', ...(listQueryKey ?? '')],
enabled: Boolean(listQueryKey),
});

return (
<Flex direction="column" gap="md">
Selected Replay: {replayId}
<Flex gap="sm">
<ProjectPicker project={project} onChange={setProject} />
<EnvironmentPicker
project={project}
environment={environment}
onChange={setEnvironment}
/>
</Flex>
<Flex style={{height: 500}}>
<Flex direction="column" gap="md" flex="1">
<ReplayList onSelect={setReplayId} queryResult={queryResult} />
</Flex>
</Flex>
</Flex>
);
});

story('Hovercard', () => {
const organization = useOrganization();

const [project, setProject] = useState<string | undefined>();
const [environment, setEnvironment] = useState<string | undefined>();

const [replayId, setReplayId] = useState<string | undefined>();

const query = {
environment: environment ? [environment] : undefined,
project: project ? [project] : undefined,
sort: '-started_at',
statsPeriod: '90d',
};

const listQueryKey = useReplayListQueryKey({
options: {query},
organization,
queryReferrer: 'replayList',
});
const queryResult = useInfiniteApiQuery<{data: ReplayListRecord[]}>({
queryKey: ['infinite', ...(listQueryKey ?? '')],
enabled: Boolean(listQueryKey),
});

return (
<ClassNames>
{({css}) => (
<Hovercard
body={
<Flex direction="column" gap="md">
<Flex gap="sm">
<ProjectPicker project={project} onChange={setProject} />
<EnvironmentPicker
project={project}
environment={environment}
onChange={setEnvironment}
/>
</Flex>
<Flex style={{height: 500}}>
<Flex direction="column" gap="md" flex="1">
<ReplayList onSelect={setReplayId} queryResult={queryResult} />
</Flex>
</Flex>
</Flex>
}
containerClassName={css`
width: max-content;
`}
>
Selected Replay: {replayId}
</Hovercard>
)}
</ClassNames>
);
});
});
89 changes: 89 additions & 0 deletions static/app/components/replays/list/__stories__/replayList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import styled from '@emotion/styled';
import uniqBy from 'lodash/uniqBy';

import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg';

import type {ApiResult} from 'sentry/api';
import {Tooltip} from 'sentry/components/core/tooltip';
import ErrorBoundary from 'sentry/components/errorBoundary';
import InfiniteListItems from 'sentry/components/infiniteList/infiniteListItems';
import InfiniteListState from 'sentry/components/infiniteList/infiniteListState';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import ReplayListItem from 'sentry/components/replays/list/__stories__/replayListItem';
import {t} from 'sentry/locale';
import {type InfiniteData, type UseInfiniteQueryResult} from 'sentry/utils/queryClient';
import type {ReplayListRecord} from 'sentry/views/replays/types';

interface Props {
onSelect: (replayId: string) => void;
queryResult: UseInfiniteQueryResult<
InfiniteData<ApiResult<{data: ReplayListRecord[]}>>
>;
}

export default function ReplayList({onSelect, queryResult}: Props) {
return (
<InfiniteListState
queryResult={queryResult}
backgroundUpdatingMessage={() => null}
loadingMessage={() => <LoadingIndicator />}
>
<InfiniteListItems<ReplayListRecord, ApiResult<{data: ReplayListRecord[]}>>
deduplicateItems={pages => pages.flatMap(page => uniqBy(page[0].data, 'id'))}
estimateSize={() => 24}
queryResult={queryResult}
itemRenderer={({item, virtualItem}) => (
<ErrorBoundary mini>
<ReplayListItem
replay={item}
rowIndex={virtualItem.index}
onClick={() => onSelect(item.id)}
/>
</ErrorBoundary>
)}
emptyMessage={() => <NoReplays />}
loadingMoreMessage={() => (
<Centered>
<Tooltip title={t('Loading more replays...')}>
<LoadingIndicator mini />
</Tooltip>
</Centered>
)}
loadingCompleteMessage={() => null}
/>
</InfiniteListState>
);
}

function NoReplays() {
return (
<NoReplaysWrapper>
<img src={waitingForEventImg} alt={t('A person waiting for a phone to ring')} />
<NoReplaysMessage>{t('Inbox Zero')}</NoReplaysMessage>
<p>{t('You have two options: take a nap or be productive.')}</p>
</NoReplaysWrapper>
);
}

const Centered = styled('div')`
justify-self: center;
`;

const NoReplaysWrapper = styled('div')`
padding: ${p => p.theme.space['3xl']};
text-align: center;
color: ${p => p.theme.subText};

@media (max-width: ${p => p.theme.breakpoints.sm}) {
font-size: ${p => p.theme.fontSize.md};
}
`;

const NoReplaysMessage = styled('div')`
font-weight: ${p => p.theme.fontWeight.bold};
color: ${p => p.theme.gray400};

@media (min-width: ${p => p.theme.breakpoints.sm}) {
font-size: ${p => p.theme.fontSize.xl};
}
`;
135 changes: 135 additions & 0 deletions static/app/components/replays/list/__stories__/replayListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import styled from '@emotion/styled';
import invariant from 'invariant';

import {ProjectAvatar} from 'sentry/components/core/avatar/projectAvatar';
import {UserAvatar} from 'sentry/components/core/avatar/userAvatar';
import InteractionStateLayer from 'sentry/components/core/interactionStateLayer';
import {Flex} from 'sentry/components/core/layout/flex';
import {Text} from 'sentry/components/core/text';
import TimeSince from 'sentry/components/timeSince';
import {IconCalendar} from 'sentry/icons/iconCalendar';
import {IconDelete} from 'sentry/icons/iconDelete';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {getShortEventId} from 'sentry/utils/events';
import useOrganization from 'sentry/utils/useOrganization';
import useProjectFromId from 'sentry/utils/useProjectFromId';
import type {ReplayListRecordWithTx} from 'sentry/views/performance/transactionSummary/transactionReplays/useReplaysWithTxData';
import {makeReplaysPathname} from 'sentry/views/replays/pathnames';
import type {ReplayListRecord} from 'sentry/views/replays/types';

interface Props {
onClick: () => void;
replay: ReplayListRecord | ReplayListRecordWithTx;
rowIndex: number;
}

export default function ReplayListItem({replay, onClick}: Props) {
const organization = useOrganization();
const project = useProjectFromId({project_id: replay.project_id ?? undefined});

const replayDetailsPathname = makeReplaysPathname({
path: `/${replay.id}/`,
organization,
});

if (replay.is_archived) {
return (
<Flex gap="md" align="center" justify="center">
<ArchivedWrapper>
<IconDelete color="gray500" size="md" />
</ArchivedWrapper>

<Flex direction="column" gap="xs">
<DisplayName>{t('Deleted Replay')}</DisplayName>
<Flex gap="xs" align="center">
{project ? <ProjectAvatar size={12} project={project} /> : null}
<Text size="sm">{getShortEventId(replay.id)}</Text>
</Flex>
</Flex>
</Flex>
);
}

invariant(
replay.started_at,
'For TypeScript: replay.started_at is implied because replay.is_archived is false'
);

return (
<CardSpacing>
<a
href={replayDetailsPathname}
onClick={e => {
e.preventDefault();
onClick();
}}
>
<Flex align="center" gap="md" padding="xs">
<UserAvatar
user={{
username: replay.user?.display_name || '',
email: replay.user?.email || '',
id: replay.user?.id || '',
ip_address: replay.user?.ip || '',
name: replay.user?.username || '',
}}
size={24}
/>
<SubText>
<Flex gap="xs" align="start">
<DisplayName data-underline-on-hover>
{replay.user.display_name || t('Anonymous User')}
</DisplayName>
</Flex>
<Flex gap="xs">
{/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */}
{project ? <ProjectAvatar size={12} project={project} /> : null}
{project ? <span>{project.slug}</span> : null}
<span>{getShortEventId(replay.id)}</span>
<Flex gap="xs">
<IconCalendar color="gray300" size="xs" />
<TimeSince date={replay.started_at} />
</Flex>
</Flex>
</SubText>
<InteractionStateLayer />
</Flex>
</a>
</CardSpacing>
);
}

const CardSpacing = styled('div')`
position: relative;
padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
`;

const ArchivedWrapper = styled(Flex)`
width: ${p => p.theme.space['2xl']};
align-items: center;
justify-content: center;
`;

const SubText = styled('div')`
font-size: 0.875em;
line-height: normal;
color: ${p => p.theme.subText};
${p => p.theme.overflowEllipsis};
display: flex;
flex-direction: column;
gap: ${space(0.25)};
align-items: flex-start;
`;

const DisplayName = styled('span')`
color: ${p => p.theme.textColor};
font-size: ${p => p.theme.fontSize.md};
font-weight: ${p => p.theme.fontWeight.bold};
line-height: normal;
${p => p.theme.overflowEllipsis};
&:hover {
color: ${p => p.theme.textColor};
}
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {useMemo} from 'react';
import uniq from 'lodash/uniq';

import {CompactSelect} from 'sentry/components/core/compactSelect';
import useProjects from 'sentry/utils/useProjects';

export default function EnvironmentPicker({
environment,
onChange,
project,
}: {
environment: string | undefined;
onChange: (environment: string) => void;
project: string | undefined;
}) {
const {projects} = useProjects();
const environments = uniq(
projects
.filter(p => (project ? p.id === project : false))
.flatMap(p => p.environments)
);

const options = useMemo(
() => environments.map(env => ({label: env, value: env})),
[environments]
);

return (
<CompactSelect
onChange={selected => onChange(selected.value)}
options={options}
searchable
size="xs"
triggerProps={{prefix: 'Environment'}}
value={environment}
/>
);
}
Loading
Loading