ruleNode.tsx 20 KB

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