index.spec.tsx 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181
  1. import {Fragment} from 'react';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {TagsFixture} 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(TagsFixture());
  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 = OrganizationFixture({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', async 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. await waitFor(() =>
  209. expect(mockOnChange).toHaveBeenCalledWith('something', expect.anything())
  210. );
  211. });
  212. });
  213. it('invokes onSearch() on enter', async function () {
  214. const mockOnSearch = jest.fn();
  215. render(<SmartSearchBar {...defaultProps} query="test" onSearch={mockOnSearch} />);
  216. await userEvent.type(screen.getByRole('textbox'), '{Enter}');
  217. expect(mockOnSearch).toHaveBeenCalledWith('test');
  218. });
  219. it('handles an empty query', function () {
  220. render(<SmartSearchBar {...defaultProps} query="" />);
  221. expect(screen.getByRole('textbox')).toHaveValue('');
  222. });
  223. it('does not fetch tag values with environment tag and excludeEnvironment', async function () {
  224. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  225. render(
  226. <SmartSearchBar
  227. {...defaultProps}
  228. onGetTagValues={getTagValuesMock}
  229. excludedTags={['environment']}
  230. />
  231. );
  232. const textbox = screen.getByRole('textbox');
  233. await userEvent.type(textbox, 'environment:');
  234. expect(getTagValuesMock).not.toHaveBeenCalled();
  235. });
  236. it('does not fetch tag values with timesSeen tag', async function () {
  237. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  238. render(
  239. <SmartSearchBar
  240. {...defaultProps}
  241. onGetTagValues={getTagValuesMock}
  242. excludedTags={['environment']}
  243. />
  244. );
  245. const textbox = screen.getByRole('textbox');
  246. await userEvent.type(textbox, 'timesSeen:');
  247. expect(getTagValuesMock).not.toHaveBeenCalled();
  248. });
  249. it('fetches and displays tag values with other tags', async function () {
  250. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  251. render(
  252. <SmartSearchBar
  253. {...defaultProps}
  254. onGetTagValues={getTagValuesMock}
  255. excludedTags={['environment']}
  256. />
  257. );
  258. const textbox = screen.getByRole('textbox');
  259. await userEvent.type(textbox, 'browser:');
  260. expect(getTagValuesMock).toHaveBeenCalledTimes(1);
  261. });
  262. it('shows correct options on cursor changes for keys and values', async function () {
  263. const getTagValuesMock = jest.fn().mockResolvedValue([]);
  264. render(
  265. <SmartSearchBar
  266. {...defaultProps}
  267. query="is:unresolved"
  268. onGetTagValues={getTagValuesMock}
  269. onGetRecentSearches={jest.fn().mockReturnValue([])}
  270. />
  271. );
  272. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  273. // Set cursor to beginning of "is" tag
  274. await userEvent.click(textbox);
  275. textbox.setSelectionRange(0, 0);
  276. // Should show "Keys" section
  277. expect(await screen.findByText('Keys')).toBeInTheDocument();
  278. // Set cursor to middle of "is" tag
  279. await userEvent.keyboard('{ArrowRight}');
  280. // Should show "Keys" and NOT "Operator Helpers" or "Values"
  281. expect(await screen.findByText('Keys')).toBeInTheDocument();
  282. expect(screen.queryByText('Operator Helpers')).not.toBeInTheDocument();
  283. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  284. // Set cursor to end of "is" tag
  285. await userEvent.keyboard('{ArrowRight}');
  286. // Should show "Tags" and "Operator Helpers" but NOT "Values"
  287. expect(await screen.findByText('Keys')).toBeInTheDocument();
  288. expect(screen.queryByText('Operator Helpers')).toBeInTheDocument();
  289. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  290. // Set cursor after the ":"
  291. await userEvent.keyboard('{ArrowRight}');
  292. // Should show "Values" and "Operator Helpers" but NOT "Keys"
  293. expect(await screen.findByText('Values')).toBeInTheDocument();
  294. expect(await screen.findByText('Operator Helpers')).toBeInTheDocument();
  295. expect(screen.queryByText('Keys')).not.toBeInTheDocument();
  296. // Set cursor inside value
  297. await userEvent.keyboard('{ArrowRight}');
  298. // Should show "Values" and NOT "Operator Helpers" or "Keys"
  299. expect(await screen.findByText('Values')).toBeInTheDocument();
  300. expect(screen.queryByText('Operator Helpers')).not.toBeInTheDocument();
  301. expect(screen.queryByText('Keys')).not.toBeInTheDocument();
  302. });
  303. it('shows syntax error for incorrect tokens', function () {
  304. render(<SmartSearchBar {...defaultProps} query="tag: is: has:" />);
  305. // Should have three invalid tokens (tag:, is:, and has:)
  306. expect(screen.getAllByTestId('filter-token-invalid')).toHaveLength(3);
  307. });
  308. it('renders nested keys correctly', async function () {
  309. render(
  310. <SmartSearchBar
  311. {...defaultProps}
  312. query=""
  313. supportedTags={{
  314. nested: {
  315. key: 'nested',
  316. name: 'nested',
  317. },
  318. 'nested.child': {
  319. key: 'nested.child',
  320. name: 'nested.child',
  321. },
  322. 'nestednoparent.child': {
  323. key: 'nestednoparent.child',
  324. name: 'nestednoparent.child',
  325. },
  326. }}
  327. />
  328. );
  329. const textbox = screen.getByRole('textbox');
  330. await userEvent.type(textbox, 'nest');
  331. await screen.findByText('Keys');
  332. });
  333. it('filters keys on name and description', async function () {
  334. render(
  335. <SmartSearchBar
  336. {...defaultProps}
  337. query=""
  338. supportedTags={{
  339. [FieldKey.DEVICE_CHARGING]: {
  340. key: FieldKey.DEVICE_CHARGING,
  341. },
  342. [FieldKey.EVENT_TYPE]: {
  343. key: FieldKey.EVENT_TYPE,
  344. },
  345. [FieldKey.DEVICE_ARCH]: {
  346. key: FieldKey.DEVICE_ARCH,
  347. },
  348. }}
  349. />
  350. );
  351. const textbox = screen.getByRole('textbox');
  352. await userEvent.type(textbox, 'event');
  353. await screen.findByText('Keys');
  354. // Should show event.type (has event in key) and device.charging (has event in description)
  355. expect(screen.getByRole('option', {name: /event . type/})).toBeInTheDocument();
  356. expect(screen.getByRole('option', {name: /charging/})).toBeInTheDocument();
  357. // But not device.arch (not in key or description)
  358. expect(screen.queryByRole('option', {name: /arch/})).not.toBeInTheDocument();
  359. });
  360. it('handles autocomplete race conditions when cursor position changed', async function () {
  361. jest.useFakeTimers();
  362. const user = userEvent.setup({delay: null});
  363. const mockOnGetTagValues = jest.fn().mockImplementation(
  364. () =>
  365. new Promise(resolve => {
  366. setTimeout(() => {
  367. resolve(['value']);
  368. }, 300);
  369. })
  370. );
  371. render(
  372. <SmartSearchBar {...defaultProps} onGetTagValues={mockOnGetTagValues} query="" />
  373. );
  374. const textbox = screen.getByRole('textbox');
  375. // Type key and start searching values
  376. await user.type(textbox, 'is:');
  377. act(() => jest.advanceTimersByTime(200));
  378. // Before values have finished searching, clear the textbox
  379. await user.clear(textbox);
  380. act(jest.runAllTimers);
  381. // Should show keys, not values in dropdown
  382. expect(await screen.findByText('Keys')).toBeInTheDocument();
  383. expect(screen.queryByText('Values')).not.toBeInTheDocument();
  384. jest.useRealTimers();
  385. });
  386. it('autocompletes tag values', async function () {
  387. const mockOnChange = jest.fn();
  388. const getTagValuesMock = jest.fn().mockResolvedValue(['Chrome', 'Firefox']);
  389. render(
  390. <SmartSearchBar
  391. {...defaultProps}
  392. onGetTagValues={getTagValuesMock}
  393. query=""
  394. onChange={mockOnChange}
  395. />
  396. );
  397. const textbox = screen.getByRole('textbox');
  398. await userEvent.type(textbox, 'browser:');
  399. const option = await screen.findByRole('option', {name: /Firefox/});
  400. await userEvent.click(option, {delay: null});
  401. await waitFor(() => {
  402. expect(mockOnChange).toHaveBeenLastCalledWith(
  403. 'browser:Firefox ',
  404. expect.anything()
  405. );
  406. });
  407. });
  408. it('autocompletes tag values when there are other tags', async function () {
  409. const mockOnChange = jest.fn();
  410. const getTagValuesMock = jest.fn().mockResolvedValue(['Chrome', 'Firefox']);
  411. render(
  412. <SmartSearchBar
  413. {...defaultProps}
  414. onGetTagValues={getTagValuesMock}
  415. excludedTags={['environment']}
  416. query="is:unresolved browser: error.handled:true"
  417. onChange={mockOnChange}
  418. />
  419. );
  420. const textbox = screen.getByRole('textbox');
  421. await userEvent.type(textbox, '{ArrowRight}', {
  422. initialSelectionStart: 'is:unresolved browser'.length,
  423. initialSelectionEnd: 'is:unresolved browser'.length,
  424. });
  425. const option = await screen.findByRole('option', {name: /Firefox/});
  426. await userEvent.click(option, {delay: null});
  427. await waitFor(() => {
  428. expect(mockOnChange).toHaveBeenLastCalledWith(
  429. 'is:unresolved browser:Firefox error.handled:true ',
  430. expect.anything()
  431. );
  432. });
  433. });
  434. it('autocompletes tag values (user tag)', async function () {
  435. jest.useFakeTimers();
  436. const mockOnChange = jest.fn();
  437. const getTagValuesMock = jest.fn().mockResolvedValue(['id:1']);
  438. render(
  439. <SmartSearchBar
  440. {...defaultProps}
  441. onGetTagValues={getTagValuesMock}
  442. query=""
  443. onChange={mockOnChange}
  444. />
  445. );
  446. const textbox = screen.getByRole('textbox');
  447. await userEvent.type(textbox, 'user:', {delay: null});
  448. act(jest.runOnlyPendingTimers);
  449. const option = await screen.findByRole('option', {name: /id:1/});
  450. await userEvent.click(option, {delay: null});
  451. await waitFor(() => {
  452. expect(mockOnChange).toHaveBeenLastCalledWith('user:"id:1" ', expect.anything());
  453. });
  454. jest.useRealTimers();
  455. });
  456. it('autocompletes assigned from string values', async function () {
  457. const mockOnChange = jest.fn();
  458. render(
  459. <SmartSearchBar
  460. {...defaultProps}
  461. query=""
  462. onChange={mockOnChange}
  463. supportedTags={{
  464. assigned: {
  465. key: 'assigned',
  466. name: 'assigned',
  467. predefined: true,
  468. values: ['me', '[me, none]', '#team-a'],
  469. },
  470. }}
  471. />
  472. );
  473. const textbox = screen.getByRole('textbox');
  474. await userEvent.type(textbox, 'assigned:', {delay: null});
  475. await userEvent.click(await screen.findByRole('option', {name: /#team-a/}), {
  476. delay: null,
  477. });
  478. await waitFor(() => {
  479. expect(mockOnChange).toHaveBeenLastCalledWith(
  480. 'assigned:#team-a ',
  481. expect.anything()
  482. );
  483. });
  484. });
  485. it('autocompletes assigned from SearchGroup objects', async function () {
  486. const mockOnChange = jest.fn();
  487. render(
  488. <SmartSearchBar
  489. {...defaultProps}
  490. query=""
  491. onChange={mockOnChange}
  492. supportedTags={{
  493. assigned: {
  494. key: 'assigned',
  495. name: 'assigned',
  496. predefined: true,
  497. values: [
  498. {
  499. title: 'Suggested Values',
  500. type: 'header',
  501. icon: <Fragment />,
  502. children: [
  503. {
  504. value: 'me',
  505. desc: 'me',
  506. type: ItemType.TAG_VALUE,
  507. },
  508. ],
  509. },
  510. {
  511. title: 'All Values',
  512. type: 'header',
  513. icon: <Fragment />,
  514. children: [
  515. {
  516. value: '#team-a',
  517. desc: '#team-a',
  518. type: ItemType.TAG_VALUE,
  519. },
  520. ],
  521. },
  522. ],
  523. },
  524. }}
  525. />
  526. );
  527. const textbox = screen.getByRole('textbox');
  528. await userEvent.type(textbox, 'assigned:', {delay: null});
  529. expect(await screen.findByText('Suggested Values')).toBeInTheDocument();
  530. expect(screen.getByText('All Values')).toBeInTheDocument();
  531. // Filter down to "team"
  532. await userEvent.type(textbox, 'team', {delay: null});
  533. expect(screen.queryByText('Suggested Values')).not.toBeInTheDocument();
  534. await userEvent.click(screen.getByRole('option', {name: /#team-a/}), {delay: null});
  535. await waitFor(() => {
  536. expect(mockOnChange).toHaveBeenLastCalledWith(
  537. 'assigned:#team-a ',
  538. expect.anything()
  539. );
  540. });
  541. });
  542. it('autocompletes tag values (predefined values with spaces)', async function () {
  543. jest.useFakeTimers();
  544. const mockOnChange = jest.fn();
  545. render(
  546. <SmartSearchBar
  547. {...defaultProps}
  548. query=""
  549. onChange={mockOnChange}
  550. supportedTags={{
  551. predefined: {
  552. key: 'predefined',
  553. name: 'predefined',
  554. predefined: true,
  555. values: ['predefined tag with spaces'],
  556. },
  557. }}
  558. />
  559. );
  560. const textbox = screen.getByRole('textbox');
  561. await userEvent.type(textbox, 'predefined:', {delay: null});
  562. act(jest.runOnlyPendingTimers);
  563. const option = await screen.findByRole('option', {
  564. name: /predefined tag with spaces/,
  565. });
  566. await userEvent.click(option, {delay: null});
  567. await waitFor(() => {
  568. expect(mockOnChange).toHaveBeenLastCalledWith(
  569. 'predefined:"predefined tag with spaces" ',
  570. expect.anything()
  571. );
  572. });
  573. jest.useRealTimers();
  574. });
  575. it('autocompletes tag values (predefined values with quotes)', async function () {
  576. jest.useFakeTimers();
  577. const mockOnChange = jest.fn();
  578. render(
  579. <SmartSearchBar
  580. {...defaultProps}
  581. query=""
  582. onChange={mockOnChange}
  583. supportedTags={{
  584. predefined: {
  585. key: 'predefined',
  586. name: 'predefined',
  587. predefined: true,
  588. values: ['"predefined" "tag" "with" "quotes"'],
  589. },
  590. }}
  591. />
  592. );
  593. const textbox = screen.getByRole('textbox');
  594. await userEvent.type(textbox, 'predefined:', {delay: null});
  595. act(jest.runOnlyPendingTimers);
  596. const option = await screen.findByRole('option', {
  597. name: /quotes/,
  598. });
  599. await userEvent.click(option, {delay: null});
  600. await waitFor(() => {
  601. expect(mockOnChange).toHaveBeenLastCalledWith(
  602. 'predefined:"\\"predefined\\" \\"tag\\" \\"with\\" \\"quotes\\"" ',
  603. expect.anything()
  604. );
  605. });
  606. jest.useRealTimers();
  607. });
  608. describe('quick actions', function () {
  609. it('can delete tokens', async function () {
  610. render(
  611. <SmartSearchBar
  612. {...defaultProps}
  613. query="is:unresolved sdk.name:sentry-cocoa has:key"
  614. />
  615. );
  616. const textbox = screen.getByRole('textbox');
  617. // Put cursor inside is:resolved
  618. await userEvent.type(textbox, '{ArrowRight}', {
  619. initialSelectionStart: 0,
  620. initialSelectionEnd: 0,
  621. });
  622. await userEvent.click(screen.getByRole('button', {name: /Delete/}));
  623. expect(textbox).toHaveValue('sdk.name:sentry-cocoa has:key');
  624. });
  625. it('can delete a middle token', async function () {
  626. render(
  627. <SmartSearchBar
  628. {...defaultProps}
  629. query="is:unresolved sdk.name:sentry-cocoa has:key"
  630. />
  631. );
  632. const textbox = screen.getByRole('textbox');
  633. // Put cursor inside sdk.name
  634. await userEvent.type(textbox, '{ArrowRight}', {
  635. initialSelectionStart: 'is:unresolved '.length,
  636. initialSelectionEnd: 'is:unresolved '.length,
  637. });
  638. await userEvent.click(screen.getByRole('button', {name: /Delete/}));
  639. expect(textbox).toHaveValue('is:unresolved has:key');
  640. });
  641. it('can exclude a token', async function () {
  642. render(
  643. <SmartSearchBar
  644. {...defaultProps}
  645. query="is:unresolved sdk.name:sentry-cocoa has:key"
  646. />
  647. );
  648. const textbox = screen.getByRole('textbox');
  649. // Put cursor inside sdk.name
  650. await userEvent.type(textbox, '{ArrowRight}', {
  651. initialSelectionStart: 'is:unresolved '.length,
  652. initialSelectionEnd: 'is:unresolved '.length,
  653. });
  654. await userEvent.click(screen.getByRole('button', {name: /Exclude/}));
  655. expect(textbox).toHaveValue('is:unresolved !sdk.name:sentry-cocoa has:key ');
  656. });
  657. it('can include a token', async function () {
  658. render(
  659. <SmartSearchBar
  660. {...defaultProps}
  661. query="is:unresolved !sdk.name:sentry-cocoa has:key"
  662. />
  663. );
  664. const textbox = screen.getByRole('textbox');
  665. // Put cursor inside sdk.name
  666. await userEvent.type(textbox, '{ArrowRight}', {
  667. initialSelectionStart: 'is:unresolved !'.length,
  668. initialSelectionEnd: 'is:unresolved !'.length,
  669. });
  670. expect(textbox).toHaveValue('is:unresolved !sdk.name:sentry-cocoa has:key ');
  671. await screen.findByRole('button', {name: /Include/});
  672. await userEvent.click(screen.getByRole('button', {name: /Include/}));
  673. expect(textbox).toHaveValue('is:unresolved sdk.name:sentry-cocoa has:key ');
  674. });
  675. });
  676. it('displays invalid field message', async function () {
  677. render(<SmartSearchBar {...defaultProps} query="" />);
  678. const textbox = screen.getByRole('textbox');
  679. await userEvent.type(textbox, 'invalid:');
  680. expect(
  681. await screen.findByRole('option', {name: /the field invalid isn't supported here/i})
  682. ).toBeInTheDocument();
  683. });
  684. it('displays invalid field messages for when wildcard is disallowed', async function () {
  685. render(<SmartSearchBar {...defaultProps} query="" disallowWildcard />);
  686. const textbox = screen.getByRole('textbox');
  687. // Value
  688. await userEvent.type(textbox, 'release:*');
  689. expect(
  690. await screen.findByRole('option', {name: /Wildcards aren't supported here/i})
  691. ).toBeInTheDocument();
  692. await userEvent.clear(textbox);
  693. // FreeText
  694. await userEvent.type(textbox, 'rel*ease');
  695. expect(
  696. await screen.findByRole('option', {name: /Wildcards aren't supported here/i})
  697. ).toBeInTheDocument();
  698. });
  699. describe('date fields', () => {
  700. // Transpile the lazy-loaded datepicker up front so tests don't flake
  701. beforeAll(async function () {
  702. await import('sentry/components/calendar/datePicker');
  703. });
  704. it('displays date picker dropdown when appropriate', async () => {
  705. render(<SmartSearchBar {...defaultProps} query="" />);
  706. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  707. await userEvent.click(textbox);
  708. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  709. // Just lastSeen: will display relative and absolute options, not the datepicker
  710. await userEvent.type(textbox, 'lastSeen:');
  711. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  712. expect(screen.getByText('Last hour')).toBeInTheDocument();
  713. expect(screen.getByText('After a custom datetime')).toBeInTheDocument();
  714. // lastSeen:> should open the date picker
  715. await userEvent.type(textbox, '>');
  716. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  717. // Continues to display with date typed out
  718. await userEvent.type(textbox, '2022-01-01');
  719. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  720. // Goes away when on next term
  721. await userEvent.type(textbox, ' ');
  722. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  723. // Pops back up when cursor is back in date token
  724. await userEvent.keyboard('{arrowleft}');
  725. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  726. // Moving cursor inside the `lastSeen` token hides the date picker
  727. textbox.setSelectionRange(1, 1);
  728. await userEvent.click(textbox);
  729. expect(screen.queryByTestId('search-bar-date-picker')).not.toBeInTheDocument();
  730. });
  731. it('can select a suggested relative time value', async () => {
  732. render(<SmartSearchBar {...defaultProps} query="" />);
  733. await userEvent.type(screen.getByRole('textbox'), 'lastSeen:');
  734. await userEvent.click(screen.getByText('Last hour'));
  735. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:-1h ');
  736. });
  737. it('can select a specific date/time', async () => {
  738. render(<SmartSearchBar {...defaultProps} query="" />);
  739. await userEvent.type(screen.getByRole('textbox'), 'lastSeen:');
  740. await userEvent.click(screen.getByText('After a custom datetime'));
  741. // Should have added '>' to query and show a date picker
  742. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:>');
  743. expect(screen.getByTestId('search-bar-date-picker')).toBeInTheDocument();
  744. // Select a day on the calendar
  745. const dateInput = await screen.findByTestId('date-picker');
  746. fireEvent.change(dateInput, {target: {value: '2022-01-02'}});
  747. expect(screen.getByRole('textbox')).toHaveValue(
  748. // -05:00 because our tests run in EST
  749. 'lastSeen:>2022-01-02T00:00:00-05:00'
  750. );
  751. const timeInput = screen.getByLabelText('Time');
  752. // Simulate changing time input one bit at a time
  753. await userEvent.click(timeInput);
  754. fireEvent.change(timeInput, {target: {value: '01:00:00'}});
  755. fireEvent.change(timeInput, {target: {value: '01:02:00'}});
  756. fireEvent.change(timeInput, {target: {value: '01:02:03'}});
  757. // Time input should have retained focus this whole time
  758. expect(timeInput).toHaveFocus();
  759. fireEvent.blur(timeInput);
  760. expect(screen.getByRole('textbox')).toHaveValue(
  761. 'lastSeen:>2022-01-02T01:02:03-05:00'
  762. );
  763. // Toggle UTC on, which should remove the timezone (-05:00) from the query
  764. await userEvent.click(screen.getByLabelText('Use UTC'));
  765. expect(screen.getByRole('textbox')).toHaveValue('lastSeen:>2022-01-02T01:02:03');
  766. });
  767. it('can change an existing datetime', async () => {
  768. render(<SmartSearchBar {...defaultProps} query="" />);
  769. const textbox = screen.getByRole<HTMLTextAreaElement>('textbox');
  770. fireEvent.change(textbox, {
  771. target: {value: 'lastSeen:2022-01-02 firstSeen:2022-01-01'},
  772. });
  773. // Move cursor to the lastSeen date
  774. await userEvent.type(textbox, '{ArrowRight}', {
  775. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  776. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  777. });
  778. const dateInput = await screen.findByTestId('date-picker');
  779. expect(dateInput).toHaveValue('2022-01-02');
  780. expect(screen.getByLabelText('Time')).toHaveValue('00:00:00');
  781. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  782. fireEvent.change(dateInput, {target: {value: '2022-01-03'}});
  783. expect(textbox).toHaveValue('lastSeen:2022-01-03T00:00:00 firstSeen:2022-01-01');
  784. // Cursor should be at end of the value we just replaced
  785. expect(textbox.selectionStart).toBe('lastSeen:2022-01-03T00:00:00'.length);
  786. });
  787. it('populates the date picker correctly for date without time', async () => {
  788. render(<SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01" />);
  789. const textbox = screen.getByRole('textbox');
  790. // Move cursor to the timestamp
  791. await userEvent.type(textbox, '{ArrowRight}', {
  792. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  793. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  794. });
  795. const dateInput = await screen.findByTestId('date-picker');
  796. expect(dateInput).toHaveValue('2022-01-01');
  797. // No time provided, so time input should be the default value
  798. expect(screen.getByLabelText('Time')).toHaveValue('00:00:00');
  799. // UTC is checked because there is no timezone
  800. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  801. });
  802. it('populates the date picker correctly for date with time and no timezone', async () => {
  803. render(<SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01T09:45:12" />);
  804. const textbox = screen.getByRole('textbox');
  805. // Move cursor to the timestamp
  806. await userEvent.type(textbox, '{ArrowRight}', {
  807. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  808. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  809. });
  810. const dateInput = await screen.findByTestId('date-picker');
  811. expect(dateInput).toHaveValue('2022-01-01');
  812. expect(screen.getByLabelText('Time')).toHaveValue('09:45:12');
  813. expect(screen.getByLabelText('Use UTC')).toBeChecked();
  814. });
  815. it('populates the date picker correctly for date with time and timezone', async () => {
  816. render(
  817. <SmartSearchBar {...defaultProps} query="lastSeen:2022-01-01T09:45:12-05:00" />
  818. );
  819. const textbox = screen.getByRole('textbox');
  820. // Move cursor to the timestamp
  821. await userEvent.type(textbox, '{ArrowRight}', {
  822. initialSelectionStart: 'lastSeen:2022-01-0'.length,
  823. initialSelectionEnd: 'lastSeen:2022-01-0'.length,
  824. });
  825. const dateInput = await screen.findByTestId('date-picker');
  826. expect(dateInput).toHaveValue('2022-01-01');
  827. expect(screen.getByLabelText('Time')).toHaveValue('09:45:12');
  828. expect(screen.getByLabelText('Use UTC')).not.toBeChecked();
  829. });
  830. });
  831. describe('defaultSearchGroup', () => {
  832. const defaultSearchGroup = {
  833. title: 'default search group',
  834. type: 'header',
  835. // childrenWrapper allows us to arrange the children with custom styles
  836. childrenWrapper: props => (
  837. <div data-test-id="default-search-group-wrapper" {...props} />
  838. ),
  839. children: [
  840. {
  841. type: ItemType.RECOMMENDED,
  842. title: 'Assignee',
  843. value: 'assigned_or_suggested:',
  844. },
  845. ],
  846. };
  847. it('displays a default group with custom wrapper', async function () {
  848. const mockOnChange = jest.fn();
  849. render(
  850. <SmartSearchBar
  851. {...defaultProps}
  852. defaultSearchGroup={defaultSearchGroup}
  853. query=""
  854. onChange={mockOnChange}
  855. />
  856. );
  857. const textbox = screen.getByRole('textbox');
  858. await userEvent.click(textbox);
  859. expect(screen.getByTestId('default-search-group-wrapper')).toBeInTheDocument();
  860. expect(screen.getByText('default search group')).toBeInTheDocument();
  861. // Default group is correctly added to the dropdown
  862. await userEvent.keyboard('{ArrowDown}{Enter}');
  863. expect(mockOnChange).toHaveBeenCalledWith(
  864. 'assigned_or_suggested:',
  865. expect.anything()
  866. );
  867. });
  868. it('hides the default group after typing', async function () {
  869. render(
  870. <SmartSearchBar {...defaultProps} defaultSearchGroup={defaultSearchGroup} />
  871. );
  872. const textbox = screen.getByRole('textbox');
  873. await userEvent.click(textbox);
  874. expect(screen.getByTestId('default-search-group-wrapper')).toBeInTheDocument();
  875. await userEvent.type(textbox, 'f');
  876. expect(
  877. screen.queryByTestId('default-search-group-wrapper')
  878. ).not.toBeInTheDocument();
  879. });
  880. it('hides the default group after picking item with applyFilter', async function () {
  881. render(
  882. <SmartSearchBar
  883. {...defaultProps}
  884. defaultSearchGroup={{
  885. ...defaultSearchGroup,
  886. children: [
  887. {
  888. type: ItemType.RECOMMENDED,
  889. title: 'Custom Tags',
  890. // Filter is applied to all search items when picked
  891. applyFilter: item => item.title === 'device',
  892. },
  893. ],
  894. }}
  895. />
  896. );
  897. const textbox = screen.getByRole('textbox');
  898. await userEvent.click(textbox);
  899. expect(await screen.findByText('User identification value')).toBeInTheDocument();
  900. await userEvent.click(screen.getByText('Custom Tags'));
  901. expect(screen.queryByText('Custom Tags')).not.toBeInTheDocument();
  902. expect(screen.queryByText('User identification value')).not.toBeInTheDocument();
  903. expect(screen.getByText('device')).toBeInTheDocument();
  904. });
  905. });
  906. });