eventAttachments.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. import {Fragment, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {
  4. useDeleteEventAttachmentOptimistic,
  5. useFetchEventAttachments,
  6. } from 'sentry/actionCreators/events';
  7. import AttachmentUrl from 'sentry/components/events/attachmentUrl';
  8. import ImageViewer from 'sentry/components/events/attachmentViewers/imageViewer';
  9. import JsonViewer from 'sentry/components/events/attachmentViewers/jsonViewer';
  10. import LogFileViewer from 'sentry/components/events/attachmentViewers/logFileViewer';
  11. import RRWebJsonViewer from 'sentry/components/events/attachmentViewers/rrwebJsonViewer';
  12. import EventAttachmentActions from 'sentry/components/events/eventAttachmentActions';
  13. import FileSize from 'sentry/components/fileSize';
  14. import LoadingError from 'sentry/components/loadingError';
  15. import {PanelTable} from 'sentry/components/panels/panelTable';
  16. import {t} from 'sentry/locale';
  17. import type {Event} from 'sentry/types/event';
  18. import type {IssueAttachment} from 'sentry/types/group';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import {FoldSectionKey} from 'sentry/views/issueDetails/streamline/foldSection';
  21. import {InterimSection} from 'sentry/views/issueDetails/streamline/interimSection';
  22. import EventAttachmentsCrashReportsNotice from './eventAttachmentsCrashReportsNotice';
  23. type EventAttachmentsProps = {
  24. event: Event;
  25. projectSlug: string;
  26. };
  27. type AttachmentPreviewOpenMap = Record<string, boolean>;
  28. interface InlineAttachmentsProps
  29. extends Pick<EventAttachmentsProps, 'event' | 'projectSlug'> {
  30. attachment: IssueAttachment;
  31. attachmentPreviews: AttachmentPreviewOpenMap;
  32. }
  33. const getInlineAttachmentRenderer = (attachment: IssueAttachment) => {
  34. switch (attachment.mimetype) {
  35. case 'text/plain':
  36. return attachment.size > 0 ? LogFileViewer : undefined;
  37. case 'text/json':
  38. case 'text/x-json':
  39. case 'application/json':
  40. if (attachment.name === 'rrweb.json' || attachment.name.startsWith('rrweb-')) {
  41. return RRWebJsonViewer;
  42. }
  43. return JsonViewer;
  44. case 'image/jpeg':
  45. case 'image/png':
  46. case 'image/gif':
  47. return ImageViewer;
  48. default:
  49. return undefined;
  50. }
  51. };
  52. const hasInlineAttachmentRenderer = (attachment: IssueAttachment): boolean => {
  53. return !!getInlineAttachmentRenderer(attachment);
  54. };
  55. const attachmentPreviewIsOpen = (
  56. attachmentPreviews: Record<string, boolean>,
  57. attachment: IssueAttachment
  58. ) => {
  59. return attachmentPreviews[attachment.id] === true;
  60. };
  61. function InlineEventAttachment({
  62. attachmentPreviews,
  63. attachment,
  64. projectSlug,
  65. event,
  66. }: InlineAttachmentsProps) {
  67. const organization = useOrganization();
  68. const AttachmentComponent = getInlineAttachmentRenderer(attachment);
  69. if (!AttachmentComponent || !attachmentPreviewIsOpen(attachmentPreviews, attachment)) {
  70. return null;
  71. }
  72. return (
  73. <AttachmentPreviewWrapper>
  74. <AttachmentComponent
  75. orgId={organization.slug}
  76. projectSlug={projectSlug}
  77. eventId={event.id}
  78. attachment={attachment}
  79. />
  80. </AttachmentPreviewWrapper>
  81. );
  82. }
  83. function EventAttachmentsContent({event, projectSlug}: EventAttachmentsProps) {
  84. const organization = useOrganization();
  85. const {
  86. data: attachments = [],
  87. isError,
  88. refetch,
  89. } = useFetchEventAttachments({
  90. orgSlug: organization.slug,
  91. projectSlug,
  92. eventId: event.id,
  93. });
  94. const {mutate: deleteAttachment} = useDeleteEventAttachmentOptimistic();
  95. const [attachmentPreviews, setAttachmentPreviews] = useState<AttachmentPreviewOpenMap>(
  96. {}
  97. );
  98. const crashFileStripped = event.metadata.stripped_crash;
  99. if (isError) {
  100. return (
  101. <InterimSection type={FoldSectionKey.ATTACHMENTS} title={t('Attachments')}>
  102. <LoadingError
  103. onRetry={refetch}
  104. message={t('An error occurred while fetching attachments')}
  105. />
  106. </InterimSection>
  107. );
  108. }
  109. if (!attachments.length && !crashFileStripped) {
  110. return null;
  111. }
  112. const title = t('Attachments (%s)', attachments.length);
  113. const lastAttachment = attachments.at(-1);
  114. const lastAttachmentPreviewed =
  115. lastAttachment && attachmentPreviewIsOpen(attachmentPreviews, lastAttachment);
  116. const togglePreview = (attachment: IssueAttachment) => {
  117. setAttachmentPreviews(previewsMap => ({
  118. ...previewsMap,
  119. [attachment.id]: !previewsMap[attachment.id],
  120. }));
  121. };
  122. return (
  123. <InterimSection type={FoldSectionKey.ATTACHMENTS} title={title}>
  124. {crashFileStripped && (
  125. <EventAttachmentsCrashReportsNotice
  126. orgSlug={organization.slug}
  127. projectSlug={projectSlug}
  128. groupId={event.groupID!}
  129. />
  130. )}
  131. {attachments.length > 0 && (
  132. <StyledPanelTable
  133. headers={[
  134. <Name key="name">{t('File Name')}</Name>,
  135. <Size key="size">{t('Size')}</Size>,
  136. t('Actions'),
  137. ]}
  138. >
  139. {attachments.map(attachment => (
  140. <Fragment key={attachment.id}>
  141. <FlexCenter>
  142. <Name>{attachment.name}</Name>
  143. </FlexCenter>
  144. <Size>
  145. <FileSize bytes={attachment.size} />
  146. </Size>
  147. <AttachmentUrl
  148. projectSlug={projectSlug}
  149. eventId={event.id}
  150. attachment={attachment}
  151. >
  152. {url => (
  153. <div>
  154. <EventAttachmentActions
  155. url={url}
  156. onDelete={(attachmentId: string) =>
  157. deleteAttachment({
  158. orgSlug: organization.slug,
  159. projectSlug,
  160. eventId: event.id,
  161. attachmentId,
  162. })
  163. }
  164. onPreview={_attachmentId => togglePreview(attachment)}
  165. withPreviewButton
  166. previewIsOpen={attachmentPreviewIsOpen(
  167. attachmentPreviews,
  168. attachment
  169. )}
  170. hasPreview={hasInlineAttachmentRenderer(attachment)}
  171. attachmentId={attachment.id}
  172. />
  173. </div>
  174. )}
  175. </AttachmentUrl>
  176. <InlineEventAttachment
  177. {...{attachment, attachmentPreviews, event, projectSlug}}
  178. />
  179. {/* XXX: hack to deal with table grid borders */}
  180. {lastAttachmentPreviewed && (
  181. <Fragment>
  182. <div style={{display: 'none'}} />
  183. <div style={{display: 'none'}} />
  184. </Fragment>
  185. )}
  186. </Fragment>
  187. ))}
  188. </StyledPanelTable>
  189. )}
  190. </InterimSection>
  191. );
  192. }
  193. export function EventAttachments(props: EventAttachmentsProps) {
  194. const organization = useOrganization();
  195. if (!organization.features.includes('event-attachments')) {
  196. return null;
  197. }
  198. return <EventAttachmentsContent {...props} />;
  199. }
  200. const StyledPanelTable = styled(PanelTable)`
  201. grid-template-columns: 1fr auto auto;
  202. `;
  203. const FlexCenter = styled('div')`
  204. ${p => p.theme.overflowEllipsis};
  205. display: flex;
  206. align-items: center;
  207. `;
  208. const Name = styled('div')`
  209. ${p => p.theme.overflowEllipsis};
  210. white-space: nowrap;
  211. `;
  212. const Size = styled('div')`
  213. display: flex;
  214. align-items: center;
  215. justify-content: flex-end;
  216. white-space: nowrap;
  217. `;
  218. const AttachmentPreviewWrapper = styled('div')`
  219. grid-column: auto / span 3;
  220. border: none;
  221. padding: 0;
  222. `;