ruleNode.tsx 20 KB

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