spikeProtectionProjects.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import {Fragment, useCallback, useEffect, useRef, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import debounce from 'lodash/debounce';
  5. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {Button} from 'sentry/components/button';
  7. import ButtonBar from 'sentry/components/buttonBar';
  8. import Confirm from 'sentry/components/confirm';
  9. import NotificationActionManager from 'sentry/components/notificationActions/notificationActionManager';
  10. import Pagination from 'sentry/components/pagination';
  11. import {PanelTable} from 'sentry/components/panels/panelTable';
  12. import SearchBar from 'sentry/components/searchBar';
  13. import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants';
  14. import {t, tct} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {
  17. AvailableNotificationAction,
  18. NotificationAction,
  19. } from 'sentry/types/notificationActions';
  20. import type {Project} from 'sentry/types/project';
  21. import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser';
  22. import useApi from 'sentry/utils/useApi';
  23. import useOrganization from 'sentry/utils/useOrganization';
  24. import {ProjectBadge} from 'sentry/views/organizationStats/teamInsights/styles';
  25. import withSubscription from 'getsentry/components/withSubscription';
  26. import type {Subscription} from 'getsentry/types';
  27. import trackSpendVisibilityAnaltyics, {
  28. SpendVisibilityEvents,
  29. } from 'getsentry/utils/trackSpendVisibilityAnalytics';
  30. import {
  31. SPIKE_PROTECTION_ERROR_MESSAGE,
  32. SPIKE_PROTECTION_OPTION_DISABLED,
  33. } from 'getsentry/views/spikeProtection/constants';
  34. import SpikeProtectionProjectToggle, {
  35. isSpikeProtectionEnabled,
  36. } from 'getsentry/views/spikeProtection/spikeProtectionProjectToggle';
  37. import AccordionRow from './components/accordionRow';
  38. interface Props {
  39. subscription: Subscription;
  40. }
  41. function SpikeProtectionProjects({subscription}: Props) {
  42. const [projects, setProjects] = useState([] as Project[]);
  43. const [pageLinks, setPageLinks] = useState<string | null>();
  44. const [currentCursor, setCurrentCursor] = useState<string | undefined>('');
  45. const [availableNotificationActions, setAvailableNotificationActions] = useState<
  46. AvailableNotificationAction[]
  47. >([]);
  48. const [notificationActionsById, setNotificationActionsById] = useState<
  49. Record<string, NotificationAction[]>
  50. >({});
  51. const [isFetchingProjects, setIsFetchingProjects] = useState(true);
  52. const [isLoading, setIsLoading] = useState(true);
  53. const organization = useOrganization();
  54. const api = useApi();
  55. const debouncedSearch = useRef(
  56. debounce(value => {
  57. fetchProjects(value);
  58. }, DEFAULT_DEBOUNCE_DURATION)
  59. ).current;
  60. const triggerType = 'spike-protection';
  61. const hasOrgAdmin = organization.access.includes('org:admin');
  62. const hasOrgWrite = organization.access.includes('org:write') || hasOrgAdmin;
  63. const fetchProjects = useCallback(
  64. async (query = '') => {
  65. let accessibleProjectsQuery = query;
  66. if (!organization.openMembership && !isActiveSuperuser() && !hasOrgAdmin) {
  67. accessibleProjectsQuery += ' is_member:1';
  68. }
  69. setIsFetchingProjects(true);
  70. const [data, _, resp] = await api.requestPromise(
  71. `/organizations/${organization.slug}/projects/`,
  72. {
  73. includeAllArgs: true,
  74. query: {
  75. cursor: currentCursor,
  76. query: accessibleProjectsQuery,
  77. options: SPIKE_PROTECTION_OPTION_DISABLED,
  78. },
  79. }
  80. );
  81. setProjects(data);
  82. const links =
  83. (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined;
  84. setPageLinks(links);
  85. if (query.length > 0) {
  86. trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_PROJECT_SEARCHED, {
  87. organization,
  88. subscription,
  89. view: 'spike_protection_settings',
  90. });
  91. }
  92. setIsFetchingProjects(false);
  93. },
  94. [api, currentCursor, organization, subscription, hasOrgAdmin]
  95. );
  96. const fetchAvailableNotificationActions = useCallback(async () => {
  97. const data = await api.requestPromise(
  98. `/organizations/${organization.slug}/notifications/available-actions/`
  99. );
  100. setAvailableNotificationActions(data.actions);
  101. }, [api, organization]);
  102. const fetchData = useCallback(async () => {
  103. setIsLoading(true);
  104. try {
  105. await fetchAvailableNotificationActions();
  106. } catch (err) {
  107. Sentry.captureException(err);
  108. addErrorMessage(t('Unable to fetch available notification actions'));
  109. }
  110. setIsLoading(false);
  111. }, [fetchAvailableNotificationActions]);
  112. const fetchProjectNotificationActions = useCallback(
  113. async (
  114. project: Project,
  115. projectNotificationActions: Record<string, NotificationAction[]>
  116. ) => {
  117. const projectId = project.id;
  118. const data = await api.requestPromise(
  119. `/organizations/${organization.slug}/notifications/actions/`,
  120. {query: {triggerType, project: projectId}}
  121. );
  122. const notifActionsById = {...projectNotificationActions};
  123. data.forEach((action: NotificationAction) => {
  124. if (notifActionsById[projectId]) {
  125. notifActionsById[projectId].push(action);
  126. } else {
  127. notifActionsById[projectId] = [action];
  128. }
  129. });
  130. setNotificationActionsById(notifActionsById);
  131. },
  132. [api, organization]
  133. );
  134. const updateAllProjects = useCallback(
  135. async (isEnabling: boolean) => {
  136. try {
  137. await api.requestPromise(
  138. `/organizations/${organization.slug}/spike-protections/?projectSlug=$all`,
  139. {method: isEnabling ? 'POST' : 'DELETE', data: {projects: []}}
  140. );
  141. const newProjects = projects.map(p => ({
  142. ...p,
  143. options: {...p.options, [SPIKE_PROTECTION_OPTION_DISABLED]: !isEnabling},
  144. }));
  145. setProjects(newProjects);
  146. await fetchData();
  147. addSuccessMessage(
  148. tct(`[action] spike protection for all projects`, {
  149. action: isEnabling ? t('Enabled') : t('Disabled'),
  150. })
  151. );
  152. } catch (err) {
  153. Sentry.captureException(err);
  154. addErrorMessage(SPIKE_PROTECTION_ERROR_MESSAGE);
  155. }
  156. },
  157. [api, organization, projects, fetchData]
  158. );
  159. useEffect(() => {
  160. fetchProjects();
  161. fetchData();
  162. }, [fetchProjects, fetchData]);
  163. function toggleSpikeProtectionOption(project: Project, isFeatureEnabled: boolean) {
  164. const updatedProject = {
  165. ...project,
  166. options: {
  167. ...project.options,
  168. // If the project option is True, the feature is disabled
  169. // Therefore, if the newValue of the field is True, the option must be set to False
  170. [SPIKE_PROTECTION_OPTION_DISABLED]: !isFeatureEnabled,
  171. },
  172. };
  173. const newProjects = projects.map(p => (p.id !== project.id ? p : updatedProject));
  174. setProjects(newProjects);
  175. }
  176. const onChange = useCallback(
  177. (value: any) => {
  178. debouncedSearch(value);
  179. },
  180. [debouncedSearch]
  181. );
  182. function AllProjectsAction(isEnabling: boolean) {
  183. const action = isEnabling ? t('Enable') : t('Disable');
  184. const confirmationText = tct(
  185. `This will [action] spike protection for all projects in the organization immediately. Are you sure?`,
  186. {action: action.toLowerCase()}
  187. );
  188. return (
  189. <Confirm
  190. onConfirm={() => updateAllProjects(isEnabling)}
  191. message={confirmationText}
  192. disabled={!hasOrgWrite}
  193. >
  194. <Button
  195. disabled={!hasOrgWrite}
  196. priority={isEnabling ? 'primary' : 'default'}
  197. data-test-id={`sp-${action.toLowerCase()}-all`}
  198. title={
  199. !hasOrgWrite
  200. ? tct(
  201. `You do not have permission to [action] spike protection for all projects.`,
  202. {action: action.toLowerCase()}
  203. )
  204. : undefined
  205. }
  206. >
  207. {tct('[action] All', {action})}
  208. </Button>
  209. </Confirm>
  210. );
  211. }
  212. const renderAccordionTitle = (project: Project) => {
  213. return (
  214. <StyledAccordionTitle>
  215. <AccordionTitleCell>
  216. <StyledProjectBadge hideOverflow project={project} displayName={project.slug} />
  217. </AccordionTitleCell>
  218. </StyledAccordionTitle>
  219. );
  220. };
  221. const renderAccordionBody = (project: Project) => {
  222. const projectNotificationActions: NotificationAction[] =
  223. notificationActionsById[project.id] ?? [];
  224. // Only render if all of the notification actions have been loaded
  225. if (isLoading) {
  226. return null;
  227. }
  228. const hasProjectWrite = project.access.includes('project:write');
  229. return (
  230. <StyledAccordionDetails>
  231. <NotificationActionManager
  232. actions={projectNotificationActions}
  233. availableActions={availableNotificationActions}
  234. recipientRoles={['owner', 'manager', 'billing']}
  235. project={project}
  236. disabled={!hasOrgWrite && !hasProjectWrite}
  237. />
  238. </StyledAccordionDetails>
  239. );
  240. };
  241. return (
  242. <Fragment>
  243. <Container>
  244. <StyledSearch placeholder={t('Search projects')} onChange={onChange} />
  245. <StyledButtonBar merged>
  246. {AllProjectsAction(false)}
  247. {AllProjectsAction(true)}
  248. </StyledButtonBar>
  249. </Container>
  250. <StyledPanelTable
  251. disablePadding={
  252. organization.features.includes('notification-actions') ? true : false
  253. }
  254. isEmpty={!projects.length}
  255. headers={[
  256. <StyledPanelTableHeader key={0}>{t('Projects')}</StyledPanelTableHeader>,
  257. ]}
  258. isLoading={isLoading || isFetchingProjects}
  259. >
  260. {projects?.map(project => {
  261. const hasProjectWrite = project.access.includes('project:write');
  262. const accordionTitle = renderAccordionTitle(project);
  263. const accordionBody = renderAccordionBody(project);
  264. const isAccordionDisabled = !isSpikeProtectionEnabled(project);
  265. return (
  266. <Fragment key={project.id}>
  267. <AccordionRowContainer
  268. data-test-id={`${project.slug}-accordion-row${
  269. isAccordionDisabled ? '-disabled' : ''
  270. }`}
  271. >
  272. <StyledPanelToggle
  273. project={project}
  274. disabled={!hasOrgWrite && !hasProjectWrite}
  275. analyticsView="spike_protection_settings"
  276. onChange={(isEnabled: any) =>
  277. toggleSpikeProtectionOption(project, isEnabled)
  278. }
  279. />
  280. <AccordionRow
  281. disabled={isAccordionDisabled}
  282. disableBody={isLoading}
  283. title={accordionTitle}
  284. body={accordionBody}
  285. onOpen={() =>
  286. fetchProjectNotificationActions(project, notificationActionsById)
  287. }
  288. />
  289. </AccordionRowContainer>
  290. </Fragment>
  291. );
  292. })}
  293. </StyledPanelTable>
  294. {pageLinks && <Pagination pageLinks={pageLinks} onCursor={setCurrentCursor} />}
  295. </Fragment>
  296. );
  297. }
  298. export default withSubscription(SpikeProtectionProjects);
  299. const Container = styled('div')`
  300. margin-bottom: ${space(2)};
  301. justify-content: space-between;
  302. display: flex;
  303. `;
  304. const StyledSearch = styled(SearchBar)`
  305. flex: 1;
  306. `;
  307. const StyledPanelTable = styled(PanelTable)`
  308. align-items: center;
  309. overflow: visible;
  310. `;
  311. const StyledProjectBadge = styled(ProjectBadge)`
  312. font-weight: bold;
  313. `;
  314. const StyledAccordionTitle = styled('div')`
  315. display: flex;
  316. justify-content: space-between;
  317. align-items: center;
  318. height: 100%;
  319. width: 100%;
  320. `;
  321. const AccordionRowContainer = styled('div')`
  322. display: flex;
  323. width: 100%;
  324. padding: ${space(1.5)};
  325. padding-left: 0;
  326. `;
  327. const AccordionTitleCell = styled('div')`
  328. display: flex;
  329. align-items: center;
  330. margin-right: ${space(2)};
  331. `;
  332. const StyledAccordionDetails = styled('div')`
  333. margin-right: ${space(3)};
  334. margin-top: ${space(2)};
  335. padding-bottom: ${space(1)};
  336. font-size: ${p => p.theme.fontSizeSmall};
  337. `;
  338. const StyledPanelTableHeader = styled('div')`
  339. padding-left: ${space(2)};
  340. `;
  341. const StyledPanelToggle = styled(SpikeProtectionProjectToggle)`
  342. height: 100%;
  343. border-bottom: none;
  344. padding: 0;
  345. padding-left: ${space(1)};
  346. align-items: start;
  347. `;
  348. const StyledButtonBar = styled(ButtonBar)`
  349. margin-left: ${space(2)};
  350. `;