ruleNode.tsx 17 KB

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