feedbackListItem.tsx 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. import type {CSSProperties} from 'react';
  2. import styled from '@emotion/styled';
  3. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  4. import Checkbox from 'sentry/components/checkbox';
  5. import {Flex} from 'sentry/components/container/flex';
  6. import IssueTrackingSignals from 'sentry/components/feedback/list/issueTrackingSignals';
  7. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  8. import InteractionStateLayer from 'sentry/components/interactionStateLayer';
  9. import Link from 'sentry/components/links/link';
  10. import TextOverflow from 'sentry/components/textOverflow';
  11. import TimeSince from 'sentry/components/timeSince';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconChat, IconCircleFill, IconFatal, IconImage, IconPlay} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {Group} from 'sentry/types/group';
  17. import {trackAnalytics} from 'sentry/utils/analytics';
  18. import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types';
  19. import {decodeScalar} from 'sentry/utils/queryString';
  20. import useReplayCountForFeedbacks from 'sentry/utils/replayCount/useReplayCountForFeedbacks';
  21. import normalizeUrl from 'sentry/utils/url/normalizeUrl';
  22. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  23. import {useLocation} from 'sentry/utils/useLocation';
  24. import useOrganization from 'sentry/utils/useOrganization';
  25. interface Props {
  26. feedbackItem: FeedbackIssueListItem;
  27. isSelected: 'all-selected' | boolean;
  28. onSelect: (isSelected: boolean) => void;
  29. style?: CSSProperties;
  30. }
  31. function useIsSelectedFeedback({feedbackItem}: {feedbackItem: FeedbackIssueListItem}) {
  32. const {feedbackSlug} = useLocationQuery({
  33. fields: {feedbackSlug: decodeScalar},
  34. });
  35. const [, feedbackId] = feedbackSlug.split(':') ?? [];
  36. return feedbackId === feedbackItem.id;
  37. }
  38. export default function FeedbackListItem({
  39. feedbackItem,
  40. isSelected,
  41. onSelect,
  42. style,
  43. }: Props) {
  44. const organization = useOrganization();
  45. const isOpen = useIsSelectedFeedback({feedbackItem});
  46. const {feedbackHasReplay} = useReplayCountForFeedbacks();
  47. const hasReplayId = feedbackHasReplay(feedbackItem.id);
  48. const location = useLocation();
  49. const isCrashReport = feedbackItem.metadata.source === 'crash_report_embed_form';
  50. const isUserReportWithError = feedbackItem.metadata.source === 'user_report_envelope';
  51. const hasAttachments = feedbackItem.latestEventHasAttachments;
  52. const hasComments = feedbackItem.numComments > 0;
  53. return (
  54. <CardSpacing style={style}>
  55. <LinkedFeedbackCard
  56. data-selected={isOpen}
  57. to={{
  58. pathname: normalizeUrl(`/organizations/${organization.slug}/feedback/`),
  59. query: {
  60. ...location.query,
  61. referrer: 'feedback_list_page',
  62. feedbackSlug: `${feedbackItem.project?.slug}:${feedbackItem.id}`,
  63. },
  64. }}
  65. onClick={() => {
  66. trackAnalytics('feedback.list-item-selected', {organization});
  67. }}
  68. >
  69. <InteractionStateLayer />
  70. <Row
  71. style={{gridArea: 'checkbox'}}
  72. onClick={e => {
  73. e.stopPropagation();
  74. }}
  75. >
  76. <Checkbox
  77. disabled={isSelected === 'all-selected'}
  78. checked={isSelected !== false}
  79. onChange={e => {
  80. onSelect(e.target.checked);
  81. }}
  82. />
  83. </Row>
  84. <ContactRow>
  85. {feedbackItem.metadata.name ??
  86. feedbackItem.metadata.contact_email ??
  87. t('Anonymous User')}
  88. </ContactRow>
  89. <StyledTimeSince date={feedbackItem.firstSeen} />
  90. {feedbackItem.hasSeen ? null : (
  91. <DotRow style={{gridArea: 'unread'}}>
  92. <IconCircleFill size="xs" color="purple400" />
  93. </DotRow>
  94. )}
  95. <PreviewRow
  96. align="flex-start"
  97. justify="flex-start"
  98. style={{
  99. gridArea: 'message',
  100. }}
  101. >
  102. <StyledTextOverflow>{feedbackItem.metadata.message}</StyledTextOverflow>
  103. </PreviewRow>
  104. <BottomGrid style={{gridArea: 'bottom'}}>
  105. <Row justify="flex-start" gap={space(0.75)}>
  106. {feedbackItem.project ? (
  107. <StyledProjectBadge
  108. disableLink
  109. project={feedbackItem.project}
  110. avatarSize={14}
  111. hideName
  112. avatarProps={{hasTooltip: false}}
  113. />
  114. ) : null}
  115. <ShortId>{feedbackItem.shortId}</ShortId>
  116. </Row>
  117. <Row justify="flex-end" gap={space(1)}>
  118. <IssueTrackingSignals group={feedbackItem as unknown as Group} />
  119. {hasComments && (
  120. <Tooltip title={t('Has Activity')} containerDisplayMode="flex">
  121. <IconChat color="gray500" size="sm" />
  122. </Tooltip>
  123. )}
  124. {(isCrashReport || isUserReportWithError) && (
  125. <Tooltip title={t('Linked Error')} containerDisplayMode="flex">
  126. <IconFatal color="red400" size="xs" />
  127. </Tooltip>
  128. )}
  129. {hasReplayId && (
  130. <Tooltip title={t('Linked Replay')} containerDisplayMode="flex">
  131. <IconPlay size="xs" />
  132. </Tooltip>
  133. )}
  134. {hasAttachments && (
  135. <Tooltip title={t('Has Screenshot')} containerDisplayMode="flex">
  136. <IconImage size="xs" />
  137. </Tooltip>
  138. )}
  139. {feedbackItem.assignedTo && (
  140. <ActorAvatar
  141. actor={feedbackItem.assignedTo}
  142. size={16}
  143. tooltipOptions={{containerDisplayMode: 'flex'}}
  144. />
  145. )}
  146. </Row>
  147. </BottomGrid>
  148. </LinkedFeedbackCard>
  149. </CardSpacing>
  150. );
  151. }
  152. const LinkedFeedbackCard = styled(Link)`
  153. position: relative;
  154. padding: ${space(1)} ${space(3)} ${space(1)} ${space(1.5)};
  155. border: 1px solid transparent;
  156. border-radius: ${space(0.75)};
  157. color: ${p => p.theme.textColor};
  158. &:hover {
  159. color: ${p => p.theme.textColor};
  160. }
  161. &[data-selected='true'] {
  162. background: ${p => p.theme.purple100};
  163. border: 1px solid ${p => p.theme.purple200};
  164. border-radius: ${space(0.75)};
  165. color: ${p => p.theme.purple300};
  166. }
  167. display: grid;
  168. grid-template-columns: max-content 1fr max-content;
  169. grid-template-rows: max-content 1fr max-content;
  170. grid-template-areas:
  171. 'checkbox user time'
  172. 'unread message message'
  173. '. bottom bottom';
  174. gap: ${space(0.5)} ${space(1)};
  175. place-items: stretch;
  176. align-items: center;
  177. `;
  178. const Row = styled(Flex)`
  179. place-items: center;
  180. `;
  181. const BottomGrid = styled('div')`
  182. display: grid;
  183. grid-template-columns: auto max-content;
  184. gap: ${space(1)};
  185. overflow: hidden;
  186. `;
  187. const StyledProjectBadge = styled(ProjectBadge)`
  188. && img {
  189. box-shadow: none;
  190. }
  191. `;
  192. const PreviewRow = styled(Row)`
  193. align-items: flex-start;
  194. font-size: ${p => p.theme.fontSizeSmall};
  195. padding-bottom: ${space(0.75)};
  196. `;
  197. const DotRow = styled(Row)`
  198. height: 1.1em;
  199. align-items: flex-start;
  200. justify-content: center;
  201. `;
  202. const StyledTextOverflow = styled(TextOverflow)`
  203. white-space: initial;
  204. height: 1.4em;
  205. -webkit-line-clamp: 1;
  206. display: -webkit-box;
  207. -webkit-box-orient: vertical;
  208. line-height: ${p => p.theme.text.lineHeightBody};
  209. `;
  210. const ContactRow = styled(TextOverflow)`
  211. font-size: ${p => p.theme.fontSizeMedium};
  212. grid-area: 'user';
  213. font-weight: bold;
  214. `;
  215. const ShortId = styled(TextOverflow)`
  216. font-size: ${p => p.theme.fontSizeSmall};
  217. color: ${p => p.theme.gray300};
  218. `;
  219. const StyledTimeSince = styled(TimeSince)`
  220. font-size: ${p => p.theme.fontSizeSmall};
  221. grid-area: 'time';
  222. `;
  223. const CardSpacing = styled('div')`
  224. padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
  225. `;