replayTable.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import {Fragment} from 'react';
  2. import {useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import Alert from 'sentry/components/alert';
  5. import Duration from 'sentry/components/duration';
  6. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  7. import UserBadge from 'sentry/components/idBadge/userBadge';
  8. import Link from 'sentry/components/links/link';
  9. import {PanelTable} from 'sentry/components/panels';
  10. import QuestionTooltip from 'sentry/components/questionTooltip';
  11. import ReplayHighlight from 'sentry/components/replays/replayHighlight';
  12. import {StringWalker} from 'sentry/components/replays/walker/urlWalker';
  13. import TimeSince from 'sentry/components/timeSince';
  14. import {IconArrow, IconCalendar} from 'sentry/icons';
  15. import {t} from 'sentry/locale';
  16. import space from 'sentry/styles/space';
  17. import type {Organization} from 'sentry/types';
  18. import type {Sort} from 'sentry/utils/discover/fields';
  19. import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
  20. import {useLocation} from 'sentry/utils/useLocation';
  21. import useMedia from 'sentry/utils/useMedia';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import useProjects from 'sentry/utils/useProjects';
  24. import {useRoutes} from 'sentry/utils/useRoutes';
  25. import type {ReplayListLocationQuery, ReplayListRecord} from 'sentry/views/replays/types';
  26. type Props = {
  27. isFetching: boolean;
  28. replays: undefined | ReplayListRecord[];
  29. showProjectColumn: boolean;
  30. sort: Sort;
  31. fetchError?: Error;
  32. };
  33. type RowProps = {
  34. minWidthIsSmall: boolean;
  35. organization: Organization;
  36. referrer: string;
  37. replay: ReplayListRecord;
  38. showProjectColumn: boolean;
  39. };
  40. function SortableHeader({
  41. fieldName,
  42. label,
  43. sort,
  44. }: {
  45. fieldName: string;
  46. label: string;
  47. sort: Sort;
  48. }) {
  49. const location = useLocation<ReplayListLocationQuery>();
  50. const arrowDirection = sort.kind === 'asc' ? 'up' : 'down';
  51. const sortArrow = <IconArrow color="gray300" size="xs" direction={arrowDirection} />;
  52. return (
  53. <SortLink
  54. role="columnheader"
  55. aria-sort={
  56. sort.field.endsWith(fieldName)
  57. ? sort.kind === 'asc'
  58. ? 'ascending'
  59. : 'descending'
  60. : 'none'
  61. }
  62. to={{
  63. pathname: location.pathname,
  64. query: {
  65. ...location.query,
  66. sort: sort.kind === 'desc' ? fieldName : '-' + fieldName,
  67. },
  68. }}
  69. >
  70. {label} {sort.field === fieldName && sortArrow}
  71. </SortLink>
  72. );
  73. }
  74. function ReplayTable({isFetching, replays, showProjectColumn, sort, fetchError}: Props) {
  75. const routes = useRoutes();
  76. const referrer = encodeURIComponent(getRouteStringFromRoutes(routes));
  77. const organization = useOrganization();
  78. const theme = useTheme();
  79. const minWidthIsSmall = useMedia(`(min-width: ${theme.breakpoints.small})`);
  80. const tableHeaders = [
  81. t('Session'),
  82. showProjectColumn && minWidthIsSmall ? (
  83. <SortableHeader
  84. key="projectId"
  85. sort={sort}
  86. fieldName="projectId"
  87. label={t('Project')}
  88. />
  89. ) : null,
  90. <SortableHeader
  91. key="startedAt"
  92. sort={sort}
  93. fieldName="startedAt"
  94. label={t('Start Time')}
  95. />,
  96. <SortableHeader
  97. key="duration"
  98. sort={sort}
  99. fieldName="duration"
  100. label={t('Duration')}
  101. />,
  102. <SortableHeader
  103. key="countErrors"
  104. sort={sort}
  105. fieldName="countErrors"
  106. label={t('Errors')}
  107. />,
  108. <Header key="activity">
  109. {t('Activity')}{' '}
  110. <QuestionTooltip
  111. size="xs"
  112. position="top"
  113. title={t(
  114. 'Activity represents how much user activity happened in a replay. It is determined by the number of errors encountered, duration, and UI events.'
  115. )}
  116. />
  117. </Header>,
  118. ].filter(Boolean);
  119. if (fetchError && !isFetching) {
  120. return (
  121. <StyledPanelTable
  122. headers={tableHeaders}
  123. showProjectColumn={showProjectColumn}
  124. isLoading={false}
  125. >
  126. <StyledAlert type="error" showIcon>
  127. {typeof fetchError === 'string'
  128. ? fetchError
  129. : t(
  130. 'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
  131. )}
  132. </StyledAlert>
  133. </StyledPanelTable>
  134. );
  135. }
  136. return (
  137. <StyledPanelTable
  138. isLoading={isFetching}
  139. isEmpty={replays?.length === 0}
  140. showProjectColumn={showProjectColumn}
  141. headers={tableHeaders}
  142. >
  143. {replays?.map(replay => (
  144. <ReplayTableRow
  145. key={replay.id}
  146. minWidthIsSmall={minWidthIsSmall}
  147. organization={organization}
  148. referrer={referrer}
  149. replay={replay}
  150. showProjectColumn={showProjectColumn}
  151. />
  152. ))}
  153. </StyledPanelTable>
  154. );
  155. }
  156. function ReplayTableRow({
  157. minWidthIsSmall,
  158. organization,
  159. referrer,
  160. replay,
  161. showProjectColumn,
  162. }: RowProps) {
  163. const {projects} = useProjects();
  164. const project = projects.find(p => p.id === replay.projectId);
  165. return (
  166. <Fragment>
  167. <UserBadge
  168. avatarSize={32}
  169. displayName={
  170. <Link
  171. to={`/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/?referrer=${referrer}`}
  172. >
  173. {replay.user.displayName || ''}
  174. </Link>
  175. }
  176. user={{
  177. username: replay.user.displayName || '',
  178. email: replay.user.email || '',
  179. id: replay.user.id || '',
  180. ip_address: replay.user.ip_address || '',
  181. name: replay.user.name || '',
  182. }}
  183. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  184. displayEmail={<StringWalker urls={replay.urls} />}
  185. />
  186. {showProjectColumn && minWidthIsSmall && (
  187. <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
  188. )}
  189. <Item>
  190. <TimeSinceWrapper>
  191. {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
  192. <TimeSince date={replay.startedAt} />
  193. </TimeSinceWrapper>
  194. </Item>
  195. <Item>
  196. <Duration seconds={Math.floor(replay.duration)} exact abbreviation />
  197. </Item>
  198. <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
  199. <Item>
  200. <ReplayHighlight replay={replay} />
  201. </Item>
  202. </Fragment>
  203. );
  204. }
  205. const StyledPanelTable = styled(PanelTable)<{showProjectColumn: boolean}>`
  206. ${p =>
  207. p.showProjectColumn
  208. ? `grid-template-columns: minmax(0, 1fr) repeat(5, max-content);`
  209. : `grid-template-columns: minmax(0, 1fr) repeat(4, max-content);`}
  210. @media (max-width: ${p => p.theme.breakpoints.small}) {
  211. grid-template-columns: minmax(0, 1fr) repeat(4, max-content);
  212. }
  213. `;
  214. const SortLink = styled(Link)`
  215. color: inherit;
  216. :hover {
  217. color: inherit;
  218. }
  219. svg {
  220. vertical-align: top;
  221. }
  222. `;
  223. const Item = styled('div')`
  224. display: flex;
  225. align-items: center;
  226. `;
  227. const TimeSinceWrapper = styled('div')`
  228. display: grid;
  229. grid-template-columns: repeat(2, minmax(auto, max-content));
  230. align-items: center;
  231. gap: ${space(1)};
  232. `;
  233. const StyledIconCalendarWrapper = styled(IconCalendar)`
  234. position: relative;
  235. top: -1px;
  236. `;
  237. const StyledAlert = styled(Alert)`
  238. border-radius: 0;
  239. border-width: 1px 0 0 0;
  240. grid-column: 1/-1;
  241. margin-bottom: 0;
  242. `;
  243. const Header = styled('div')`
  244. display: grid;
  245. grid-template-columns: repeat(2, max-content);
  246. gap: ${space(0.5)};
  247. align-items: center;
  248. `;
  249. export default ReplayTable;