replays.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import {Fragment, useEffect, useState} from 'react';
  2. import {browserHistory, withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import FeatureBadge from 'sentry/components/featureBadge';
  5. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  6. import UserBadge from 'sentry/components/idBadge/userBadge';
  7. import Link from 'sentry/components/links/link';
  8. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  9. import PageHeading from 'sentry/components/pageHeading';
  10. import Pagination from 'sentry/components/pagination';
  11. import {PanelTable} from 'sentry/components/panels';
  12. import TimeSince from 'sentry/components/timeSince';
  13. import {IconArrow, IconCalendar} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {PageContent, PageHeader} from 'sentry/styles/organization';
  16. import space from 'sentry/styles/space';
  17. import {NewQuery, Organization, PageFilters} from 'sentry/types';
  18. import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
  19. import EventView from 'sentry/utils/discover/eventView';
  20. import {generateEventSlug} from 'sentry/utils/discover/urls';
  21. import getUrlPathname from 'sentry/utils/getUrlPathname';
  22. import {useLocation} from 'sentry/utils/useLocation';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import useProjects from 'sentry/utils/useProjects';
  25. import withOrganization from 'sentry/utils/withOrganization';
  26. import withPageFilters from 'sentry/utils/withPageFilters';
  27. import AsyncView from 'sentry/views/asyncView';
  28. import ReplaysFilters from './filters';
  29. import {Replay} from './types';
  30. type Props = AsyncView['props'] &
  31. WithRouterProps<{orgId: string}> & {
  32. organization: Organization;
  33. selection: PageFilters;
  34. statsPeriod?: string | undefined; // revisit i'm sure i'm doing statsperiod wrong
  35. };
  36. // certain query params can be either a string or an array of strings
  37. // so if we have an array we reduce it down to a string
  38. const getQueryParamAsString = query => {
  39. if (!query) {
  40. return '';
  41. }
  42. return Array.isArray(query) ? query.join(' ') : query;
  43. };
  44. function Replays(props: Props) {
  45. const location = useLocation();
  46. const organization = useOrganization();
  47. const {projects} = useProjects();
  48. const [searchQuery, setSearchQuery] = useState<string>(
  49. getQueryParamAsString(location.query.query)
  50. );
  51. useEffect(() => {
  52. setSearchQuery(getQueryParamAsString(location.query.query));
  53. }, [location.query.query]);
  54. const getEventView = () => {
  55. const {selection} = props;
  56. const {query} = location;
  57. const eventQueryParams: NewQuery = {
  58. id: '',
  59. name: '',
  60. version: 2,
  61. fields: ['eventID', 'project', 'timestamp', 'user.display', 'url'],
  62. orderby: getQueryParamAsString(query.sort) || '-timestamp',
  63. environment: selection.environments,
  64. projects: selection.projects,
  65. query: `transaction:sentry-replay ${searchQuery}`, // future: change to replay event
  66. };
  67. if (selection.datetime.period) {
  68. eventQueryParams.range = selection.datetime.period;
  69. }
  70. return EventView.fromNewQueryWithLocation(eventQueryParams, location);
  71. };
  72. const handleSearchQuery = (query: string) => {
  73. browserHistory.push({
  74. pathname: location.pathname,
  75. query: {
  76. ...location.query,
  77. cursor: undefined,
  78. query: String(query).trim() || undefined,
  79. },
  80. });
  81. };
  82. const renderTable = (replayList: Array<Replay>) => {
  83. return replayList?.map(replay => (
  84. <Fragment key={replay.id}>
  85. <Link
  86. to={`/organizations/${organization.slug}/replays/${generateEventSlug({
  87. project: replay.project,
  88. id: replay.id,
  89. })}/`}
  90. >
  91. <ReplayUserBadge
  92. avatarSize={32}
  93. displayName={replay['user.display']}
  94. user={{
  95. username: replay['user.display'],
  96. id: replay['user.display'],
  97. ip_address: replay['user.display'],
  98. name: replay['user.display'],
  99. email: replay['user.display'],
  100. }}
  101. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  102. displayEmail={getUrlPathname(replay.url) ?? ''}
  103. />
  104. </Link>
  105. <ProjectBadge
  106. project={
  107. projects.find(p => p.slug === replay.project) || {slug: replay.project}
  108. }
  109. avatarSize={16}
  110. />
  111. <div>
  112. <TimeSinceWrapper>
  113. <StyledIconCalendarWrapper color="gray500" size="sm" />
  114. <TimeSince date={replay.timestamp} />
  115. </TimeSinceWrapper>
  116. </div>
  117. </Fragment>
  118. ));
  119. };
  120. const {query} = location;
  121. const {cursor: _cursor, page: _page, ...currentQuery} = query;
  122. const sort: {
  123. field: string;
  124. } = {
  125. field: getQueryParamAsString(query.sort) || '-timestamp',
  126. };
  127. const arrowDirection = sort.field.startsWith('-') ? 'down' : 'up';
  128. const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
  129. return (
  130. <Fragment>
  131. <StyledPageHeader>
  132. <HeaderTitle>
  133. <div>
  134. {t('Replays')} <FeatureBadge type="alpha" />
  135. </div>
  136. </HeaderTitle>
  137. </StyledPageHeader>
  138. <PageFiltersContainer hideGlobalHeader resetParamsOnChange={['cursor']}>
  139. <StyledPageContent>
  140. <DiscoverQuery
  141. eventView={getEventView()}
  142. location={props.location}
  143. orgSlug={organization.slug}
  144. >
  145. {data => {
  146. return (
  147. <Fragment>
  148. <ReplaysFilters
  149. query={searchQuery}
  150. organization={organization}
  151. handleSearchQuery={handleSearchQuery}
  152. />
  153. <PanelTable
  154. isLoading={data.isLoading}
  155. isEmpty={data.tableData?.data.length === 0}
  156. headers={[
  157. t('Session'),
  158. t('Project'),
  159. <SortLink
  160. key="timestamp"
  161. role="columnheader"
  162. aria-sort={
  163. !sort.field.endsWith('timestamp')
  164. ? 'none'
  165. : sort.field === '-timestamp'
  166. ? 'descending'
  167. : 'ascending'
  168. }
  169. to={{
  170. pathname: location.pathname,
  171. query: {
  172. ...currentQuery,
  173. // sort by timestamp should start by ascending on first click
  174. sort:
  175. sort.field === '-timestamp' ? 'timestamp' : '-timestamp',
  176. },
  177. }}
  178. >
  179. {t('Timestamp')} {sort.field.endsWith('timestamp') && sortArrow}
  180. </SortLink>,
  181. ]}
  182. >
  183. {data.tableData ? renderTable(data.tableData.data as Replay[]) : null}
  184. </PanelTable>
  185. <Pagination pageLinks={data.pageLinks} />
  186. </Fragment>
  187. );
  188. }}
  189. </DiscoverQuery>
  190. </StyledPageContent>
  191. </PageFiltersContainer>
  192. </Fragment>
  193. );
  194. }
  195. const StyledPageHeader = styled(PageHeader)`
  196. background-color: ${p => p.theme.surface100};
  197. margin-top: ${space(1.5)};
  198. margin-left: ${space(4)};
  199. `;
  200. const StyledPageContent = styled(PageContent)`
  201. box-shadow: 0px 0px 1px ${p => p.theme.gray200};
  202. background-color: ${p => p.theme.white};
  203. `;
  204. const HeaderTitle = styled(PageHeading)`
  205. display: flex;
  206. align-items: center;
  207. justify-content: space-between;
  208. flex: 1;
  209. `;
  210. const ReplayUserBadge = styled(UserBadge)`
  211. font-size: ${p => p.theme.fontSizeMedium};
  212. color: ${p => p.theme.linkColor};
  213. `;
  214. const TimeSinceWrapper = styled('div')`
  215. display: grid;
  216. grid-template-columns: repeat(2, minmax(auto, max-content));
  217. align-items: center;
  218. gap: ${space(1.5)};
  219. `;
  220. const StyledIconCalendarWrapper = styled(IconCalendar)`
  221. position: relative;
  222. top: -1px;
  223. `;
  224. const SortLink = styled(Link)`
  225. color: inherit;
  226. :hover {
  227. color: inherit;
  228. }
  229. svg {
  230. vertical-align: top;
  231. }
  232. `;
  233. export default withRouter(withPageFilters(withOrganization(Replays)));