ruleNode.tsx 16 KB

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