ruleNode.tsx 20 KB

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