groupEventAttachments.tsx 6.9 KB

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