Browse Source

feat(crons): Bulk edit frontend modal (#63612)

<img width="1261" alt="image"
src="https://github.com/getsentry/sentry/assets/9372512/6ec68757-67e4-4f84-a5a6-135d977fac81">

dependent on: https://github.com/getsentry/sentry/pull/63525


https://www.figma.com/file/cfcxnhwGet0cHZKEZXY5kV/Crons?type=design&node-id=2709-25029&mode=design&t=inZDh33mug00rcoc-0
David Wang 1 year ago
parent
commit
0a81d6aa3b

+ 7 - 0
static/app/actionCreators/modal.tsx

@@ -371,3 +371,10 @@ export async function openNavigateToExternalLinkModal(
 
   openModal(deps => <Modal {...deps} {...options} />);
 }
+
+export async function openBulkEditMonitorsModal({onClose, ...options}: ModalOptions) {
+  const mod = await import('sentry/components/modals/bulkEditMonitorsModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {modalCss, onClose});
+}

+ 40 - 0
static/app/actionCreators/monitors.tsx

@@ -5,6 +5,7 @@ import {
 } from 'sentry/actionCreators/indicator';
 import type {Client} from 'sentry/api';
 import {t} from 'sentry/locale';
+import type {ObjectStatus} from 'sentry/types';
 import {logException} from 'sentry/utils/logging';
 import type RequestError from 'sentry/utils/requestError/requestError';
 import type {Monitor} from 'sentry/views/monitors/types';
@@ -101,3 +102,42 @@ export async function setEnvironmentIsMuted(
 
   return null;
 }
+
+export interface BulkEditOperation {
+  isMuted?: boolean;
+  status?: ObjectStatus;
+}
+
+interface BulkEditResponse {
+  errored: Monitor[];
+  updated: Monitor[];
+}
+
+export async function bulkEditMonitors(
+  api: Client,
+  orgId: string,
+  slugs: string[],
+  operation: BulkEditOperation
+): Promise<BulkEditResponse | null> {
+  addLoadingMessage();
+
+  try {
+    const resp: BulkEditResponse = await api.requestPromise(
+      `/organizations/${orgId}/monitors/`,
+      {
+        method: 'PUT',
+        data: {...operation, slugs},
+      }
+    );
+    clearIndicators();
+    if (resp.errored?.length > 0) {
+      addErrorMessage(t('Unable to apply the changes to all monitors'));
+    }
+    return resp;
+  } catch (err) {
+    logException(err);
+    addErrorMessage(t('Unable to apply the changes to all monitors'));
+  }
+
+  return null;
+}

+ 209 - 0
static/app/components/modals/bulkEditMonitorsModal.tsx

@@ -0,0 +1,209 @@
+import {Fragment, useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import type {ModalRenderProps} from 'sentry/actionCreators/modal';
+import type {BulkEditOperation} from 'sentry/actionCreators/monitors';
+import {bulkEditMonitors} from 'sentry/actionCreators/monitors';
+import {Button} from 'sentry/components/button';
+import ButtonBar from 'sentry/components/buttonBar';
+import Checkbox from 'sentry/components/checkbox';
+import Pagination from 'sentry/components/pagination';
+import PanelTable from 'sentry/components/panels/panelTable';
+import Placeholder from 'sentry/components/placeholder';
+import SearchBar from 'sentry/components/searchBar';
+import Text from 'sentry/components/text';
+import {t, tct, tn} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {setApiQueryData, useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
+import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import type {Monitor} from 'sentry/views/monitors/types';
+import {makeMonitorListQueryKey, scheduleAsText} from 'sentry/views/monitors/utils';
+
+interface Props extends ModalRenderProps {}
+
+const NUM_PLACEHOLDER_ROWS = 5;
+
+export function BulkEditMonitorsModal({Header, Body, Footer, closeModal}: Props) {
+  const organization = useOrganization();
+  const location = useLocation();
+  const queryClient = useQueryClient();
+  const api = useApi();
+
+  const [isUpdating, setIsUpdating] = useState<boolean>(false);
+  const [searchQuery, setSearchQuery] = useState<string>('');
+  const [cursor, setCursor] = useState<string | undefined>();
+  const queryKey = makeMonitorListQueryKey(organization, {
+    ...location.query,
+    query: searchQuery,
+    cursor,
+  });
+
+  const [selectedMonitors, setSelectedMonitors] = useState<Monitor[]>([]);
+  const isMonitorSelected = (monitor: Monitor): boolean => {
+    return !!selectedMonitors.find(m => m.slug === monitor.slug);
+  };
+
+  const handleToggleMonitor = (monitor: Monitor) => {
+    const checked = isMonitorSelected(monitor);
+    if (!checked) {
+      setSelectedMonitors([...selectedMonitors, monitor]);
+    } else {
+      setSelectedMonitors(selectedMonitors.filter(m => m.slug !== monitor.slug));
+    }
+  };
+
+  const handleBulkEdit = async (operation: BulkEditOperation) => {
+    setIsUpdating(true);
+    const resp = await bulkEditMonitors(
+      api,
+      organization.slug,
+      selectedMonitors.map(monitor => monitor.slug),
+      operation
+    );
+    setSelectedMonitors([]);
+
+    if (resp?.updated) {
+      setApiQueryData(queryClient, queryKey, (oldMonitorList: Monitor[]) => {
+        return oldMonitorList.map(
+          monitor =>
+            resp.updated.find(newMonitor => newMonitor.slug === monitor.slug) ?? monitor
+        );
+      });
+    }
+    setIsUpdating(false);
+  };
+
+  const {
+    data: monitorList,
+    getResponseHeader: monitorListHeaders,
+    isLoading,
+  } = useApiQuery<Monitor[]>(queryKey, {
+    staleTime: 0,
+  });
+  const monitorPageLinks = monitorListHeaders?.('Link');
+
+  const headers = [t('Monitor'), t('State'), t('Muted'), t('Schedule')];
+  const shouldDisable = selectedMonitors.every(monitor => monitor.status !== 'disabled');
+  const shouldMute = selectedMonitors.every(monitor => !monitor.isMuted);
+
+  const disableEnableBtnParams = {
+    operation: {status: shouldDisable ? 'disabled' : 'active'} as BulkEditOperation,
+    actionText: shouldDisable ? t('Disable') : t('Enable'),
+  };
+  const muteUnmuteBtnParams = {
+    operation: {isMuted: shouldMute ? true : false},
+    actionText: shouldMute ? t('Mute') : t('Unmute'),
+  };
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h3>{t('Manage Monitors')}</h3>
+      </Header>
+      <Body>
+        <Actions>
+          <ActionButtons gap={1}>
+            {[disableEnableBtnParams, muteUnmuteBtnParams].map(
+              ({operation, actionText}, i) => (
+                <Button
+                  key={i}
+                  size="sm"
+                  onClick={() => handleBulkEdit(operation)}
+                  disabled={isUpdating || selectedMonitors.length === 0}
+                  title={
+                    selectedMonitors.length === 0 &&
+                    tct('Please select monitors to [actionText]', {actionText})
+                  }
+                  aria-label={actionText}
+                >
+                  {selectedMonitors.length > 0
+                    ? `${actionText} ${tn(
+                        '%s monitor',
+                        '%s monitors',
+                        selectedMonitors.length
+                      )}`
+                    : actionText}
+                </Button>
+              )
+            )}
+          </ActionButtons>
+          <SearchBar
+            size="sm"
+            placeholder={t('Search Monitors')}
+            query={searchQuery}
+            onSearch={setSearchQuery}
+          />
+        </Actions>
+        <StyledPanelTable headers={headers} stickyHeaders>
+          {isLoading || !monitorList
+            ? [...new Array(NUM_PLACEHOLDER_ROWS)].map((_, i) => (
+                <RowPlaceholder key={i}>
+                  <Placeholder height="2rem" />
+                </RowPlaceholder>
+              ))
+            : monitorList.map(monitor => (
+                <Fragment key={monitor.slug}>
+                  <MonitorSlug>
+                    <Checkbox
+                      checked={isMonitorSelected(monitor)}
+                      onChange={() => {
+                        handleToggleMonitor(monitor);
+                      }}
+                    />
+                    <Text>{monitor.slug}</Text>
+                  </MonitorSlug>
+                  <Text>{monitor.status === 'active' ? t('Active') : t('Disabled')}</Text>
+                  <Text>{monitor.isMuted ? t('Yes') : t('No')}</Text>
+                  <Text>{scheduleAsText(monitor.config)}</Text>
+                </Fragment>
+              ))}
+        </StyledPanelTable>
+        {monitorPageLinks && (
+          <Pagination pageLinks={monitorListHeaders?.('Link')} onCursor={setCursor} />
+        )}
+      </Body>
+      <Footer>
+        <Button priority="primary" onClick={closeModal} aria-label={t('Done')}>
+          {t('Done')}
+        </Button>
+      </Footer>
+    </Fragment>
+  );
+}
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 800px;
+`;
+
+const Actions = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr max-content;
+  margin-bottom: ${space(2)};
+`;
+
+const ActionButtons = styled(ButtonBar)`
+  margin-right: auto;
+`;
+
+const StyledPanelTable = styled(PanelTable)`
+  overflow: scroll;
+  max-height: 425px;
+`;
+
+const RowPlaceholder = styled('div')`
+  grid-column: 1 / -1;
+  padding: ${space(1)};
+`;
+
+const MonitorSlug = styled('div')`
+  display: grid;
+  grid-template-columns: max-content 1fr;
+  gap: ${space(1)};
+  align-items: center;
+`;
+
+export default BulkEditMonitorsModal;

+ 15 - 1
static/app/views/monitors/overview.tsx

@@ -2,6 +2,8 @@ import {Fragment} from 'react';
 import styled from '@emotion/styled';
 import * as qs from 'query-string';
 
+import {openBulkEditMonitorsModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
 import HookOrDefault from 'sentry/components/hookOrDefault';
@@ -15,7 +17,7 @@ import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionT
 import Pagination from 'sentry/components/pagination';
 import SearchBar from 'sentry/components/searchBar';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {IconAdd} from 'sentry/icons';
+import {IconAdd, IconList} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {useApiQuery} from 'sentry/utils/queryClient';
@@ -51,6 +53,7 @@ export default function Monitors() {
     data: monitorList,
     getResponseHeader: monitorListHeaders,
     isLoading,
+    refetch,
   } = useApiQuery<Monitor[]>(queryKey, {
     staleTime: 0,
   });
@@ -89,6 +92,17 @@ export default function Monitors() {
           <Layout.HeaderActions>
             <ButtonBar gap={1}>
               <FeedbackWidgetButton />
+              <Button
+                icon={<IconList />}
+                size="sm"
+                onClick={() =>
+                  openBulkEditMonitorsModal({
+                    onClose: refetch,
+                  })
+                }
+              >
+                {t('Manage Monitors')}
+              </Button>
               {showAddMonitor && (
                 <NewMonitorButton size="sm" icon={<IconAdd isCircled />}>
                   {t('Add Monitor')}