index.spec.tsx 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130
  1. import {
  2. act,
  3. fireEvent,
  4. render,
  5. screen,
  6. userEvent,
  7. waitFor,
  8. } from 'sentry-test/reactTestingLibrary';
  9. import {SmartSearchBar} from 'sentry/components/smartSearchBar';
  10. import TagStore from 'sentry/stores/tagStore';
  11. import {FieldKey} from 'sentry/utils/fields';
  12. import {ItemType} from './types';
  13. describe('SmartSearchBar', function () {
  14. let defaultProps;
  15. beforeEach(function () {
  16. TagStore.reset();
  17. TagStore.loadTagsSuccess(TestStubs.Tags());
  18. const supportedTags = TagStore.getState();
  19. supportedTags.firstRelease = {
  20. key: 'firstRelease',
  21. name: 'firstRelease',
  22. };
  23. supportedTags.is = {
  24. key: 'is',
  25. name: 'is',
  26. };
  27. const organization = TestStubs.Organization({id: '123'});
  28. const location = {
  29. pathname: '/organizations/org-slug/recent-searches/',
  30. query: {
  31. projectId: '0',
  32. },
  33. };
  34. MockApiClient.clearMockResponses();
  35. MockApiClient.addMockResponse({
  36. url: '/organizations/org-slug/recent-searches/',
  37. body: [],
  38. });
  39. defaultProps = {
  40. query: '',
  41. organization,
  42. location,
  43. supportedTags,
  44. onGetTagValues: jest.fn().mockResolvedValue([]),
  45. onSearch: jest.fn(),
  46. };
  47. });
  48. afterEach(function () {
  49. MockApiClient.clearMockResponses();
  50. });
  51. it('quotes in values with spaces when autocompleting', async function () {
  52. const onGetTagValuesMock = jest
  53. .fn()
  54. .mockResolvedValue(['this is filled with spaces']);
  55. render(<SmartSearchBar {...defaultProps} onGetTagValues={onGetTagValuesMock} />);
  56. const textbox = screen.getByRole('textbox');
  57. await userEvent.click(textbox);
  58. await userEvent.type(textbox, 'device:this');
  59. const option = await screen.findByText(/this is filled with spaces/);
  60. await userEvent.click(option);
  61. expect(textbox).toHaveValue('device:"this is filled with spaces" ');
  62. });
  63. it('escapes quotes in values properly when autocompleting', async function () {
  64. const onGetTagValuesMock = jest
  65. .fn()
  66. .mockResolvedValue(['this " is " filled " with " quotes']);
  67. render(<SmartSearchBar {...defaultProps} onGetTagValues={onGetTagValuesMock} />);
  68. const textbox = screen.getByRole('textbox');
  69. await userEvent.click(textbox);
  70. await userEvent.type(textbox, 'device:this');
  71. const option = await screen.findByText(/this \\" is \\" filled \\" with \\" quotes/);
  72. await userEvent.click(option);
  73. expect(textbox).toHaveValue('device:"this \\" is \\" filled \\" with \\" quotes" ');
  74. });
  75. it('does not search when pressing enter on a tag without a value', async function () {
  76. const onSearchMock = jest.fn();
  77. render(<SmartSearchBar {...defaultProps} onSearch={onSearchMock} />);
  78. const textbox = screen.getByRole('textbox');
  79. await userEvent.type(textbox, 'browser:{enter}');
  80. expect(onSearchMock).not.toHaveBeenCalled();
  81. });
  82. it('autocompletes value with tab', async function () {
  83. const onSearchMock = jest.fn();
  84. render(<SmartSearchBar {...defaultProps} onSearch={onSearchMock} />);
  85. const textbox = screen.getByRole('textbox');
  86. await userEvent.type(textbox, 'bro');
  87. expect(
  88. await screen.findByRole('option', {name: 'bro wser - field'})
  89. ).toBeInTheDocument();
  90. // down once to 'browser' dropdown item
  91. await userEvent.keyboard('{ArrowDown}{Tab}');
  92. await waitFor(() => {
  93. expect(textbox).toHaveValue('browser:');
  94. });
  95. expect(textbox).toHaveFocus();
  96. // Should not have executed the search
  97. expect(onSearchMock).not.toHaveBeenCalled();
  98. });
  99. it('autocompletes value with enter', async function () {
  100. const onSearchMock = jest.fn();
  101. render(<SmartSearchBar {...defaultProps} onSearch={onSearchMock} />);
  102. const textbox = screen.getByRole('textbox');
  103. await userEvent.type(textbox, 'bro');
  104. expect(
  105. await screen.findByRole('option', {name: 'bro wser - field'})
  106. ).toBeInTheDocument();
  107. // down once to 'browser' dropdown item
  108. await userEvent.keyboard('{ArrowDown}{Enter}');
  109. await waitFor(() => {
  110. expect(textbox).toHaveValue('browser:');
  111. });
  112. expect(textbox).toHaveFocus();
  113. // Should not have executed the search
  114. expect(onSearchMock).not.toHaveBeenCalled();
  115. });
  116. it('searches and completes tags with negation operator', async function () {
  117. render(<SmartSearchBar {...defaultProps} />);
  118. const textbox = screen.getByRole('textbox');
  119. await userEvent.type(textbox, '!bro');
  120. const field = await screen.findByRole('option', {name: 'bro wser - field'});
  121. await userEvent.click(field);
  122. expect(textbox).toHaveValue('!browser:');
  123. });
  124. describe('componentWillReceiveProps()', function () {
  125. it('should add a space when setting query', function () {
  126. render(<SmartSearchBar {...defaultProps} query="one" />);
  127. expect(screen.getByRole('textbox')).toHaveValue('one ');
  128. });
  129. it('updates query when prop changes', function () {
  130. const {rerender} = render(<SmartSearchBar {...defaultProps} query="one" />);
  131. rerender(<SmartSearchBar {...defaultProps} query="two" />);
  132. expect(screen.getByRole('textbox')).toHaveValue('two ');
  133. });
  134. it('updates query when prop set to falsey value', function () {
  135. const {rerender} = render(<SmartSearchBar {...defaultProps} query="one" />);
  136. rerender(<SmartSearchBar {...defaultProps} query={null} />);
  137. expect(screen.getByRole('textbox')).toHaveValue('');
  138. });
  139. it('should not reset user textarea if a noop props change happens', async function () {
  140. const {rerender} = render(<SmartSearchBar {...defaultProps} query="one" />);
  141. await userEvent.type(screen.getByRole('textbox'), 'two');
  142. rerender(<SmartSearchBar {...defaultProps} query="one" />);
  143. expect(screen.getByRole('textbox')).toHaveValue('one two');
  144. });
  145. it('should reset user textarea if a meaningful props change happens', async function () {
  146. const {rerender} = render(<SmartSearchBar {...defaultProps} query="one" />);
  147. await userEvent.type(screen.getByRole('textbox'), 'two');
  148. rerender(<SmartSearchBar {...defaultProps} query="blah" />);
  149. expect(screen.getByRole('textbox')).toHaveValue('blah ');
  150. });
  151. });
  152. describe('clear search', function () {
  153. it('clicking the clear search button clears the query and calls onSearch', async function () {
  154. const mockOnSearch = jest.fn();
  155. render(
  156. <SmartSearchBar {...defaultProps} onSearch={mockOnSearch} query="is:unresolved" />
  157. );
  158. expect(screen.getByRole('textbox')).toHaveValue('is:unresolved ');
  159. await userEvent.click(screen.getByRole('button', {name: 'Clear search'}));
  160. expect(screen.getByRole('textbox')).toHaveValue('');
  161. expect(mockOnSearch).toHaveBeenCalledTimes(1);
  162. expect(mockOnSearch).toHaveBeenCalledWith('');
  163. });
  164. });
  165. describe('dropdown open state', function () {
  166. it('opens the dropdown when the search box is clicked', async function () {
  167. render(<SmartSearchBar {...defaultProps} />);
  168. const textbox = screen.getByRole('textbox');
  169. await userEvent.click(textbox);
  170. expect(screen.getByTestId('smart-search-dropdown')).toBeInTheDocument();
  171. });
  172. it('opens the dropdown when the search box gains focus', function () {
  173. render(<SmartSearchBar {...defaultProps} />);
  174. const textbox = screen.getByRole('textbox');
  175. fireEvent.focus(textbox);
  176. expect(screen.getByTestId('smart-search-dropdown')).toBeInTheDocument();
  177. });
  178. it('hides the drop down when clicking outside', async function () {
  179. render(
  180. <div data-test-id="test-container">
  181. <SmartSearchBar {...defaultProps} />
  182. </div>
  183. );
  184. const textbox = screen.getByRole('textbox');
  185. // Open the dropdown
  186. fireEvent.focus(textbox);
  187. await userEvent.click(screen.getByTestId('test-container'));
  188. expect(screen.queryByTestId('smart-search-dropdown')).not.toBeInTheDocument();
  189. });
  190. it('hides the drop down when pressing escape', async function () {
  191. render(<SmartSearchBar {...defaultProps} />);
  192. const textbox = screen.getByRole('textbox');
  193. // Open the dropdown
  194. fireEvent.focus(textbox);
  195. await userEvent.type(textbox, '{Escape}');
  196. expect(screen.queryByTestId('smart-search-dropdown')).not.toBeInTheDocument();
  197. });
  198. });
  199. describe('pasting', function () {
  200. it('trims pasted content', function () {
  201. const mockOnChange = jest.fn();
  202. render(<SmartSearchBar {...defaultProps} onChange={mockOnChange} />);
  203. const textbox = screen.getByRole('textbox');
  204. fireEvent.paste(textbox, {clipboardData: {getData: () => ' something'}});
  205. expect(textbox).toHaveValue('something');
  206. expect(mockOnChange).toHaveBeenCalledWith('something', expect.anything());
  207. });
  208. });
  209. it('invokes onSearch() on enter', async function () {
  210. const mockOnSearch = jest.fn();
  211. render(<SmartSearchBar {...defaultProps} query="test" onSearch={mockOnSearch} />);
  212. await userEvent.type(screen.getByRole('textbox'), '{Enter}');
  213. expect(mockOnSearch).toHaveBeenCalledWith('test');
  214. });
  215. it('handles an empty query', function () {
  216. render(<SmartSearchBar {...defaultProps} query="" />);
  217. expect(screen.getByRole('textbox')).toHaveValue('');
  218. });
  219. it('does not fetch tag values with environment tag and excludeEnvironment', async function () {
  220. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  221. render(
  222. <SmartSearchBar
  223. {...defaultProps}
  224. onGetTagValues={getTagValuesMock}
  225. excludedTags={['environment']}
  226. />
  227. );
  228. const textbox = screen.getByRole('textbox');
  229. await userEvent.type(textbox, 'environment:');
  230. expect(getTagValuesMock).not.toHaveBeenCalled();
  231. });
  232. it('does not fetch tag values with timesSeen tag', async function () {
  233. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  234. render(
  235. <SmartSearchBar
  236. {...defaultProps}
  237. onGetTagValues={getTagValuesMock}
  238. excludedTags={['environment']}
  239. />
  240. );
  241. const textbox = screen.getByRole('textbox');
  242. await userEvent.type(textbox, 'timesSeen:');
  243. expect(getTagValuesMock).not.toHaveBeenCalled();
  244. });
  245. it('fetches and displays tag values with other tags', async function () {
  246. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  247. render(
  248. <SmartSearchBar
  249. {...defaultProps}
  250. onGetTagValues={getTagValuesMock}
  251. excludedTags={['environment']}
  252. />
  253. );
  254. const textbox = screen.getByRole('textbox');
  255. await userEvent.type(textbox, 'browser:');
  256. expect(getTagValuesMock).toHaveBeenCalledTimes(1);
  257. });
  258. it('shows correct options on cursor changes for keys and values', async function () {
  259. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  260. render(
  261. <SmartSearchBar
  262. {...defaultProps}
  263. query="is:unresolved"
  264. onGetTagValues={getTagValuesMock}
  265. onGetRecentSearches={jest.fn().mockReturnValue([])}
  266. />
  267. );
  268. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  269. // Set cursor to beginning of "is" tag
  270. await userEvent.click(textbox);
  271. textbox.setSelectionRange(0, 0);
  272. // Should show "Keys" section
  273. expect(await screen.findByText('Keys')).toBeInTheDocument();
  274. // Set cursor to middle of "is" tag
  275. await userEvent.keyboard('{ArrowRight}');
  276. // Should show "Keys" and NOT "Operator Helpers" or "Values"
  277. expect(await screen.findByText('Keys')).toBeInTheDocument();
  278. expect(screen.queryByText('Operator Helpers')).not.toBeInTheDocument();
  279. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  280. // Set cursor to end of "is" tag
  281. await userEvent.keyboard('{ArrowRight}');
  282. // Should show "Tags" and "Operator Helpers" but NOT "Values"
  283. expect(await screen.findByText('Keys')).toBeInTheDocument();
  284. expect(screen.queryByText('Operator Helpers')).toBeInTheDocument();
  285. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  286. // Set cursor after the ":"
  287. await userEvent.keyboard('{ArrowRight}');
  288. // Should show "Values" and "Operator Helpers" but NOT "Keys"
  289. expect(await screen.findByText('Values')).toBeInTheDocument();
  290. expect(await screen.findByText('Operator Helpers')).toBeInTheDocument();
  291. expect(screen.queryByText('Keys')).not.toBeInTheDocument();
  292. // Set cursor inside value
  293. await userEvent.keyboard('{ArrowRight}');
  294. // Should show "Values" and NOT "Operator Helpers" or "Keys"
  295. expect(await screen.findByText('Values')).toBeInTheDocument();
  296. expect(screen.queryByText('Operator Helpers')).not.toBeInTheDocument();
  297. expect(screen.queryByText('Keys')).not.toBeInTheDocument();
  298. });
  299. it('shows syntax error for incorrect tokens', function () {
  300. render(<SmartSearchBar {...defaultProps} query="tag: is: has:" />);
  301. // Should have three invalid tokens (tag:, is:, and has:)
  302. expect(screen.getAllByTestId('filter-token-invalid')).toHaveLength(3);
  303. });
  304. it('renders nested keys correctly', async function () {
  305. const {container} = render(
  306. <SmartSearchBar
  307. {...defaultProps}
  308. query=""
  309. supportedTags={{
  310. nested: {
  311. key: 'nested',
  312. name: 'nested',
  313. },
  314. 'nested.child': {
  315. key: 'nested.child',
  316. name: 'nested.child',
  317. },
  318. 'nestednoparent.child': {
  319. key: 'nestednoparent.child',
  320. name: 'nestednoparent.child',
  321. },
  322. }}
  323. />
  324. );
  325. const textbox = screen.getByRole('textbox');
  326. await userEvent.type(textbox, 'nest');
  327. await screen.findByText('Keys');
  328. expect(container).toSnapshot();
  329. });
  330. it('filters keys on name and description', async function () {
  331. render(
  332. <SmartSearchBar
  333. {...defaultProps}
  334. query=""
  335. supportedTags={{
  336. [FieldKey.DEVICE_CHARGING]: {
  337. key: FieldKey.DEVICE_CHARGING,
  338. },
  339. [FieldKey.EVENT_TYPE]: {
  340. key: FieldKey.EVENT_TYPE,
  341. },
  342. [FieldKey.DEVICE_ARCH]: {
  343. key: FieldKey.DEVICE_ARCH,
  344. },
  345. }}
  346. />
  347. );
  348. const textbox = screen.getByRole('textbox');
  349. await userEvent.type(textbox, 'event');
  350. await screen.findByText('Keys');
  351. // Should show event.type (has event in key) and device.charging (has event in description)
  352. expect(screen.getByRole('option', {name: /event . type/})).toBeInTheDocument();
  353. expect(screen.getByRole('option', {name: /charging/})).toBeInTheDocument();
  354. // But not device.arch (not in key or description)
  355. expect(screen.queryByRole('option', {name: /arch/})).not.toBeInTheDocument();
  356. });
  357. it('handles autocomplete race conditions when cursor position changed', async function () {
  358. jest.useFakeTimers();
  359. const user = userEvent.setup({delay: null});
  360. const mockOnGetTagValues = jest.fn().mockImplementation(
  361. () =>
  362. new Promise(resolve => {
  363. setTimeout(() => {
  364. resolve(['value']);
  365. }, 300);
  366. })
  367. );
  368. render(
  369. <SmartSearchBar {...defaultProps} onGetTagValues={mockOnGetTagValues} query="" />
  370. );
  371. const textbox = screen.getByRole('textbox');
  372. // Type key and start searching values
  373. await user.type(textbox, 'is:');
  374. act(() => jest.advanceTimersByTime(200));
  375. // Before values have finished searching, clear the textbox
  376. await user.clear(textbox);
  377. act(jest.runAllTimers);
  378. // Should show keys, not values in dropdown
  379. expect(await screen.findByText('Keys')).toBeInTheDocument();
  380. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  381. jest.useRealTimers();
  382. });
  383. it('autocompletes tag values', async function () {
  384. const mockOnChange = jest.fn();
  385. const getTagValuesMock = jest.fn().mockResolvedValue(['Chrome', 'Firefox']);
  386. render(
  387. <SmartSearchBar
  388. {...defaultProps}
  389. onGetTagValues={getTagValuesMock}
  390. query=""
  391. onChange={mockOnChange}
  392. />
  393. );
  394. const textbox = screen.getByRole('textbox');
  395. await userEvent.type(textbox, 'browser:');
  396. const option = await screen.findByRole('option', {name: /Firefox/});
  397. await userEvent.click(option, {delay: null});
  398. await waitFor(() => {
  399. expect(mockOnChange).toHaveBeenLastCalledWith(
  400. 'browser:Firefox ',
  401. expect.anything()
  402. );
  403. });
  404. });
  405. it('autocompletes tag values when there are other tags', async function () {
  406. const mockOnChange = jest.fn();
  407. const getTagValuesMock = jest.fn().mockResolvedValue(['Chrome', 'Firefox']);
  408. render(
  409. <SmartSearchBar
  410. {...defaultProps}
  411. onGetTagValues={getTagValuesMock}
  412. excludedTags={['environment']}
  413. query="is:unresolved browser: error.handled:true"
  414. onChange={mockOnChange}
  415. />
  416. );
  417. const textbox = screen.getByRole('textbox');
  418. await userEvent.type(textbox, '{ArrowRight}', {
  419. initialSelectionStart: 'is:unresolved browser'.length,
  420. initialSelectionEnd: 'is:unresolved browser'.length,
  421. });
  422. const option = await screen.findByRole('option', {name: /Firefox/});
  423. await userEvent.click(option, {delay: null});
  424. await waitFor(() => {
  425. expect(mockOnChange).toHaveBeenLastCalledWith(
  426. 'is:unresolved browser:Firefox error.handled:true ',
  427. expect.anything()
  428. );
  429. });
  430. });
  431. it('autocompletes tag values (user tag)', async function () {
  432. jest.useFakeTimers();
  433. const mockOnChange = jest.fn();
  434. const getTagValuesMock = jest.fn().mockResolvedValue(['id:1']);
  435. render(
  436. <SmartSearchBar
  437. {...defaultProps}
  438. onGetTagValues={getTagValuesMock}
  439. query=""
  440. onChange={mockOnChange}
  441. />
  442. );
  443. const textbox = screen.getByRole('textbox');
  444. await userEvent.type(textbox, 'user:', {delay: null});
  445. act(jest.runOnlyPendingTimers);
  446. const option = await screen.findByRole('option', {name: /id:1/});
  447. await userEvent.click(option, {delay: null});
  448. await waitFor(() => {
  449. expect(mockOnChange).toHaveBeenLastCalledWith('user:"id:1" ', expect.anything());
  450. });
  451. jest.useRealTimers();
  452. });
  453. it('autocompletes tag values (predefined values with spaces)', async function () {
  454. jest.useFakeTimers();
  455. const mockOnChange = jest.fn();
  456. render(
  457. <SmartSearchBar
  458. {...defaultProps}
  459. query=""
  460. onChange={mockOnChange}
  461. supportedTags={{
  462. predefined: {
  463. key: 'predefined',
  464. name: 'predefined',
  465. predefined: true,
  466. values: ['predefined tag with spaces'],
  467. },
  468. }}
  469. />
  470. );
  471. const textbox = screen.getByRole('textbox');
  472. await userEvent.type(textbox, 'predefined:', {delay: null});
  473. act(jest.runOnlyPendingTimers);
  474. const option = await screen.findByRole('option', {
  475. name: /predefined tag with spaces/,
  476. });
  477. await userEvent.click(option, {delay: null});
  478. await waitFor(() => {
  479. expect(mockOnChange).toHaveBeenLastCalledWith(
  480. 'predefined:"predefined tag with spaces" ',
  481. expect.anything()
  482. );
  483. });
  484. jest.useRealTimers();
  485. });
  486. it('autocompletes tag values (predefined values with quotes)', async function () {
  487. jest.useFakeTimers();
  488. const mockOnChange = jest.fn();
  489. render(
  490. <SmartSearchBar
  491. {...defaultProps}
  492. query=""
  493. onChange={mockOnChange}
  494. supportedTags={{
  495. predefined: {
  496. key: 'predefined',
  497. name: 'predefined',
  498. predefined: true,
  499. values: ['"predefined" "tag" "with" "quotes"'],
  500. },
  501. }}
  502. />
  503. );
  504. const textbox = screen.getByRole('textbox');
  505. await userEvent.type(textbox, 'predefined:', {delay: null});
  506. act(jest.runOnlyPendingTimers);
  507. const option = await screen.findByRole('option', {
  508. name: /quotes/,
  509. });
  510. await userEvent.click(option, {delay: null});
  511. await waitFor(() => {
  512. expect(mockOnChange).toHaveBeenLastCalledWith(
  513. 'predefined:"\\"predefined\\" \\"tag\\" \\"with\\" \\"quotes\\"" ',
  514. expect.anything()
  515. );
  516. });
  517. jest.useRealTimers();
  518. });
  519. describe('quick actions', function () {
  520. it('can delete tokens', async function () {
  521. render(
  522. <SmartSearchBar
  523. {...defaultProps}
  524. query="is:unresolved sdk.name:sentry-cocoa has:key"
  525. />
  526. );
  527. const textbox = screen.getByRole('textbox');
  528. // Put cursor inside is:resolved
  529. await userEvent.type(textbox, '{ArrowRight}', {
  530. initialSelectionStart: 0,
  531. initialSelectionEnd: 0,
  532. });
  533. await userEvent.click(screen.getByRole('button', {name: /Delete/}));
  534. expect(textbox).toHaveValue('sdk.name:sentry-cocoa has:key');
  535. });
  536. it('can delete a middle token', async function () {
  537. render(
  538. <SmartSearchBar
  539. {...defaultProps}
  540. query="is:unresolved sdk.name:sentry-cocoa has:key"
  541. />
  542. );
  543. const textbox = screen.getByRole('textbox');
  544. // Put cursor inside sdk.name
  545. await userEvent.type(textbox, '{ArrowRight}', {
  546. initialSelectionStart: 'is:unresolved '.length,
  547. initialSelectionEnd: 'is:unresolved '.length,
  548. });
  549. await userEvent.click(screen.getByRole('button', {name: /Delete/}));
  550. expect(textbox).toHaveValue('is:unresolved has:key');
  551. });
  552. it('can exclude a token', async function () {
  553. render(
  554. <SmartSearchBar
  555. {...defaultProps}
  556. query="is:unresolved sdk.name:sentry-cocoa has:key"
  557. />
  558. );
  559. const textbox = screen.getByRole('textbox');
  560. // Put cursor inside sdk.name
  561. await userEvent.type(textbox, '{ArrowRight}', {
  562. initialSelectionStart: 'is:unresolved '.length,
  563. initialSelectionEnd: 'is:unresolved '.length,
  564. });
  565. await userEvent.click(screen.getByRole('button', {name: /Exclude/}));
  566. expect(textbox).toHaveValue('is:unresolved !sdk.name:sentry-cocoa has:key ');
  567. });
  568. it('can include a token', async function () {
  569. render(
  570. <SmartSearchBar
  571. {...defaultProps}
  572. query="is:unresolved !sdk.name:sentry-cocoa has:key"
  573. />
  574. );
  575. const textbox = screen.getByRole('textbox');
  576. // Put cursor inside sdk.name
  577. await userEvent.type(textbox, '{ArrowRight}', {
  578. initialSelectionStart: 'is:unresolved !'.length,
  579. initialSelectionEnd: 'is:unresolved !'.length,
  580. });
  581. expect(textbox).toHaveValue('is:unresolved !sdk.name:sentry-cocoa has:key ');
  582. await screen.findByRole('button', {name: /Include/});
  583. await userEvent.click(screen.getByRole('button', {name: /Include/}));
  584. expect(textbox).toHaveValue('is:unresolved sdk.name:sentry-cocoa has:key ');
  585. });
  586. });
  587. it('displays invalid field message', async function () {
  588. render(<SmartSearchBar {...defaultProps} query="" />);
  589. const textbox = screen.getByRole('textbox');
  590. await userEvent.type(textbox, 'invalid:');
  591. expect(
  592. await screen.findByRole('option', {name: /the field invalid isn't supported here/i})
  593. ).toBeInTheDocument();
  594. });
  595. it('displays invalid field messages for when wildcard is disallowed', async function () {
  596. render(<SmartSearchBar {...defaultProps} query="" disallowWildcard />);
  597. const textbox = screen.getByRole('textbox');
  598. // Value
  599. await userEvent.type(textbox, 'release:*');
  600. expect(
  601. await screen.findByRole('option', {name: /Wildcards aren't supported here/i})
  602. ).toBeInTheDocument();
  603. await userEvent.clear(textbox);
  604. // FreeText
  605. await userEvent.type(textbox, 'rel*ease');
  606. expect(
  607. await screen.findByRole('option', {name: /Wildcards aren't supported here/i})
  608. ).toBeInTheDocument();
  609. });
  610. describe('date fields', () => {
  611. // Transpile the lazy-loaded datepicker up front so tests don't flake
  612. beforeAll(async function () {
  613. await import('sentry/components/calendar/datePicker');
  614. });
  615. it('displays date picker dropdown when appropriate', async () => {
  616. render(<SmartSearchBar {...defaultProps} query="" />);
  617. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  618. await userEvent.click(textbox);
  619. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  620. // Just lastSeen: will display relative and absolute options, not the datepicker
  621. await userEvent.type(textbox, 'lastSeen:');
  622. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  623. expect(screen.getByText('Last hour')).toBeInTheDocument();
  624. expect(screen.getByText('After a custom datetime')).toBeInTheDocument();
  625. // lastSeen:> should open the date picker
  626. await userEvent.type(textbox, '>');
  627. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  628. // Continues to display with date typed out
  629. await userEvent.type(textbox, '2022-01-01');
  630. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  631. // Goes away when on next term
  632. await userEvent.type(textbox, ' ');
  633. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  634. // Pops back up when cursor is back in date token
  635. await userEvent.keyboard('{arrowleft}');
  636. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  637. // Moving cursor inside the `lastSeen` token hides the date picker
  638. textbox.setSelectionRange(1, 1);
  639. await userEvent.click(textbox);
  640. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  641. });
  642. it('can select a suggested relative time value', async () => {
  643. render(<SmartSearchBar {...defaultProps} query="" />);
  644. await userEvent.type(screen.getByRole('textbox'), 'lastSeen:');
  645. await userEvent.click(screen.getByText('Last hour'));
  646. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:-1h ');
  647. });
  648. it('can select a specific date/time', async () => {
  649. render(<SmartSearchBar {...defaultProps} query="" />);
  650. await userEvent.type(screen.getByRole('textbox'), 'lastSeen:');
  651. await userEvent.click(screen.getByText('After a custom datetime'));
  652. // Should have added '>' to query and show a date picker
  653. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:>');
  654. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  655. // Select a day on the calendar
  656. const dateInput = await screen.findByTestId('date-picker');
  657. fireEvent.change(dateInput, {target: {value: '2022-01-02'}});
  658. expect(screen.getByRole('textbox')).toHaveValue(
  659. // -05:00 because our tests run in EST
  660. 'lastSeen:>2022-01-02T00:00:00-05:00'
  661. );
  662. const timeInput = screen.getByLabelText('Time');
  663. // Simulate changing time input one bit at a time
  664. await userEvent.click(timeInput);
  665. fireEvent.change(timeInput, {target: {value: '01:00:00'}});
  666. fireEvent.change(timeInput, {target: {value: '01:02:00'}});
  667. fireEvent.change(timeInput, {target: {value: '01:02:03'}});
  668. // Time input should have retained focus this whole time
  669. expect(timeInput).toHaveFocus();
  670. fireEvent.blur(timeInput);
  671. expect(screen.getByRole('textbox')).toHaveValue(
  672. 'lastSeen:>2022-01-02T01:02:03-05:00'
  673. );
  674. // Toggle UTC on, which should remove the timezone (-05:00) from the query
  675. await userEvent.click(screen.getByLabelText('Use UTC'));
  676. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:>2022-01-02T01:02:03');
  677. });
  678. it('can change an existing datetime', async () => {
  679. render(<SmartSearchBar {...defaultProps} query="" />);
  680. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  681. fireEvent.change(textbox, {
  682. target: {value: 'lastSeen:2022-01-02 firstSeen:2022-01-01'},
  683. });
  684. // Move cursor to the lastSeen date
  685. await userEvent.type(textbox, '{ArrowRight}', {
  686. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  687. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  688. });
  689. const dateInput = await screen.findByTestId('date-picker');
  690. expect(dateInput).toHaveValue('2022-01-02');
  691. expect(screen.getByLabelText('Time')).toHaveValue('00:00:00');
  692. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  693. fireEvent.change(dateInput, {target: {value: '2022-01-03'}});
  694. expect(textbox).toHaveValue('lastSeen:2022-01-03T00:00:00 firstSeen:2022-01-01');
  695. // Cursor should be at end of the value we just replaced
  696. expect(textbox.selectionStart).toBe('lastSeen:2022-01-03T00:00:00'.length);
  697. });
  698. it('populates the date picker correctly for date without time', async () => {
  699. render(<SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01" />);
  700. const textbox = screen.getByRole('textbox');
  701. // Move cursor to the timestamp
  702. await userEvent.type(textbox, '{ArrowRight}', {
  703. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  704. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  705. });
  706. const dateInput = await screen.findByTestId('date-picker');
  707. expect(dateInput).toHaveValue('2022-01-01');
  708. // No time provided, so time input should be the default value
  709. expect(screen.getByLabelText('Time')).toHaveValue('00:00:00');
  710. // UTC is checked because there is no timezone
  711. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  712. });
  713. it('populates the date picker correctly for date with time and no timezone', async () => {
  714. render(<SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01T09:45:12" />);
  715. const textbox = screen.getByRole('textbox');
  716. // Move cursor to the timestamp
  717. await userEvent.type(textbox, '{ArrowRight}', {
  718. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  719. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  720. });
  721. const dateInput = await screen.findByTestId('date-picker');
  722. expect(dateInput).toHaveValue('2022-01-01');
  723. expect(screen.getByLabelText('Time')).toHaveValue('09:45:12');
  724. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  725. });
  726. it('populates the date picker correctly for date with time and timezone', async () => {
  727. render(
  728. <SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01T09:45:12-05:00" />
  729. );
  730. const textbox = screen.getByRole('textbox');
  731. // Move cursor to the timestamp
  732. await userEvent.type(textbox, '{ArrowRight}', {
  733. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  734. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  735. });
  736. const dateInput = await screen.findByTestId('date-picker');
  737. expect(dateInput).toHaveValue('2022-01-01');
  738. expect(screen.getByLabelText('Time')).toHaveValue('09:45:12');
  739. expect(screen.getByLabelText('Use UTC')).not.toBeChecked();
  740. });
  741. });
  742. describe('custom performance metric filters', () => {
  743. it('raises Invalid file size when parsed filter unit is not a valid size unit', async () => {
  744. render(
  745. <SmartSearchBar
  746. {...defaultProps}
  747. customPerformanceMetrics={{
  748. 'measurements.custom.kibibyte': {
  749. fieldType: 'size',
  750. },
  751. }}
  752. />
  753. );
  754. const textbox = screen.getByRole('textbox');
  755. await userEvent.click(textbox);
  756. await userEvent.type(textbox, 'measurements.custom.kibibyte:10ms ');
  757. await userEvent.keyboard('{arrowleft}');
  758. expect(
  759. screen.getByText(
  760. 'Invalid file size. Expected number followed by file size unit suffix'
  761. )
  762. ).toBeInTheDocument();
  763. });
  764. it('raises Invalid duration when parsed filter unit is not a valid duration unit', async () => {
  765. render(
  766. <SmartSearchBar
  767. {...defaultProps}
  768. customPerformanceMetrics={{
  769. 'measurements.custom.minute': {
  770. fieldType: 'duration',
  771. },
  772. }}
  773. />
  774. );
  775. const textbox = screen.getByRole('textbox');
  776. await userEvent.click(textbox);
  777. await userEvent.type(textbox, 'measurements.custom.minute:10kb ');
  778. await userEvent.keyboard('{arrowleft}');
  779. expect(
  780. screen.getByText(
  781. 'Invalid duration. Expected number followed by duration unit suffix'
  782. )
  783. ).toBeInTheDocument();
  784. });
  785. });
  786. describe('defaultSearchGroup', () => {
  787. const defaultSearchGroup = {
  788. title: 'default search group',
  789. type: 'header',
  790. // childrenWrapper allows us to arrange the children with custom styles
  791. childrenWrapper: props => (
  792. <div data-test-id="default-search-group-wrapper" {...props} />
  793. ),
  794. children: [
  795. {
  796. type: ItemType.RECOMMENDED,
  797. title: 'Assignee',
  798. desc: 'Filter by team or member.',
  799. value: 'assigned_or_suggested:',
  800. },
  801. ],
  802. };
  803. it('displays a default group with custom wrapper', async function () {
  804. const mockOnChange = jest.fn();
  805. render(
  806. <SmartSearchBar
  807. {...defaultProps}
  808. defaultSearchGroup={defaultSearchGroup}
  809. query=""
  810. onChange={mockOnChange}
  811. />
  812. );
  813. const textbox = screen.getByRole('textbox');
  814. await userEvent.click(textbox);
  815. expect(screen.getByTestId('default-search-group-wrapper')).toBeInTheDocument();
  816. expect(screen.getByText('default search group')).toBeInTheDocument();
  817. // Default group is correctly added to the dropdown
  818. await userEvent.keyboard('{ArrowDown}{Enter}');
  819. expect(mockOnChange).toHaveBeenCalledWith(
  820. 'assigned_or_suggested:',
  821. expect.anything()
  822. );
  823. });
  824. it('hides the default group after typing', async function () {
  825. render(
  826. <SmartSearchBar {...defaultProps} defaultSearchGroup={defaultSearchGroup} />
  827. );
  828. const textbox = screen.getByRole('textbox');
  829. await userEvent.click(textbox);
  830. expect(screen.getByTestId('default-search-group-wrapper')).toBeInTheDocument();
  831. await userEvent.type(textbox, 'f');
  832. expect(
  833. screen.queryByTestId('default-search-group-wrapper')
  834. ).not.toBeInTheDocument();
  835. });
  836. it('hides the default group after picking item with applyFilter', async function () {
  837. render(
  838. <SmartSearchBar
  839. {...defaultProps}
  840. defaultSearchGroup={{
  841. ...defaultSearchGroup,
  842. children: [
  843. {
  844. type: ItemType.RECOMMENDED,
  845. title: 'Custom Tags',
  846. // Filter is applied to all search items when picked
  847. applyFilter: item => item.title === 'device',
  848. },
  849. ],
  850. }}
  851. />
  852. );
  853. const textbox = screen.getByRole('textbox');
  854. await userEvent.click(textbox);
  855. expect(await screen.findByText('User identification value')).toBeInTheDocument();
  856. await userEvent.click(screen.getByText('Custom Tags'));
  857. expect(screen.queryByText('Custom Tags')).not.toBeInTheDocument();
  858. expect(screen.queryByText('User identification value')).not.toBeInTheDocument();
  859. expect(screen.getByText('device')).toBeInTheDocument();
  860. });
  861. });
  862. });