ruleNode.tsx 20 KB

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