index.tsx 17 KB

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