123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384 |
- import {Fragment, useState} from 'react';
- import styled from '@emotion/styled';
- import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
- } from 'sentry/actionCreators/indicator';
- import Badge from 'sentry/components/badge';
- import {Button} from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import Card from 'sentry/components/card';
- import {openConfirmModal} from 'sentry/components/confirm';
- import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
- import OnCallServiceForm from 'sentry/components/notificationActions/forms/onCallServiceForm';
- import SlackForm from 'sentry/components/notificationActions/forms/slackForm';
- import {Tooltip} from 'sentry/components/tooltip';
- import {IconEllipsis, IconMail} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import PluginIcon from 'sentry/plugins/components/pluginIcon';
- import {space} from 'sentry/styles/space';
- import {Project} from 'sentry/types';
- import {
- AvailableNotificationAction,
- NotificationAction,
- NotificationActionService,
- } from 'sentry/types/notificationActions';
- import useApi from 'sentry/utils/useApi';
- import useOrganization from 'sentry/utils/useOrganization';
- type NotificationActionItemProps = {
- /**
- * The notification action being represented
- */
- action: Partial<NotificationAction>;
- /**
- * The available actions for the action's serviceType
- * (serviceType as in "slack", "pagerduty")
- */
- availableActions: AvailableNotificationAction[];
- /**
- * The notif action's index in the parent component (NotificationActionManager)
- */
- index: number;
- /**
- * Update state in the parent component upon deleting this notification action
- */
- onDelete: (actionId: number) => void;
- /**
- * Update state in the parent component upon updating this notification action
- */
- onUpdate: (actionId: number, editedAction: NotificationAction) => void;
- /**
- * Map of opsgenie integration IDs to available actions for those IDs
- */
- opsgenieIntegrations: Record<number, AvailableNotificationAction[]>;
- /**
- * Map of pagerduty integration IDs to available actions for those IDs
- */
- pagerdutyIntegrations: Record<number, AvailableNotificationAction[]>;
- project: Project;
- /**
- * Whether to initially display edit mode
- * Set to "true" when adding a new notification action
- */
- defaultEdit?: boolean;
- disabled?: boolean;
- /**
- * Optional list of roles to display as recipients of Sentry notifications
- */
- recipientRoles?: string[];
- };
- function NotificationActionItem({
- action,
- index,
- availableActions,
- defaultEdit = false,
- pagerdutyIntegrations,
- opsgenieIntegrations,
- project,
- recipientRoles,
- onDelete,
- onUpdate,
- disabled = false,
- }: NotificationActionItemProps) {
- const [isEditing, setIsEditing] = useState(defaultEdit);
- const [editedAction, setEditedAction] = useState(action);
- const serviceType = action.serviceType;
- const api = useApi();
- const organization = useOrganization();
- const renderIcon = () => {
- switch (serviceType) {
- // Currently email and Sentry notification use the same icon
- case NotificationActionService.EMAIL:
- case NotificationActionService.SENTRY_NOTIFICATION:
- return <IconMail size="sm" />;
- default:
- return <PluginIcon pluginId={serviceType} size={16} />;
- }
- };
- const renderDescription = () => {
- switch (serviceType) {
- case NotificationActionService.SENTRY_NOTIFICATION:
- return (
- <Fragment>
- <div>{t('Send an email notification to the following roles')}</div>
- {recipientRoles?.map(role => (
- <NotificationRecipient key={role}>{role}</NotificationRecipient>
- ))}
- </Fragment>
- );
- case NotificationActionService.SLACK:
- return (
- <Fragment>
- <div>{t('Send a notification to the')}</div>
- <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
- <div>{t('channel')}</div>
- </Fragment>
- );
- case NotificationActionService.PAGERDUTY:
- return (
- <Fragment>
- <div>{t('Send a notification to the')}</div>
- <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
- <div>{t('service')}</div>
- </Fragment>
- );
- case NotificationActionService.OPSGENIE:
- return (
- <Fragment>
- <div>{t('Send a notification to the')}</div>
- <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
- <div>{t('team')}</div>
- </Fragment>
- );
- default:
- // TODO(enterprise): descriptions for email, msteams, sentry_app
- return null;
- }
- };
- const handleDelete = async () => {
- const endpoint = `/organizations/${organization.slug}/notifications/actions/${action.id}/`;
- try {
- await api.requestPromise(endpoint, {
- method: 'DELETE',
- });
- addSuccessMessage(t('Successfully deleted notification action'));
- onDelete(index);
- } catch (err) {
- addErrorMessage(t('Unable to delete notification action'));
- }
- };
- const handleCancel = () => {
- if (action.id) {
- setEditedAction(action);
- setIsEditing(false);
- return;
- }
- // Delete the unsaved notification action
- onDelete(index);
- };
- const handleSave = async () => {
- const {apiMethod, apiEndpoint, successMessage, errorMessage} = getFormData();
- addLoadingMessage();
- // TODO(enterprise): use "requires" to get data to send
- // This is currently optimized for spike protection
- const data = {...editedAction};
- // Remove keys from the data if they are falsy
- Object.keys(data).forEach(key => {
- if (!data[key]) {
- delete data[key];
- }
- });
- try {
- const resp = await api.requestPromise(apiEndpoint, {
- method: apiMethod,
- data: {...data, projects: [project.slug]},
- });
- addSuccessMessage(successMessage);
- onUpdate(index, resp);
- setEditedAction(resp);
- setIsEditing(false);
- } catch (err) {
- addErrorMessage(errorMessage);
- }
- };
- // Used for PagerDuty/Opsgenie
- const handleChange = (names: string[], values: any[]) => {
- const updatedAction = {...editedAction};
- names.forEach((name, i) => {
- const value = values[i];
- updatedAction[name] = value;
- });
- setEditedAction(updatedAction);
- };
- // Edit button is located outside of the form
- const renderEditButton = () => {
- const menuItems: MenuItemProps[] = [
- {
- key: 'notificationaction-delete',
- label: t('Delete'),
- priority: 'danger',
- onAction: () => {
- openConfirmModal({
- message: t('Are you sure you want to delete this notification action?'),
- onConfirm: handleDelete,
- });
- },
- },
- ];
- // No edit mode for Sentry notifications
- if (serviceType !== NotificationActionService.SENTRY_NOTIFICATION) {
- menuItems.unshift({
- key: 'notificationaction-edit',
- label: t('Edit'),
- onAction: () => setIsEditing(true),
- });
- }
- return (
- <Tooltip
- disabled={!disabled}
- title={t('You do not have permission to edit notification actions.')}
- >
- <DropdownMenu
- items={menuItems}
- trigger={triggerProps => (
- <Button
- {...triggerProps}
- aria-label={t('Actions')}
- size="xs"
- icon={<IconEllipsis direction="down" size="sm" />}
- data-test-id="edit-dropdown"
- />
- )}
- isDisabled={disabled}
- />
- </Tooltip>
- );
- };
- const getFormData = () => {
- if (editedAction.id) {
- return {
- apiMethod: 'PUT' as const,
- apiEndpoint: `/organizations/${organization.slug}/notifications/actions/${action.id}/`,
- successMessage: t('Successfully updated notification action'),
- errorMessage: t('Unable to update notification action'),
- };
- }
- return {
- apiMethod: 'POST' as const,
- apiEndpoint: `/organizations/${organization.slug}/notifications/actions/`,
- successMessage: t('Successfully added notification action'),
- errorMessage: t('Unable to add notification action'),
- };
- };
- const renderNotificationActionForm = () => {
- switch (serviceType) {
- case NotificationActionService.SENTRY_NOTIFICATION:
- return (
- <NotificationActionFormContainer>
- <NotificationActionCell>{renderDescription()}</NotificationActionCell>
- <ButtonBar gap={0.5}>
- <Button onClick={handleCancel} size="xs">
- {t('Cancel')}
- </Button>
- <Button priority="primary" size="xs" onClick={handleSave}>
- {t('Save')}
- </Button>
- </ButtonBar>
- </NotificationActionFormContainer>
- );
- case NotificationActionService.SLACK:
- return (
- <SlackForm
- action={editedAction}
- onChange={(name: string, value: any) =>
- setEditedAction({...editedAction, [name]: value})
- }
- onSave={handleSave}
- onCancel={handleCancel}
- availableActions={availableActions}
- />
- );
- case NotificationActionService.PAGERDUTY:
- return (
- <OnCallServiceForm
- action={editedAction}
- onChange={handleChange}
- onSave={handleSave}
- onCancel={handleCancel}
- Integrations={pagerdutyIntegrations}
- onCallService="pagerduty"
- />
- );
- case NotificationActionService.OPSGENIE:
- return (
- <OnCallServiceForm
- action={editedAction}
- onChange={handleChange}
- onSave={handleSave}
- onCancel={handleCancel}
- Integrations={opsgenieIntegrations}
- onCallService="opsgenie"
- />
- );
- default:
- return null;
- }
- };
- return (
- <StyledCard isEditing={isEditing} data-test-id="notification-action">
- {isEditing ? (
- <NotificationActionContainer data-test-id={`${serviceType}-form`}>
- <IconContainer>{renderIcon()}</IconContainer>
- {renderNotificationActionForm()}
- </NotificationActionContainer>
- ) : (
- <Fragment>
- <NotificationActionContainer data-test-id={`${serviceType}-action`}>
- <IconContainer>{renderIcon()}</IconContainer>
- <NotificationActionCell>{renderDescription()}</NotificationActionCell>
- </NotificationActionContainer>
- {renderEditButton()}
- </Fragment>
- )}
- </StyledCard>
- );
- }
- const StyledCard = styled(Card)<{isEditing: boolean}>`
- padding: ${space(1)} ${space(1.5)};
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- margin-bottom: ${space(1)};
- background-color: ${props => (props.isEditing ? props.theme.surface200 : 'inherit')};
- `;
- const IconContainer = styled('div')`
- margin-right: ${space(1)};
- display: flex;
- align-items: center;
- `;
- const NotificationActionContainer = styled('div')`
- display: flex;
- align-items: center;
- width: 100%;
- `;
- export const NotificationActionCell = styled('div')`
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: ${space(0.5)};
- `;
- export const NotificationActionFormContainer = styled('div')`
- display: flex;
- justify-content: space-between;
- width: 100%;
- `;
- const NotificationRecipient = styled(Badge)`
- border-radius: ${p => p.theme.borderRadius};
- font-weight: normal;
- `;
- export default NotificationActionItem;
|