Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.

Issue-it.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  1. /*
  2. * SonarQube
  3. * Copyright (C) 2009-2023 SonarSource SA
  4. * mailto:info AT sonarsource DOT com
  5. *
  6. * This program is free software; you can redistribute it and/or
  7. * modify it under the terms of the GNU Lesser General Public
  8. * License as published by the Free Software Foundation; either
  9. * version 3 of the License, or (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  14. * Lesser General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Lesser General Public License
  17. * along with this program; if not, write to the Free Software Foundation,
  18. * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  19. */
  20. import { act, screen } from '@testing-library/react';
  21. import userEvent from '@testing-library/user-event';
  22. import { omit, pick } from 'lodash';
  23. import * as React from 'react';
  24. import { Route } from 'react-router-dom';
  25. import IssuesServiceMock from '../../../api/mocks/IssuesServiceMock';
  26. import { KeyboardKeys } from '../../../helpers/keycodes';
  27. import { mockIssue, mockLoggedInUser, mockRawIssue } from '../../../helpers/testMocks';
  28. import { renderAppRoutes } from '../../../helpers/testReactTestingUtils';
  29. import { byLabelText, byRole, byText } from '../../../helpers/testSelector';
  30. import { ComponentPropsType } from '../../../helpers/testUtils';
  31. import {
  32. IssueActions,
  33. IssueSeverity,
  34. IssueStatus,
  35. IssueTransition,
  36. IssueType,
  37. } from '../../../types/issues';
  38. import Issue from '../Issue';
  39. jest.mock('../../../helpers/preferences', () => ({
  40. getKeyboardShortcutEnabled: jest.fn(() => true),
  41. }));
  42. const issuesHandler = new IssuesServiceMock();
  43. beforeEach(() => {
  44. issuesHandler.reset();
  45. });
  46. describe('rendering', () => {
  47. it('should render correctly for issue message and effort', async () => {
  48. const { ui } = getPageObject();
  49. const issue = mockIssue(true, { effort: '2 days', message: 'This is an issue' });
  50. const onClick = jest.fn();
  51. renderIssue({ issue, onSelect: onClick });
  52. expect(ui.effort('2 days').get()).toBeInTheDocument();
  53. expect(ui.issueMessageLink.get()).toHaveAttribute(
  54. 'href',
  55. '/issues?scopes=MAIN&impactSeverities=LOW&types=VULNERABILITY&open=AVsae-CQS-9G3txfbFN2',
  56. );
  57. await ui.clickIssueMessage();
  58. expect(onClick).toHaveBeenCalledWith(issue.key);
  59. });
  60. it('should render correctly for external rule engines', () => {
  61. renderIssue({ issue: mockIssue(true, { externalRuleEngine: 'ESLINT' }) });
  62. expect(screen.getByRole('status', { name: 'ESLINT' })).toBeInTheDocument();
  63. });
  64. it('should render the SonarLint icon correctly', async () => {
  65. renderIssue({ issue: mockIssue(false, { quickFixAvailable: true }) });
  66. await expect(
  67. screen.getByText('issue.quick_fix_available_with_sonarlint_no_link'),
  68. ).toHaveATooltipWithContent('issue.quick_fix_available_with_sonarlint');
  69. });
  70. it('should render correctly with a checkbox', async () => {
  71. const { ui } = getPageObject();
  72. const onCheck = jest.fn();
  73. const issue = mockIssue();
  74. renderIssue({ onCheck, issue });
  75. await ui.toggleCheckbox();
  76. expect(onCheck).toHaveBeenCalledWith(issue.key);
  77. });
  78. it('should correctly render any code variants', async () => {
  79. const { ui } = getPageObject();
  80. renderIssue({ issue: mockIssue(false, { codeVariants: ['variant 1', 'variant 2'] }) });
  81. await expect(ui.variants(2).get()).toHaveATooltipWithContent('variant 1, variant 2');
  82. });
  83. });
  84. describe('updating', () => {
  85. it('should allow updating the status', async () => {
  86. const { ui } = getPageObject();
  87. const issue = mockRawIssue(false, {
  88. issueStatus: IssueStatus.Open,
  89. transitions: [IssueTransition.Confirm, IssueTransition.UnConfirm],
  90. });
  91. issuesHandler.setIssueList([{ issue, snippets: {} }]);
  92. renderIssue({
  93. issue: mockIssue(false, { ...pick(issue, 'key', 'status', 'transitions') }),
  94. });
  95. await ui.updateStatus(IssueStatus.Open, IssueTransition.Confirm);
  96. expect(ui.updateStatusBtn(IssueStatus.Confirmed).get()).toBeInTheDocument();
  97. });
  98. it('should allow assigning', async () => {
  99. const { ui } = getPageObject();
  100. const issue = mockRawIssue(false, {
  101. assignee: 'leia',
  102. actions: [IssueActions.Assign],
  103. });
  104. issuesHandler.setIssueList([{ issue, snippets: {} }]);
  105. renderIssue({
  106. issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'assignee') }),
  107. });
  108. await ui.updateAssignee('leia', 'Skywalker');
  109. expect(ui.updateAssigneeBtn('luke').get()).toBeInTheDocument();
  110. });
  111. it('should allow updating the tags', async () => {
  112. const { ui } = getPageObject();
  113. const issue = mockRawIssue(false, {
  114. tags: [],
  115. actions: [IssueActions.SetTags],
  116. });
  117. issuesHandler.setIssueList([{ issue, snippets: {} }]);
  118. renderIssue({ issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'tags') }) });
  119. await ui.addTag('accessibility');
  120. await ui.addTag('android', ['accessibility']);
  121. expect(ui.updateTagsBtn(['accessibility', 'android']).get()).toBeInTheDocument();
  122. });
  123. });
  124. it('should correctly handle keyboard shortcuts', async () => {
  125. const { ui } = getPageObject();
  126. const onCheck = jest.fn();
  127. const issue = mockRawIssue(false, {
  128. actions: Object.values(IssueActions),
  129. assignee: 'luke',
  130. transitions: [IssueTransition.Confirm, IssueTransition.UnConfirm],
  131. });
  132. issuesHandler.setIssueList([{ issue, snippets: {} }]);
  133. issuesHandler.setCurrentUser(mockLoggedInUser({ login: 'leia', name: 'Organa' }));
  134. renderIssue({
  135. onCheck,
  136. selected: true,
  137. issue: mockIssue(false, { ...pick(issue, 'actions', 'key', 'assignee', 'transitions') }),
  138. });
  139. await ui.pressTransitionShortcut();
  140. expect(ui.setStatusBtn(IssueTransition.UnConfirm).get()).toBeInTheDocument();
  141. await ui.pressDismissShortcut();
  142. await ui.pressAssignShortcut();
  143. expect(ui.setAssigneeBtn(/Organa/).get()).toBeInTheDocument();
  144. await ui.pressDismissShortcut();
  145. await ui.pressCommentShortcut();
  146. expect(ui.commentTextInput.get()).toBeInTheDocument();
  147. await ui.pressDismissShortcut();
  148. await ui.pressTagsShortcut();
  149. expect(ui.tagsSearchInput.get()).toBeInTheDocument();
  150. await ui.pressDismissShortcut();
  151. await ui.pressCheckShortcut();
  152. expect(onCheck).toHaveBeenCalled();
  153. expect(ui.updateAssigneeBtn('luke').get()).toBeInTheDocument();
  154. await ui.pressAssignToMeShortcut();
  155. expect(ui.updateAssigneeBtn('leia').get()).toBeInTheDocument();
  156. });
  157. function getPageObject() {
  158. const user = userEvent.setup();
  159. const selectors = {
  160. // Issue
  161. locationsBadge: (count: number) => byText(count),
  162. lineInfo: (line: number) => byText(`L${line}`),
  163. effort: (effort: string) => byText(`issue.x_effort.${effort}`),
  164. whyLink: byRole('link', { name: 'issue.why_this_issue.long' }),
  165. checkbox: byRole('checkbox'),
  166. issueMessageLink: byRole('link', { name: 'This is an issue' }),
  167. variants: (n: number) => byText(`issue.x_code_variants.${n}`),
  168. // Changelog
  169. toggleChangelogBtn: byRole('button', {
  170. name: /issue.changelog.found_on_x_show_more/,
  171. }),
  172. changelogRow: (key: string, oldValue: string, newValue: string) =>
  173. byRole('row', {
  174. name: new RegExp(
  175. `issue\\.changelog\\.changed_to\\.issue\\.changelog\\.field\\.${key}\\.${newValue} \\(issue\\.changelog\\.was\\.${oldValue}\\)`,
  176. ),
  177. }),
  178. // Similar issues
  179. toggleSimilarIssuesBtn: byRole('button', { name: 'issue.filter_similar_issues' }),
  180. similarIssueTypeLink: byRole('button', { name: 'issue.type.BUG' }),
  181. similarIssueSeverityLink: byRole('button', { name: 'severity.MAJOR' }),
  182. similarIssueStatusLink: byRole('button', { name: 'issue.status.OPEN' }),
  183. similarIssueResolutionLink: byRole('button', { name: 'unresolved' }),
  184. similarIssueAssigneeLink: byRole('button', { name: 'unassigned' }),
  185. similarIssueRuleLink: byRole('button', { name: 'Rule Foo' }),
  186. similarIssueTagLink: (name: string) => byRole('button', { name }),
  187. similarIssueProjectLink: byRole('button', { name: 'qualifier.TRK Project Bar' }),
  188. similarIssueFileLink: byRole('button', { name: 'qualifier.FIL main.js' }),
  189. // Comment
  190. commentsList: () => {
  191. const list = byRole('list')
  192. .getAll()
  193. .find((el) => el.getAttribute('data-testid') === 'issue-comments');
  194. if (list === undefined) {
  195. throw new Error('Could not find comments list');
  196. }
  197. return list;
  198. },
  199. commentAddBtn: byRole('button', { name: 'issue.comment.add_comment' }),
  200. commentEditBtn: byRole('button', { name: 'issue.comment.edit' }),
  201. commentTextInput: byRole('textbox', { name: 'issue.comment.enter_comment' }),
  202. commentSaveBtn: byRole('button', { name: 'issue.comment.formlink' }),
  203. commentUpdateBtn: byRole('button', { name: 'save' }),
  204. commentDeleteBtn: byRole('button', { name: 'issue.comment.delete' }),
  205. commentConfirmDeleteBtn: byRole('button', { name: 'delete' }),
  206. // Type
  207. updateTypeBtn: (currentType: IssueType) =>
  208. byLabelText(`issue.type.type_x_click_to_change.issue.type.${currentType}`),
  209. setTypeBtn: (type: IssueType) => byText(`issue.type.${type}`),
  210. // Severity
  211. updateSeverityBtn: (currentSeverity: IssueSeverity) =>
  212. byLabelText(`issue.severity.severity_x_click_to_change.severity.${currentSeverity}`),
  213. setSeverityBtn: (severity: IssueSeverity) => byText(`severity.${severity}`),
  214. // Status
  215. updateStatusBtn: (currentStatus: IssueStatus) =>
  216. byLabelText(`issue.transition.status_x_click_to_change.issue.issue_status.${currentStatus}`),
  217. setStatusBtn: (transition: IssueTransition) => byText(`issue.transition.${transition}`),
  218. // Assignee
  219. assigneeSearchInput: byLabelText('search.search_for_users'),
  220. updateAssigneeBtn: (currentAssignee: string) =>
  221. byRole('combobox', {
  222. name: `issue.assign.assigned_to_x_click_to_change.${currentAssignee}`,
  223. }),
  224. setAssigneeBtn: (name: RegExp) => byLabelText(name),
  225. // Tags
  226. tagsSearchInput: byRole('searchbox'),
  227. updateTagsBtn: (currentTags?: string[]) =>
  228. byRole('button', { name: `${currentTags ? currentTags.join(' ') : 'issue.no_tag'} +` }),
  229. toggleTagCheckbox: (name: string) => byRole('checkbox', { name }),
  230. };
  231. const ui = {
  232. ...selectors,
  233. async addComment(content: string) {
  234. await user.click(selectors.commentAddBtn.get());
  235. await user.type(selectors.commentTextInput.get(), content);
  236. await act(async () => {
  237. await user.click(selectors.commentSaveBtn.get());
  238. });
  239. },
  240. async updateComment(content: string) {
  241. await user.click(selectors.commentEditBtn.get());
  242. await user.type(selectors.commentTextInput.get(), content);
  243. await act(async () => {
  244. await user.keyboard(`{Control>}{${KeyboardKeys.Enter}}{/Control}`);
  245. });
  246. },
  247. async deleteComment() {
  248. await user.click(selectors.commentDeleteBtn.get());
  249. await act(async () => {
  250. await user.click(selectors.commentConfirmDeleteBtn.get());
  251. });
  252. },
  253. async updateType(currentType: IssueType, newType: IssueType) {
  254. await user.click(selectors.updateTypeBtn(currentType).get());
  255. await act(async () => {
  256. await user.click(selectors.setTypeBtn(newType).get());
  257. });
  258. },
  259. async updateSeverity(currentSeverity: IssueSeverity, newSeverity: IssueSeverity) {
  260. await user.click(selectors.updateSeverityBtn(currentSeverity).get());
  261. await act(async () => {
  262. await user.click(selectors.setSeverityBtn(newSeverity).get());
  263. });
  264. },
  265. async updateStatus(currentStatus: IssueStatus, transition: IssueTransition) {
  266. await user.click(selectors.updateStatusBtn(currentStatus).get());
  267. await act(async () => {
  268. await user.click(selectors.setStatusBtn(transition).get());
  269. });
  270. },
  271. async updateAssignee(currentAssignee: string, newAssignee: string) {
  272. await user.click(selectors.updateAssigneeBtn(currentAssignee).get());
  273. await act(async () => {
  274. await user.type(selectors.assigneeSearchInput.get(), newAssignee);
  275. });
  276. await act(async () => {
  277. await user.click(selectors.setAssigneeBtn(new RegExp(newAssignee)).get());
  278. });
  279. },
  280. async addTag(tag: string, currentTagList?: string[]) {
  281. await user.click(selectors.updateTagsBtn(currentTagList).get());
  282. await act(async () => {
  283. await user.click(selectors.toggleTagCheckbox(tag).get());
  284. });
  285. await act(async () => {
  286. await user.keyboard('{Escape}');
  287. });
  288. },
  289. async showChangelog() {
  290. await user.click(selectors.toggleChangelogBtn.get());
  291. },
  292. async toggleCheckbox() {
  293. await user.click(selectors.checkbox.get());
  294. },
  295. async clickIssueMessage() {
  296. await user.click(selectors.issueMessageLink.get());
  297. },
  298. async pressDismissShortcut() {
  299. await act(async () => {
  300. await user.keyboard(`{${KeyboardKeys.Escape}}`);
  301. });
  302. },
  303. async pressTransitionShortcut() {
  304. await act(async () => {
  305. await user.keyboard(`{${KeyboardKeys.KeyF}}`);
  306. });
  307. },
  308. async pressAssignShortcut() {
  309. await act(async () => {
  310. await user.keyboard(`{${KeyboardKeys.KeyA}}`);
  311. });
  312. },
  313. async pressAssignToMeShortcut() {
  314. await act(async () => {
  315. await user.keyboard(`{${KeyboardKeys.KeyM}}`);
  316. });
  317. },
  318. async pressSeverityShortcut() {
  319. await act(async () => {
  320. await user.keyboard(`{${KeyboardKeys.KeyI}}`);
  321. });
  322. },
  323. async pressCommentShortcut() {
  324. await act(async () => {
  325. await user.keyboard(`{${KeyboardKeys.KeyC}}`);
  326. });
  327. },
  328. async pressTagsShortcut() {
  329. await act(async () => {
  330. await user.keyboard(`{${KeyboardKeys.KeyT}}`);
  331. });
  332. },
  333. async pressCheckShortcut() {
  334. await act(async () => {
  335. await user.keyboard(`{${KeyboardKeys.Space}}`);
  336. });
  337. },
  338. };
  339. return { ui, user };
  340. }
  341. function renderIssue(
  342. props: Partial<Omit<ComponentPropsType<typeof Issue>, 'onChange' | 'onPopupToggle'>> = {},
  343. ) {
  344. function Wrapper(
  345. wrapperProps: Omit<ComponentPropsType<typeof Issue>, 'onChange' | 'onPopupToggle'>,
  346. ) {
  347. const [issue, setIssue] = React.useState(wrapperProps.issue);
  348. const [openPopup, setOpenPopup] = React.useState<string | undefined>();
  349. return (
  350. <Issue
  351. issue={issue}
  352. openPopup={openPopup}
  353. onChange={(newIssue) => {
  354. setIssue({ ...issue, ...newIssue });
  355. }}
  356. onPopupToggle={(_key, popup, open) => {
  357. setOpenPopup(open === false ? undefined : popup);
  358. }}
  359. {...omit(wrapperProps, 'issue')}
  360. />
  361. );
  362. }
  363. return renderAppRoutes(
  364. 'issues?scopes=MAIN&impactSeverities=LOW&types=VULNERABILITY',
  365. () => (
  366. <Route
  367. path="issues"
  368. element={<Wrapper onSelect={jest.fn()} issue={mockIssue()} selected={false} {...props} />}
  369. />
  370. ),
  371. {
  372. currentUser: mockLoggedInUser({ login: 'leia', name: 'Organa' }),
  373. },
  374. );
  375. }