feedbackListItem.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  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. project={feedbackItem.project}
  109. avatarSize={14}
  110. hideName
  111. avatarProps={{hasTooltip: false}}
  112. />
  113. ) : null}
  114. <ShortId>{feedbackItem.shortId}</ShortId>
  115. </Row>
  116. <Row justify="flex-end" gap={space(1)}>
  117. <IssueTrackingSignals group={feedbackItem as unknown as Group} />
  118. {hasComments && (
  119. <Tooltip title={t('Has Activity')} containerDisplayMode="flex">
  120. <IconChat color="gray500" size="sm" />
  121. </Tooltip>
  122. )}
  123. {(isCrashReport || isUserReportWithError) && (
  124. <Tooltip title={t('Linked Error')} containerDisplayMode="flex">
  125. <IconFatal color="red400" size="xs" />
  126. </Tooltip>
  127. )}
  128. {hasReplayId && (
  129. <Tooltip title={t('Linked Replay')} containerDisplayMode="flex">
  130. <IconPlay size="xs" />
  131. </Tooltip>
  132. )}
  133. {hasAttachments && (
  134. <Tooltip title={t('Has Screenshot')} containerDisplayMode="flex">
  135. <IconImage size="xs" />
  136. </Tooltip>
  137. )}
  138. {feedbackItem.assignedTo && (
  139. <ActorAvatar
  140. actor={feedbackItem.assignedTo}
  141. size={16}
  142. tooltipOptions={{containerDisplayMode: 'flex'}}
  143. />
  144. )}
  145. </Row>
  146. </BottomGrid>
  147. </LinkedFeedbackCard>
  148. </CardSpacing>
  149. );
  150. }
  151. const LinkedFeedbackCard = styled(Link)`
  152. position: relative;
  153. padding: ${space(1)} ${space(3)} ${space(1)} ${space(1.5)};
  154. border: 1px solid transparent;
  155. color: ${p => p.theme.textColor};
  156. &:hover {
  157. color: ${p => p.theme.textColor};
  158. }
  159. &[data-selected='true'] {
  160. background: ${p => p.theme.purple100};
  161. border: 1px solid ${p => p.theme.purple200};
  162. border-radius: ${space(0.75)};
  163. color: ${p => p.theme.purple300};
  164. }
  165. display: grid;
  166. grid-template-columns: max-content 1fr max-content;
  167. grid-template-rows: max-content 1fr max-content;
  168. grid-template-areas:
  169. 'checkbox user time'
  170. 'unread message message'
  171. '. bottom bottom';
  172. gap: ${space(0.5)} ${space(1)};
  173. place-items: stretch;
  174. align-items: center;
  175. `;
  176. const Row = styled(Flex)`
  177. place-items: center;
  178. `;
  179. const BottomGrid = styled('div')`
  180. display: grid;
  181. grid-template-columns: auto max-content;
  182. gap: ${space(1)};
  183. overflow: hidden;
  184. `;
  185. const StyledProjectBadge = styled(ProjectBadge)`
  186. && img {
  187. box-shadow: none;
  188. }
  189. `;
  190. const PreviewRow = styled(Row)`
  191. align-items: flex-start;
  192. font-size: ${p => p.theme.fontSizeSmall};
  193. padding-bottom: ${space(0.75)};
  194. `;
  195. const DotRow = styled(Row)`
  196. height: 1.1em;
  197. align-items: flex-start;
  198. justify-content: center;
  199. `;
  200. const StyledTextOverflow = styled(TextOverflow)`
  201. white-space: initial;
  202. height: 1.4em;
  203. -webkit-line-clamp: 1;
  204. display: -webkit-box;
  205. -webkit-box-orient: vertical;
  206. line-height: ${p => p.theme.text.lineHeightBody};
  207. `;
  208. const ContactRow = styled(TextOverflow)`
  209. font-size: ${p => p.theme.fontSizeMedium};
  210. grid-area: 'user';
  211. font-weight: bold;
  212. `;
  213. const ShortId = styled(TextOverflow)`
  214. font-size: ${p => p.theme.fontSizeSmall};
  215. color: ${p => p.theme.gray300};
  216. `;
  217. const StyledTimeSince = styled(TimeSince)`
  218. font-size: ${p => p.theme.fontSizeSmall};
  219. grid-area: 'time';
  220. `;
  221. const CardSpacing = styled('div')`
  222. padding: ${space(0.5)} ${space(0.5)} 0 ${space(0.5)};
  223. `;