replayTable.tsx 6.8 KB

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