index.spec.tsx 37 KB

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