import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import debounce from 'lodash/debounce'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import Confirm from 'sentry/components/confirm'; import NotificationActionManager from 'sentry/components/notificationActions/notificationActionManager'; import Pagination from 'sentry/components/pagination'; import {PanelTable} from 'sentry/components/panels/panelTable'; import SearchBar from 'sentry/components/searchBar'; import {DEFAULT_DEBOUNCE_DURATION} from 'sentry/constants'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type { AvailableNotificationAction, NotificationAction, } from 'sentry/types/notificationActions'; import type {Project} from 'sentry/types/project'; import {isActiveSuperuser} from 'sentry/utils/isActiveSuperuser'; import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {ProjectBadge} from 'sentry/views/organizationStats/teamInsights/styles'; import withSubscription from 'getsentry/components/withSubscription'; import type {Subscription} from 'getsentry/types'; import trackSpendVisibilityAnaltyics, { SpendVisibilityEvents, } from 'getsentry/utils/trackSpendVisibilityAnalytics'; import { SPIKE_PROTECTION_ERROR_MESSAGE, SPIKE_PROTECTION_OPTION_DISABLED, } from 'getsentry/views/spikeProtection/constants'; import SpikeProtectionProjectToggle, { isSpikeProtectionEnabled, } from 'getsentry/views/spikeProtection/spikeProtectionProjectToggle'; import AccordionRow from './components/accordionRow'; interface Props { subscription: Subscription; } function SpikeProtectionProjects({subscription}: Props) { const [projects, setProjects] = useState([] as Project[]); const [pageLinks, setPageLinks] = useState(); const [currentCursor, setCurrentCursor] = useState(''); const [availableNotificationActions, setAvailableNotificationActions] = useState< AvailableNotificationAction[] >([]); const [notificationActionsById, setNotificationActionsById] = useState< Record >({}); const [isFetchingProjects, setIsFetchingProjects] = useState(true); const [isLoading, setIsLoading] = useState(true); const organization = useOrganization(); const api = useApi(); const debouncedSearch = useRef( debounce(value => { fetchProjects(value); }, DEFAULT_DEBOUNCE_DURATION) ).current; const triggerType = 'spike-protection'; const hasOrgAdmin = organization.access.includes('org:admin'); const hasOrgWrite = organization.access.includes('org:write') || hasOrgAdmin; const fetchProjects = useCallback( async (query = '') => { let accessibleProjectsQuery = query; if (!organization.openMembership && !isActiveSuperuser() && !hasOrgAdmin) { accessibleProjectsQuery += ' is_member:1'; } setIsFetchingProjects(true); const [data, _, resp] = await api.requestPromise( `/organizations/${organization.slug}/projects/`, { includeAllArgs: true, query: { cursor: currentCursor, query: accessibleProjectsQuery, options: SPIKE_PROTECTION_OPTION_DISABLED, }, } ); setProjects(data); const links = (resp?.getResponseHeader('Link') || resp?.getResponseHeader('link')) ?? undefined; setPageLinks(links); if (query.length > 0) { trackSpendVisibilityAnaltyics(SpendVisibilityEvents.SP_PROJECT_SEARCHED, { organization, subscription, view: 'spike_protection_settings', }); } setIsFetchingProjects(false); }, [api, currentCursor, organization, subscription, hasOrgAdmin] ); const fetchAvailableNotificationActions = useCallback(async () => { const data = await api.requestPromise( `/organizations/${organization.slug}/notifications/available-actions/` ); setAvailableNotificationActions(data.actions); }, [api, organization]); const fetchData = useCallback(async () => { setIsLoading(true); try { await fetchAvailableNotificationActions(); } catch (err) { Sentry.captureException(err); addErrorMessage(t('Unable to fetch available notification actions')); } setIsLoading(false); }, [fetchAvailableNotificationActions]); const fetchProjectNotificationActions = useCallback( async ( project: Project, projectNotificationActions: Record ) => { const projectId = project.id; const data = await api.requestPromise( `/organizations/${organization.slug}/notifications/actions/`, {query: {triggerType, project: projectId}} ); const notifActionsById = {...projectNotificationActions}; data.forEach((action: NotificationAction) => { if (notifActionsById[projectId]) { notifActionsById[projectId].push(action); } else { notifActionsById[projectId] = [action]; } }); setNotificationActionsById(notifActionsById); }, [api, organization] ); const updateAllProjects = useCallback( async (isEnabling: boolean) => { try { await api.requestPromise( `/organizations/${organization.slug}/spike-protections/?projectSlug=$all`, {method: isEnabling ? 'POST' : 'DELETE', data: {projects: []}} ); const newProjects = projects.map(p => ({ ...p, options: {...p.options, [SPIKE_PROTECTION_OPTION_DISABLED]: !isEnabling}, })); setProjects(newProjects); await fetchData(); addSuccessMessage( tct(`[action] spike protection for all projects`, { action: isEnabling ? t('Enabled') : t('Disabled'), }) ); } catch (err) { Sentry.captureException(err); addErrorMessage(SPIKE_PROTECTION_ERROR_MESSAGE); } }, [api, organization, projects, fetchData] ); useEffect(() => { fetchProjects(); fetchData(); }, [fetchProjects, fetchData]); function toggleSpikeProtectionOption(project: Project, isFeatureEnabled: boolean) { const updatedProject = { ...project, options: { ...project.options, // If the project option is True, the feature is disabled // Therefore, if the newValue of the field is True, the option must be set to False [SPIKE_PROTECTION_OPTION_DISABLED]: !isFeatureEnabled, }, }; const newProjects = projects.map(p => (p.id !== project.id ? p : updatedProject)); setProjects(newProjects); } const onChange = useCallback( (value: any) => { debouncedSearch(value); }, [debouncedSearch] ); function AllProjectsAction(isEnabling: boolean) { const action = isEnabling ? t('Enable') : t('Disable'); const confirmationText = tct( `This will [action] spike protection for all projects in the organization immediately. Are you sure?`, {action: action.toLowerCase()} ); return ( updateAllProjects(isEnabling)} message={confirmationText} disabled={!hasOrgWrite} > ); } const renderAccordionTitle = (project: Project) => { return ( ); }; const renderAccordionBody = (project: Project) => { const projectNotificationActions: NotificationAction[] = notificationActionsById[project.id] ?? []; // Only render if all of the notification actions have been loaded if (isLoading) { return null; } const hasProjectWrite = project.access.includes('project:write'); return ( ); }; return ( {AllProjectsAction(false)} {AllProjectsAction(true)} {t('Projects')}, ]} isLoading={isLoading || isFetchingProjects} > {projects?.map(project => { const hasProjectWrite = project.access.includes('project:write'); const accordionTitle = renderAccordionTitle(project); const accordionBody = renderAccordionBody(project); const isAccordionDisabled = !isSpikeProtectionEnabled(project); return ( toggleSpikeProtectionOption(project, isEnabled) } /> fetchProjectNotificationActions(project, notificationActionsById) } /> ); })} {pageLinks && } ); } export default withSubscription(SpikeProtectionProjects); const Container = styled('div')` margin-bottom: ${space(2)}; justify-content: space-between; display: flex; `; const StyledSearch = styled(SearchBar)` flex: 1; `; const StyledPanelTable = styled(PanelTable)` align-items: center; overflow: visible; `; const StyledProjectBadge = styled(ProjectBadge)` font-weight: bold; `; const StyledAccordionTitle = styled('div')` display: flex; justify-content: space-between; align-items: center; height: 100%; width: 100%; `; const AccordionRowContainer = styled('div')` display: flex; width: 100%; padding: ${space(1.5)}; padding-left: 0; `; const AccordionTitleCell = styled('div')` display: flex; align-items: center; margin-right: ${space(2)}; `; const StyledAccordionDetails = styled('div')` margin-right: ${space(3)}; margin-top: ${space(2)}; padding-bottom: ${space(1)}; font-size: ${p => p.theme.fontSizeSmall}; `; const StyledPanelTableHeader = styled('div')` padding-left: ${space(2)}; `; const StyledPanelToggle = styled(SpikeProtectionProjectToggle)` height: 100%; border-bottom: none; padding: 0; padding-left: ${space(1)}; align-items: start; `; const StyledButtonBar = styled(ButtonBar)` margin-left: ${space(2)}; `;