groupEventAttachments.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import {WithRouterProps} from 'react-router';
  2. import styled from '@emotion/styled';
  3. import pick from 'lodash/pick';
  4. import xor from 'lodash/xor';
  5. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  6. import AsyncComponent from 'sentry/components/asyncComponent';
  7. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  8. import * as Layout from 'sentry/components/layouts/thirds';
  9. import LoadingIndicator from 'sentry/components/loadingIndicator';
  10. import Pagination from 'sentry/components/pagination';
  11. import {Panel, PanelBody} from 'sentry/components/panels';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import {IssueAttachment, Project} from 'sentry/types';
  15. import {decodeList} from 'sentry/utils/queryString';
  16. // eslint-disable-next-line no-restricted-imports
  17. import withSentryRouter from 'sentry/utils/withSentryRouter';
  18. import GroupEventAttachmentsFilter, {
  19. crashReportTypes,
  20. SCREENSHOT_TYPE,
  21. } from './groupEventAttachmentsFilter';
  22. import GroupEventAttachmentsTable from './groupEventAttachmentsTable';
  23. import {ScreenshotCard} from './screenshotCard';
  24. type Props = {
  25. project: Project;
  26. } & WithRouterProps<{groupId: string; orgId: string}> &
  27. AsyncComponent['props'];
  28. enum EventAttachmentFilter {
  29. ALL = 'all',
  30. CRASH_REPORTS = 'onlyCrash',
  31. SCREENSHOTS = 'screenshot',
  32. }
  33. type State = {
  34. deletedAttachments: string[];
  35. eventAttachments?: IssueAttachment[];
  36. } & AsyncComponent['state'];
  37. export const MAX_SCREENSHOTS_PER_PAGE = 12;
  38. class GroupEventAttachments extends AsyncComponent<Props, State> {
  39. getDefaultState() {
  40. return {
  41. ...super.getDefaultState(),
  42. deletedAttachments: [],
  43. };
  44. }
  45. getActiveAttachmentsTab() {
  46. const {location} = this.props;
  47. const types = decodeList(location.query.types);
  48. if (types.length === 0) {
  49. return EventAttachmentFilter.ALL;
  50. }
  51. if (types[0] === SCREENSHOT_TYPE) {
  52. return EventAttachmentFilter.SCREENSHOTS;
  53. }
  54. if (xor(crashReportTypes, types).length === 0) {
  55. return EventAttachmentFilter.CRASH_REPORTS;
  56. }
  57. return EventAttachmentFilter.ALL;
  58. }
  59. getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
  60. const {params, location} = this.props;
  61. if (this.getActiveAttachmentsTab() === EventAttachmentFilter.SCREENSHOTS) {
  62. return [
  63. [
  64. 'eventAttachments',
  65. `/issues/${params.groupId}/attachments/`,
  66. {
  67. query: {
  68. ...location.query,
  69. types: undefined, // need to explicitly set this to undefined because AsyncComponent adds location query back into the params
  70. screenshot: 1,
  71. per_page: MAX_SCREENSHOTS_PER_PAGE,
  72. },
  73. },
  74. ],
  75. ];
  76. }
  77. return [
  78. [
  79. 'eventAttachments',
  80. `/issues/${params.groupId}/attachments/`,
  81. {
  82. query: {
  83. ...pick(location.query, ['cursor', 'environment', 'types']),
  84. per_page: 50,
  85. },
  86. },
  87. ],
  88. ];
  89. }
  90. handleDelete = async (deletedAttachmentId: string) => {
  91. const {params, project} = this.props;
  92. const attachment = this.state?.eventAttachments?.find(
  93. item => item.id === deletedAttachmentId
  94. );
  95. if (!attachment) {
  96. return;
  97. }
  98. this.setState(prevState => ({
  99. deletedAttachments: [...prevState.deletedAttachments, deletedAttachmentId],
  100. }));
  101. try {
  102. await this.api.requestPromise(
  103. `/projects/${params.orgId}/${project.slug}/events/${attachment.event_id}/attachments/${attachment.id}/`,
  104. {
  105. method: 'DELETE',
  106. }
  107. );
  108. } catch (error) {
  109. addErrorMessage('An error occurred while deleteting the attachment');
  110. }
  111. };
  112. renderNoQueryResults() {
  113. return (
  114. <EmptyStateWarning>
  115. <p>{t('No crash reports found')}</p>
  116. </EmptyStateWarning>
  117. );
  118. }
  119. renderNoScreenshotsResults() {
  120. return (
  121. <EmptyStateWarning>
  122. <p>{t('No screenshots found')}</p>
  123. </EmptyStateWarning>
  124. );
  125. }
  126. renderEmpty() {
  127. return (
  128. <EmptyStateWarning>
  129. <p>{t('No attachments found')}</p>
  130. </EmptyStateWarning>
  131. );
  132. }
  133. renderLoading() {
  134. return this.renderBody();
  135. }
  136. renderInnerBody() {
  137. const {project, params} = this.props;
  138. const {loading, eventAttachments, deletedAttachments} = this.state;
  139. if (loading) {
  140. return <LoadingIndicator />;
  141. }
  142. if (eventAttachments && eventAttachments.length > 0) {
  143. return (
  144. <GroupEventAttachmentsTable
  145. attachments={eventAttachments}
  146. orgId={params.orgId}
  147. projectSlug={project.slug}
  148. groupId={params.groupId}
  149. onDelete={this.handleDelete}
  150. deletedAttachments={deletedAttachments}
  151. />
  152. );
  153. }
  154. if (this.getActiveAttachmentsTab() === EventAttachmentFilter.CRASH_REPORTS) {
  155. return this.renderNoQueryResults();
  156. }
  157. return this.renderEmpty();
  158. }
  159. renderScreenshotGallery() {
  160. const {eventAttachments, loading} = this.state;
  161. const {project, params} = this.props;
  162. if (loading) {
  163. return <LoadingIndicator />;
  164. }
  165. if (eventAttachments && eventAttachments.length > 0) {
  166. return (
  167. <ScreenshotGrid>
  168. {eventAttachments?.map((screenshot, index) => {
  169. return (
  170. <ScreenshotCard
  171. key={`${index}-${screenshot.id}`}
  172. eventAttachment={screenshot}
  173. eventId={screenshot.event_id}
  174. projectSlug={project.slug}
  175. groupId={params.groupId}
  176. onDelete={this.handleDelete}
  177. pageLinks={this.state.eventAttachmentsPageLinks}
  178. attachments={eventAttachments}
  179. attachmentIndex={index}
  180. />
  181. );
  182. })}
  183. </ScreenshotGrid>
  184. );
  185. }
  186. return this.renderNoScreenshotsResults();
  187. }
  188. renderAttachmentsTable() {
  189. return (
  190. <Panel className="event-list">
  191. <PanelBody>{this.renderInnerBody()}</PanelBody>
  192. </Panel>
  193. );
  194. }
  195. renderBody() {
  196. const {project} = this.props;
  197. return (
  198. <Layout.Body>
  199. <Layout.Main fullWidth>
  200. <GroupEventAttachmentsFilter project={project} />
  201. {this.getActiveAttachmentsTab() === EventAttachmentFilter.SCREENSHOTS
  202. ? this.renderScreenshotGallery()
  203. : this.renderAttachmentsTable()}
  204. <Pagination pageLinks={this.state.eventAttachmentsPageLinks} />
  205. </Layout.Main>
  206. </Layout.Body>
  207. );
  208. }
  209. }
  210. export default withSentryRouter(GroupEventAttachments);
  211. const ScreenshotGrid = styled('div')`
  212. display: grid;
  213. grid-template-columns: minmax(100px, 1fr);
  214. grid-template-rows: repeat(2, max-content);
  215. gap: ${space(2)};
  216. @media (min-width: ${p => p.theme.breakpoints.small}) {
  217. grid-template-columns: repeat(3, minmax(100px, 1fr));
  218. }
  219. @media (min-width: ${p => p.theme.breakpoints.large}) {
  220. grid-template-columns: repeat(4, minmax(100px, 1fr));
  221. }
  222. @media (min-width: ${p => p.theme.breakpoints.xxlarge}) {
  223. grid-template-columns: repeat(6, minmax(100px, 1fr));
  224. }
  225. `;