smartSearchBar.spec.jsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. import React from 'react';
  2. import {shallow, mountWithTheme} from 'sentry-test/enzyme';
  3. import {Client} from 'app/api';
  4. import {SmartSearchBar, addSpace, removeSpace} from 'app/components/smartSearchBar';
  5. import TagStore from 'app/stores/tagStore';
  6. describe('addSpace()', function() {
  7. it('should add a space when there is no trailing space', function() {
  8. expect(addSpace('one')).toEqual('one ');
  9. });
  10. it('should not add another space when there is already one', function() {
  11. expect(addSpace('one ')).toEqual('one ');
  12. });
  13. it('should leave the empty string alone', function() {
  14. expect(addSpace('')).toEqual('');
  15. });
  16. });
  17. describe('removeSpace()', function() {
  18. it('should remove a trailing space', function() {
  19. expect(removeSpace('one ')).toEqual('one');
  20. });
  21. it('should not remove the last character if it is not a space', function() {
  22. expect(removeSpace('one')).toEqual('one');
  23. });
  24. it('should leave the empty string alone', function() {
  25. expect(removeSpace('')).toEqual('');
  26. });
  27. });
  28. describe('SmartSearchBar', function() {
  29. let options, organization, supportedTags;
  30. let environmentTagValuesMock;
  31. const tagValuesMock = jest.fn(() => Promise.resolve([]));
  32. beforeEach(function() {
  33. TagStore.reset();
  34. TagStore.onLoadTagsSuccess(TestStubs.Tags());
  35. tagValuesMock.mockClear();
  36. supportedTags = TagStore.getAllTags();
  37. organization = TestStubs.Organization({id: '123'});
  38. const location = {
  39. pathname: '/organizations/org-slug/recent-searches/',
  40. query: {
  41. projectId: '0',
  42. },
  43. };
  44. options = TestStubs.routerContext([
  45. {
  46. organization,
  47. location,
  48. router: {location},
  49. },
  50. ]);
  51. MockApiClient.clearMockResponses();
  52. MockApiClient.addMockResponse({
  53. url: '/organizations/org-slug/recent-searches/',
  54. body: [],
  55. });
  56. environmentTagValuesMock = MockApiClient.addMockResponse({
  57. url: '/projects/123/456/tags/environment/values/',
  58. body: [],
  59. });
  60. });
  61. afterEach(function() {
  62. MockApiClient.clearMockResponses();
  63. });
  64. describe('componentWillReceiveProps()', function() {
  65. it('should add a space when setting state.query', function() {
  66. const searchBar = shallow(
  67. <SmartSearchBar
  68. organization={organization}
  69. supportedTags={supportedTags}
  70. query="one"
  71. />,
  72. options
  73. );
  74. expect(searchBar.state().query).toEqual('one ');
  75. });
  76. it('should update state.query if props.query is updated from outside', function() {
  77. const searchBar = shallow(
  78. <SmartSearchBar
  79. organization={organization}
  80. supportedTags={supportedTags}
  81. query="one"
  82. />,
  83. options
  84. );
  85. searchBar.setProps({query: 'two'});
  86. expect(searchBar.state().query).toEqual('two ');
  87. });
  88. it('should not reset user input if a noop props change happens', function() {
  89. const searchBar = shallow(
  90. <SmartSearchBar
  91. organization={organization}
  92. supportedTags={supportedTags}
  93. query="one"
  94. />,
  95. options
  96. );
  97. searchBar.setState({query: 'two'});
  98. searchBar.setProps({query: 'one'});
  99. expect(searchBar.state().query).toEqual('two');
  100. });
  101. it('should reset user input if a meaningful props change happens', function() {
  102. const searchBar = shallow(
  103. <SmartSearchBar
  104. organization={organization}
  105. supportedTags={supportedTags}
  106. query="one"
  107. />,
  108. options
  109. );
  110. searchBar.setState({query: 'two'});
  111. searchBar.setProps({query: 'three'});
  112. expect(searchBar.state().query).toEqual('three ');
  113. });
  114. });
  115. describe('getQueryTerms()', function() {
  116. it('should extract query terms from a query string', function() {
  117. let query = 'tagname: ';
  118. expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual(['tagname:']);
  119. query = 'tagname:derp browser:';
  120. expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual([
  121. 'tagname:derp',
  122. 'browser:',
  123. ]);
  124. query = ' browser:"Chrome 33.0" ';
  125. expect(SmartSearchBar.getQueryTerms(query, query.length)).toEqual([
  126. 'browser:"Chrome 33.0"',
  127. ]);
  128. });
  129. });
  130. describe('getLastTermIndex()', function() {
  131. it('should provide the index of the last query term, given cursor index', function() {
  132. let query = 'tagname:';
  133. expect(SmartSearchBar.getLastTermIndex(query, 0)).toEqual(8);
  134. query = 'tagname:foo'; // 'f' (index 9)
  135. expect(SmartSearchBar.getLastTermIndex(query, 9)).toEqual(11);
  136. query = 'tagname:foo anothertag:bar'; // 'f' (index 9)
  137. expect(SmartSearchBar.getLastTermIndex(query, 9)).toEqual(11);
  138. });
  139. });
  140. describe('clearSearch()', function() {
  141. it('clears the query', function() {
  142. const props = {
  143. organization,
  144. query: 'is:unresolved ruby',
  145. defaultQuery: 'is:unresolved',
  146. supportedTags,
  147. };
  148. const searchBar = shallow(<SmartSearchBar {...props} />, options).instance();
  149. searchBar.clearSearch();
  150. expect(searchBar.state.query).toEqual('');
  151. });
  152. it('calls onSearch()', async function() {
  153. const props = {
  154. organization,
  155. query: 'is:unresolved ruby',
  156. defaultQuery: 'is:unresolved',
  157. supportedTags,
  158. onSearch: jest.fn(),
  159. };
  160. const searchBar = shallow(<SmartSearchBar {...props} />, options).instance();
  161. await searchBar.clearSearch();
  162. expect(props.onSearch).toHaveBeenCalledWith('');
  163. });
  164. });
  165. describe('onQueryFocus()', function() {
  166. it('displays the drop down', function() {
  167. const searchBar = shallow(
  168. <SmartSearchBar
  169. organization={organization}
  170. supportedTags={supportedTags}
  171. onGetTagValues={tagValuesMock}
  172. />,
  173. options
  174. ).instance();
  175. expect(searchBar.state.dropdownVisible).toBe(false);
  176. searchBar.onQueryFocus();
  177. expect(searchBar.state.dropdownVisible).toBe(true);
  178. });
  179. it('displays dropdown in hasPinnedSearch mode', function() {
  180. const searchBar = shallow(
  181. <SmartSearchBar
  182. organization={organization}
  183. supportedTags={supportedTags}
  184. onGetTagValues={tagValuesMock}
  185. hasPinnedSearch
  186. />,
  187. options
  188. ).instance();
  189. expect(searchBar.state.dropdownVisible).toBe(false);
  190. searchBar.onQueryFocus();
  191. expect(searchBar.state.dropdownVisible).toBe(true);
  192. });
  193. });
  194. describe('onQueryBlur()', function() {
  195. it('hides the drop down', function() {
  196. const searchBar = shallow(
  197. <SmartSearchBar organization={organization} supportedTags={supportedTags} />,
  198. options
  199. ).instance();
  200. searchBar.state.dropdownVisible = true;
  201. jest.useFakeTimers();
  202. searchBar.onQueryBlur();
  203. jest.advanceTimersByTime(201); // doesn't close until 200ms
  204. expect(searchBar.state.dropdownVisible).toBe(false);
  205. });
  206. });
  207. describe('onKeyUp()', function() {
  208. describe('escape', function() {
  209. it('blurs the input', function() {
  210. const wrapper = mountWithTheme(
  211. <SmartSearchBar organization={organization} supportedTags={supportedTags} />,
  212. options
  213. );
  214. wrapper.setState({dropdownVisible: true});
  215. const instance = wrapper.instance();
  216. jest.spyOn(instance, 'blur');
  217. wrapper.find('input').simulate('keyup', {key: 'Escape'});
  218. expect(instance.blur).toHaveBeenCalledTimes(1);
  219. });
  220. });
  221. });
  222. describe('render()', function() {
  223. it('invokes onSearch() when submitting the form', function() {
  224. const stubbedOnSearch = jest.fn();
  225. const wrapper = mountWithTheme(
  226. <SmartSearchBar
  227. onSearch={stubbedOnSearch}
  228. organization={organization}
  229. query="is:unresolved"
  230. supportedTags={supportedTags}
  231. />,
  232. options
  233. );
  234. wrapper.find('form').simulate('submit', {
  235. preventDefault() {},
  236. });
  237. expect(stubbedOnSearch).toHaveBeenCalledWith('is:unresolved');
  238. });
  239. it('invokes onSearch() when search is cleared', async function() {
  240. jest.useRealTimers();
  241. const props = {
  242. organization,
  243. query: 'is:unresolved',
  244. supportedTags,
  245. onSearch: jest.fn(),
  246. };
  247. const wrapper = mountWithTheme(<SmartSearchBar {...props} />, options);
  248. wrapper.find('button[aria-label="Clear search"]').simulate('click');
  249. await tick();
  250. expect(props.onSearch).toHaveBeenCalledWith('');
  251. });
  252. it('invokes onSearch() on submit in hasPinnedSearch mode', function() {
  253. const stubbedOnSearch = jest.fn();
  254. const wrapper = mountWithTheme(
  255. <SmartSearchBar
  256. onSearch={stubbedOnSearch}
  257. organization={organization}
  258. query="is:unresolved"
  259. supportedTags={supportedTags}
  260. hasPinnedSearch
  261. />,
  262. options
  263. );
  264. wrapper.find('form').simulate('submit');
  265. expect(stubbedOnSearch).toHaveBeenCalledWith('is:unresolved');
  266. });
  267. });
  268. it('handles an empty query', function() {
  269. const props = {
  270. query: '',
  271. defaultQuery: 'is:unresolved',
  272. organization,
  273. supportedTags,
  274. };
  275. const wrapper = mountWithTheme(<SmartSearchBar {...props} />, options);
  276. expect(wrapper.state('query')).toEqual('');
  277. });
  278. describe('updateAutoCompleteItems()', function() {
  279. beforeEach(function() {
  280. jest.useFakeTimers();
  281. });
  282. it('sets state when empty', function() {
  283. const props = {
  284. query: '',
  285. organization,
  286. supportedTags,
  287. };
  288. const searchBar = mountWithTheme(<SmartSearchBar {...props} />, options).instance();
  289. searchBar.updateAutoCompleteItems();
  290. expect(searchBar.state.searchTerm).toEqual('');
  291. expect(searchBar.state.searchItems).toEqual([]);
  292. expect(searchBar.state.activeSearchItem).toEqual(-1);
  293. });
  294. it('sets state when incomplete tag', async function() {
  295. const props = {
  296. query: 'fu',
  297. organization,
  298. supportedTags,
  299. };
  300. jest.useRealTimers();
  301. const wrapper = mountWithTheme(<SmartSearchBar {...props} />, options);
  302. const searchBar = wrapper.instance();
  303. searchBar.updateAutoCompleteItems();
  304. await tick();
  305. wrapper.update();
  306. expect(searchBar.state.searchTerm).toEqual('fu');
  307. expect(searchBar.state.searchItems).toEqual([
  308. expect.objectContaining({children: []}),
  309. ]);
  310. expect(searchBar.state.activeSearchItem).toEqual(-1);
  311. });
  312. it('sets state when incomplete tag has negation operator', async function() {
  313. const props = {
  314. query: '!fu',
  315. organization,
  316. supportedTags,
  317. };
  318. jest.useRealTimers();
  319. const wrapper = mountWithTheme(<SmartSearchBar {...props} />, options);
  320. const searchBar = wrapper.instance();
  321. searchBar.updateAutoCompleteItems();
  322. await tick();
  323. wrapper.update();
  324. expect(searchBar.state.searchTerm).toEqual('fu');
  325. expect(searchBar.state.searchItems).toEqual([
  326. expect.objectContaining({children: []}),
  327. ]);
  328. expect(searchBar.state.activeSearchItem).toEqual(-1);
  329. });
  330. it('sets state when incomplete tag as second input', async function() {
  331. const props = {
  332. query: 'is:unresolved fu',
  333. organization,
  334. supportedTags,
  335. };
  336. jest.useRealTimers();
  337. const wrapper = mountWithTheme(<SmartSearchBar {...props} />, options);
  338. const searchBar = wrapper.instance();
  339. searchBar.getCursorPosition = jest.fn();
  340. searchBar.getCursorPosition.mockReturnValue(15); // end of line
  341. searchBar.updateAutoCompleteItems();
  342. await tick();
  343. wrapper.update();
  344. expect(searchBar.state.searchTerm).toEqual('fu');
  345. // 1 items because of headers ("Tags")
  346. expect(searchBar.state.searchItems).toHaveLength(1);
  347. expect(searchBar.state.activeSearchItem).toEqual(-1);
  348. });
  349. it('does not request values when tag is environments', function() {
  350. const props = {
  351. query: 'environment:production',
  352. excludeEnvironment: true,
  353. organization,
  354. supportedTags,
  355. };
  356. const searchBar = mountWithTheme(<SmartSearchBar {...props} />, options).instance();
  357. searchBar.updateAutoCompleteItems();
  358. jest.advanceTimersByTime(301);
  359. expect(environmentTagValuesMock).not.toHaveBeenCalled();
  360. });
  361. it('does not request values when tag is `timesSeen`', function() {
  362. // This should never get called
  363. const mock = MockApiClient.addMockResponse({
  364. url: '/projects/123/456/tags/timesSeen/values/',
  365. body: [],
  366. });
  367. const props = {
  368. query: 'timesSeen:',
  369. organization,
  370. supportedTags,
  371. };
  372. const searchBar = mountWithTheme(
  373. <SmartSearchBar {...props} api={new Client()} />,
  374. options
  375. ).instance();
  376. searchBar.updateAutoCompleteItems();
  377. jest.advanceTimersByTime(301);
  378. expect(mock).not.toHaveBeenCalled();
  379. });
  380. it('requests values when tag is `firstRelease`', function() {
  381. const mock = MockApiClient.addMockResponse({
  382. url: '/organizations/org-slug/releases/',
  383. body: [],
  384. });
  385. const props = {
  386. orgId: 'org-slug',
  387. projectId: '0',
  388. query: 'firstRelease:',
  389. organization,
  390. supportedTags,
  391. };
  392. const searchBar = mountWithTheme(
  393. <SmartSearchBar {...props} api={new Client()} />,
  394. options
  395. ).instance();
  396. searchBar.updateAutoCompleteItems();
  397. jest.advanceTimersByTime(301);
  398. expect(mock).toHaveBeenCalledWith(
  399. '/organizations/org-slug/releases/',
  400. expect.objectContaining({
  401. method: 'GET',
  402. query: {
  403. project: '0',
  404. per_page: 5, // Limit results to 5 for autocomplete
  405. },
  406. })
  407. );
  408. });
  409. });
  410. describe('onTogglePinnedSearch', function() {
  411. let pinRequest, unpinRequest;
  412. beforeEach(function() {
  413. pinRequest = MockApiClient.addMockResponse({
  414. url: '/organizations/org-slug/pinned-searches/',
  415. method: 'PUT',
  416. body: [],
  417. });
  418. unpinRequest = MockApiClient.addMockResponse({
  419. url: '/organizations/org-slug/pinned-searches/',
  420. method: 'DELETE',
  421. body: [],
  422. });
  423. MockApiClient.addMockResponse({
  424. url: '/organizations/org-slug/recent-searches/',
  425. method: 'POST',
  426. body: {},
  427. });
  428. });
  429. it('does not pin when query is empty', async function() {
  430. const wrapper = mountWithTheme(
  431. <SmartSearchBar
  432. api={new Client()}
  433. organization={organization}
  434. query=""
  435. supportedTags={supportedTags}
  436. savedSearchType={0}
  437. hasPinnedSearch
  438. />,
  439. options
  440. );
  441. wrapper.find('button[aria-label="Pin this search"]').simulate('click');
  442. await wrapper.update();
  443. expect(pinRequest).not.toHaveBeenCalled();
  444. });
  445. it('adds pins', async function() {
  446. const wrapper = mountWithTheme(
  447. <SmartSearchBar
  448. api={new Client()}
  449. organization={organization}
  450. query="is:unresolved"
  451. supportedTags={supportedTags}
  452. savedSearchType={0}
  453. hasPinnedSearch
  454. />,
  455. options
  456. );
  457. wrapper.find('button[aria-label="Pin this search"]').simulate('click');
  458. await wrapper.update();
  459. expect(pinRequest).toHaveBeenCalled();
  460. expect(unpinRequest).not.toHaveBeenCalled();
  461. });
  462. it('removes pins', async function() {
  463. const pinnedSearch = TestStubs.Search({isPinned: true});
  464. const wrapper = mountWithTheme(
  465. <SmartSearchBar
  466. api={new Client()}
  467. organization={organization}
  468. query="is:unresolved"
  469. supportedTags={supportedTags}
  470. savedSearchType={0}
  471. pinnedSearch={pinnedSearch}
  472. hasPinnedSearch
  473. />,
  474. options
  475. );
  476. wrapper.find('button[aria-label="Unpin this search"]').simulate('click');
  477. await wrapper.update();
  478. expect(pinRequest).not.toHaveBeenCalled();
  479. expect(unpinRequest).toHaveBeenCalled();
  480. });
  481. });
  482. });