ruleNode.tsx 20 KB

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