eventAttachments.tsx 7.0 KB

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