ruleNode.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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 type {Choices, Organization, Project} from 'sentry/types';
  16. import type {
  17. IssueAlertConfiguration,
  18. IssueAlertRuleAction,
  19. IssueAlertRuleCondition,
  20. } from 'sentry/types/alerts';
  21. import {
  22. AssigneeTargetType,
  23. IssueAlertActionType,
  24. IssueAlertConditionType,
  25. IssueAlertFilterType,
  26. MailActionTargetType,
  27. } from 'sentry/types/alerts';
  28. import MemberTeamFields from 'sentry/views/alerts/rules/issue/memberTeamFields';
  29. import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal';
  30. import TicketRuleModal from 'sentry/views/alerts/rules/issue/ticketRuleModal';
  31. import type {SchemaFormConfig} from 'sentry/views/settings/organizationIntegrations/sentryAppExternalForm';
  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. const issueOwnersLabel = t('Suggested Assignees');
  112. return (
  113. <MemberTeamFields
  114. disabled={disabled}
  115. project={project}
  116. organization={organization}
  117. loading={!isInitialized}
  118. ruleData={data as IssueAlertRuleAction}
  119. onChange={onMemberTeamChange}
  120. options={[
  121. {value: MailActionTargetType.ISSUE_OWNERS, label: issueOwnersLabel},
  122. {value: MailActionTargetType.TEAM, label: t('Team')},
  123. {value: MailActionTargetType.MEMBER, label: t('Member')},
  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. incompatibleBanner?: boolean;
  219. incompatibleRule?: boolean;
  220. node?: IssueAlertConfiguration[keyof IssueAlertConfiguration][number] | null;
  221. }
  222. function RuleNode({
  223. index,
  224. data,
  225. node,
  226. organization,
  227. project,
  228. disabled,
  229. onDelete,
  230. onPropertyChange,
  231. onReset,
  232. incompatibleRule,
  233. incompatibleBanner,
  234. }: Props) {
  235. const handleDelete = useCallback(() => {
  236. onDelete(index);
  237. }, [index, onDelete]);
  238. const handleMemberTeamChange = useCallback(
  239. ({targetType, targetIdentifier}: IssueAlertRuleAction | IssueAlertRuleCondition) => {
  240. onPropertyChange(index, 'targetType', `${targetType}`);
  241. onPropertyChange(index, 'targetIdentifier', `${targetIdentifier}`);
  242. },
  243. [index, onPropertyChange]
  244. );
  245. function getField(name: string, fieldConfig: FormField) {
  246. const fieldProps: FieldProps = {
  247. index,
  248. name,
  249. fieldConfig,
  250. data,
  251. organization,
  252. project,
  253. disabled,
  254. onMemberTeamChange: handleMemberTeamChange,
  255. onPropertyChange,
  256. onReset,
  257. };
  258. if (name === 'environment') {
  259. return (
  260. <ChoiceField
  261. {...merge(fieldProps, {
  262. fieldConfig: {choices: project.environments.map(env => [env, env])},
  263. })}
  264. />
  265. );
  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 === IssueAlertActionType.NOTIFY_EMAIL &&
  294. data.targetType !== MailActionTargetType.ISSUE_OWNERS
  295. ) {
  296. // Hide the fallback options when targeting team or member
  297. label = 'Send a notification to {targetType}';
  298. }
  299. if (data.id === IssueAlertConditionType.REAPPEARED_EVENT) {
  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?.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 />}
  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 />}
  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. return null;
  456. }
  457. function renderIncompatibleRuleBanner() {
  458. if (!incompatibleBanner) {
  459. return null;
  460. }
  461. return (
  462. <MarginlessAlert type="error" showIcon>
  463. {t(
  464. 'The conditions highlighted in red are in conflict. They may prevent the alert from ever being triggered.'
  465. )}
  466. </MarginlessAlert>
  467. );
  468. }
  469. /**
  470. * Update all the AlertRuleAction's fields from the TicketRuleModal together
  471. * only after the user clicks "Apply Changes".
  472. * @param formData Form data
  473. * @param fetchedFieldOptionsCache Object
  474. */
  475. const updateParentFromTicketRule = useCallback(
  476. (
  477. formData: Record<string, string>,
  478. fetchedFieldOptionsCache: Record<string, Choices>
  479. ): void => {
  480. // We only know the choices after the form loads.
  481. formData.dynamic_form_fields = ((formData.dynamic_form_fields as any) || []).map(
  482. (field: any) => {
  483. // Overwrite the choices because the user's pick is in this list.
  484. if (
  485. field.name in formData &&
  486. fetchedFieldOptionsCache?.hasOwnProperty(field.name)
  487. ) {
  488. field.choices = fetchedFieldOptionsCache[field.name];
  489. }
  490. return field;
  491. }
  492. );
  493. for (const [name, value] of Object.entries(formData)) {
  494. onPropertyChange(index, name, value);
  495. }
  496. },
  497. [index, onPropertyChange]
  498. );
  499. /**
  500. * Update all the AlertRuleAction's fields from the SentryAppRuleModal together
  501. * only after the user clicks "Save Changes".
  502. * @param formData Form data
  503. */
  504. const updateParentFromSentryAppRule = useCallback(
  505. (formData: Record<string, string>): void => {
  506. for (const [name, value] of Object.entries(formData)) {
  507. onPropertyChange(index, name, value);
  508. }
  509. },
  510. [index, onPropertyChange]
  511. );
  512. return (
  513. <RuleRowContainer incompatible={incompatibleRule}>
  514. <RuleRow>
  515. <Rule>
  516. <input type="hidden" name="id" value={data.id} />
  517. {renderRow()}
  518. {renderIntegrationButton()}
  519. </Rule>
  520. <DeleteButton
  521. disabled={disabled}
  522. aria-label={t('Delete Node')}
  523. onClick={handleDelete}
  524. size="sm"
  525. icon={<IconDelete />}
  526. />
  527. </RuleRow>
  528. {renderIncompatibleRuleBanner()}
  529. {conditionallyRenderHelpfulBanner()}
  530. </RuleRowContainer>
  531. );
  532. }
  533. export default RuleNode;
  534. const InlineInput = styled(Input)`
  535. width: auto;
  536. height: 28px;
  537. min-height: 28px;
  538. `;
  539. const InlineNumberInput = styled(NumberInput)`
  540. width: 90px;
  541. height: 28px;
  542. min-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')<{incompatible?: boolean}>`
  558. background-color: ${p => p.theme.backgroundSecondary};
  559. border-radius: ${p => p.theme.borderRadius};
  560. border: 1px ${p => p.theme.innerBorder} solid;
  561. border-color: ${p => (p.incompatible ? p.theme.red200 : 'none')};
  562. `;
  563. const Rule = styled('div')`
  564. display: flex;
  565. align-items: center;
  566. flex: 1;
  567. flex-wrap: wrap;
  568. `;
  569. const DeleteButton = styled(Button)`
  570. flex-shrink: 0;
  571. `;
  572. const MarginlessAlert = styled(Alert)`
  573. border-top-left-radius: 0;
  574. border-top-right-radius: 0;
  575. border-width: 0;
  576. border-top: 1px ${p => p.theme.innerBorder} solid;
  577. margin: 0;
  578. padding: ${space(1)} ${space(1)};
  579. `;