index.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import * as Sentry from '@sentry/react';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import {openModal} from 'sentry/actionCreators/modal';
  6. import {Alert} from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import SelectControl from 'sentry/components/forms/controls/selectControl';
  9. import ListItem from 'sentry/components/list/listItem';
  10. import LoadingIndicator from 'sentry/components/loadingIndicator';
  11. import PanelItem from 'sentry/components/panels/panelItem';
  12. import {IconAdd, IconSettings} from 'sentry/icons';
  13. import {t} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import {Organization, Project, SelectValue} from 'sentry/types';
  16. import {uniqueId} from 'sentry/utils/guid';
  17. import {removeAtArrayIndex} from 'sentry/utils/removeAtArrayIndex';
  18. import {replaceAtArrayIndex} from 'sentry/utils/replaceAtArrayIndex';
  19. import withOrganization from 'sentry/utils/withOrganization';
  20. import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal';
  21. import ActionSpecificTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionSpecificTargetSelector';
  22. import ActionTargetSelector from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/actionTargetSelector';
  23. import DeleteActionButton from 'sentry/views/alerts/rules/metric/triggers/actionsPanel/deleteActionButton';
  24. import {
  25. Action,
  26. ActionLabel,
  27. ActionType,
  28. MetricActionTemplate,
  29. TargetLabel,
  30. Trigger,
  31. } from 'sentry/views/alerts/rules/metric/types';
  32. type Props = {
  33. availableActions: MetricActionTemplate[] | null;
  34. currentProject: string;
  35. disabled: boolean;
  36. error: boolean;
  37. loading: boolean;
  38. onAdd: (triggerIndex: number, action: Action) => void;
  39. onChange: (triggerIndex: number, triggers: Trigger[], actions: Action[]) => void;
  40. organization: Organization;
  41. projects: Project[];
  42. triggers: Trigger[];
  43. className?: string;
  44. };
  45. /**
  46. * When a new action is added, all of it's settings should be set to their default values.
  47. * @param actionConfig
  48. * @param dateCreated kept to maintain order of unsaved actions
  49. */
  50. const getCleanAction = (actionConfig, dateCreated?: string): Action => {
  51. return {
  52. unsavedId: uniqueId(),
  53. unsavedDateCreated: dateCreated ?? new Date().toISOString(),
  54. type: actionConfig.type,
  55. targetType:
  56. actionConfig &&
  57. actionConfig.allowedTargetTypes &&
  58. actionConfig.allowedTargetTypes.length > 0
  59. ? actionConfig.allowedTargetTypes[0]
  60. : null,
  61. targetIdentifier: actionConfig.sentryAppId || '',
  62. inputChannelId: null,
  63. integrationId: actionConfig.integrationId,
  64. sentryAppId: actionConfig.sentryAppId,
  65. options: actionConfig.options || null,
  66. };
  67. };
  68. /**
  69. * Actions have a type (e.g. email, slack, etc), but only some have
  70. * an integrationId (e.g. email is null). This helper creates a unique
  71. * id based on the type and integrationId so that we know what action
  72. * a user's saved action corresponds to.
  73. */
  74. const getActionUniqueKey = ({
  75. type,
  76. integrationId,
  77. sentryAppId,
  78. }: Pick<Action, 'type' | 'integrationId' | 'sentryAppId'>) => {
  79. if (integrationId) {
  80. return `${type}-${integrationId}`;
  81. }
  82. if (sentryAppId) {
  83. return `${type}-${sentryAppId}`;
  84. }
  85. return type;
  86. };
  87. /**
  88. * Creates a human-friendly display name for the integration based on type and
  89. * server provided `integrationName`
  90. *
  91. * e.g. for slack we show that it is slack and the `integrationName` is the workspace name
  92. */
  93. const getFullActionTitle = ({
  94. type,
  95. integrationName,
  96. sentryAppName,
  97. status,
  98. }: Pick<
  99. MetricActionTemplate,
  100. 'type' | 'integrationName' | 'sentryAppName' | 'status'
  101. >) => {
  102. if (sentryAppName) {
  103. if (status && status !== 'published') {
  104. return `${sentryAppName} (${status})`;
  105. }
  106. return `${sentryAppName}`;
  107. }
  108. const label = ActionLabel[type];
  109. if (integrationName) {
  110. return `${label} - ${integrationName}`;
  111. }
  112. return label;
  113. };
  114. /**
  115. * Lists saved actions as well as control to add a new action
  116. */
  117. class ActionsPanel extends PureComponent<Props> {
  118. handleChangeKey(
  119. triggerIndex: number,
  120. index: number,
  121. key: 'targetIdentifier' | 'inputChannelId',
  122. value: string
  123. ) {
  124. const {triggers, onChange} = this.props;
  125. const {actions} = triggers[triggerIndex];
  126. const newAction = {
  127. ...actions[index],
  128. [key]: value,
  129. };
  130. onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
  131. }
  132. conditionallyRenderHelpfulBanner(triggerIndex: number, index: number) {
  133. const {triggers} = this.props;
  134. const {actions} = triggers[triggerIndex];
  135. const newAction = {...actions[index]};
  136. if (newAction.type !== 'slack') {
  137. return null;
  138. }
  139. return (
  140. <MarginlessAlert
  141. type="info"
  142. showIcon
  143. trailingItems={
  144. <Button
  145. href="https://docs.sentry.io/product/integrations/notification-incidents/slack/#rate-limiting-error"
  146. external
  147. size="xs"
  148. >
  149. {t('Learn More')}
  150. </Button>
  151. }
  152. >
  153. {t('Having rate limiting problems? Enter a channel or user ID.')}
  154. </MarginlessAlert>
  155. );
  156. }
  157. handleAddAction = () => {
  158. const {availableActions, onAdd} = this.props;
  159. const actionConfig = availableActions?.[0];
  160. if (!actionConfig) {
  161. addErrorMessage(t('There was a problem adding an action'));
  162. Sentry.captureException(new Error('Unable to add an action'));
  163. return;
  164. }
  165. const action: Action = getCleanAction(actionConfig);
  166. // Add new actions to critical by default
  167. const triggerIndex = 0;
  168. onAdd(triggerIndex, action);
  169. };
  170. handleDeleteAction = (triggerIndex: number, index: number) => {
  171. const {triggers, onChange} = this.props;
  172. const {actions} = triggers[triggerIndex];
  173. onChange(triggerIndex, triggers, removeAtArrayIndex(actions, index));
  174. };
  175. handleChangeActionLevel = (
  176. triggerIndex: number,
  177. index: number,
  178. value: SelectValue<number>
  179. ) => {
  180. const {triggers, onChange} = this.props;
  181. // Convert saved action to unsaved by removing id
  182. const {id: _, ...action} = triggers[triggerIndex].actions[index];
  183. action.unsavedId = uniqueId();
  184. triggers[value.value].actions.push(action);
  185. onChange(value.value, triggers, triggers[value.value].actions);
  186. this.handleDeleteAction(triggerIndex, index);
  187. };
  188. handleChangeActionType = (
  189. triggerIndex: number,
  190. index: number,
  191. value: SelectValue<ActionType>
  192. ) => {
  193. const {triggers, onChange, availableActions} = this.props;
  194. const {actions} = triggers[triggerIndex];
  195. const actionConfig = availableActions?.find(
  196. availableAction => getActionUniqueKey(availableAction) === value.value
  197. );
  198. if (!actionConfig) {
  199. addErrorMessage(t('There was a problem changing an action'));
  200. Sentry.captureException(new Error('Unable to change an action type'));
  201. return;
  202. }
  203. const existingDateCreated =
  204. actions[index].dateCreated ?? actions[index].unsavedDateCreated;
  205. const newAction: Action = getCleanAction(actionConfig, existingDateCreated);
  206. onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
  207. };
  208. handleChangeTarget = (
  209. triggerIndex: number,
  210. index: number,
  211. value: SelectValue<keyof typeof TargetLabel>
  212. ) => {
  213. const {triggers, onChange} = this.props;
  214. const {actions} = triggers[triggerIndex];
  215. const newAction = {
  216. ...actions[index],
  217. targetType: value.value,
  218. targetIdentifier: '',
  219. };
  220. onChange(triggerIndex, triggers, replaceAtArrayIndex(actions, index, newAction));
  221. };
  222. /**
  223. * Update the Trigger's Action fields from the SentryAppRuleModal together
  224. * only after the user clicks "Save Changes".
  225. * @param formData Form data
  226. */
  227. updateParentFromSentryAppRule = (
  228. triggerIndex: number,
  229. actionIndex: number,
  230. formData: {[key: string]: string}
  231. ): void => {
  232. const {triggers, onChange} = this.props;
  233. const {actions} = triggers[triggerIndex];
  234. const newAction = {
  235. ...actions[actionIndex],
  236. ...formData,
  237. };
  238. onChange(
  239. triggerIndex,
  240. triggers,
  241. replaceAtArrayIndex(actions, actionIndex, newAction)
  242. );
  243. };
  244. render() {
  245. const {
  246. availableActions,
  247. currentProject,
  248. disabled,
  249. loading,
  250. organization,
  251. projects,
  252. triggers,
  253. } = this.props;
  254. const project = projects.find(({slug}) => slug === currentProject);
  255. const items = availableActions?.map(availableAction => ({
  256. value: getActionUniqueKey(availableAction),
  257. label: getFullActionTitle(availableAction),
  258. }));
  259. const levels = [
  260. {value: 0, label: 'Critical Status'},
  261. {value: 1, label: 'Warning Status'},
  262. ];
  263. // Create single array of unsaved and saved trigger actions
  264. // Sorted by date created ascending
  265. const actions = triggers
  266. .flatMap((trigger, triggerIndex) => {
  267. return trigger.actions.map((action, actionIdx) => {
  268. const availableAction = availableActions?.find(
  269. a => getActionUniqueKey(a) === getActionUniqueKey(action)
  270. );
  271. return {
  272. dateCreated: new Date(
  273. action.dateCreated ?? action.unsavedDateCreated
  274. ).getTime(),
  275. triggerIndex,
  276. action,
  277. actionIdx,
  278. availableAction,
  279. };
  280. });
  281. })
  282. .sort((a, b) => a.dateCreated - b.dateCreated);
  283. return (
  284. <Fragment>
  285. <PerformActionsListItem>{t('Set actions')}</PerformActionsListItem>
  286. {loading && <LoadingIndicator />}
  287. {actions.map(({action, actionIdx, triggerIndex, availableAction}) => {
  288. const actionDisabled =
  289. triggers[triggerIndex].actions[actionIdx]?.disabled || disabled;
  290. return (
  291. <div key={action.id ?? action.unsavedId}>
  292. <RuleRowContainer>
  293. <PanelItemGrid>
  294. <PanelItemSelects>
  295. <SelectControl
  296. name="select-level"
  297. aria-label={t('Select a status level')}
  298. isDisabled={disabled || loading}
  299. placeholder={t('Select Level')}
  300. onChange={this.handleChangeActionLevel.bind(
  301. this,
  302. triggerIndex,
  303. actionIdx
  304. )}
  305. value={triggerIndex}
  306. options={levels}
  307. />
  308. <SelectControl
  309. name="select-action"
  310. aria-label={t('Select an Action')}
  311. isDisabled={disabled || loading}
  312. placeholder={t('Select Action')}
  313. onChange={this.handleChangeActionType.bind(
  314. this,
  315. triggerIndex,
  316. actionIdx
  317. )}
  318. value={getActionUniqueKey(action)}
  319. options={items ?? []}
  320. />
  321. {availableAction && availableAction.allowedTargetTypes.length > 1 ? (
  322. <SelectControl
  323. isDisabled={disabled || loading}
  324. value={action.targetType}
  325. options={availableAction?.allowedTargetTypes?.map(
  326. allowedType => ({
  327. value: allowedType,
  328. label: TargetLabel[allowedType],
  329. })
  330. )}
  331. onChange={this.handleChangeTarget.bind(
  332. this,
  333. triggerIndex,
  334. actionIdx
  335. )}
  336. />
  337. ) : availableAction &&
  338. availableAction.type === 'sentry_app' &&
  339. availableAction.settings ? (
  340. <Button
  341. icon={<IconSettings />}
  342. disabled={actionDisabled}
  343. onClick={() => {
  344. openModal(
  345. deps => (
  346. <SentryAppRuleModal
  347. {...deps}
  348. // Using ! for keys that will exist for sentryapps
  349. sentryAppInstallationUuid={
  350. availableAction.sentryAppInstallationUuid!
  351. }
  352. config={availableAction.settings!}
  353. appName={availableAction.sentryAppName!}
  354. onSubmitSuccess={this.updateParentFromSentryAppRule.bind(
  355. this,
  356. triggerIndex,
  357. actionIdx
  358. )}
  359. resetValues={
  360. triggers[triggerIndex].actions[actionIdx] || {}
  361. }
  362. />
  363. ),
  364. {closeEvents: 'escape-key'}
  365. );
  366. }}
  367. >
  368. {t('Settings')}
  369. </Button>
  370. ) : null}
  371. <ActionTargetSelector
  372. action={action}
  373. availableAction={availableAction}
  374. disabled={disabled}
  375. loading={loading}
  376. onChange={this.handleChangeKey.bind(
  377. this,
  378. triggerIndex,
  379. actionIdx,
  380. 'targetIdentifier'
  381. )}
  382. organization={organization}
  383. project={project}
  384. />
  385. <ActionSpecificTargetSelector
  386. action={action}
  387. disabled={disabled}
  388. onChange={this.handleChangeKey.bind(
  389. this,
  390. triggerIndex,
  391. actionIdx,
  392. 'inputChannelId'
  393. )}
  394. />
  395. </PanelItemSelects>
  396. <DeleteActionButton
  397. triggerIndex={triggerIndex}
  398. index={actionIdx}
  399. onClick={this.handleDeleteAction}
  400. disabled={disabled}
  401. />
  402. </PanelItemGrid>
  403. </RuleRowContainer>
  404. {this.conditionallyRenderHelpfulBanner(triggerIndex, actionIdx)}
  405. </div>
  406. );
  407. })}
  408. <ActionSection>
  409. <Button
  410. disabled={disabled || loading}
  411. icon={<IconAdd isCircled color="gray300" />}
  412. onClick={this.handleAddAction}
  413. >
  414. {t('Add Action')}
  415. </Button>
  416. </ActionSection>
  417. </Fragment>
  418. );
  419. }
  420. }
  421. const ActionsPanelWithSpace = styled(ActionsPanel)`
  422. margin-top: ${space(4)};
  423. `;
  424. const ActionSection = styled('div')`
  425. margin-top: ${space(1)};
  426. margin-bottom: ${space(3)};
  427. `;
  428. const PanelItemGrid = styled(PanelItem)`
  429. display: flex;
  430. align-items: center;
  431. border-bottom: 0;
  432. padding: ${space(1)};
  433. `;
  434. const PanelItemSelects = styled('div')`
  435. display: flex;
  436. width: 100%;
  437. margin-right: ${space(1)};
  438. > * {
  439. flex: 0 1 200px;
  440. &:not(:last-child) {
  441. margin-right: ${space(1)};
  442. }
  443. }
  444. `;
  445. const RuleRowContainer = styled('div')`
  446. background-color: ${p => p.theme.backgroundSecondary};
  447. border: 1px ${p => p.theme.border} solid;
  448. border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0;
  449. &:last-child {
  450. border-radius: ${p => p.theme.borderRadius};
  451. }
  452. `;
  453. const StyledListItem = styled(ListItem)`
  454. margin: ${space(2)} 0 ${space(3)} 0;
  455. font-size: ${p => p.theme.fontSizeExtraLarge};
  456. `;
  457. const PerformActionsListItem = styled(StyledListItem)`
  458. margin-bottom: 0;
  459. line-height: 1.3;
  460. `;
  461. const MarginlessAlert = styled(Alert)`
  462. border-radius: 0 0 ${p => p.theme.borderRadius} ${p => p.theme.borderRadius};
  463. border: 1px ${p => p.theme.border} solid;
  464. border-top-width: 0;
  465. margin: 0;
  466. padding: ${space(1)} ${space(1)};
  467. font-size: ${p => p.theme.fontSizeSmall};
  468. `;
  469. export default withOrganization(ActionsPanelWithSpace);