ruleNode.tsx 17 KB

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