ruleNode.tsx 18 KB

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