sentryAppRuleModal.spec.tsx 17 KB

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