ruleNode.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import {Fragment, useCallback, useEffect} from 'react';
  2. import styled from '@emotion/styled';
  3. import merge from 'lodash/merge';
  4. import {openModal} from 'sentry/actionCreators/modal';
  5. import {Alert} from 'sentry/components/alert';
  6. import {Button, LinkButton} from 'sentry/components/button';
  7. import SelectControl from 'sentry/components/forms/controls/selectControl';
  8. import Input from 'sentry/components/input';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import NumberInput from 'sentry/components/numberInput';
  11. import {releaseHealth} from 'sentry/data/platformCategories';
  12. import {IconDelete, IconSettings} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import {space} from 'sentry/styles/space';
  15. import type {
  16. IssueAlertConfiguration,
  17. IssueAlertRuleAction,
  18. IssueAlertRuleCondition,
  19. } from 'sentry/types/alerts';
  20. import {
  21. AssigneeTargetType,
  22. IssueAlertActionType,
  23. IssueAlertConditionType,
  24. IssueAlertFilterType,
  25. MailActionTargetType,
  26. } from 'sentry/types/alerts';
  27. import type {Choices} from 'sentry/types/core';
  28. import type {Organization} from 'sentry/types/organization';
  29. import type {Project} from 'sentry/types/project';
  30. import MemberTeamFields from 'sentry/views/alerts/rules/issue/memberTeamFields';
  31. import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal';
  32. import TicketRuleModal from 'sentry/views/alerts/rules/issue/ticketRuleModal';
  33. interface FieldProps {
  34. data: Props['data'];
  35. disabled: boolean;
  36. fieldConfig: FormField;
  37. index: number;
  38. name: string;
  39. onMemberTeamChange: (data: Props['data']) => void;
  40. onPropertyChange: Props['onPropertyChange'];
  41. onReset: Props['onReset'];
  42. organization: Organization;
  43. project: Project;
  44. }
  45. function NumberField({
  46. data,
  47. index,
  48. disabled,
  49. name,
  50. fieldConfig,
  51. onPropertyChange,
  52. }: FieldProps) {
  53. const value =
  54. (data[name] && typeof data[name] !== 'boolean') || data[name] === 0
  55. ? Number(data[name])
  56. : NaN;
  57. // Set default value of number fields to the placeholder value
  58. useEffect(() => {
  59. if (
  60. data.id === IssueAlertFilterType.ISSUE_OCCURRENCES &&
  61. isNaN(value) &&
  62. !isNaN(Number(fieldConfig.placeholder))
  63. ) {
  64. onPropertyChange(index, name, `${fieldConfig.placeholder}`);
  65. }
  66. // Value omitted on purpose to avoid overwriting user changes
  67. // eslint-disable-next-line react-hooks/exhaustive-deps
  68. }, [onPropertyChange, index, name, fieldConfig.placeholder, data.id]);
  69. return (
  70. <InlineNumberInput
  71. min={0}
  72. name={name}
  73. value={value}
  74. placeholder={`${fieldConfig.placeholder}`}
  75. disabled={disabled}
  76. onChange={newVal => onPropertyChange(index, name, String(newVal))}
  77. aria-label={t('Value')}
  78. />
  79. );
  80. }
  81. function AssigneeFilterFields({
  82. data,
  83. organization,
  84. project,
  85. disabled,
  86. onMemberTeamChange,
  87. }: FieldProps) {
  88. const isInitialized = data.targetType !== undefined && `${data.targetType}`.length > 0;
  89. return (
  90. <MemberTeamFields
  91. disabled={disabled}
  92. project={project}
  93. organization={organization}
  94. loading={!isInitialized}
  95. ruleData={data}
  96. onChange={onMemberTeamChange}
  97. options={[
  98. {value: AssigneeTargetType.UNASSIGNED, label: t('No One')},
  99. {value: AssigneeTargetType.TEAM, label: t('Team')},
  100. {value: AssigneeTargetType.MEMBER, label: t('Member')},
  101. ]}
  102. memberValue={AssigneeTargetType.MEMBER}
  103. teamValue={AssigneeTargetType.TEAM}
  104. />
  105. );
  106. }
  107. function MailActionFields({
  108. data,
  109. organization,
  110. project,
  111. disabled,
  112. onMemberTeamChange,
  113. }: FieldProps) {
  114. const isInitialized = data.targetType !== undefined && `${data.targetType}`.length > 0;
  115. const issueOwnersLabel = t('Suggested Assignees');
  116. return (
  117. <MemberTeamFields
  118. disabled={disabled}
  119. project={project}
  120. organization={organization}
  121. loading={!isInitialized}
  122. ruleData={data as IssueAlertRuleAction}
  123. onChange={onMemberTeamChange}
  124. options={[
  125. {value: MailActionTargetType.ISSUE_OWNERS, label: issueOwnersLabel},
  126. {value: MailActionTargetType.TEAM, label: t('Team')},
  127. {value: MailActionTargetType.MEMBER, label: t('Member')},
  128. ]}
  129. memberValue={MailActionTargetType.MEMBER}
  130. teamValue={MailActionTargetType.TEAM}
  131. />
  132. );
  133. }
  134. function ChoiceField({
  135. data,
  136. disabled,
  137. index,
  138. onPropertyChange,
  139. onReset,
  140. name,
  141. fieldConfig,
  142. }: FieldProps) {
  143. // Select the first item on this list
  144. // If it's not yet defined, call onPropertyChange to make sure the value is set on state
  145. let initialVal: string | undefined;
  146. if (data[name] === undefined && !!fieldConfig.choices.length) {
  147. initialVal = fieldConfig.initial
  148. ? `${fieldConfig.initial}`
  149. : `${fieldConfig.choices[0][0]}`;
  150. } else {
  151. initialVal = `${data[name]}`;
  152. }
  153. // All `value`s are cast to string
  154. // There are integrations that give the form field choices with the value as number, but
  155. // when the integration configuration gets saved, it gets saved and returned as a string
  156. // @ts-expect-error TS(7031): Binding element 'value' implicitly has an 'any' ty... Remove this comment to see the full error message
  157. const options = fieldConfig.choices.map(([value, label]) => ({
  158. value: `${value}`,
  159. label,
  160. }));
  161. return (
  162. <InlineSelectControl
  163. isClearable={false}
  164. name={name}
  165. value={initialVal}
  166. styles={{
  167. control: (provided: any) => ({
  168. ...provided,
  169. minHeight: '28px',
  170. height: '28px',
  171. }),
  172. }}
  173. disabled={disabled}
  174. options={options}
  175. onChange={({value}: {value: string}) => {
  176. if (fieldConfig.resetsForm) {
  177. onReset(index, name, value);
  178. } else {
  179. onPropertyChange(index, name, value);
  180. }
  181. }}
  182. />
  183. );
  184. }
  185. function TextField({
  186. data,
  187. index,
  188. onPropertyChange,
  189. disabled,
  190. name,
  191. fieldConfig,
  192. }: FieldProps) {
  193. const value =
  194. data[name] && typeof data[name] !== 'boolean' ? (data[name] as string | number) : '';
  195. return (
  196. <InlineInput
  197. type="text"
  198. name={name}
  199. value={value}
  200. placeholder={`${fieldConfig.placeholder}`}
  201. disabled={disabled}
  202. onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
  203. onPropertyChange(index, name, e.target.value)
  204. }
  205. />
  206. );
  207. }
  208. export type FormField = {
  209. // The rest is configuration for the form field
  210. [key: string]: any;
  211. // Type of form fields
  212. type: string;
  213. };
  214. interface Props {
  215. data: IssueAlertRuleAction | IssueAlertRuleCondition;
  216. disabled: boolean;
  217. index: number;
  218. onDelete: (rowIndex: number) => void;
  219. onPropertyChange: (rowIndex: number, name: string, value: string) => void;
  220. onReset: (rowIndex: number, name: string, value: string) => void;
  221. organization: Organization;
  222. project: Project;
  223. incompatibleBanner?: boolean;
  224. incompatibleRule?: boolean;
  225. node?: IssueAlertConfiguration[keyof IssueAlertConfiguration][number] | null;
  226. }
  227. function RuleNode({
  228. index,
  229. data,
  230. node,
  231. organization,
  232. project,
  233. disabled,
  234. onDelete,
  235. onPropertyChange,
  236. onReset,
  237. incompatibleRule,
  238. incompatibleBanner,
  239. }: Props) {
  240. const handleDelete = useCallback(() => {
  241. onDelete(index);
  242. }, [index, onDelete]);
  243. const handleMemberTeamChange = useCallback(
  244. ({targetType, targetIdentifier}: IssueAlertRuleAction | IssueAlertRuleCondition) => {
  245. onPropertyChange(index, 'targetType', `${targetType}`);
  246. onPropertyChange(index, 'targetIdentifier', `${targetIdentifier}`);
  247. },
  248. [index, onPropertyChange]
  249. );
  250. function getField(name: string, fieldConfig: FormField) {
  251. const fieldProps: FieldProps = {
  252. index,
  253. name,
  254. fieldConfig,
  255. data,
  256. organization,
  257. project,
  258. disabled,
  259. onMemberTeamChange: handleMemberTeamChange,
  260. onPropertyChange,
  261. onReset,
  262. };
  263. if (name === 'environment') {
  264. return (
  265. <ChoiceField
  266. {...merge(fieldProps, {
  267. fieldConfig: {choices: project.environments.map(env => [env, env])},
  268. })}
  269. />
  270. );
  271. }
  272. switch (fieldConfig.type) {
  273. case 'choice':
  274. return <ChoiceField {...fieldProps} />;
  275. case 'number':
  276. return <NumberField {...fieldProps} />;
  277. case 'string':
  278. return <TextField {...fieldProps} />;
  279. case 'mailAction':
  280. return <MailActionFields {...fieldProps} />;
  281. case 'assignee':
  282. return <AssigneeFilterFields {...fieldProps} />;
  283. default:
  284. return null;
  285. }
  286. }
  287. function renderRow() {
  288. if (!node) {
  289. return (
  290. <Separator>
  291. This node failed to render. It may have migrated to another section of the alert
  292. conditions
  293. </Separator>
  294. );
  295. }
  296. let {label} = node;
  297. if (
  298. data.id === IssueAlertActionType.NOTIFY_EMAIL &&
  299. data.targetType !== MailActionTargetType.ISSUE_OWNERS
  300. ) {
  301. // Hide the fallback options when targeting team or member
  302. label = 'Send a notification to {targetType}';
  303. }
  304. if (data.id === IssueAlertConditionType.REAPPEARED_EVENT) {
  305. label = t('The issue changes state from archived to escalating');
  306. }
  307. const parts = label.split(/({\w+})/).map((part, i) => {
  308. if (!/^{\w+}$/.test(part)) {
  309. return <Separator key={i}>{part}</Separator>;
  310. }
  311. const key = part.slice(1, -1);
  312. // If matcher is "is set" or "is not set", then we do not want to show the value input
  313. // because it is not required
  314. if (key === 'value' && (data.match === 'is' || data.match === 'ns')) {
  315. return null;
  316. }
  317. return (
  318. <Separator key={key}>
  319. {node.formFields?.hasOwnProperty(key)
  320. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  321. getField(key, node.formFields[key])
  322. : part}
  323. </Separator>
  324. );
  325. });
  326. const [title, ...inputs] = parts;
  327. // We return this so that it can be a grid
  328. return (
  329. <Fragment>
  330. {title}
  331. {inputs}
  332. </Fragment>
  333. );
  334. }
  335. /**
  336. * Displays a button to open a custom modal for sentry apps or ticket integrations
  337. */
  338. function renderIntegrationButton() {
  339. if (!node || !('actionType' in node)) {
  340. return null;
  341. }
  342. if (node.actionType === 'ticket') {
  343. return (
  344. <Button
  345. size="sm"
  346. icon={<IconSettings />}
  347. onClick={() =>
  348. openModal(deps => (
  349. <TicketRuleModal
  350. {...deps}
  351. formFields={node.formFields || {}}
  352. link={node.link}
  353. ticketType={node.ticketType}
  354. instance={data}
  355. index={index}
  356. onSubmitAction={updateParentFromTicketRule}
  357. organization={organization}
  358. />
  359. ))
  360. }
  361. >
  362. {t('Issue Link Settings')}
  363. </Button>
  364. );
  365. }
  366. if (node.actionType === 'sentryapp' && node.sentryAppInstallationUuid) {
  367. return (
  368. <Button
  369. size="sm"
  370. icon={<IconSettings />}
  371. disabled={Boolean(data.disabled) || disabled}
  372. onClick={() => {
  373. openModal(
  374. deps => (
  375. <SentryAppRuleModal
  376. {...deps}
  377. sentryAppInstallationUuid={node.sentryAppInstallationUuid}
  378. config={node.formFields}
  379. appName={node.prompt ?? node.label}
  380. onSubmitSuccess={updateParentFromSentryAppRule}
  381. resetValues={data}
  382. />
  383. ),
  384. {closeEvents: 'escape-key'}
  385. );
  386. }}
  387. >
  388. {t('Settings')}
  389. </Button>
  390. );
  391. }
  392. return null;
  393. }
  394. function conditionallyRenderHelpfulBanner() {
  395. if (data.id === IssueAlertConditionType.EVENT_FREQUENCY_PERCENT) {
  396. if (!project.platform || !releaseHealth.includes(project.platform)) {
  397. return (
  398. <MarginlessAlert type="error">
  399. {tct(
  400. "This project doesn't support sessions. [link:View supported platforms]",
  401. {
  402. link: (
  403. <ExternalLink href="https://docs.sentry.io/product/releases/setup/#release-health" />
  404. ),
  405. }
  406. )}
  407. </MarginlessAlert>
  408. );
  409. }
  410. return (
  411. <MarginlessAlert type="warning">
  412. {tct(
  413. 'Percent of sessions affected is approximated by the ratio of the issue frequency to the number of sessions in the project. [link:Learn more.]',
  414. {
  415. link: (
  416. <ExternalLink href="https://docs.sentry.io/product/alerts/create-alerts/issue-alert-config/" />
  417. ),
  418. }
  419. )}
  420. </MarginlessAlert>
  421. );
  422. }
  423. if (data.id === IssueAlertActionType.SLACK) {
  424. return (
  425. <MarginlessAlert
  426. type="info"
  427. showIcon
  428. trailingItems={
  429. <LinkButton
  430. href="https://docs.sentry.io/product/integrations/notification-incidents/slack/#rate-limiting-error"
  431. external
  432. size="xs"
  433. >
  434. {t('Learn More')}
  435. </LinkButton>
  436. }
  437. >
  438. {t('Having rate limiting problems? Enter a channel or user ID.')}
  439. </MarginlessAlert>
  440. );
  441. }
  442. if (data.id === IssueAlertActionType.DISCORD) {
  443. return (
  444. <MarginlessAlert
  445. type="info"
  446. showIcon
  447. trailingItems={
  448. <LinkButton
  449. href="https://docs.sentry.io/product/accounts/early-adopter-features/discord/#issue-alerts"
  450. external
  451. size="xs"
  452. >
  453. {t('Learn More')}
  454. </LinkButton>
  455. }
  456. >
  457. {t('Note that you must enter a Discord channel ID, not a channel name.')}
  458. </MarginlessAlert>
  459. );
  460. }
  461. return null;
  462. }
  463. function renderIncompatibleRuleBanner() {
  464. if (!incompatibleBanner) {
  465. return null;
  466. }
  467. return (
  468. <MarginlessAlert type="error" showIcon>
  469. {t(
  470. 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.'
  471. )}
  472. </MarginlessAlert>
  473. );
  474. }
  475. /**
  476. * Update all the AlertRuleAction's fields from the TicketRuleModal together
  477. * only after the user clicks "Apply Changes".
  478. * @param formData Form data
  479. * @param fetchedFieldOptionsCache Object
  480. */
  481. const updateParentFromTicketRule = useCallback(
  482. (
  483. formData: Record<string, string>,
  484. fetchedFieldOptionsCache: Record<string, Choices>
  485. ): void => {
  486. // We only know the choices after the form loads.
  487. formData.dynamic_form_fields = ((formData.dynamic_form_fields as any) || []).map(
  488. (field: any) => {
  489. // Overwrite the choices because the user's pick is in this list.
  490. if (
  491. field.name in formData &&
  492. fetchedFieldOptionsCache?.hasOwnProperty(field.name)
  493. ) {
  494. field.choices = fetchedFieldOptionsCache[field.name];
  495. }
  496. return field;
  497. }
  498. );
  499. for (const [name, value] of Object.entries(formData)) {
  500. onPropertyChange(index, name, value);
  501. }
  502. },
  503. [index, onPropertyChange]
  504. );
  505. /**
  506. * Update all the AlertRuleAction's fields from the SentryAppRuleModal together
  507. * only after the user clicks "Save Changes".
  508. * @param formData Form data
  509. */
  510. const updateParentFromSentryAppRule = useCallback(
  511. (formData: Record<string, string>): void => {
  512. for (const [name, value] of Object.entries(formData)) {
  513. onPropertyChange(index, name, value);
  514. }
  515. },
  516. [index, onPropertyChange]
  517. );
  518. return (
  519. <RuleRowContainer incompatible={incompatibleRule}>
  520. <RuleRow>
  521. <Rule>
  522. <input type="hidden" name="id" value={data.id} />
  523. {renderRow()}
  524. {renderIntegrationButton()}
  525. </Rule>
  526. <DeleteButton
  527. disabled={disabled}
  528. aria-label={t('Delete Node')}
  529. onClick={handleDelete}
  530. size="sm"
  531. icon={<IconDelete />}
  532. />
  533. </RuleRow>
  534. {renderIncompatibleRuleBanner()}
  535. {conditionallyRenderHelpfulBanner()}
  536. </RuleRowContainer>
  537. );
  538. }
  539. export default RuleNode;
  540. const InlineInput = styled(Input)`
  541. width: auto;
  542. height: 28px;
  543. min-height: 28px;
  544. `;
  545. const InlineNumberInput = styled(NumberInput)`
  546. width: 90px;
  547. height: 28px;
  548. min-height: 28px;
  549. `;
  550. const InlineSelectControl = styled(SelectControl)`
  551. width: 180px;
  552. `;
  553. const Separator = styled('span')`
  554. margin-right: ${space(1)};
  555. padding-top: ${space(0.5)};
  556. padding-bottom: ${space(0.5)};
  557. `;
  558. const RuleRow = styled('div')`
  559. display: flex;
  560. align-items: center;
  561. padding: ${space(1)};
  562. `;
  563. const RuleRowContainer = styled('div')<{incompatible?: boolean}>`
  564. background-color: ${p => p.theme.backgroundSecondary};
  565. border-radius: ${p => p.theme.borderRadius};
  566. border: 1px ${p => p.theme.innerBorder} solid;
  567. border-color: ${p => (p.incompatible ? p.theme.red200 : 'none')};
  568. `;
  569. const Rule = styled('div')`
  570. display: flex;
  571. align-items: center;
  572. flex: 1;
  573. flex-wrap: wrap;
  574. `;
  575. const DeleteButton = styled(Button)`
  576. flex-shrink: 0;
  577. `;
  578. const MarginlessAlert = styled(Alert)`
  579. border-top-left-radius: 0;
  580. border-top-right-radius: 0;
  581. border-width: 0;
  582. border-top: 1px ${p => p.theme.innerBorder} solid;
  583. margin: 0;
  584. padding: ${space(1)} ${space(1)};
  585. `;