sentryAppRuleModal.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import type {PropsWithChildren, ReactElement} from 'react';
  2. import styled from '@emotion/styled';
  3. import {SentryAppFixture} from 'sentry-fixture/sentryApp';
  4. import {SentryAppInstallationFixture} from 'sentry-fixture/sentryAppInstallation';
  5. import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
  6. import {makeCloseButton} from 'sentry/components/globalModal/components';
  7. import SentryAppRuleModal from 'sentry/views/alerts/rules/issue/sentryAppRuleModal';
  8. import type {
  9. FieldFromSchema,
  10. SchemaFormConfig,
  11. } from 'sentry/views/settings/organizationIntegrations/sentryAppExternalForm';
  12. describe('SentryAppRuleModal', function () {
  13. const modalElements = {
  14. Header: (p: PropsWithChildren) => p.children as ReactElement,
  15. Body: (p: PropsWithChildren) => p.children,
  16. Footer: (p: PropsWithChildren) => p.children,
  17. };
  18. let sentryApp: any;
  19. let sentryAppInstallation: any;
  20. beforeEach(function () {
  21. sentryApp = SentryAppFixture();
  22. sentryAppInstallation = SentryAppInstallationFixture();
  23. });
  24. const _submit = async () => {
  25. await userEvent.click(screen.getByText('Save Changes'));
  26. return screen.queryAllByText('Field is required');
  27. };
  28. const submitSuccess = async () => {
  29. const errors = await _submit();
  30. expect(errors).toHaveLength(0);
  31. };
  32. const defaultConfig: SchemaFormConfig = {
  33. uri: '/integration/test/',
  34. description: '',
  35. required_fields: [
  36. {
  37. type: 'text',
  38. label: 'Alert Title',
  39. name: 'title',
  40. },
  41. {
  42. type: 'textarea',
  43. label: 'Alert Description',
  44. name: 'description',
  45. },
  46. {
  47. type: 'select',
  48. label: 'Team Channel',
  49. name: 'channel',
  50. choices: [
  51. ['valor', 'valor'],
  52. ['mystic', 'mystic'],
  53. ['instinct', 'instinct'],
  54. ],
  55. },
  56. ],
  57. optional_fields: [
  58. {
  59. type: 'text',
  60. label: 'Extra Details',
  61. name: 'extra',
  62. },
  63. {
  64. type: 'select',
  65. label: 'Assignee',
  66. name: 'assignee',
  67. uri: '/link/assignee/',
  68. },
  69. {
  70. type: 'select',
  71. label: 'Workspace',
  72. name: 'workspace',
  73. uri: '/link/workspace/',
  74. },
  75. ],
  76. };
  77. const resetValues = {
  78. settings: [
  79. {
  80. name: 'extra',
  81. value: 'saved details from last edit',
  82. },
  83. {
  84. name: 'assignee',
  85. value: 'edna-mode',
  86. label: 'Edna Mode',
  87. },
  88. ],
  89. };
  90. const createWrapper = (props = {}) => {
  91. const styledWrapper = styled((c: PropsWithChildren) => c.children);
  92. return render(
  93. <SentryAppRuleModal
  94. {...modalElements}
  95. sentryAppInstallationUuid={sentryAppInstallation.uuid}
  96. appName={sentryApp.name}
  97. config={defaultConfig}
  98. onSubmitSuccess={() => {}}
  99. resetValues={resetValues}
  100. closeModal={jest.fn()}
  101. CloseButton={makeCloseButton(() => {})}
  102. Body={styledWrapper()}
  103. Footer={styledWrapper()}
  104. {...props}
  105. />
  106. );
  107. };
  108. describe('Create UI Alert Rule', function () {
  109. it('should render the Alert Rule modal with the config fields', function () {
  110. createWrapper();
  111. const {required_fields, optional_fields} = defaultConfig;
  112. const allFields = [...required_fields!, ...optional_fields!];
  113. allFields.forEach((field: FieldFromSchema) => {
  114. if (typeof field.label === 'string') {
  115. expect(screen.getByText(field.label)).toBeInTheDocument();
  116. }
  117. });
  118. });
  119. it('submit button shall be disabled if form is incomplete', async function () {
  120. createWrapper();
  121. expect(screen.getByRole('button', {name: 'Save Changes'})).toBeDisabled();
  122. await userEvent.hover(screen.getByRole('button', {name: 'Save Changes'}));
  123. expect(
  124. await screen.findByText('Required fields must be filled out')
  125. ).toBeInTheDocument();
  126. });
  127. it('should submit when "Save Changes" is clicked with valid data', async function () {
  128. createWrapper();
  129. const titleInput = screen.getByTestId('title');
  130. await userEvent.type(titleInput, 'some title');
  131. const descriptionInput = screen.getByTestId('description');
  132. await userEvent.type(descriptionInput, 'some description');
  133. const channelInput = screen.getAllByText('Type to search')[0]!;
  134. await userEvent.type(channelInput, '{keyDown}');
  135. await userEvent.click(screen.getByText('valor'));
  136. // Ensure text fields are persisted on edit
  137. const savedExtraDetailsInput = screen.getByDisplayValue(
  138. resetValues.settings[0]!.value
  139. );
  140. expect(savedExtraDetailsInput).toBeInTheDocument();
  141. // Ensure select fields are persisted with labels on edit
  142. const savedAssigneeInput = screen.getByText(resetValues.settings[1]!.label!);
  143. expect(savedAssigneeInput).toBeInTheDocument();
  144. // Ensure async select fields filter correctly
  145. const workspaceChoices = [
  146. ['WS0', 'Primary Workspace'],
  147. ['WS1', 'Secondary Workspace'],
  148. ];
  149. const workspaceResponse = MockApiClient.addMockResponse({
  150. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  151. body: {choices: workspaceChoices},
  152. });
  153. const workspaceInput = screen.getByText('Type to search');
  154. // Search by value
  155. await userEvent.type(workspaceInput, workspaceChoices[1]![0]!);
  156. await waitFor(() => expect(workspaceResponse).toHaveBeenCalled());
  157. // Select by label
  158. await userEvent.click(screen.getByText(workspaceChoices[1]![1]!));
  159. await submitSuccess();
  160. });
  161. it('should load all default fields correctly', function () {
  162. const schema: SchemaFormConfig = {
  163. uri: '/api/sentry/issue-link/create/',
  164. required_fields: [
  165. {
  166. type: 'text',
  167. label: 'Task Name',
  168. name: 'title',
  169. default: 'issue.title',
  170. },
  171. ],
  172. optional_fields: [
  173. {
  174. type: 'select',
  175. label: 'What is the estimated complexity?',
  176. name: 'complexity',
  177. choices: [
  178. ['low', 'low'],
  179. ['high', 'high'],
  180. ['medium', 'medium'],
  181. ],
  182. },
  183. ],
  184. };
  185. const defaultValues = {
  186. settings: [
  187. {
  188. name: 'title',
  189. value: 'poiggers',
  190. },
  191. {
  192. name: 'complexity',
  193. value: 'low',
  194. },
  195. ],
  196. };
  197. createWrapper({config: schema, resetValues: defaultValues});
  198. expect(screen.getByText('low')).toBeInTheDocument();
  199. expect(screen.queryByText('poiggers')).not.toBeInTheDocument();
  200. });
  201. it('should not make external calls until depends on fields are filled in', async function () {
  202. const mockApi = MockApiClient.addMockResponse({
  203. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  204. body: {
  205. choices: [
  206. ['low', 'Low'],
  207. ['medium', 'Medium'],
  208. ['high', 'High'],
  209. ],
  210. },
  211. });
  212. const schema: SchemaFormConfig = {
  213. uri: '/api/sentry/issue-link/create/',
  214. required_fields: [
  215. {
  216. type: 'text',
  217. label: 'Task Name',
  218. name: 'title',
  219. },
  220. ],
  221. optional_fields: [
  222. {
  223. type: 'select',
  224. label: 'What is the estimated complexity?',
  225. name: 'complexity',
  226. depends_on: ['title'],
  227. skip_load_on_open: true,
  228. uri: '/api/sentry/options/complexity-options/',
  229. choices: [],
  230. },
  231. ],
  232. };
  233. const defaultValues = {
  234. settings: [
  235. {
  236. name: 'extra',
  237. value: 'saved details from last edit',
  238. },
  239. ],
  240. };
  241. createWrapper({config: schema, resetValues: defaultValues});
  242. await waitFor(() => expect(mockApi).not.toHaveBeenCalled());
  243. await userEvent.type(screen.getByText('Task Name'), 'sooo coooool');
  244. // Now that the title is filled we should get the options
  245. await waitFor(() => expect(mockApi).toHaveBeenCalled());
  246. });
  247. it('should load complexity options from backend when column has a default value', async function () {
  248. const mockApi = MockApiClient.addMockResponse({
  249. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  250. body: {
  251. choices: [
  252. ['low', 'Low'],
  253. ['medium', 'Medium'],
  254. ['high', 'High'],
  255. ],
  256. },
  257. });
  258. const schema: SchemaFormConfig = {
  259. uri: '/api/sentry/issue-link/create/',
  260. required_fields: [
  261. {
  262. type: 'text',
  263. label: 'Task Name',
  264. name: 'title',
  265. default: 'issue.title',
  266. },
  267. {
  268. type: 'select',
  269. label: "What's the status of this task?",
  270. name: 'column',
  271. uri: '/api/sentry/options/status/',
  272. defaultValue: 'ongoing',
  273. choices: [
  274. ['ongoing', 'ongoing'],
  275. ['completed', 'completed'],
  276. ['pending', 'pending'],
  277. ['cancelled', 'cancelled'],
  278. ],
  279. },
  280. ],
  281. optional_fields: [
  282. {
  283. type: 'select',
  284. label: 'What is the estimated complexity?',
  285. name: 'complexity',
  286. depends_on: ['column'],
  287. skip_load_on_open: true,
  288. uri: '/api/sentry/options/complexity-options/',
  289. choices: [],
  290. },
  291. ],
  292. };
  293. createWrapper({config: schema});
  294. // Wait for component to mount and state to update
  295. await waitFor(() => expect(mockApi).toHaveBeenCalled());
  296. // Check if complexity options are loaded
  297. const complexityInput = screen.getByLabelText('What is the estimated complexity?', {
  298. selector: 'input#complexity',
  299. });
  300. expect(screen.queryByText('Low')).not.toBeInTheDocument();
  301. await userEvent.click(complexityInput);
  302. expect(screen.getByText('Low')).toBeInTheDocument();
  303. expect(screen.getByText('Medium')).toBeInTheDocument();
  304. expect(screen.getByText('High')).toBeInTheDocument();
  305. });
  306. it('should populate skip_load_on fields with the default value', async function () {
  307. const mockApi = MockApiClient.addMockResponse({
  308. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  309. body: {
  310. choices: [
  311. ['low', 'Low'],
  312. ['medium', 'Medium'],
  313. ['high', 'High'],
  314. ],
  315. },
  316. });
  317. const schema: SchemaFormConfig = {
  318. uri: '/api/sentry/issue-link/create/',
  319. required_fields: [
  320. {
  321. type: 'text',
  322. label: 'Task Name',
  323. name: 'title',
  324. defaultValue: 'pog',
  325. default: 'issue.title',
  326. },
  327. ],
  328. optional_fields: [
  329. {
  330. type: 'select',
  331. label: 'What is the estimated complexity?',
  332. name: 'complexity',
  333. depends_on: ['title'],
  334. skip_load_on_open: true,
  335. uri: '/api/sentry/options/complexity-options/',
  336. choices: [],
  337. },
  338. ],
  339. };
  340. const defaultValues = {
  341. settings: [
  342. {
  343. name: 'extra',
  344. value: 'saved details from last edit',
  345. },
  346. {
  347. name: 'assignee',
  348. value: 'edna-mode',
  349. label: 'Edna Mode',
  350. },
  351. {
  352. name: 'complexity',
  353. value: 'low',
  354. },
  355. ],
  356. };
  357. createWrapper({config: schema, resetValues: defaultValues});
  358. // Wait for component to mount and state to update
  359. await waitFor(() => expect(mockApi).toHaveBeenCalled());
  360. expect(screen.getByText('Low')).toBeInTheDocument();
  361. expect(screen.queryByText('Medium')).not.toBeInTheDocument();
  362. expect(screen.queryByText('High')).not.toBeInTheDocument();
  363. });
  364. it('should populate dependent fields on load if the parent field is loaded with default value', async function () {
  365. const mockApi = MockApiClient.addMockResponse({
  366. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  367. body: {
  368. defaultValue: 'high',
  369. choices: [
  370. ['low', 'Low'],
  371. ['medium', 'Medium'],
  372. ['high', 'High'],
  373. ],
  374. },
  375. });
  376. const schema: SchemaFormConfig = {
  377. uri: '/api/sentry/issue-link/create/',
  378. required_fields: [
  379. {
  380. type: 'select',
  381. label: 'Task Name',
  382. name: 'title',
  383. uri: '/api/sentry/options/create/',
  384. choices: [
  385. ['yay', 'YAY'],
  386. ['pog', 'POG'],
  387. ],
  388. },
  389. ],
  390. optional_fields: [
  391. {
  392. type: 'select',
  393. label: 'What is the estimated complexity?',
  394. name: 'complexity',
  395. depends_on: ['title'],
  396. uri: '/api/sentry/options/complexity-options/',
  397. choices: [],
  398. },
  399. ],
  400. };
  401. const defaultValues = {
  402. settings: [
  403. {
  404. name: 'title',
  405. value: 'yay',
  406. },
  407. ],
  408. };
  409. createWrapper({config: schema, resetValues: defaultValues});
  410. // because we have a default value in title, we should immeadiatly fetch for complexity
  411. await waitFor(() => expect(mockApi).toHaveBeenCalled());
  412. expect(screen.getByText('High')).toBeInTheDocument();
  413. expect(screen.getByText('YAY')).toBeInTheDocument();
  414. });
  415. it('should populate dependent fields with skip_load_on_open if the parent field is loaded with default value', async function () {
  416. const mockApi = MockApiClient.addMockResponse({
  417. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  418. body: {
  419. defaultValue: 'high',
  420. choices: [
  421. ['low', 'Low'],
  422. ['medium', 'Medium'],
  423. ['high', 'High'],
  424. ],
  425. },
  426. });
  427. const schema: SchemaFormConfig = {
  428. uri: '/api/sentry/issue-link/create/',
  429. required_fields: [
  430. {
  431. type: 'select',
  432. label: 'Task Name',
  433. name: 'title',
  434. uri: '/api/sentry/options/create/',
  435. choices: [
  436. ['yay', 'YAY'],
  437. ['pog', 'POG'],
  438. ],
  439. },
  440. ],
  441. optional_fields: [
  442. {
  443. type: 'select',
  444. label: 'What is the estimated complexity?',
  445. name: 'complexity',
  446. skip_load_on_open: true,
  447. depends_on: ['title'],
  448. uri: '/api/sentry/options/complexity-options/',
  449. choices: [],
  450. },
  451. ],
  452. };
  453. const defaultValues = {
  454. settings: [
  455. {
  456. name: 'title',
  457. value: 'yay',
  458. },
  459. ],
  460. };
  461. createWrapper({config: schema, resetValues: defaultValues});
  462. // because we have a default value in title, we should immediately fetch for complexity
  463. await waitFor(() => expect(mockApi).toHaveBeenCalled());
  464. expect(screen.getByText('High')).toBeInTheDocument();
  465. expect(screen.getByText('YAY')).toBeInTheDocument();
  466. });
  467. it('does not make external req for non skip on load fields that dont depend on another field', async function () {
  468. const mockApi = MockApiClient.addMockResponse({
  469. url: `/sentry-app-installations/${sentryAppInstallation.uuid}/external-requests/`,
  470. body: {
  471. defaultValue: 'high',
  472. choices: [
  473. ['low', 'Low'],
  474. ['medium', 'Medium'],
  475. ['high', 'High'],
  476. ],
  477. },
  478. });
  479. const schema: SchemaFormConfig = {
  480. uri: '/api/sentry/issue-link/create/',
  481. required_fields: [
  482. {
  483. type: 'select',
  484. label: 'Task Name',
  485. name: 'title',
  486. uri: '/api/sentry/options/create/',
  487. choices: [
  488. ['yay', 'YAY'],
  489. ['pog', 'POG'],
  490. ],
  491. },
  492. ],
  493. optional_fields: [
  494. {
  495. type: 'select',
  496. label: 'What is the estimated complexity?',
  497. name: 'complexity',
  498. depends_on: [],
  499. uri: '/api/sentry/options/complexity-options/',
  500. choices: [],
  501. },
  502. ],
  503. };
  504. const defaultValues = {
  505. settings: [
  506. {
  507. name: 'title',
  508. value: 'yay',
  509. },
  510. ],
  511. };
  512. createWrapper({config: schema, resetValues: defaultValues});
  513. // Because this is a skip_load_on_open: false field that means we have already made the api call to get options on the page load
  514. await waitFor(() => expect(mockApi).not.toHaveBeenCalled());
  515. expect(screen.getByText('YAY')).toBeInTheDocument();
  516. });
  517. });
  518. });