replayTable.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  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. {t(
  128. 'Sorry, the list of replays could not be loaded. This could be due to invalid search parameters or an internal systems error.'
  129. )}
  130. </StyledAlert>
  131. </StyledPanelTable>
  132. );
  133. }
  134. return (
  135. <StyledPanelTable
  136. isLoading={isFetching}
  137. isEmpty={replays?.length === 0}
  138. showProjectColumn={showProjectColumn}
  139. headers={tableHeaders}
  140. >
  141. {replays?.map(replay => (
  142. <ReplayTableRow
  143. key={replay.id}
  144. minWidthIsSmall={minWidthIsSmall}
  145. organization={organization}
  146. referrer={referrer}
  147. replay={replay}
  148. showProjectColumn={showProjectColumn}
  149. />
  150. ))}
  151. </StyledPanelTable>
  152. );
  153. }
  154. function ReplayTableRow({
  155. minWidthIsSmall,
  156. organization,
  157. referrer,
  158. replay,
  159. showProjectColumn,
  160. }: RowProps) {
  161. const {projects} = useProjects();
  162. const project = projects.find(p => p.id === replay.projectId);
  163. return (
  164. <Fragment>
  165. <UserBadge
  166. avatarSize={32}
  167. displayName={
  168. <Link
  169. to={`/organizations/${organization.slug}/replays/${project?.slug}:${replay.id}/?referrer=${referrer}`}
  170. >
  171. {replay.user.displayName || ''}
  172. </Link>
  173. }
  174. user={{
  175. username: replay.user.displayName || '',
  176. email: replay.user.email || '',
  177. id: replay.user.id || '',
  178. ip_address: replay.user.ip_address || '',
  179. name: replay.user.name || '',
  180. }}
  181. // this is the subheading for the avatar, so displayEmail in this case is a misnomer
  182. displayEmail={<StringWalker urls={replay.urls} />}
  183. />
  184. {showProjectColumn && minWidthIsSmall && (
  185. <Item>{project ? <ProjectBadge project={project} avatarSize={16} /> : null}</Item>
  186. )}
  187. <Item>
  188. <TimeSinceWrapper>
  189. {minWidthIsSmall && <StyledIconCalendarWrapper color="gray500" size="sm" />}
  190. <TimeSince date={replay.startedAt} />
  191. </TimeSinceWrapper>
  192. </Item>
  193. <Item>
  194. <Duration seconds={Math.floor(replay.duration)} exact abbreviation />
  195. </Item>
  196. <Item data-test-id="replay-table-count-errors">{replay.countErrors || 0}</Item>
  197. <Item>
  198. <ReplayHighlight replay={replay} />
  199. </Item>
  200. </Fragment>
  201. );
  202. }
  203. const StyledPanelTable = styled(PanelTable)<{showProjectColumn: boolean}>`
  204. ${p =>
  205. p.showProjectColumn
  206. ? `grid-template-columns: minmax(0, 1fr) repeat(5, max-content);`
  207. : `grid-template-columns: minmax(0, 1fr) repeat(4, max-content);`}
  208. @media (max-width: ${p => p.theme.breakpoints.small}) {
  209. grid-template-columns: minmax(0, 1fr) repeat(4, max-content);
  210. }
  211. `;
  212. const SortLink = styled(Link)`
  213. color: inherit;
  214. :hover {
  215. color: inherit;
  216. }
  217. svg {
  218. vertical-align: top;
  219. }
  220. `;
  221. const Item = styled('div')`
  222. display: flex;
  223. align-items: center;
  224. `;
  225. const TimeSinceWrapper = styled('div')`
  226. display: grid;
  227. grid-template-columns: repeat(2, minmax(auto, max-content));
  228. align-items: center;
  229. gap: ${space(1)};
  230. `;
  231. const StyledIconCalendarWrapper = styled(IconCalendar)`
  232. position: relative;
  233. top: -1px;
  234. `;
  235. const StyledAlert = styled(Alert)`
  236. position: relative;
  237. bottom: 0.5px;
  238. grid-column-start: span 99;
  239. margin-bottom: 0;
  240. `;
  241. const Header = styled('div')`
  242. display: grid;
  243. grid-template-columns: repeat(2, max-content);
  244. gap: ${space(0.5)};
  245. align-items: center;
  246. `;
  247. export default ReplayTable;