index.spec.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769
  1. import type {ComponentProps} from 'react';
  2. import {
  3. render,
  4. screen,
  5. userEvent,
  6. waitFor,
  7. within,
  8. } from 'sentry-test/reactTestingLibrary';
  9. import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder';
  10. import {
  11. type FilterKeySection,
  12. QueryInterfaceType,
  13. } from 'sentry/components/searchQueryBuilder/types';
  14. import {INTERFACE_TYPE_LOCALSTORAGE_KEY} from 'sentry/components/searchQueryBuilder/utils';
  15. import {FieldKey, FieldKind} from 'sentry/utils/fields';
  16. import localStorageWrapper from 'sentry/utils/localStorage';
  17. const FITLER_KEY_SECTIONS: FilterKeySection[] = [
  18. {
  19. value: FieldKind.FIELD,
  20. label: 'Category 1',
  21. children: [
  22. {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD},
  23. {
  24. key: FieldKey.ASSIGNED,
  25. name: 'Assigned To',
  26. kind: FieldKind.FIELD,
  27. predefined: true,
  28. values: [
  29. {
  30. title: 'Suggested',
  31. type: 'header',
  32. icon: null,
  33. children: [{value: 'me'}, {value: 'unassigned'}],
  34. },
  35. {
  36. title: 'All',
  37. type: 'header',
  38. icon: null,
  39. children: [{value: 'person1@sentry.io'}, {value: 'person2@sentry.io'}],
  40. },
  41. ],
  42. },
  43. {
  44. key: FieldKey.BROWSER_NAME,
  45. name: 'Browser Name',
  46. kind: FieldKind.FIELD,
  47. predefined: true,
  48. values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
  49. },
  50. {
  51. key: FieldKey.IS,
  52. name: 'is',
  53. alias: 'status',
  54. predefined: true,
  55. },
  56. {
  57. key: FieldKey.TIMES_SEEN,
  58. name: 'timesSeen',
  59. kind: FieldKind.FIELD,
  60. },
  61. ],
  62. },
  63. {
  64. value: FieldKind.TAG,
  65. label: 'Category 2',
  66. children: [
  67. {
  68. key: 'custom_tag_name',
  69. name: 'Custom_Tag_Name',
  70. values: ['tag value one', 'tag value two', 'tag value three'],
  71. },
  72. ],
  73. },
  74. ];
  75. describe('SearchQueryBuilder', function () {
  76. beforeEach(() => {
  77. localStorageWrapper.clear();
  78. });
  79. afterEach(function () {
  80. jest.restoreAllMocks();
  81. });
  82. const defaultProps: ComponentProps<typeof SearchQueryBuilder> = {
  83. getTagValues: jest.fn(),
  84. initialQuery: '',
  85. filterKeySections: FITLER_KEY_SECTIONS,
  86. label: 'Query Builder',
  87. };
  88. describe('callbacks', function () {
  89. it('calls onChange, onBlur, and onSearch with the query string', async function () {
  90. const mockOnChange = jest.fn();
  91. const mockOnBlur = jest.fn();
  92. const mockOnSearch = jest.fn();
  93. render(
  94. <SearchQueryBuilder
  95. {...defaultProps}
  96. initialQuery=""
  97. onChange={mockOnChange}
  98. onBlur={mockOnBlur}
  99. onSearch={mockOnSearch}
  100. />
  101. );
  102. await userEvent.click(screen.getByRole('grid'));
  103. await userEvent.keyboard('foo{enter}');
  104. // Should call onChange and onSearch after enter
  105. await waitFor(() => {
  106. expect(mockOnChange).toHaveBeenCalledWith('foo');
  107. expect(mockOnSearch).toHaveBeenCalledWith('foo');
  108. });
  109. await userEvent.click(document.body);
  110. // Clicking outside activates onBlur
  111. await waitFor(() => {
  112. expect(mockOnBlur).toHaveBeenCalledWith('foo');
  113. });
  114. });
  115. });
  116. describe('filter key aliases', function () {
  117. it('displays the key alias instead of the actual value', async function () {
  118. render(<SearchQueryBuilder {...defaultProps} initialQuery="is:resolved" />);
  119. expect(await screen.findByText('status')).toBeInTheDocument();
  120. });
  121. it('when adding a filter by typing, replaces aliased tokens', async function () {
  122. const mockOnChange = jest.fn();
  123. render(
  124. <SearchQueryBuilder {...defaultProps} initialQuery="" onChange={mockOnChange} />
  125. );
  126. await userEvent.click(screen.getByRole('grid'));
  127. await userEvent.keyboard('status:');
  128. // Component should display alias `status`
  129. expect(await screen.findByText('status')).toBeInTheDocument();
  130. // Query should use the actual key `is`
  131. expect(mockOnChange).toHaveBeenCalledWith('is:');
  132. });
  133. });
  134. describe('actions', function () {
  135. it('can clear the query', async function () {
  136. const mockOnChange = jest.fn();
  137. const mockOnSearch = jest.fn();
  138. render(
  139. <SearchQueryBuilder
  140. {...defaultProps}
  141. initialQuery="browser.name:firefox"
  142. onChange={mockOnChange}
  143. onSearch={mockOnSearch}
  144. />
  145. );
  146. userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
  147. await waitFor(() => {
  148. expect(mockOnChange).toHaveBeenCalledWith('');
  149. expect(mockOnSearch).toHaveBeenCalledWith('');
  150. });
  151. expect(
  152. screen.queryByRole('row', {name: 'browser.name:firefox'})
  153. ).not.toBeInTheDocument();
  154. expect(screen.getByRole('combobox')).toHaveFocus();
  155. });
  156. });
  157. describe('plain text interface', function () {
  158. beforeEach(() => {
  159. localStorageWrapper.setItem(
  160. INTERFACE_TYPE_LOCALSTORAGE_KEY,
  161. JSON.stringify(QueryInterfaceType.TEXT)
  162. );
  163. });
  164. it('can change the query by typing', async function () {
  165. const mockOnChange = jest.fn();
  166. render(
  167. <SearchQueryBuilder
  168. {...defaultProps}
  169. initialQuery="browser.name:firefox"
  170. onChange={mockOnChange}
  171. queryInterface={QueryInterfaceType.TEXT}
  172. />
  173. );
  174. expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox');
  175. await userEvent.type(screen.getByRole('textbox'), ' assigned:me');
  176. expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox assigned:me');
  177. await waitFor(() => {
  178. expect(mockOnChange).toHaveBeenLastCalledWith('browser.name:firefox assigned:me');
  179. });
  180. });
  181. });
  182. describe('mouse interactions', function () {
  183. it('can remove a token by clicking the delete button', async function () {
  184. render(
  185. <SearchQueryBuilder
  186. {...defaultProps}
  187. initialQuery="browser.name:firefox custom_tag_name:123"
  188. />
  189. );
  190. expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
  191. expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
  192. await userEvent.click(
  193. within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByRole(
  194. 'button',
  195. {name: 'Remove filter: browser.name'}
  196. )
  197. );
  198. // Browser name token should be removed
  199. expect(
  200. screen.queryByRole('row', {name: 'browser.name:firefox'})
  201. ).not.toBeInTheDocument();
  202. // Custom tag token should still be present
  203. expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
  204. });
  205. it('can modify the operator by clicking into it', async function () {
  206. render(
  207. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  208. );
  209. // Should display as "is" to start
  210. expect(
  211. within(
  212. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  213. ).getByText('is')
  214. ).toBeInTheDocument();
  215. await userEvent.click(
  216. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  217. );
  218. await userEvent.click(screen.getByRole('menuitemradio', {name: 'is not'}));
  219. // Token should be modified to be negated
  220. expect(
  221. screen.getByRole('row', {name: '!browser.name:firefox'})
  222. ).toBeInTheDocument();
  223. // Should now have "is not" label
  224. expect(
  225. within(
  226. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  227. ).getByText('is not')
  228. ).toBeInTheDocument();
  229. });
  230. it('can modify operator for filter with multiple values', async function () {
  231. render(
  232. <SearchQueryBuilder
  233. {...defaultProps}
  234. initialQuery="browser.name:[firefox,chrome]"
  235. />
  236. );
  237. // Should display as "is" to start
  238. expect(
  239. within(
  240. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  241. ).getByText('is')
  242. ).toBeInTheDocument();
  243. await userEvent.click(
  244. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  245. );
  246. await userEvent.click(screen.getByRole('menuitemradio', {name: 'is not'}));
  247. // Token should be modified to be negated
  248. expect(
  249. screen.getByRole('row', {name: '!browser.name:[firefox,chrome]'})
  250. ).toBeInTheDocument();
  251. // Should now have "is not" label
  252. expect(
  253. within(
  254. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  255. ).getByText('is not')
  256. ).toBeInTheDocument();
  257. });
  258. it('can modify the value by clicking into it (single-select)', async function () {
  259. // `age` is a duration filter which only accepts single values
  260. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-1d" />);
  261. // Should display as "-1d" to start
  262. expect(
  263. within(
  264. screen.getByRole('button', {name: 'Edit value for filter: age'})
  265. ).getByText('-1d')
  266. ).toBeInTheDocument();
  267. await userEvent.click(
  268. screen.getByRole('button', {name: 'Edit value for filter: age'})
  269. );
  270. // Should have placeholder text of previous value
  271. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveAttribute(
  272. 'placeholder',
  273. '-1d'
  274. );
  275. await userEvent.click(screen.getByRole('combobox', {name: 'Edit filter value'}));
  276. // Clicking the "+14d" option should update the value
  277. await userEvent.click(screen.getByRole('option', {name: '-14d'}));
  278. expect(screen.getByRole('row', {name: 'age:-14d'})).toBeInTheDocument();
  279. expect(
  280. within(
  281. screen.getByRole('button', {name: 'Edit value for filter: age'})
  282. ).getByText('-14d')
  283. ).toBeInTheDocument();
  284. });
  285. it('can modify the value by clicking into it (multi-select)', async function () {
  286. // `browser.name` is a string filter which accepts multiple values
  287. render(
  288. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  289. );
  290. // Should display as "firefox" to start
  291. expect(
  292. within(
  293. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  294. ).getByText('firefox')
  295. ).toBeInTheDocument();
  296. await userEvent.click(
  297. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  298. );
  299. // Previous value should be rendered before the input
  300. expect(
  301. within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByText(
  302. 'firefox,'
  303. )
  304. ).toBeInTheDocument();
  305. await userEvent.click(screen.getByRole('combobox', {name: 'Edit filter value'}));
  306. // Clicking the "Chrome option should add it to the list and commit changes
  307. await userEvent.click(screen.getByRole('option', {name: 'Chrome'}));
  308. expect(
  309. screen.getByRole('row', {name: 'browser.name:[firefox,Chrome]'})
  310. ).toBeInTheDocument();
  311. const valueButton = screen.getByRole('button', {
  312. name: 'Edit value for filter: browser.name',
  313. });
  314. expect(within(valueButton).getByText('firefox')).toBeInTheDocument();
  315. expect(within(valueButton).getByText('or')).toBeInTheDocument();
  316. expect(within(valueButton).getByText('Chrome')).toBeInTheDocument();
  317. });
  318. it('escapes values with spaces and reserved characters', async function () {
  319. render(<SearchQueryBuilder {...defaultProps} initialQuery="" />);
  320. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  321. await userEvent.type(
  322. screen.getByRole('combobox', {name: 'Add a search term'}),
  323. 'assigned:some" value{enter}'
  324. );
  325. // Value should be surrounded by quotes and escaped
  326. expect(
  327. screen.getByRole('row', {name: 'assigned:"some\\" value"'})
  328. ).toBeInTheDocument();
  329. // Display text should be display the original value
  330. expect(
  331. within(
  332. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  333. ).getByText('some" value')
  334. ).toBeInTheDocument();
  335. });
  336. it('opens the value suggestions menu when clicking anywhere in the filter value', async function () {
  337. render(
  338. <SearchQueryBuilder
  339. {...defaultProps}
  340. initialQuery="browser.name:[Chrome,Firefox]"
  341. />
  342. );
  343. // Start editing value
  344. await userEvent.click(
  345. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  346. );
  347. // Click in the filter value area, should open the menu
  348. await userEvent.click(screen.getByTestId('filter-value-editing'));
  349. expect(await screen.findByRole('option', {name: 'Chrome'})).toBeInTheDocument();
  350. });
  351. it('can remove parens by clicking the delete button', async function () {
  352. render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
  353. expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
  354. await userEvent.click(screen.getByRole('gridcell', {name: 'Delete ('}));
  355. expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
  356. });
  357. it('can remove boolean ops by clicking the delete button', async function () {
  358. render(<SearchQueryBuilder {...defaultProps} initialQuery="OR" />);
  359. expect(screen.getByRole('row', {name: 'OR'})).toBeInTheDocument();
  360. await userEvent.click(screen.getByRole('gridcell', {name: 'Delete OR'}));
  361. expect(screen.queryByRole('row', {name: 'OR'})).not.toBeInTheDocument();
  362. });
  363. });
  364. describe('new search tokens', function () {
  365. it('can add an unsupported filter key and value', async function () {
  366. render(<SearchQueryBuilder {...defaultProps} />);
  367. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  368. await userEvent.type(
  369. screen.getByRole('combobox', {name: 'Add a search term'}),
  370. 'a:b{enter}'
  371. );
  372. expect(screen.getByRole('row', {name: 'a:b'})).toBeInTheDocument();
  373. });
  374. it('breaks keys into sections', async function () {
  375. render(<SearchQueryBuilder {...defaultProps} />);
  376. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  377. const menu = screen.getByRole('listbox');
  378. const groups = within(menu).getAllByRole('group');
  379. expect(groups).toHaveLength(2);
  380. // First group (Field) should have age, assigned, browser.name
  381. const group1 = groups[0];
  382. expect(within(group1).getByRole('option', {name: 'age'})).toBeInTheDocument();
  383. expect(within(group1).getByRole('option', {name: 'assigned'})).toBeInTheDocument();
  384. expect(
  385. within(group1).getByRole('option', {name: 'browser.name'})
  386. ).toBeInTheDocument();
  387. // Second group (Tag) should have custom_tag_name
  388. const group2 = groups[1];
  389. expect(
  390. within(group2).getByRole('option', {name: 'custom_tag_name'})
  391. ).toBeInTheDocument();
  392. });
  393. it('can add a new token by clicking a key suggestion', async function () {
  394. render(<SearchQueryBuilder {...defaultProps} />);
  395. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  396. await userEvent.click(screen.getByRole('option', {name: 'browser.name'}));
  397. // New token should be added with the correct key
  398. expect(screen.getByRole('row', {name: 'browser.name:'})).toBeInTheDocument();
  399. await userEvent.click(screen.getByRole('combobox', {name: 'Edit filter value'}));
  400. await userEvent.click(screen.getByRole('option', {name: 'Firefox'}));
  401. // New token should have a value
  402. expect(screen.getByRole('row', {name: 'browser.name:Firefox'})).toBeInTheDocument();
  403. });
  404. it('can add free text by typing', async function () {
  405. render(<SearchQueryBuilder {...defaultProps} />);
  406. await userEvent.click(screen.getByRole('grid'));
  407. await userEvent.type(screen.getByRole('combobox'), 'some free text{enter}');
  408. expect(screen.getByRole('combobox')).toHaveValue('some free text');
  409. });
  410. it('can add a filter after some free text', async function () {
  411. render(<SearchQueryBuilder {...defaultProps} />);
  412. await userEvent.click(screen.getByRole('grid'));
  413. // XXX(malwilley): SearchQueryBuilderInput updates state in the render
  414. // function which causes an act warning despite using userEvent.click.
  415. // Cannot find a way to avoid this warning.
  416. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  417. await userEvent.type(
  418. screen.getByRole('combobox'),
  419. 'some free text brow{ArrowDown}{Enter}'
  420. );
  421. jest.restoreAllMocks();
  422. // Filter value should have focus
  423. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
  424. await userEvent.keyboard('foo{enter}');
  425. // Should have a free text token "some free text"
  426. expect(
  427. await screen.findByRole('row', {name: /some free text/})
  428. ).toBeInTheDocument();
  429. // Should have a filter token "browser.name:foo"
  430. expect(screen.getByRole('row', {name: 'browser.name:foo'})).toBeInTheDocument();
  431. });
  432. it('can add parens by typing', async function () {
  433. render(<SearchQueryBuilder {...defaultProps} />);
  434. await userEvent.click(screen.getByRole('grid'));
  435. await userEvent.keyboard('(');
  436. expect(await screen.findByRole('row', {name: '('})).toBeInTheDocument();
  437. // Last input (the one after the paren) should have focus
  438. expect(screen.getAllByRole('combobox').at(-1)).toHaveFocus();
  439. });
  440. });
  441. describe('keyboard interactions', function () {
  442. it('can remove a previous token by pressing backspace', async function () {
  443. render(
  444. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  445. );
  446. // Focus into search (cursor be at end of the query)
  447. await userEvent.click(screen.getByRole('grid'));
  448. // Pressing backspace once should focus the previous token
  449. await userEvent.keyboard('{backspace}');
  450. expect(screen.queryByRole('row', {name: 'browser.name:firefox'})).toHaveFocus();
  451. // Pressing backspace again should remove the token
  452. await userEvent.keyboard('{backspace}');
  453. expect(
  454. screen.queryByRole('row', {name: 'browser.name:firefox'})
  455. ).not.toBeInTheDocument();
  456. });
  457. it('can remove a subsequent token by pressing delete', async function () {
  458. render(
  459. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  460. );
  461. // Put focus into the first input (before the token)
  462. await userEvent.click(
  463. screen.getAllByRole('combobox', {name: 'Add a search term'})[0]
  464. );
  465. // Pressing delete once should focus the previous token
  466. await userEvent.keyboard('{delete}');
  467. expect(screen.queryByRole('row', {name: 'browser.name:firefox'})).toHaveFocus();
  468. // Pressing delete again should remove the token
  469. await userEvent.keyboard('{delete}');
  470. expect(
  471. screen.queryByRole('row', {name: 'browser.name:firefox'})
  472. ).not.toBeInTheDocument();
  473. });
  474. it('can navigate between tokens with arrow keys', async function () {
  475. render(
  476. <SearchQueryBuilder
  477. {...defaultProps}
  478. initialQuery="browser.name:firefox abc assigned:me"
  479. />
  480. );
  481. await userEvent.click(screen.getByRole('grid'));
  482. // Focus should be in the last text input
  483. expect(
  484. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  485. ).toHaveFocus();
  486. // Left once focuses the assigned remove button
  487. await userEvent.keyboard('{arrowleft}');
  488. expect(screen.getByRole('button', {name: 'Remove filter: assigned'})).toHaveFocus();
  489. // Left again focuses the assigned filter value
  490. await userEvent.keyboard('{arrowleft}');
  491. expect(
  492. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  493. ).toHaveFocus();
  494. // Left again focuses the assigned operator
  495. await userEvent.keyboard('{arrowleft}');
  496. expect(
  497. screen.getByRole('button', {name: 'Edit operator for filter: assigned'})
  498. ).toHaveFocus();
  499. // Left again goes to the next text input between tokens
  500. await userEvent.keyboard('{arrowleft}');
  501. expect(
  502. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-2)
  503. ).toHaveFocus();
  504. // 4 more lefts go through the input text "abc" and to the next token
  505. await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{arrowleft}');
  506. expect(
  507. screen.getByRole('button', {name: 'Remove filter: browser.name'})
  508. ).toHaveFocus();
  509. // 1 right goes back to the text input
  510. await userEvent.keyboard('{arrowright}');
  511. expect(
  512. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-2)
  513. ).toHaveFocus();
  514. });
  515. it('has a single tab stop', async function () {
  516. render(
  517. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  518. );
  519. expect(document.body).toHaveFocus();
  520. // Tabbing in should focus the last input
  521. await userEvent.keyboard('{Tab}');
  522. expect(
  523. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  524. ).toHaveFocus();
  525. // Shift-tabbing should exit the component
  526. await userEvent.keyboard('{Shift>}{Tab}{/Shift}');
  527. expect(document.body).toHaveFocus();
  528. });
  529. it('converts pasted text into tokens', async function () {
  530. render(<SearchQueryBuilder {...defaultProps} initialQuery="" />);
  531. await userEvent.click(screen.getByRole('grid'));
  532. await userEvent.paste('browser.name:firefox');
  533. // Should have tokenized the pasted text
  534. expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
  535. // Focus should be at the end of the pasted text
  536. expect(
  537. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  538. ).toHaveFocus();
  539. });
  540. it('can remove parens with the keyboard', async function () {
  541. render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
  542. expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
  543. await userEvent.click(screen.getByRole('grid'));
  544. await userEvent.keyboard('{backspace}{backspace}');
  545. expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
  546. });
  547. it('can remove boolean ops with the keyboard', async function () {
  548. render(<SearchQueryBuilder {...defaultProps} initialQuery="and" />);
  549. expect(screen.getByRole('row', {name: 'and'})).toBeInTheDocument();
  550. await userEvent.click(screen.getByRole('grid'));
  551. await userEvent.keyboard('{backspace}{backspace}');
  552. expect(screen.queryByRole('row', {name: 'and'})).not.toBeInTheDocument();
  553. });
  554. it('exits filter value when pressing escape', async function () {
  555. render(
  556. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:Firefox" />
  557. );
  558. // Click into filter value (button to edit will no longer exist)
  559. await userEvent.click(
  560. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  561. );
  562. expect(
  563. screen.queryByRole('button', {name: 'Edit value for filter: browser.name'})
  564. ).not.toBeInTheDocument();
  565. // Pressing escape will exit the filter value, so edit button will come back
  566. await userEvent.keyboard('{Escape}');
  567. expect(
  568. await screen.findByRole('button', {name: 'Edit value for filter: browser.name'})
  569. ).toBeInTheDocument();
  570. // Focus should now be to the right of the filter
  571. expect(
  572. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  573. ).toHaveFocus();
  574. });
  575. });
  576. describe('token values', function () {
  577. it('supports grouped token value suggestions', async function () {
  578. render(<SearchQueryBuilder {...defaultProps} initialQuery="assigned:me" />);
  579. await userEvent.click(
  580. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  581. );
  582. const groups = within(screen.getByRole('listbox')).getAllByRole('group');
  583. // First group is selected option, second is "Suggested", third is "All"
  584. expect(groups).toHaveLength(3);
  585. expect(
  586. within(screen.getByRole('listbox')).getByText('Suggested')
  587. ).toBeInTheDocument();
  588. expect(within(screen.getByRole('listbox')).getByText('All')).toBeInTheDocument();
  589. // First group is the selected "me"
  590. expect(within(groups[0]).getByRole('option', {name: 'me'})).toBeInTheDocument();
  591. // Second group is the remaining option in the "Suggested" section
  592. expect(
  593. within(groups[1]).getByRole('option', {name: 'unassigned'})
  594. ).toBeInTheDocument();
  595. // Third group are the options under the "All" section
  596. expect(
  597. within(groups[2]).getByRole('option', {name: 'person1@sentry.io'})
  598. ).toBeInTheDocument();
  599. expect(
  600. within(groups[2]).getByRole('option', {name: 'person2@sentry.io'})
  601. ).toBeInTheDocument();
  602. });
  603. });
  604. describe('filter types', function () {
  605. describe('numeric', function () {
  606. it('new numeric filters start with a value', async function () {
  607. render(<SearchQueryBuilder {...defaultProps} />);
  608. await userEvent.click(screen.getByRole('grid'));
  609. await userEvent.keyboard('time{ArrowDown}{Enter}');
  610. // Should start with the > operator and a value of 100
  611. expect(
  612. await screen.findByRole('row', {name: 'timesSeen:>100'})
  613. ).toBeInTheDocument();
  614. });
  615. it('does not allow invalid values', async function () {
  616. render(<SearchQueryBuilder {...defaultProps} initialQuery="timesSeen:>100" />);
  617. await userEvent.click(
  618. screen.getByRole('button', {name: 'Edit value for filter: timesSeen'})
  619. );
  620. await userEvent.keyboard('a{Enter}');
  621. // Should have the same value because "a" is not a numeric value
  622. expect(screen.getByRole('row', {name: 'timesSeen:>100'})).toBeInTheDocument();
  623. await userEvent.keyboard('{Backspace}7k{Enter}');
  624. // Should accept "7k" as a valid value
  625. expect(
  626. await screen.findByRole('row', {name: 'timesSeen:>7k'})
  627. ).toBeInTheDocument();
  628. });
  629. it('can change the operator', async function () {
  630. render(<SearchQueryBuilder {...defaultProps} initialQuery="timesSeen:>100k" />);
  631. await userEvent.click(
  632. screen.getByRole('button', {name: 'Edit operator for filter: timesSeen'})
  633. );
  634. await userEvent.click(screen.getByRole('menuitemradio', {name: '<='}));
  635. expect(
  636. await screen.findByRole('row', {name: 'timesSeen:<=100k'})
  637. ).toBeInTheDocument();
  638. });
  639. });
  640. });
  641. });