ruleNode.tsx 19 KB

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