notificationActionItem.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import {Fragment, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {
  4. addErrorMessage,
  5. addLoadingMessage,
  6. addSuccessMessage,
  7. } from 'sentry/actionCreators/indicator';
  8. import Badge from 'sentry/components/badge';
  9. import {Button} from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import Card from 'sentry/components/card';
  12. import {openConfirmModal} from 'sentry/components/confirm';
  13. import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
  14. import OnCallServiceForm from 'sentry/components/notificationActions/forms/onCallServiceForm';
  15. import SlackForm from 'sentry/components/notificationActions/forms/slackForm';
  16. import {Tooltip} from 'sentry/components/tooltip';
  17. import {IconEllipsis, IconMail} from 'sentry/icons';
  18. import {t} from 'sentry/locale';
  19. import PluginIcon from 'sentry/plugins/components/pluginIcon';
  20. import {space} from 'sentry/styles/space';
  21. import {Project} from 'sentry/types';
  22. import {
  23. AvailableNotificationAction,
  24. NotificationAction,
  25. NotificationActionService,
  26. } from 'sentry/types/notificationActions';
  27. import useApi from 'sentry/utils/useApi';
  28. import useOrganization from 'sentry/utils/useOrganization';
  29. type NotificationActionItemProps = {
  30. /**
  31. * The notification action being represented
  32. */
  33. action: Partial<NotificationAction>;
  34. /**
  35. * The available actions for the action's serviceType
  36. * (serviceType as in "slack", "pagerduty")
  37. */
  38. availableActions: AvailableNotificationAction[];
  39. /**
  40. * The notif action's index in the parent component (NotificationActionManager)
  41. */
  42. index: number;
  43. /**
  44. * Update state in the parent component upon deleting this notification action
  45. */
  46. onDelete: (actionId: number) => void;
  47. /**
  48. * Update state in the parent component upon updating this notification action
  49. */
  50. onUpdate: (actionId: number, editedAction: NotificationAction) => void;
  51. /**
  52. * Map of opsgenie integration IDs to available actions for those IDs
  53. */
  54. opsgenieIntegrations: Record<number, AvailableNotificationAction[]>;
  55. /**
  56. * Map of pagerduty integration IDs to available actions for those IDs
  57. */
  58. pagerdutyIntegrations: Record<number, AvailableNotificationAction[]>;
  59. project: Project;
  60. /**
  61. * Whether to initially display edit mode
  62. * Set to "true" when adding a new notification action
  63. */
  64. defaultEdit?: boolean;
  65. disabled?: boolean;
  66. /**
  67. * Optional list of roles to display as recipients of Sentry notifications
  68. */
  69. recipientRoles?: string[];
  70. };
  71. function NotificationActionItem({
  72. action,
  73. index,
  74. availableActions,
  75. defaultEdit = false,
  76. pagerdutyIntegrations,
  77. opsgenieIntegrations,
  78. project,
  79. recipientRoles,
  80. onDelete,
  81. onUpdate,
  82. disabled = false,
  83. }: NotificationActionItemProps) {
  84. const [isEditing, setIsEditing] = useState(defaultEdit);
  85. const [editedAction, setEditedAction] = useState(action);
  86. const serviceType = action.serviceType;
  87. const api = useApi();
  88. const organization = useOrganization();
  89. const renderIcon = () => {
  90. switch (serviceType) {
  91. // Currently email and Sentry notification use the same icon
  92. case NotificationActionService.EMAIL:
  93. case NotificationActionService.SENTRY_NOTIFICATION:
  94. return <IconMail size="sm" />;
  95. default:
  96. return <PluginIcon pluginId={serviceType} size={16} />;
  97. }
  98. };
  99. const renderDescription = () => {
  100. switch (serviceType) {
  101. case NotificationActionService.SENTRY_NOTIFICATION:
  102. return (
  103. <Fragment>
  104. <div>{t('Send an email notification to the following roles')}</div>
  105. {recipientRoles?.map(role => (
  106. <NotificationRecipient key={role}>{role}</NotificationRecipient>
  107. ))}
  108. </Fragment>
  109. );
  110. case NotificationActionService.SLACK:
  111. return (
  112. <Fragment>
  113. <div>{t('Send a notification to the')}</div>
  114. <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
  115. <div>{t('channel')}</div>
  116. </Fragment>
  117. );
  118. case NotificationActionService.PAGERDUTY:
  119. return (
  120. <Fragment>
  121. <div>{t('Send a notification to the')}</div>
  122. <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
  123. <div>{t('service')}</div>
  124. </Fragment>
  125. );
  126. case NotificationActionService.OPSGENIE:
  127. return (
  128. <Fragment>
  129. <div>{t('Send a notification to the')}</div>
  130. <NotificationRecipient>{action.targetDisplay}</NotificationRecipient>
  131. <div>{t('team')}</div>
  132. </Fragment>
  133. );
  134. default:
  135. // TODO(enterprise): descriptions for email, msteams, sentry_app
  136. return null;
  137. }
  138. };
  139. const handleDelete = async () => {
  140. const endpoint = `/organizations/${organization.slug}/notifications/actions/${action.id}/`;
  141. try {
  142. await api.requestPromise(endpoint, {
  143. method: 'DELETE',
  144. });
  145. addSuccessMessage(t('Successfully deleted notification action'));
  146. onDelete(index);
  147. } catch (err) {
  148. addErrorMessage(t('Unable to delete notification action'));
  149. }
  150. };
  151. const handleCancel = () => {
  152. if (action.id) {
  153. setEditedAction(action);
  154. setIsEditing(false);
  155. return;
  156. }
  157. // Delete the unsaved notification action
  158. onDelete(index);
  159. };
  160. const handleSave = async () => {
  161. const {apiMethod, apiEndpoint, successMessage, errorMessage} = getFormData();
  162. addLoadingMessage();
  163. // TODO(enterprise): use "requires" to get data to send
  164. // This is currently optimized for spike protection
  165. const data = {...editedAction};
  166. // Remove keys from the data if they are falsy
  167. Object.keys(data).forEach(key => {
  168. if (!data[key]) {
  169. delete data[key];
  170. }
  171. });
  172. try {
  173. const resp = await api.requestPromise(apiEndpoint, {
  174. method: apiMethod,
  175. data: {...data, projects: [project.slug]},
  176. });
  177. addSuccessMessage(successMessage);
  178. onUpdate(index, resp);
  179. setEditedAction(resp);
  180. setIsEditing(false);
  181. } catch (err) {
  182. addErrorMessage(errorMessage);
  183. }
  184. };
  185. // Used for PagerDuty/Opsgenie
  186. const handleChange = (names: string[], values: any[]) => {
  187. const updatedAction = {...editedAction};
  188. names.forEach((name, i) => {
  189. const value = values[i];
  190. updatedAction[name] = value;
  191. });
  192. setEditedAction(updatedAction);
  193. };
  194. // Edit button is located outside of the form
  195. const renderEditButton = () => {
  196. const menuItems: MenuItemProps[] = [
  197. {
  198. key: 'notificationaction-delete',
  199. label: t('Delete'),
  200. priority: 'danger',
  201. onAction: () => {
  202. openConfirmModal({
  203. message: t('Are you sure you want to delete this notification action?'),
  204. onConfirm: handleDelete,
  205. });
  206. },
  207. },
  208. ];
  209. // No edit mode for Sentry notifications
  210. if (serviceType !== NotificationActionService.SENTRY_NOTIFICATION) {
  211. menuItems.unshift({
  212. key: 'notificationaction-edit',
  213. label: t('Edit'),
  214. onAction: () => setIsEditing(true),
  215. });
  216. }
  217. return (
  218. <Tooltip
  219. disabled={!disabled}
  220. title={t('You do not have permission to edit notification actions.')}
  221. >
  222. <DropdownMenu
  223. items={menuItems}
  224. trigger={triggerProps => (
  225. <Button
  226. {...triggerProps}
  227. aria-label={t('Actions')}
  228. size="xs"
  229. icon={<IconEllipsis direction="down" size="sm" />}
  230. data-test-id="edit-dropdown"
  231. />
  232. )}
  233. isDisabled={disabled}
  234. />
  235. </Tooltip>
  236. );
  237. };
  238. const getFormData = () => {
  239. if (editedAction.id) {
  240. return {
  241. apiMethod: 'PUT' as const,
  242. apiEndpoint: `/organizations/${organization.slug}/notifications/actions/${action.id}/`,
  243. successMessage: t('Successfully updated notification action'),
  244. errorMessage: t('Unable to update notification action'),
  245. };
  246. }
  247. return {
  248. apiMethod: 'POST' as const,
  249. apiEndpoint: `/organizations/${organization.slug}/notifications/actions/`,
  250. successMessage: t('Successfully added notification action'),
  251. errorMessage: t('Unable to add notification action'),
  252. };
  253. };
  254. const renderNotificationActionForm = () => {
  255. switch (serviceType) {
  256. case NotificationActionService.SENTRY_NOTIFICATION:
  257. return (
  258. <NotificationActionFormContainer>
  259. <NotificationActionCell>{renderDescription()}</NotificationActionCell>
  260. <ButtonBar gap={0.5}>
  261. <Button onClick={handleCancel} size="xs">
  262. {t('Cancel')}
  263. </Button>
  264. <Button priority="primary" size="xs" onClick={handleSave}>
  265. {t('Save')}
  266. </Button>
  267. </ButtonBar>
  268. </NotificationActionFormContainer>
  269. );
  270. case NotificationActionService.SLACK:
  271. return (
  272. <SlackForm
  273. action={editedAction}
  274. onChange={(name: string, value: any) =>
  275. setEditedAction({...editedAction, [name]: value})
  276. }
  277. onSave={handleSave}
  278. onCancel={handleCancel}
  279. availableActions={availableActions}
  280. />
  281. );
  282. case NotificationActionService.PAGERDUTY:
  283. return (
  284. <OnCallServiceForm
  285. action={editedAction}
  286. onChange={handleChange}
  287. onSave={handleSave}
  288. onCancel={handleCancel}
  289. Integrations={pagerdutyIntegrations}
  290. onCallService="pagerduty"
  291. />
  292. );
  293. case NotificationActionService.OPSGENIE:
  294. return (
  295. <OnCallServiceForm
  296. action={editedAction}
  297. onChange={handleChange}
  298. onSave={handleSave}
  299. onCancel={handleCancel}
  300. Integrations={opsgenieIntegrations}
  301. onCallService="opsgenie"
  302. />
  303. );
  304. default:
  305. return null;
  306. }
  307. };
  308. return (
  309. <StyledCard isEditing={isEditing} data-test-id="notification-action">
  310. {isEditing ? (
  311. <NotificationActionContainer data-test-id={`${serviceType}-form`}>
  312. <IconContainer>{renderIcon()}</IconContainer>
  313. {renderNotificationActionForm()}
  314. </NotificationActionContainer>
  315. ) : (
  316. <Fragment>
  317. <NotificationActionContainer data-test-id={`${serviceType}-action`}>
  318. <IconContainer>{renderIcon()}</IconContainer>
  319. <NotificationActionCell>{renderDescription()}</NotificationActionCell>
  320. </NotificationActionContainer>
  321. {renderEditButton()}
  322. </Fragment>
  323. )}
  324. </StyledCard>
  325. );
  326. }
  327. const StyledCard = styled(Card)<{isEditing: boolean}>`
  328. padding: ${space(1)} ${space(1.5)};
  329. flex-direction: row;
  330. align-items: center;
  331. justify-content: space-between;
  332. margin-bottom: ${space(1)};
  333. background-color: ${props => (props.isEditing ? props.theme.surface200 : 'inherit')};
  334. `;
  335. const IconContainer = styled('div')`
  336. margin-right: ${space(1)};
  337. display: flex;
  338. align-items: center;
  339. `;
  340. const NotificationActionContainer = styled('div')`
  341. display: flex;
  342. align-items: center;
  343. width: 100%;
  344. `;
  345. export const NotificationActionCell = styled('div')`
  346. display: flex;
  347. align-items: center;
  348. flex-wrap: wrap;
  349. gap: ${space(0.5)};
  350. `;
  351. export const NotificationActionFormContainer = styled('div')`
  352. display: flex;
  353. justify-content: space-between;
  354. width: 100%;
  355. `;
  356. const NotificationRecipient = styled(Badge)`
  357. border-radius: ${p => p.theme.borderRadius};
  358. font-weight: normal;
  359. `;
  360. export default NotificationActionItem;