diff --git a/static/app/components/replays/list/__stories__/replayList.stories.tsx b/static/app/components/replays/list/__stories__/replayList.stories.tsx new file mode 100644 index 00000000000000..f905ab8d6121bb --- /dev/null +++ b/static/app/components/replays/list/__stories__/replayList.stories.tsx @@ -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(); + const [environment, setEnvironment] = useState(); + const [replayId, setReplayId] = useState(); + + 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 ( + + Selected Replay: {replayId} + + + + + + + + + + + ); + }); + + story('Hovercard', () => { + const organization = useOrganization(); + + const [project, setProject] = useState(); + const [environment, setEnvironment] = useState(); + + const [replayId, setReplayId] = useState(); + + 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 ( + + {({css}) => ( + + + + + + + + + + + + } + containerClassName={css` + width: max-content; + `} + > + Selected Replay: {replayId} + + )} + + ); + }); +}); diff --git a/static/app/components/replays/list/__stories__/replayList.tsx b/static/app/components/replays/list/__stories__/replayList.tsx new file mode 100644 index 00000000000000..75581e83a12dfa --- /dev/null +++ b/static/app/components/replays/list/__stories__/replayList.tsx @@ -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> + >; +} + +export default function ReplayList({onSelect, queryResult}: Props) { + return ( + null} + loadingMessage={() => } + > + > + deduplicateItems={pages => pages.flatMap(page => uniqBy(page[0].data, 'id'))} + estimateSize={() => 24} + queryResult={queryResult} + itemRenderer={({item, virtualItem}) => ( + + onSelect(item.id)} + /> + + )} + emptyMessage={() => } + loadingMoreMessage={() => ( + + + + + + )} + loadingCompleteMessage={() => null} + /> + + ); +} + +function NoReplays() { + return ( + + {t('A + {t('Inbox Zero')} +

{t('You have two options: take a nap or be productive.')}

+
+ ); +} + +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}; + } +`; diff --git a/static/app/components/replays/list/__stories__/replayListItem.tsx b/static/app/components/replays/list/__stories__/replayListItem.tsx new file mode 100644 index 00000000000000..e5dfd6624ef0f9 --- /dev/null +++ b/static/app/components/replays/list/__stories__/replayListItem.tsx @@ -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 ( + + + + + + + {t('Deleted Replay')} + + {project ? : null} + {getShortEventId(replay.id)} + + + + ); + } + + invariant( + replay.started_at, + 'For TypeScript: replay.started_at is implied because replay.is_archived is false' + ); + + return ( + + { + e.preventDefault(); + onClick(); + }} + > + + + + + + {replay.user.display_name || t('Anonymous User')} + + + + {/* Avatar is used instead of ProjectBadge because using ProjectBadge increases spacing, which doesn't look as good */} + {project ? : null} + {project ? {project.slug} : null} + {getShortEventId(replay.id)} + + + + + + + + + + + ); +} + +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}; + } +`; diff --git a/static/app/components/replays/player/__stories__/environmentPicker.tsx b/static/app/components/replays/player/__stories__/environmentPicker.tsx new file mode 100644 index 00000000000000..cbb10ea93d7dfa --- /dev/null +++ b/static/app/components/replays/player/__stories__/environmentPicker.tsx @@ -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 ( + onChange(selected.value)} + options={options} + searchable + size="xs" + triggerProps={{prefix: 'Environment'}} + value={environment} + /> + ); +} diff --git a/static/app/components/replays/player/__stories__/projectPicker.tsx b/static/app/components/replays/player/__stories__/projectPicker.tsx new file mode 100644 index 00000000000000..2ea05f1831a8cd --- /dev/null +++ b/static/app/components/replays/player/__stories__/projectPicker.tsx @@ -0,0 +1,30 @@ +import {useMemo} from 'react'; + +import {CompactSelect} from 'sentry/components/core/compactSelect'; +import useProjects from 'sentry/utils/useProjects'; + +export default function ProjectPicker({ + onChange, + project, +}: { + onChange: (project: string) => void; + project: string | undefined; +}) { + const {projects} = useProjects(); + + const options = useMemo( + () => projects.map(p => ({value: p.id, label: p.slug})), + [projects] + ); + + return ( + onChange(selected.value)} + options={options} + searchable + size="xs" + triggerProps={{prefix: 'Project'}} + value={project} + /> + ); +} diff --git a/static/app/components/replays/player/__stories__/replaySlugChooser.tsx b/static/app/components/replays/player/__stories__/replaySlugChooser.tsx index b14a95ffc6b691..315887a632b9b6 100644 --- a/static/app/components/replays/player/__stories__/replaySlugChooser.tsx +++ b/static/app/components/replays/player/__stories__/replaySlugChooser.tsx @@ -1,66 +1,113 @@ -import {Fragment, type ReactNode} from 'react'; -import {css} from '@emotion/react'; +import {Fragment, useState, type ReactNode} from 'react'; +import {ClassNames} from '@emotion/react'; -import {Input} from 'sentry/components/core/input'; +import {InputGroup} from 'sentry/components/core/input/inputGroup'; +import {Flex} from 'sentry/components/core/layout/flex'; +import {Text} from 'sentry/components/core/text'; +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 Providers from 'sentry/components/replays/player/__stories__/providers'; import ReplayLoadingState from 'sentry/components/replays/player/replayLoadingState'; +import {useInfiniteApiQuery} from 'sentry/utils/queryClient'; import useLoadReplayReader from 'sentry/utils/replays/hooks/useLoadReplayReader'; +import useReplayListQueryKey from 'sentry/utils/replays/hooks/useReplayListQueryKey'; import useOrganization from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; +import type {ReplayListRecord} from 'sentry/views/replays/types'; -type Props = - | {render: (replaySlug: string) => ReactNode; children?: never} - | {children: ReactNode; render?: never}; - -export default function ReplaySlugChooser(props: Props) { - const {children, render} = props; +interface Props { + children: ReactNode; +} +export default function ReplaySlugChooser({children}: Props) { + const organization = useOrganization(); + const [project, setProject] = useState(); + const [environment, setEnvironment] = useState(); const [replaySlug, setReplaySlug] = useSessionStorage('stories:replaySlug', ''); + 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), + }); + const input = ( - { - setReplaySlug(event.target.value); - }} - placeholder="Paste a replaySlug" - css={css` - font-variant-numeric: tabular-nums; - `} - size="sm" - /> - ); + + + - if (replaySlug && children) { - function Content() { - const organization = useOrganization(); - const readerResult = useLoadReplayReader({ - orgSlug: organization.slug, - replaySlug, - clipWindow: undefined, - }); - return ( - - {({replay}) => {children}} - - ); - } - return ( - - {input} - - - ); - } + + {({css}) => ( + + + + + + + + } + containerClassName={css` + width: max-content; + `} + > + + Replay ID: + { + setReplaySlug(event.target.value); + }} + placeholder="Paste a replaySlug" + css={css` + font-variant-numeric: tabular-nums; + min-width: calc(32ch + 1em); + `} + size="sm" + /> + + + )} + + + ); - if (replaySlug && render) { - return ( - - {input} - {render(replaySlug)} - - ); - } + return ( + + {input} + {replaySlug ? {children} : null} + + ); +} - return input; +function Content({children, replaySlug}: {children: ReactNode; replaySlug: string}) { + const organization = useOrganization(); + const readerResult = useLoadReplayReader({ + orgSlug: organization.slug, + replaySlug, + clipWindow: undefined, + }); + return ( + + {({replay}) => {children}} + + ); }