index.spec.tsx 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154
  1. import type {ComponentProps} from 'react';
  2. import {destroyAnnouncer} from '@react-aria/live-announcer';
  3. import {
  4. render,
  5. screen,
  6. userEvent,
  7. waitFor,
  8. within,
  9. } from 'sentry-test/reactTestingLibrary';
  10. import {
  11. SearchQueryBuilder,
  12. type SearchQueryBuilderProps,
  13. } from 'sentry/components/searchQueryBuilder';
  14. import {
  15. type FieldDefinitionGetter,
  16. type FilterKeySection,
  17. QueryInterfaceType,
  18. } from 'sentry/components/searchQueryBuilder/types';
  19. import {INTERFACE_TYPE_LOCALSTORAGE_KEY} from 'sentry/components/searchQueryBuilder/utils';
  20. import {InvalidReason} from 'sentry/components/searchSyntax/parser';
  21. import type {TagCollection} from 'sentry/types/group';
  22. import {FieldKey, FieldKind, FieldValueType} from 'sentry/utils/fields';
  23. import localStorageWrapper from 'sentry/utils/localStorage';
  24. const FILTER_KEYS: TagCollection = {
  25. [FieldKey.AGE]: {key: FieldKey.AGE, name: 'Age', kind: FieldKind.FIELD},
  26. [FieldKey.ASSIGNED]: {
  27. key: FieldKey.ASSIGNED,
  28. name: 'Assigned To',
  29. kind: FieldKind.FIELD,
  30. predefined: true,
  31. values: [
  32. {
  33. title: 'Suggested',
  34. type: 'header',
  35. icon: null,
  36. children: [{value: 'me'}, {value: 'unassigned'}],
  37. },
  38. {
  39. title: 'All',
  40. type: 'header',
  41. icon: null,
  42. children: [{value: 'person1@sentry.io'}, {value: 'person2@sentry.io'}],
  43. },
  44. ],
  45. },
  46. [FieldKey.BROWSER_NAME]: {
  47. key: FieldKey.BROWSER_NAME,
  48. name: 'Browser Name',
  49. kind: FieldKind.FIELD,
  50. predefined: true,
  51. values: ['Chrome', 'Firefox', 'Safari', 'Edge'],
  52. },
  53. [FieldKey.IS]: {
  54. key: FieldKey.IS,
  55. name: 'is',
  56. predefined: true,
  57. values: ['resolved', 'unresolved', 'ignored'],
  58. },
  59. [FieldKey.TIMES_SEEN]: {
  60. key: FieldKey.TIMES_SEEN,
  61. name: 'timesSeen',
  62. kind: FieldKind.FIELD,
  63. },
  64. custom_tag_name: {
  65. key: 'custom_tag_name',
  66. name: 'Custom_Tag_Name',
  67. },
  68. };
  69. const FITLER_KEY_SECTIONS: FilterKeySection[] = [
  70. {
  71. value: FieldKind.FIELD,
  72. label: 'Category 1',
  73. children: [
  74. FieldKey.AGE,
  75. FieldKey.ASSIGNED,
  76. FieldKey.BROWSER_NAME,
  77. FieldKey.IS,
  78. FieldKey.TIMES_SEEN,
  79. ],
  80. },
  81. {
  82. value: FieldKind.TAG,
  83. label: 'Category 2',
  84. children: ['custom_tag_name'],
  85. },
  86. ];
  87. function getLastInput() {
  88. const input = screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1);
  89. expect(input).toBeInTheDocument();
  90. return input!;
  91. }
  92. describe('SearchQueryBuilder', function () {
  93. beforeEach(() => {
  94. // `useDimensions` is used to hide things when the component is too small, so we need to mock a large width
  95. Object.defineProperty(Element.prototype, 'clientWidth', {value: 1000});
  96. // Combobox announcements will pollute the test output if we don't clear them
  97. destroyAnnouncer();
  98. });
  99. afterEach(function () {
  100. jest.restoreAllMocks();
  101. });
  102. const defaultProps: ComponentProps<typeof SearchQueryBuilder> = {
  103. getTagValues: jest.fn(),
  104. initialQuery: '',
  105. filterKeySections: FITLER_KEY_SECTIONS,
  106. filterKeys: FILTER_KEYS,
  107. label: 'Query Builder',
  108. searchSource: '',
  109. };
  110. it('displays a placeholder when empty', async function () {
  111. render(<SearchQueryBuilder {...defaultProps} placeholder="foo" />);
  112. expect(await screen.findByPlaceholderText('foo')).toBeInTheDocument();
  113. });
  114. describe('callbacks', function () {
  115. it('calls onChange, onBlur, and onSearch with the query string', async function () {
  116. const mockOnChange = jest.fn();
  117. const mockOnBlur = jest.fn();
  118. const mockOnSearch = jest.fn();
  119. render(
  120. <SearchQueryBuilder
  121. {...defaultProps}
  122. initialQuery="a"
  123. onChange={mockOnChange}
  124. onBlur={mockOnBlur}
  125. onSearch={mockOnSearch}
  126. />
  127. );
  128. await userEvent.click(getLastInput());
  129. await userEvent.keyboard('b{enter}');
  130. const expectedQueryState = expect.objectContaining({
  131. parsedQuery: expect.arrayContaining([expect.any(Object)]),
  132. queryIsValid: true,
  133. });
  134. // Should call onChange and onSearch after enter
  135. await waitFor(() => {
  136. expect(mockOnChange).toHaveBeenCalledTimes(1);
  137. expect(mockOnChange).toHaveBeenCalledWith('ab', expectedQueryState);
  138. expect(mockOnSearch).toHaveBeenCalledTimes(1);
  139. expect(mockOnSearch).toHaveBeenCalledWith('ab', expectedQueryState);
  140. });
  141. await userEvent.click(document.body);
  142. // Clicking outside activates onBlur
  143. await waitFor(() => {
  144. expect(mockOnBlur).toHaveBeenCalledTimes(1);
  145. expect(mockOnBlur).toHaveBeenCalledWith('ab', expectedQueryState);
  146. });
  147. });
  148. });
  149. describe('actions', function () {
  150. it('can clear the query', async function () {
  151. const mockOnChange = jest.fn();
  152. const mockOnSearch = jest.fn();
  153. render(
  154. <SearchQueryBuilder
  155. {...defaultProps}
  156. initialQuery="browser.name:firefox"
  157. onChange={mockOnChange}
  158. onSearch={mockOnSearch}
  159. />
  160. );
  161. userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
  162. await waitFor(() => {
  163. expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
  164. expect(mockOnSearch).toHaveBeenCalledWith('', expect.anything());
  165. });
  166. expect(
  167. screen.queryByRole('row', {name: 'browser.name:firefox'})
  168. ).not.toBeInTheDocument();
  169. expect(screen.getByRole('combobox')).toHaveFocus();
  170. });
  171. it('is hidden at small sizes', function () {
  172. Object.defineProperty(Element.prototype, 'clientWidth', {value: 100});
  173. const mockOnChange = jest.fn();
  174. render(
  175. <SearchQueryBuilder
  176. {...defaultProps}
  177. initialQuery="browser.name:firefox"
  178. onChange={mockOnChange}
  179. />
  180. );
  181. expect(
  182. screen.queryByRole('button', {name: 'Clear search query'})
  183. ).not.toBeInTheDocument();
  184. });
  185. });
  186. describe('disabled', function () {
  187. it('disables all interactable elements', function () {
  188. const mockOnChange = jest.fn();
  189. render(
  190. <SearchQueryBuilder
  191. {...defaultProps}
  192. initialQuery="browser.name:firefox"
  193. onChange={mockOnChange}
  194. disabled
  195. />
  196. );
  197. expect(getLastInput()).toBeDisabled();
  198. expect(
  199. screen.queryByRole('button', {name: 'Clear search query'})
  200. ).not.toBeInTheDocument();
  201. expect(
  202. screen.getByRole('button', {name: 'Remove filter: browser.name'})
  203. ).toBeDisabled();
  204. expect(
  205. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  206. ).toBeDisabled();
  207. expect(
  208. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  209. ).toBeDisabled();
  210. });
  211. });
  212. describe('plain text interface', function () {
  213. beforeEach(() => {
  214. localStorageWrapper.setItem(
  215. INTERFACE_TYPE_LOCALSTORAGE_KEY,
  216. JSON.stringify(QueryInterfaceType.TEXT)
  217. );
  218. });
  219. it('can change the query by typing', async function () {
  220. const mockOnChange = jest.fn();
  221. render(
  222. <SearchQueryBuilder
  223. {...defaultProps}
  224. initialQuery="browser.name:firefox"
  225. onChange={mockOnChange}
  226. queryInterface={QueryInterfaceType.TEXT}
  227. />
  228. );
  229. expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox');
  230. await userEvent.type(screen.getByRole('textbox'), ' assigned:me');
  231. expect(screen.getByRole('textbox')).toHaveValue('browser.name:firefox assigned:me');
  232. await waitFor(() => {
  233. expect(mockOnChange).toHaveBeenLastCalledWith(
  234. 'browser.name:firefox assigned:me',
  235. expect.anything()
  236. );
  237. });
  238. });
  239. });
  240. describe('mouse interactions', function () {
  241. it('can remove a token by clicking the delete button', async function () {
  242. render(
  243. <SearchQueryBuilder
  244. {...defaultProps}
  245. initialQuery="browser.name:firefox custom_tag_name:123"
  246. />
  247. );
  248. expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
  249. expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
  250. await userEvent.click(
  251. within(screen.getByRole('row', {name: 'browser.name:firefox'})).getByRole(
  252. 'button',
  253. {name: 'Remove filter: browser.name'}
  254. )
  255. );
  256. // Browser name token should be removed
  257. expect(
  258. screen.queryByRole('row', {name: 'browser.name:firefox'})
  259. ).not.toBeInTheDocument();
  260. // Custom tag token should still be present
  261. expect(screen.getByRole('row', {name: 'custom_tag_name:123'})).toBeInTheDocument();
  262. });
  263. it('can modify the operator by clicking into it', async function () {
  264. render(
  265. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  266. );
  267. // Should display as "is" to start
  268. expect(
  269. within(
  270. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  271. ).getByText('is')
  272. ).toBeInTheDocument();
  273. await userEvent.click(
  274. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  275. );
  276. await userEvent.click(screen.getByRole('option', {name: 'browser.name is not'}));
  277. // Token should be modified to be negated
  278. expect(
  279. screen.getByRole('row', {name: '!browser.name:firefox'})
  280. ).toBeInTheDocument();
  281. // Should now have "is not" label
  282. expect(
  283. within(
  284. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  285. ).getByText('is not')
  286. ).toBeInTheDocument();
  287. });
  288. it('escapes values with spaces and reserved characters', async function () {
  289. render(<SearchQueryBuilder {...defaultProps} initialQuery="" />);
  290. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  291. await userEvent.type(
  292. screen.getByRole('combobox', {name: 'Add a search term'}),
  293. 'assigned:some" value{enter}'
  294. );
  295. // Value should be surrounded by quotes and escaped
  296. expect(
  297. screen.getByRole('row', {name: 'assigned:"some\\" value"'})
  298. ).toBeInTheDocument();
  299. // Display text should be display the original value
  300. expect(
  301. within(
  302. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  303. ).getByText('some" value')
  304. ).toBeInTheDocument();
  305. });
  306. it('can remove parens by clicking the delete button', async function () {
  307. render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
  308. expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
  309. await userEvent.click(screen.getByRole('gridcell', {name: 'Delete ('}));
  310. expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
  311. });
  312. it('can remove boolean ops by clicking the delete button', async function () {
  313. render(<SearchQueryBuilder {...defaultProps} initialQuery="OR" />);
  314. expect(screen.getByRole('row', {name: 'OR'})).toBeInTheDocument();
  315. await userEvent.click(screen.getByRole('gridcell', {name: 'Delete OR'}));
  316. expect(screen.queryByRole('row', {name: 'OR'})).not.toBeInTheDocument();
  317. });
  318. it('can click and drag to select tokens', async function () {
  319. render(<SearchQueryBuilder {...defaultProps} initialQuery="is:unresolved" />);
  320. const grid = screen.getByRole('grid');
  321. const tokens = screen.getAllByRole('row');
  322. const freeText1 = tokens[0];
  323. const filter = tokens[1];
  324. const freeText2 = tokens[2];
  325. // jsdom does not support getBoundingClientRect, so we need to mock it for each item
  326. // First freeText area is 5px wide
  327. freeText1.getBoundingClientRect = () => {
  328. return {
  329. top: 0,
  330. left: 10,
  331. bottom: 10,
  332. right: 15,
  333. width: 5,
  334. height: 10,
  335. } as DOMRect;
  336. };
  337. // "is:unresolved" filter is 100px wide
  338. filter.getBoundingClientRect = () => {
  339. return {
  340. top: 0,
  341. left: 15,
  342. bottom: 10,
  343. right: 115,
  344. width: 100,
  345. height: 10,
  346. } as DOMRect;
  347. };
  348. // Last freeText area is 200px wide
  349. freeText2.getBoundingClientRect = () => {
  350. return {
  351. top: 0,
  352. left: 115,
  353. bottom: 10,
  354. right: 315,
  355. width: 200,
  356. height: 10,
  357. } as DOMRect;
  358. };
  359. // Note that jsdom does not do layout, so all coordinates are 0, 0
  360. await userEvent.pointer([
  361. // Start with 0, 5 so that we are on the first token
  362. {keys: '[MouseLeft>]', target: grid, coords: {x: 0, y: 5}},
  363. // Move to 50, 5 (within filter token)
  364. {target: grid, coords: {x: 50, y: 5}},
  365. ]);
  366. // all should be selected except the last free text
  367. await waitFor(() => {
  368. expect(freeText1).toHaveAttribute('aria-selected', 'true');
  369. });
  370. expect(filter).toHaveAttribute('aria-selected', 'true');
  371. expect(freeText2).toHaveAttribute('aria-selected', 'false');
  372. // Now move pointer to the end and below to select everything
  373. await userEvent.pointer([{target: grid, coords: {x: 400, y: 50}}]);
  374. // All tokens should be selected
  375. await waitFor(() => {
  376. expect(freeText2).toHaveAttribute('aria-selected', 'true');
  377. });
  378. expect(freeText1).toHaveAttribute('aria-selected', 'true');
  379. expect(filter).toHaveAttribute('aria-selected', 'true');
  380. // Now move pointer back to original position
  381. await userEvent.pointer([
  382. // Move to 100, 1 to select all tokens (which are at 0, 0)
  383. {target: grid, coords: {x: 0, y: 5}},
  384. // Release mouse button to finish selection
  385. {keys: '[/MouseLeft]', target: getLastInput()},
  386. ]);
  387. // All tokens should be deselected
  388. await waitFor(() => {
  389. expect(freeText1).toHaveAttribute('aria-selected', 'false');
  390. });
  391. expect(filter).toHaveAttribute('aria-selected', 'false');
  392. expect(freeText2).toHaveAttribute('aria-selected', 'false');
  393. });
  394. });
  395. describe('new search tokens', function () {
  396. it('can add an unsupported filter key and value', async function () {
  397. render(<SearchQueryBuilder {...defaultProps} />);
  398. await userEvent.click(getLastInput());
  399. // Typing "foo", then " a:b" should add the "foo" text followed by a new token "a:b"
  400. await userEvent.type(
  401. screen.getByRole('combobox', {name: 'Add a search term'}),
  402. 'foo a:b{enter}'
  403. );
  404. expect(screen.getByRole('row', {name: 'foo'})).toBeInTheDocument();
  405. expect(screen.getByRole('row', {name: 'a:b'})).toBeInTheDocument();
  406. });
  407. it('adds default value for filter when typing <filter>:', async function () {
  408. render(<SearchQueryBuilder {...defaultProps} />);
  409. await userEvent.click(getLastInput());
  410. // Typing `is:` and escaping should result in `is:unresolved`
  411. await userEvent.type(
  412. screen.getByRole('combobox', {name: 'Add a search term'}),
  413. 'is:{escape}'
  414. );
  415. expect(await screen.findByRole('row', {name: 'is:unresolved'})).toBeInTheDocument();
  416. });
  417. it('does not automatically create a filter if the user intends to wrap in quotes', async function () {
  418. render(<SearchQueryBuilder {...defaultProps} />);
  419. await userEvent.click(getLastInput());
  420. // Starting with an opening quote and typing out Error: should stay as raw text
  421. await userEvent.type(
  422. screen.getByRole('combobox', {name: 'Add a search term'}),
  423. '"Error: foo"'
  424. );
  425. await waitFor(() => {
  426. expect(getLastInput()).toHaveValue('"Error: foo"');
  427. });
  428. });
  429. it('breaks keys into sections', async function () {
  430. render(<SearchQueryBuilder {...defaultProps} />);
  431. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  432. const menu = screen.getByRole('listbox');
  433. const groups = within(menu).getAllByRole('group');
  434. expect(groups).toHaveLength(2);
  435. // First group (Field) should have age, assigned, browser.name
  436. const group1 = groups[0];
  437. expect(within(group1).getByRole('option', {name: 'age'})).toBeInTheDocument();
  438. expect(within(group1).getByRole('option', {name: 'assigned'})).toBeInTheDocument();
  439. expect(
  440. within(group1).getByRole('option', {name: 'browser.name'})
  441. ).toBeInTheDocument();
  442. // Second group (Tag) should have custom_tag_name
  443. const group2 = groups[1];
  444. expect(
  445. within(group2).getByRole('option', {name: 'custom_tag_name'})
  446. ).toBeInTheDocument();
  447. });
  448. it('can search by key description', async function () {
  449. render(<SearchQueryBuilder {...defaultProps} />);
  450. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  451. await userEvent.keyboard('assignee');
  452. // "assignee" is in the description of "assigned"
  453. expect(await screen.findByRole('option', {name: 'assigned'})).toBeInTheDocument();
  454. });
  455. it('can add a new token by clicking a key suggestion', async function () {
  456. render(<SearchQueryBuilder {...defaultProps} />);
  457. await userEvent.click(screen.getByRole('combobox', {name: 'Add a search term'}));
  458. await userEvent.click(screen.getByRole('option', {name: 'browser.name'}));
  459. // New token should be added with the correct key and default value
  460. expect(screen.getByRole('row', {name: 'browser.name:""'})).toBeInTheDocument();
  461. await userEvent.click(screen.getByRole('option', {name: 'Firefox'}));
  462. // New token should have a value
  463. expect(screen.getByRole('row', {name: 'browser.name:Firefox'})).toBeInTheDocument();
  464. });
  465. it('can add free text by typing', async function () {
  466. const mockOnSearch = jest.fn();
  467. render(<SearchQueryBuilder {...defaultProps} onSearch={mockOnSearch} />);
  468. await userEvent.click(getLastInput());
  469. await userEvent.type(screen.getByRole('combobox'), 'some free text{enter}');
  470. await waitFor(() => {
  471. expect(mockOnSearch).toHaveBeenCalledWith('some free text', expect.anything());
  472. });
  473. // Should still have text in the input
  474. expect(screen.getByRole('combobox')).toHaveValue('some free text');
  475. // Should have closed the menu
  476. expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false');
  477. });
  478. it('can add a filter after some free text', async function () {
  479. render(<SearchQueryBuilder {...defaultProps} />);
  480. await userEvent.click(getLastInput());
  481. // XXX(malwilley): SearchQueryBuilderInput updates state in the render
  482. // function which causes an act warning despite using userEvent.click.
  483. // Cannot find a way to avoid this warning.
  484. jest.spyOn(console, 'error').mockImplementation(jest.fn());
  485. await userEvent.type(
  486. screen.getByRole('combobox'),
  487. 'some free text brow{ArrowDown}{Enter}'
  488. );
  489. jest.restoreAllMocks();
  490. // Filter value should have focus
  491. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
  492. await userEvent.keyboard('foo{enter}');
  493. // Should have a free text token "some free text"
  494. expect(
  495. await screen.findByRole('row', {name: /some free text/})
  496. ).toBeInTheDocument();
  497. // Should have a filter token "browser.name:foo"
  498. expect(screen.getByRole('row', {name: 'browser.name:foo'})).toBeInTheDocument();
  499. });
  500. it('can add parens by typing', async function () {
  501. render(<SearchQueryBuilder {...defaultProps} />);
  502. await userEvent.click(getLastInput());
  503. await userEvent.keyboard('(');
  504. expect(await screen.findByRole('row', {name: '('})).toBeInTheDocument();
  505. expect(getLastInput()).toHaveFocus();
  506. });
  507. });
  508. describe('keyboard interactions', function () {
  509. beforeEach(() => {
  510. // jsdom does not support clipboard API
  511. Object.assign(navigator, {
  512. clipboard: {
  513. writeText: jest.fn().mockResolvedValue(''),
  514. },
  515. });
  516. });
  517. it('can remove a previous token by pressing backspace', async function () {
  518. render(
  519. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  520. );
  521. // Focus into search (cursor be at end of the query)
  522. await userEvent.click(getLastInput());
  523. // Pressing backspace once should focus the previous token
  524. await userEvent.keyboard('{backspace}');
  525. expect(screen.queryByRole('row', {name: 'browser.name:firefox'})).toHaveFocus();
  526. // Pressing backspace again should remove the token
  527. await userEvent.keyboard('{backspace}');
  528. expect(
  529. screen.queryByRole('row', {name: 'browser.name:firefox'})
  530. ).not.toBeInTheDocument();
  531. });
  532. it('can remove a subsequent token by pressing delete', async function () {
  533. render(
  534. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  535. );
  536. // Put focus into the first input (before the token)
  537. await userEvent.click(
  538. screen.getAllByRole('combobox', {name: 'Add a search term'})[0]
  539. );
  540. // Pressing delete once should focus the previous token
  541. await userEvent.keyboard('{delete}');
  542. expect(screen.queryByRole('row', {name: 'browser.name:firefox'})).toHaveFocus();
  543. // Pressing delete again should remove the token
  544. await userEvent.keyboard('{delete}');
  545. expect(
  546. screen.queryByRole('row', {name: 'browser.name:firefox'})
  547. ).not.toBeInTheDocument();
  548. });
  549. it('can navigate between tokens with arrow keys', async function () {
  550. render(
  551. <SearchQueryBuilder
  552. {...defaultProps}
  553. initialQuery="browser.name:firefox abc assigned:me"
  554. />
  555. );
  556. await userEvent.click(getLastInput());
  557. // Focus should be in the last text input
  558. expect(
  559. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  560. ).toHaveFocus();
  561. // Left once focuses the assigned remove button
  562. await userEvent.keyboard('{arrowleft}');
  563. expect(screen.getByRole('button', {name: 'Remove filter: assigned'})).toHaveFocus();
  564. // Left again focuses the assigned filter value
  565. await userEvent.keyboard('{arrowleft}');
  566. expect(
  567. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  568. ).toHaveFocus();
  569. // Left again focuses the assigned operator
  570. await userEvent.keyboard('{arrowleft}');
  571. expect(
  572. screen.getByRole('button', {name: 'Edit operator for filter: assigned'})
  573. ).toHaveFocus();
  574. // Left again goes to the next text input between tokens
  575. await userEvent.keyboard('{arrowleft}');
  576. expect(
  577. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-2)
  578. ).toHaveFocus();
  579. // 4 more lefts go through the input text "abc" and to the next token
  580. await userEvent.keyboard('{arrowleft}{arrowleft}{arrowleft}{arrowleft}');
  581. expect(
  582. screen.getByRole('button', {name: 'Remove filter: browser.name'})
  583. ).toHaveFocus();
  584. // 1 right goes back to the text input
  585. await userEvent.keyboard('{arrowright}');
  586. expect(
  587. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-2)
  588. ).toHaveFocus();
  589. });
  590. it('when focus is in a filter segment, backspace first focuses the filter then deletes it', async function () {
  591. render(
  592. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  593. );
  594. // Focus into search (cursor be at end of the query)
  595. screen
  596. .getByRole('button', {name: 'Edit operator for filter: browser.name'})
  597. .focus();
  598. // Pressing backspace once should focus the token
  599. await userEvent.keyboard('{backspace}');
  600. expect(screen.queryByRole('row', {name: 'browser.name:firefox'})).toHaveFocus();
  601. // Pressing backspace again should remove the token
  602. await userEvent.keyboard('{backspace}');
  603. expect(
  604. screen.queryByRole('row', {name: 'browser.name:firefox'})
  605. ).not.toBeInTheDocument();
  606. });
  607. it('has a single tab stop', async function () {
  608. render(
  609. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  610. );
  611. expect(document.body).toHaveFocus();
  612. // Tabbing in should focus the last input
  613. await userEvent.keyboard('{Tab}');
  614. expect(
  615. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  616. ).toHaveFocus();
  617. // One more tab should go to the clear button
  618. await userEvent.keyboard('{Tab}');
  619. expect(screen.getByRole('button', {name: 'Clear search query'})).toHaveFocus();
  620. // Another should exit component
  621. await userEvent.keyboard('{Tab}');
  622. expect(document.body).toHaveFocus();
  623. });
  624. it('converts pasted text into tokens', async function () {
  625. render(<SearchQueryBuilder {...defaultProps} initialQuery="" />);
  626. await userEvent.click(getLastInput());
  627. await userEvent.paste('browser.name:firefox');
  628. // Should have tokenized the pasted text
  629. expect(screen.getByRole('row', {name: 'browser.name:firefox'})).toBeInTheDocument();
  630. // Focus should be at the end of the pasted text
  631. expect(
  632. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  633. ).toHaveFocus();
  634. });
  635. it('can remove parens with the keyboard', async function () {
  636. render(<SearchQueryBuilder {...defaultProps} initialQuery="(" />);
  637. expect(screen.getByRole('row', {name: '('})).toBeInTheDocument();
  638. await userEvent.click(getLastInput());
  639. await userEvent.keyboard('{backspace}{backspace}');
  640. expect(screen.queryByRole('row', {name: '('})).not.toBeInTheDocument();
  641. });
  642. it('can remove boolean ops with the keyboard', async function () {
  643. render(<SearchQueryBuilder {...defaultProps} initialQuery="and" />);
  644. expect(screen.getByRole('row', {name: 'and'})).toBeInTheDocument();
  645. await userEvent.click(getLastInput());
  646. await userEvent.keyboard('{backspace}{backspace}');
  647. expect(screen.queryByRole('row', {name: 'and'})).not.toBeInTheDocument();
  648. });
  649. it('exits filter value when pressing escape', async function () {
  650. render(
  651. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:Firefox" />
  652. );
  653. // Click into filter value (button to edit will no longer exist)
  654. await userEvent.click(
  655. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  656. );
  657. expect(
  658. screen.queryByRole('button', {name: 'Edit value for filter: browser.name'})
  659. ).not.toBeInTheDocument();
  660. // Pressing escape will exit the filter value, so edit button will come back
  661. await userEvent.keyboard('{Escape}');
  662. expect(
  663. await screen.findByRole('button', {name: 'Edit value for filter: browser.name'})
  664. ).toBeInTheDocument();
  665. // Focus should now be to the right of the filter
  666. expect(
  667. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  668. ).toHaveFocus();
  669. });
  670. it('backspace focuses filter when input is empty', async function () {
  671. const mockOnChange = jest.fn();
  672. render(
  673. <SearchQueryBuilder
  674. {...defaultProps}
  675. onChange={mockOnChange}
  676. initialQuery="age:-24h"
  677. />
  678. );
  679. // Click into filter value (button to edit will no longer exist)
  680. await userEvent.click(
  681. screen.getByRole('button', {name: 'Edit value for filter: age'})
  682. );
  683. await userEvent.keyboard('{Backspace}');
  684. // Filter should now have focus, and no changes should have been made
  685. expect(screen.getByRole('row', {name: 'age:-24h'})).toHaveFocus();
  686. expect(mockOnChange).not.toHaveBeenCalled();
  687. });
  688. it('can select all and delete with ctrl+a', async function () {
  689. const mockOnChange = jest.fn();
  690. render(
  691. <SearchQueryBuilder
  692. {...defaultProps}
  693. onChange={mockOnChange}
  694. initialQuery="browser.name:firefox foo"
  695. />
  696. );
  697. await userEvent.click(getLastInput());
  698. await userEvent.keyboard('{Control>}a{/Control}');
  699. // Should have selected the entire query
  700. for (const token of screen.getAllByRole('row')) {
  701. expect(token).toHaveAttribute('aria-selected', 'true');
  702. }
  703. // Focus should be on the selection key handler input
  704. expect(screen.getByTestId('selection-key-handler')).toHaveFocus();
  705. // Pressing delete should remove all selected tokens
  706. await userEvent.keyboard('{Backspace}');
  707. expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
  708. });
  709. it('focus goes to first input after ctrl+a and arrow left', async function () {
  710. render(
  711. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  712. );
  713. await userEvent.click(getLastInput());
  714. await userEvent.keyboard('{Control>}a{/Control}');
  715. // Pressing arrow left should put focus in first text input
  716. await userEvent.keyboard('{ArrowLeft}');
  717. expect(
  718. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(0)
  719. ).toHaveFocus();
  720. });
  721. it('focus goes to last input after ctrl+a and arrow right', async function () {
  722. render(
  723. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  724. );
  725. await userEvent.click(getLastInput());
  726. await userEvent.keyboard('{Control>}a{/Control}');
  727. // Pressing arrow right should put focus in last text input
  728. await userEvent.keyboard('{ArrowRight}');
  729. expect(
  730. screen.getAllByRole('combobox', {name: 'Add a search term'}).at(-1)
  731. ).toHaveFocus();
  732. });
  733. it('replaces selection when a key is pressed', async function () {
  734. const mockOnChange = jest.fn();
  735. render(
  736. <SearchQueryBuilder
  737. {...defaultProps}
  738. initialQuery="browser.name:firefox"
  739. onChange={mockOnChange}
  740. />
  741. );
  742. await userEvent.click(getLastInput());
  743. await userEvent.keyboard('{Control>}a{/Control}');
  744. await userEvent.keyboard('foo');
  745. expect(
  746. screen.queryByRole('row', {name: 'browser.name:firefox'})
  747. ).not.toBeInTheDocument();
  748. expect(getLastInput()).toHaveFocus();
  749. expect(getLastInput()).toHaveValue('foo');
  750. });
  751. it('replaces selection with pasted content with ctrl+v', async function () {
  752. const mockOnChange = jest.fn();
  753. render(
  754. <SearchQueryBuilder
  755. {...defaultProps}
  756. initialQuery="browser.name:firefox"
  757. onChange={mockOnChange}
  758. />
  759. );
  760. await userEvent.click(getLastInput());
  761. await userEvent.keyboard('{Control>}a{/Control}');
  762. await userEvent.paste('foo');
  763. expect(
  764. screen.queryByRole('row', {name: 'browser.name:firefox'})
  765. ).not.toBeInTheDocument();
  766. expect(getLastInput()).toHaveFocus();
  767. expect(getLastInput()).toHaveValue('foo');
  768. });
  769. it('can copy selection with ctrl-c', async function () {
  770. render(
  771. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox foo" />
  772. );
  773. await userEvent.click(getLastInput());
  774. await userEvent.keyboard('{Control>}a{/Control}');
  775. await userEvent.keyboard('{Control>}c{/Control}');
  776. expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
  777. 'browser.name:firefox foo'
  778. );
  779. });
  780. it('can cut selection with ctrl-x', async function () {
  781. const mockOnChange = jest.fn();
  782. render(
  783. <SearchQueryBuilder
  784. {...defaultProps}
  785. initialQuery="browser.name:firefox"
  786. onChange={mockOnChange}
  787. />
  788. );
  789. await userEvent.click(getLastInput());
  790. await userEvent.keyboard('{Control>}a{/Control}');
  791. await userEvent.keyboard('{Control>}x{/Control}');
  792. expect(navigator.clipboard.writeText).toHaveBeenCalledWith('browser.name:firefox');
  793. expect(mockOnChange).toHaveBeenCalledWith('', expect.anything());
  794. });
  795. it('can undo last action with ctrl-z', async function () {
  796. render(
  797. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  798. );
  799. // Clear search query removes the token
  800. await userEvent.click(screen.getByRole('button', {name: 'Clear search query'}));
  801. expect(
  802. screen.queryByRole('row', {name: 'browser.name:firefox'})
  803. ).not.toBeInTheDocument();
  804. // Ctrl+Z adds it back
  805. await userEvent.keyboard('{Control>}z{/Control}');
  806. expect(
  807. await screen.findByRole('row', {name: 'browser.name:firefox'})
  808. ).toBeInTheDocument();
  809. });
  810. it('works with excess undo actions', async function () {
  811. render(
  812. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  813. );
  814. // Remove the token
  815. await userEvent.click(
  816. screen.getByRole('button', {name: 'Remove filter: browser.name'})
  817. );
  818. await waitFor(() => {
  819. expect(
  820. screen.queryByRole('row', {name: 'browser.name:firefox'})
  821. ).not.toBeInTheDocument();
  822. });
  823. // Ctrl+Z adds it back
  824. await userEvent.keyboard('{Control>}z{/Control}');
  825. expect(
  826. await screen.findByRole('row', {name: 'browser.name:firefox'})
  827. ).toBeInTheDocument();
  828. // Extra Ctrl-Z should not do anything
  829. await userEvent.keyboard('{Control>}z{/Control}');
  830. // Remove token again
  831. await userEvent.click(
  832. screen.getByRole('button', {name: 'Remove filter: browser.name'})
  833. );
  834. await waitFor(() => {
  835. expect(
  836. screen.queryByRole('row', {name: 'browser.name:firefox'})
  837. ).not.toBeInTheDocument();
  838. });
  839. // Ctrl+Z adds it back again
  840. await userEvent.keyboard('{Control>}z{/Control}');
  841. expect(
  842. await screen.findByRole('row', {name: 'browser.name:firefox'})
  843. ).toBeInTheDocument();
  844. });
  845. });
  846. describe('token values', function () {
  847. it('supports grouped token value suggestions', async function () {
  848. render(<SearchQueryBuilder {...defaultProps} initialQuery="assigned:me" />);
  849. await userEvent.click(
  850. screen.getByRole('button', {name: 'Edit value for filter: assigned'})
  851. );
  852. const groups = within(screen.getByRole('listbox')).getAllByRole('group');
  853. // First group is selected option, second is "Suggested", third is "All"
  854. expect(groups).toHaveLength(3);
  855. expect(
  856. within(screen.getByRole('listbox')).getByText('Suggested')
  857. ).toBeInTheDocument();
  858. expect(within(screen.getByRole('listbox')).getByText('All')).toBeInTheDocument();
  859. // First group is the selected "me"
  860. expect(within(groups[0]).getByRole('option', {name: 'me'})).toBeInTheDocument();
  861. // Second group is the remaining option in the "Suggested" section
  862. expect(
  863. within(groups[1]).getByRole('option', {name: 'unassigned'})
  864. ).toBeInTheDocument();
  865. // Third group are the options under the "All" section
  866. expect(
  867. within(groups[2]).getByRole('option', {name: 'person1@sentry.io'})
  868. ).toBeInTheDocument();
  869. expect(
  870. within(groups[2]).getByRole('option', {name: 'person2@sentry.io'})
  871. ).toBeInTheDocument();
  872. });
  873. it('fetches tag values', async function () {
  874. const mockGetTagValues = jest.fn().mockResolvedValue(['tag_value_one']);
  875. render(
  876. <SearchQueryBuilder
  877. {...defaultProps}
  878. initialQuery="custom_tag_name:"
  879. getTagValues={mockGetTagValues}
  880. />
  881. );
  882. await userEvent.click(
  883. screen.getByRole('button', {name: 'Edit value for filter: custom_tag_name'})
  884. );
  885. await screen.findByRole('option', {name: 'tag_value_one'});
  886. await userEvent.click(screen.getByRole('option', {name: 'tag_value_one'}));
  887. expect(
  888. await screen.findByRole('row', {name: 'custom_tag_name:tag_value_one'})
  889. ).toBeInTheDocument();
  890. });
  891. });
  892. describe('filter types', function () {
  893. describe('is', function () {
  894. it('can modify the value by clicking into it', async function () {
  895. // `is` only accepts single values
  896. render(<SearchQueryBuilder {...defaultProps} initialQuery="is:unresolved" />);
  897. // Should display as "unresolved" to start
  898. expect(
  899. within(
  900. screen.getByRole('button', {name: 'Edit value for filter: is'})
  901. ).getByText('unresolved')
  902. ).toBeInTheDocument();
  903. await userEvent.click(
  904. screen.getByRole('button', {name: 'Edit value for filter: is'})
  905. );
  906. // Should have placeholder text of previous value
  907. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveAttribute(
  908. 'placeholder',
  909. 'unresolved'
  910. );
  911. // Clicking the "resolved" option should update the value
  912. await userEvent.click(await screen.findByRole('option', {name: 'resolved'}));
  913. expect(screen.getByRole('row', {name: 'is:resolved'})).toBeInTheDocument();
  914. expect(
  915. within(
  916. screen.getByRole('button', {name: 'Edit value for filter: is'})
  917. ).getByText('resolved')
  918. ).toBeInTheDocument();
  919. });
  920. it('defaults to unresolved when there is no value', async function () {
  921. render(<SearchQueryBuilder {...defaultProps} initialQuery="is:" />);
  922. // Click into value and press enter with no value
  923. await userEvent.click(
  924. screen.getByRole('button', {name: 'Edit value for filter: is'})
  925. );
  926. await userEvent.keyboard('{enter}');
  927. // Should be is:unresolved
  928. expect(
  929. await screen.findByRole('row', {name: 'is:unresolved'})
  930. ).toBeInTheDocument();
  931. });
  932. });
  933. describe('has', function () {
  934. it('display has and does not have as options', async function () {
  935. const mockOnChange = jest.fn();
  936. render(
  937. <SearchQueryBuilder
  938. {...defaultProps}
  939. onChange={mockOnChange}
  940. initialQuery="has:key"
  941. />
  942. );
  943. expect(
  944. within(
  945. screen.getByRole('button', {name: 'Edit value for filter: has'})
  946. ).getByText('key')
  947. ).toBeInTheDocument();
  948. await userEvent.click(
  949. screen.getByRole('button', {name: 'Edit operator for filter: has'})
  950. );
  951. await userEvent.click(await screen.findByRole('option', {name: 'does not have'}));
  952. await waitFor(() => {
  953. expect(mockOnChange).toHaveBeenCalledWith('!has:key', expect.anything());
  954. });
  955. expect(
  956. within(
  957. screen.getByRole('button', {name: 'Edit operator for filter: has'})
  958. ).getByText('does not have')
  959. ).toBeInTheDocument();
  960. });
  961. });
  962. describe('string', function () {
  963. it('defaults to an empty string when no value is provided', async function () {
  964. render(
  965. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  966. );
  967. await userEvent.click(
  968. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  969. );
  970. await userEvent.clear(
  971. await screen.findByRole('combobox', {name: 'Edit filter value'})
  972. );
  973. await userEvent.keyboard('{enter}');
  974. // Should have empty quotes `""`
  975. expect(
  976. await screen.findByRole('row', {name: 'browser.name:""'})
  977. ).toBeInTheDocument();
  978. expect(
  979. within(
  980. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  981. ).getByText('""')
  982. ).toBeInTheDocument();
  983. });
  984. it('can modify operator for filter with multiple values', async function () {
  985. render(
  986. <SearchQueryBuilder
  987. {...defaultProps}
  988. initialQuery="browser.name:[firefox,chrome]"
  989. />
  990. );
  991. // Should display as "is" to start
  992. expect(
  993. within(
  994. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  995. ).getByText('is')
  996. ).toBeInTheDocument();
  997. await userEvent.click(
  998. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  999. );
  1000. await userEvent.click(screen.getByRole('option', {name: 'browser.name is not'}));
  1001. // Token should be modified to be negated
  1002. expect(
  1003. screen.getByRole('row', {name: '!browser.name:[firefox,chrome]'})
  1004. ).toBeInTheDocument();
  1005. // Should now have "is not" label
  1006. expect(
  1007. within(
  1008. screen.getByRole('button', {name: 'Edit operator for filter: browser.name'})
  1009. ).getByText('is not')
  1010. ).toBeInTheDocument();
  1011. });
  1012. it('can modify the value by clicking into it (multi-select)', async function () {
  1013. render(
  1014. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  1015. );
  1016. // Should display as "firefox" to start
  1017. expect(
  1018. within(
  1019. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1020. ).getByText('firefox')
  1021. ).toBeInTheDocument();
  1022. await userEvent.click(
  1023. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1024. );
  1025. // Should start with previous values and an appended ',' for the next value
  1026. await waitFor(() => {
  1027. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
  1028. 'firefox,'
  1029. );
  1030. });
  1031. // Clicking the "Chrome option should add it to the list and commit changes
  1032. await userEvent.click(screen.getByRole('option', {name: 'Chrome'}));
  1033. expect(
  1034. screen.getByRole('row', {name: 'browser.name:[firefox,Chrome]'})
  1035. ).toBeInTheDocument();
  1036. const valueButton = screen.getByRole('button', {
  1037. name: 'Edit value for filter: browser.name',
  1038. });
  1039. expect(within(valueButton).getByText('firefox')).toBeInTheDocument();
  1040. expect(within(valueButton).getByText('or')).toBeInTheDocument();
  1041. expect(within(valueButton).getByText('Chrome')).toBeInTheDocument();
  1042. });
  1043. it('keeps focus inside value when multi-selecting with checkboxes', async function () {
  1044. render(
  1045. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:firefox" />
  1046. );
  1047. await userEvent.click(
  1048. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1049. );
  1050. // Input value should start with previous value and appended ','
  1051. await waitFor(() => {
  1052. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
  1053. 'firefox,'
  1054. );
  1055. });
  1056. // Toggling off the "firefox" option should:
  1057. // - Commit an empty string as the filter value
  1058. // - Input value should be cleared
  1059. // - Keep focus inside the input
  1060. await userEvent.click(
  1061. await screen.findByRole('checkbox', {name: 'Toggle firefox'})
  1062. );
  1063. expect(
  1064. await screen.findByRole('row', {name: 'browser.name:""'})
  1065. ).toBeInTheDocument();
  1066. await waitFor(() => {
  1067. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
  1068. ''
  1069. );
  1070. });
  1071. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
  1072. // Toggling on the "Chrome" option should:
  1073. // - Commit the value "Chrome" to the filter
  1074. // - Input value should be "Chrome,"
  1075. // - Keep focus inside the input
  1076. await userEvent.click(
  1077. await screen.findByRole('checkbox', {name: 'Toggle Chrome'})
  1078. );
  1079. expect(
  1080. await screen.findByRole('row', {name: 'browser.name:Chrome'})
  1081. ).toBeInTheDocument();
  1082. await waitFor(() => {
  1083. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
  1084. 'Chrome,'
  1085. );
  1086. });
  1087. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveFocus();
  1088. });
  1089. it('collapses many selected options', function () {
  1090. render(
  1091. <SearchQueryBuilder
  1092. {...defaultProps}
  1093. initialQuery="browser.name:[one,two,three,four]"
  1094. />
  1095. );
  1096. const valueButton = screen.getByRole('button', {
  1097. name: 'Edit value for filter: browser.name',
  1098. });
  1099. expect(within(valueButton).getByText('one')).toBeInTheDocument();
  1100. expect(within(valueButton).getByText('two')).toBeInTheDocument();
  1101. expect(within(valueButton).getByText('three')).toBeInTheDocument();
  1102. expect(within(valueButton).getByText('+1')).toBeInTheDocument();
  1103. expect(within(valueButton).queryByText('four')).not.toBeInTheDocument();
  1104. expect(within(valueButton).getAllByText('or')).toHaveLength(2);
  1105. });
  1106. it.each([
  1107. ['spaces', 'a b', '"a b"'],
  1108. ['quotes', 'a"b', '"a\\"b"'],
  1109. ['parens', 'foo()', '"foo()"'],
  1110. ])('tag values escape %s', async (_, value, expected) => {
  1111. const mockOnChange = jest.fn();
  1112. render(
  1113. <SearchQueryBuilder
  1114. {...defaultProps}
  1115. onChange={mockOnChange}
  1116. initialQuery="browser.name:"
  1117. />
  1118. );
  1119. await userEvent.click(
  1120. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1121. );
  1122. await userEvent.keyboard(`${value}{enter}`);
  1123. // Value should be surrounded by quotes and escaped
  1124. await waitFor(() => {
  1125. expect(mockOnChange).toHaveBeenCalledWith(
  1126. `browser.name:${expected}`,
  1127. expect.anything()
  1128. );
  1129. });
  1130. });
  1131. it('can replace a value with a new one', async function () {
  1132. render(
  1133. <SearchQueryBuilder {...defaultProps} initialQuery="browser.name:[1,c,3]" />
  1134. );
  1135. await userEvent.click(
  1136. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1137. );
  1138. await waitFor(() => {
  1139. expect(screen.getByRole('combobox', {name: 'Edit filter value'})).toHaveValue(
  1140. '1,c,3,'
  1141. );
  1142. });
  1143. // Arrow left three times to put cursor inside "c" value
  1144. await userEvent.keyboard('{ArrowLeft}{ArrowLeft}{ArrowLeft}');
  1145. // When on c value, should show options matching "c"
  1146. const chromeOption = await screen.findByRole('option', {name: 'Chrome'});
  1147. // Clicking the "Chrome option should replace "c" with "Chrome" and commit chagnes
  1148. await userEvent.click(chromeOption);
  1149. expect(
  1150. await screen.findByRole('row', {name: 'browser.name:[1,Chrome,3]'})
  1151. ).toBeInTheDocument();
  1152. });
  1153. it('can enter a custom value', async function () {
  1154. render(<SearchQueryBuilder {...defaultProps} initialQuery="browser.name:" />);
  1155. await userEvent.click(
  1156. screen.getByRole('button', {name: 'Edit value for filter: browser.name'})
  1157. );
  1158. await userEvent.keyboard('foo,bar{enter}');
  1159. expect(
  1160. await screen.findByRole('row', {name: 'browser.name:[foo,bar]'})
  1161. ).toBeInTheDocument();
  1162. });
  1163. it('displays comparison operator values with allowAllOperators: true', async function () {
  1164. const filterKeys = {
  1165. [FieldKey.RELEASE_VERSION]: {
  1166. key: FieldKey.RELEASE_VERSION,
  1167. name: '',
  1168. allowAllOperators: true,
  1169. },
  1170. };
  1171. render(
  1172. <SearchQueryBuilder
  1173. {...defaultProps}
  1174. filterKeys={filterKeys}
  1175. filterKeySections={[]}
  1176. initialQuery="release.version:1.0"
  1177. />
  1178. );
  1179. await userEvent.click(
  1180. screen.getByRole('button', {name: 'Edit operator for filter: release.version'})
  1181. );
  1182. // Normally text filters only have 'is' and 'is not' as options
  1183. expect(
  1184. await screen.findByRole('option', {name: 'release.version >'})
  1185. ).toBeInTheDocument();
  1186. await userEvent.click(screen.getByRole('option', {name: 'release.version >'}));
  1187. expect(
  1188. await screen.findByRole('row', {name: 'release.version:>1.0'})
  1189. ).toBeInTheDocument();
  1190. });
  1191. });
  1192. describe('numeric', function () {
  1193. it('new numeric filters start with a value', async function () {
  1194. render(<SearchQueryBuilder {...defaultProps} />);
  1195. await userEvent.click(getLastInput());
  1196. await userEvent.keyboard('time{ArrowDown}{Enter}');
  1197. // Should start with the > operator and a value of 100
  1198. expect(
  1199. await screen.findByRole('row', {name: 'timesSeen:>100'})
  1200. ).toBeInTheDocument();
  1201. });
  1202. it('keeps previous value when confirming empty value', async function () {
  1203. const mockOnChange = jest.fn();
  1204. render(
  1205. <SearchQueryBuilder
  1206. {...defaultProps}
  1207. onChange={mockOnChange}
  1208. initialQuery="timesSeen:>5"
  1209. />
  1210. );
  1211. await userEvent.click(
  1212. screen.getByRole('button', {name: 'Edit value for filter: timesSeen'})
  1213. );
  1214. await userEvent.clear(
  1215. await screen.findByRole('combobox', {name: 'Edit filter value'})
  1216. );
  1217. await userEvent.keyboard('{enter}');
  1218. // Should have the same value
  1219. expect(
  1220. await screen.findByRole('row', {name: 'timesSeen:>5'})
  1221. ).toBeInTheDocument();
  1222. expect(mockOnChange).not.toHaveBeenCalled();
  1223. });
  1224. it('does not allow invalid values', async function () {
  1225. render(<SearchQueryBuilder {...defaultProps} initialQuery="timesSeen:>100" />);
  1226. await userEvent.click(
  1227. screen.getByRole('button', {name: 'Edit value for filter: timesSeen'})
  1228. );
  1229. await userEvent.keyboard('a{Enter}');
  1230. // Should have the same value because "a" is not a numeric value
  1231. expect(screen.getByRole('row', {name: 'timesSeen:>100'})).toBeInTheDocument();
  1232. await userEvent.keyboard('{Backspace}7k{Enter}');
  1233. // Should accept "7k" as a valid value
  1234. expect(
  1235. await screen.findByRole('row', {name: 'timesSeen:>7k'})
  1236. ).toBeInTheDocument();
  1237. });
  1238. it('can change the operator', async function () {
  1239. render(<SearchQueryBuilder {...defaultProps} initialQuery="timesSeen:>100k" />);
  1240. await userEvent.click(
  1241. screen.getByRole('button', {name: 'Edit operator for filter: timesSeen'})
  1242. );
  1243. await userEvent.click(screen.getByRole('option', {name: 'timesSeen <='}));
  1244. expect(
  1245. await screen.findByRole('row', {name: 'timesSeen:<=100k'})
  1246. ).toBeInTheDocument();
  1247. });
  1248. });
  1249. describe('duration', function () {
  1250. const durationFilterKeys: TagCollection = {
  1251. duration: {
  1252. key: 'duration',
  1253. name: 'Duration',
  1254. },
  1255. };
  1256. const fieldDefinitionGetter: FieldDefinitionGetter = () => ({
  1257. valueType: FieldValueType.DURATION,
  1258. kind: FieldKind.FIELD,
  1259. });
  1260. const durationProps: SearchQueryBuilderProps = {
  1261. ...defaultProps,
  1262. filterKeys: durationFilterKeys,
  1263. filterKeySections: [],
  1264. fieldDefinitionGetter,
  1265. };
  1266. it('new duration filters start with greater than operator and default value', async function () {
  1267. render(<SearchQueryBuilder {...durationProps} />);
  1268. await userEvent.click(getLastInput());
  1269. await userEvent.click(screen.getByRole('option', {name: 'duration'}));
  1270. // Should start with the > operator and a value of 10ms
  1271. expect(
  1272. await screen.findByRole('row', {name: 'duration:>10ms'})
  1273. ).toBeInTheDocument();
  1274. });
  1275. it('duration filters have the correct operator options', async function () {
  1276. render(<SearchQueryBuilder {...durationProps} initialQuery="duration:>100ms" />);
  1277. await userEvent.click(
  1278. screen.getByRole('button', {name: 'Edit operator for filter: duration'})
  1279. );
  1280. expect(
  1281. await screen.findByRole('option', {name: 'duration is'})
  1282. ).toBeInTheDocument();
  1283. expect(screen.getByRole('option', {name: 'duration is not'})).toBeInTheDocument();
  1284. expect(screen.getByRole('option', {name: 'duration >'})).toBeInTheDocument();
  1285. expect(screen.getByRole('option', {name: 'duration <'})).toBeInTheDocument();
  1286. expect(screen.getByRole('option', {name: 'duration >='})).toBeInTheDocument();
  1287. expect(screen.getByRole('option', {name: 'duration <='})).toBeInTheDocument();
  1288. });
  1289. it('duration filters have the correct value suggestions', async function () {
  1290. render(<SearchQueryBuilder {...durationProps} initialQuery="duration:>100ms" />);
  1291. await userEvent.click(
  1292. screen.getByRole('button', {name: 'Edit value for filter: duration'})
  1293. );
  1294. // Default suggestions
  1295. expect(await screen.findByRole('option', {name: '100ms'})).toBeInTheDocument();
  1296. expect(screen.getByRole('option', {name: '100s'})).toBeInTheDocument();
  1297. expect(screen.getByRole('option', {name: '100m'})).toBeInTheDocument();
  1298. expect(screen.getByRole('option', {name: '100h'})).toBeInTheDocument();
  1299. // Entering a number will show unit suggestions for that value
  1300. await userEvent.keyboard('7');
  1301. expect(await screen.findByRole('option', {name: '7ms'})).toBeInTheDocument();
  1302. expect(screen.getByRole('option', {name: '7s'})).toBeInTheDocument();
  1303. expect(screen.getByRole('option', {name: '7m'})).toBeInTheDocument();
  1304. expect(screen.getByRole('option', {name: '7h'})).toBeInTheDocument();
  1305. });
  1306. it('duration filters can change operator', async function () {
  1307. render(<SearchQueryBuilder {...durationProps} initialQuery="duration:>100ms" />);
  1308. await userEvent.click(
  1309. screen.getByRole('button', {name: 'Edit operator for filter: duration'})
  1310. );
  1311. await userEvent.click(await screen.findByRole('option', {name: 'duration <='}));
  1312. expect(
  1313. await screen.findByRole('row', {name: 'duration:<=100ms'})
  1314. ).toBeInTheDocument();
  1315. });
  1316. it('duration filters do not allow invalid values', async function () {
  1317. render(<SearchQueryBuilder {...durationProps} initialQuery="duration:>100ms" />);
  1318. await userEvent.click(
  1319. screen.getByRole('button', {name: 'Edit value for filter: duration'})
  1320. );
  1321. await userEvent.keyboard('a{Enter}');
  1322. // Should have the same value because "a" is not a numeric value
  1323. expect(screen.getByRole('row', {name: 'duration:>100ms'})).toBeInTheDocument();
  1324. await userEvent.keyboard('{Backspace}7m{Enter}');
  1325. // Should accept "7m" as a valid value
  1326. expect(
  1327. await screen.findByRole('row', {name: 'duration:>7m'})
  1328. ).toBeInTheDocument();
  1329. });
  1330. it('duration filters will add a default unit to entered numbers', async function () {
  1331. render(<SearchQueryBuilder {...durationProps} initialQuery="duration:>100ms" />);
  1332. await userEvent.click(
  1333. screen.getByRole('button', {name: 'Edit value for filter: duration'})
  1334. );
  1335. await userEvent.keyboard('7{Enter}');
  1336. // Should accept "7" and add "ms" as the default unit
  1337. expect(
  1338. await screen.findByRole('row', {name: 'duration:>7ms'})
  1339. ).toBeInTheDocument();
  1340. });
  1341. it('keeps previous value when confirming empty value', async function () {
  1342. const mockOnChange = jest.fn();
  1343. render(
  1344. <SearchQueryBuilder
  1345. {...durationProps}
  1346. onChange={mockOnChange}
  1347. initialQuery="duration:>100ms"
  1348. />
  1349. );
  1350. await userEvent.click(
  1351. screen.getByRole('button', {name: 'Edit value for filter: duration'})
  1352. );
  1353. await userEvent.clear(
  1354. await screen.findByRole('combobox', {name: 'Edit filter value'})
  1355. );
  1356. await userEvent.keyboard('{enter}');
  1357. // Should have the same value
  1358. expect(
  1359. await screen.findByRole('row', {name: 'duration:>100ms'})
  1360. ).toBeInTheDocument();
  1361. expect(mockOnChange).not.toHaveBeenCalled();
  1362. });
  1363. });
  1364. describe('percentage', function () {
  1365. const percentageFilterKeys: TagCollection = {
  1366. rate: {
  1367. key: 'rate',
  1368. name: 'rate',
  1369. },
  1370. };
  1371. const fieldDefinitionGetter: FieldDefinitionGetter = () => ({
  1372. valueType: FieldValueType.PERCENTAGE,
  1373. kind: FieldKind.FIELD,
  1374. });
  1375. const percentageProps: SearchQueryBuilderProps = {
  1376. ...defaultProps,
  1377. filterKeys: percentageFilterKeys,
  1378. filterKeySections: [],
  1379. fieldDefinitionGetter,
  1380. };
  1381. it('new percentage filters start with greater than operator and default value', async function () {
  1382. render(<SearchQueryBuilder {...percentageProps} />);
  1383. await userEvent.click(getLastInput());
  1384. await userEvent.click(screen.getByRole('option', {name: 'rate'}));
  1385. // Should start with the > operator and a value of 50%
  1386. expect(await screen.findByRole('row', {name: 'rate:>0.5'})).toBeInTheDocument();
  1387. });
  1388. it('percentage filters have the correct operator options', async function () {
  1389. render(<SearchQueryBuilder {...percentageProps} initialQuery="rate:>0.5" />);
  1390. await userEvent.click(
  1391. screen.getByRole('button', {name: 'Edit operator for filter: rate'})
  1392. );
  1393. expect(await screen.findByRole('option', {name: 'rate is'})).toBeInTheDocument();
  1394. expect(screen.getByRole('option', {name: 'rate is not'})).toBeInTheDocument();
  1395. expect(screen.getByRole('option', {name: 'rate >'})).toBeInTheDocument();
  1396. expect(screen.getByRole('option', {name: 'rate <'})).toBeInTheDocument();
  1397. expect(screen.getByRole('option', {name: 'rate >='})).toBeInTheDocument();
  1398. expect(screen.getByRole('option', {name: 'rate <='})).toBeInTheDocument();
  1399. });
  1400. it('percentage filters can change operator', async function () {
  1401. render(<SearchQueryBuilder {...percentageProps} initialQuery="rate:>0.5" />);
  1402. await userEvent.click(
  1403. screen.getByRole('button', {name: 'Edit operator for filter: rate'})
  1404. );
  1405. await userEvent.click(await screen.findByRole('option', {name: 'rate <='}));
  1406. expect(await screen.findByRole('row', {name: 'rate:<=0.5'})).toBeInTheDocument();
  1407. });
  1408. it('percentage filters do not allow invalid values', async function () {
  1409. render(<SearchQueryBuilder {...percentageProps} initialQuery="rate:>0.5" />);
  1410. await userEvent.click(
  1411. screen.getByRole('button', {name: 'Edit value for filter: rate'})
  1412. );
  1413. await userEvent.keyboard('a{Enter}');
  1414. // Should have the same value because "a" is not a numeric value
  1415. expect(screen.getByRole('row', {name: 'rate:>0.5'})).toBeInTheDocument();
  1416. await userEvent.keyboard('{Backspace}0.2{Enter}');
  1417. // Should accept "0.2" as a valid value
  1418. expect(await screen.findByRole('row', {name: 'rate:>0.2'})).toBeInTheDocument();
  1419. });
  1420. it('percentage filters will convert values with % to ratio', async function () {
  1421. render(<SearchQueryBuilder {...percentageProps} initialQuery="rate:>0.5" />);
  1422. await userEvent.click(
  1423. screen.getByRole('button', {name: 'Edit value for filter: rate'})
  1424. );
  1425. await userEvent.keyboard('70%{Enter}');
  1426. // 70% should be accepted and converted to 0.7
  1427. expect(await screen.findByRole('row', {name: 'rate:>0.7'})).toBeInTheDocument();
  1428. });
  1429. it('keeps previous value when confirming empty value', async function () {
  1430. const mockOnChange = jest.fn();
  1431. render(
  1432. <SearchQueryBuilder
  1433. {...percentageProps}
  1434. onChange={mockOnChange}
  1435. initialQuery="rate:>0.5"
  1436. />
  1437. );
  1438. await userEvent.click(
  1439. screen.getByRole('button', {name: 'Edit value for filter: rate'})
  1440. );
  1441. await userEvent.clear(
  1442. await screen.findByRole('combobox', {name: 'Edit filter value'})
  1443. );
  1444. await userEvent.keyboard('{enter}');
  1445. // Should have the same value
  1446. expect(await screen.findByRole('row', {name: 'rate:>0.5'})).toBeInTheDocument();
  1447. expect(mockOnChange).not.toHaveBeenCalled();
  1448. });
  1449. });
  1450. describe('date', function () {
  1451. // Transpile the lazy-loaded datepicker up front so tests don't flake
  1452. beforeAll(async function () {
  1453. await import('sentry/components/calendar/datePicker');
  1454. });
  1455. it('new date filters start with a value', async function () {
  1456. render(<SearchQueryBuilder {...defaultProps} />);
  1457. await userEvent.click(getLastInput());
  1458. await userEvent.keyboard('age{ArrowDown}{Enter}');
  1459. // Should start with a relative date value
  1460. expect(await screen.findByRole('row', {name: 'age:-24h'})).toBeInTheDocument();
  1461. });
  1462. it('does not allow invalid values', async function () {
  1463. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
  1464. await userEvent.click(
  1465. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1466. );
  1467. await userEvent.keyboard('a{Enter}');
  1468. // Should have the same value because "a" is not a date value
  1469. expect(screen.getByRole('row', {name: 'age:-24h'})).toBeInTheDocument();
  1470. });
  1471. it('keeps previous value when confirming empty value', async function () {
  1472. const mockOnChange = jest.fn();
  1473. render(
  1474. <SearchQueryBuilder
  1475. {...defaultProps}
  1476. onChange={mockOnChange}
  1477. initialQuery="age:-24h"
  1478. />
  1479. );
  1480. await userEvent.click(
  1481. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1482. );
  1483. await userEvent.clear(
  1484. await screen.findByRole('combobox', {name: 'Edit filter value'})
  1485. );
  1486. await userEvent.keyboard('{enter}');
  1487. // Should have the same value
  1488. expect(await screen.findByRole('row', {name: 'age:-24h'})).toBeInTheDocument();
  1489. expect(mockOnChange).not.toHaveBeenCalled();
  1490. });
  1491. it('shows default date suggestions', async function () {
  1492. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
  1493. await userEvent.click(
  1494. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1495. );
  1496. await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'}));
  1497. expect(screen.getByRole('row', {name: 'age:-1h'})).toBeInTheDocument();
  1498. });
  1499. it('shows date suggestions when typing', async function () {
  1500. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
  1501. await userEvent.click(
  1502. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1503. );
  1504. // Typing "7" should show suggestions for 7 minutes, hours, days, and weeks
  1505. await userEvent.keyboard('7');
  1506. await screen.findByRole('option', {name: '7 minutes ago'});
  1507. expect(screen.getByRole('option', {name: '7 hours ago'})).toBeInTheDocument();
  1508. expect(screen.getByRole('option', {name: '7 days ago'})).toBeInTheDocument();
  1509. expect(screen.getByRole('option', {name: '7 weeks ago'})).toBeInTheDocument();
  1510. await userEvent.click(screen.getByRole('option', {name: '7 weeks ago'}));
  1511. expect(screen.getByRole('row', {name: 'age:-7w'})).toBeInTheDocument();
  1512. });
  1513. it('can search before a relative date', async function () {
  1514. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
  1515. await userEvent.click(
  1516. screen.getByRole('button', {name: 'Edit operator for filter: age'})
  1517. );
  1518. await userEvent.click(await screen.findByRole('option', {name: 'age is before'}));
  1519. // Should flip from "-" to "+"
  1520. expect(await screen.findByRole('row', {name: 'age:+24h'})).toBeInTheDocument();
  1521. });
  1522. it('switches to an absolute date when choosing operator with equality', async function () {
  1523. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:-24h" />);
  1524. await userEvent.click(
  1525. screen.getByRole('button', {name: 'Edit operator for filter: age'})
  1526. );
  1527. await userEvent.click(
  1528. await screen.findByRole('option', {name: 'age is on or after'})
  1529. );
  1530. // Changes operator and fills in the current date (ISO format)
  1531. expect(
  1532. await screen.findByRole('row', {name: 'age:>=2017-10-17T02:41:20.000Z'})
  1533. ).toBeInTheDocument();
  1534. });
  1535. it('can switch from after an absolute date to a relative one', async function () {
  1536. const mockOnChange = jest.fn();
  1537. render(
  1538. <SearchQueryBuilder
  1539. {...defaultProps}
  1540. onChange={mockOnChange}
  1541. initialQuery="foo age:>=2017-10-17"
  1542. />
  1543. );
  1544. await userEvent.click(
  1545. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1546. );
  1547. // Go back to relative date suggestions
  1548. await userEvent.click(await screen.findByRole('button', {name: 'Back'}));
  1549. await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'}));
  1550. // Because relative dates only work with ":", should change the operator to "is after"
  1551. expect(
  1552. within(
  1553. screen.getByRole('button', {name: 'Edit operator for filter: age'})
  1554. ).getByText('is after')
  1555. ).toBeInTheDocument();
  1556. await waitFor(() => {
  1557. expect(mockOnChange).toHaveBeenCalledWith('foo age:-1h', expect.anything());
  1558. });
  1559. });
  1560. it('can switch from before an absolute date to a relative one', async function () {
  1561. const mockOnChange = jest.fn();
  1562. render(
  1563. <SearchQueryBuilder
  1564. {...defaultProps}
  1565. onChange={mockOnChange}
  1566. initialQuery="foo age:<=2017-10-17"
  1567. />
  1568. );
  1569. await userEvent.click(
  1570. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1571. );
  1572. // Go back to relative date suggestions
  1573. await userEvent.click(await screen.findByRole('button', {name: 'Back'}));
  1574. await userEvent.click(await screen.findByRole('option', {name: '1 hour ago'}));
  1575. // Because relative dates only work with ":", should change the operator to "is before"
  1576. expect(
  1577. within(
  1578. screen.getByRole('button', {name: 'Edit operator for filter: age'})
  1579. ).getByText('is before')
  1580. ).toBeInTheDocument();
  1581. await waitFor(() => {
  1582. expect(mockOnChange).toHaveBeenCalledWith('foo age:+1h', expect.anything());
  1583. });
  1584. });
  1585. it('can set an absolute date', async function () {
  1586. const mockOnChange = jest.fn();
  1587. render(
  1588. <SearchQueryBuilder
  1589. {...defaultProps}
  1590. onChange={mockOnChange}
  1591. initialQuery="age:-24h"
  1592. />
  1593. );
  1594. await userEvent.click(
  1595. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1596. );
  1597. await userEvent.click(await screen.findByRole('option', {name: 'Absolute date'}));
  1598. const dateInput = await screen.findByTestId('date-picker');
  1599. await userEvent.type(dateInput, '2017-10-17');
  1600. await userEvent.click(screen.getByRole('button', {name: 'Save'}));
  1601. await waitFor(() => {
  1602. expect(mockOnChange).toHaveBeenCalledWith('age:>2017-10-17', expect.anything());
  1603. });
  1604. });
  1605. it('can set an absolute date with time (UTC)', async function () {
  1606. const mockOnChange = jest.fn();
  1607. render(
  1608. <SearchQueryBuilder
  1609. {...defaultProps}
  1610. onChange={mockOnChange}
  1611. initialQuery="age:>2017-10-17"
  1612. />
  1613. );
  1614. await userEvent.click(
  1615. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1616. );
  1617. await userEvent.click(
  1618. await screen.findByRole('checkbox', {name: 'Include time'})
  1619. );
  1620. await userEvent.click(await screen.findByRole('button', {name: 'Save'}));
  1621. await waitFor(() => {
  1622. expect(mockOnChange).toHaveBeenCalledWith(
  1623. 'age:>2017-10-17T00:00:00Z',
  1624. expect.anything()
  1625. );
  1626. });
  1627. });
  1628. it('can set an absolute date with time (local)', async function () {
  1629. const mockOnChange = jest.fn();
  1630. render(
  1631. <SearchQueryBuilder
  1632. {...defaultProps}
  1633. onChange={mockOnChange}
  1634. initialQuery="age:>2017-10-17"
  1635. />
  1636. );
  1637. await userEvent.click(
  1638. screen.getByRole('button', {name: 'Edit value for filter: age'})
  1639. );
  1640. await userEvent.click(
  1641. await screen.findByRole('checkbox', {name: 'Include time'})
  1642. );
  1643. await userEvent.click(await screen.findByRole('checkbox', {name: 'UTC'}));
  1644. await userEvent.click(await screen.findByRole('button', {name: 'Save'}));
  1645. await waitFor(() => {
  1646. expect(mockOnChange).toHaveBeenCalledWith(
  1647. 'age:>2017-10-17T00:00:00+00:00',
  1648. expect.anything()
  1649. );
  1650. });
  1651. });
  1652. it('displays absolute date value correctly (just date)', function () {
  1653. render(<SearchQueryBuilder {...defaultProps} initialQuery="age:>=2017-10-17" />);
  1654. expect(screen.getByText('is on or after')).toBeInTheDocument();
  1655. expect(screen.getByText('Oct 17')).toBeInTheDocument();
  1656. });
  1657. it('displays absolute date value correctly (with local time)', function () {
  1658. render(
  1659. <SearchQueryBuilder
  1660. {...defaultProps}
  1661. initialQuery="age:>=2017-10-17T14:00:00-00:00"
  1662. />
  1663. );
  1664. expect(screen.getByText('is on or after')).toBeInTheDocument();
  1665. expect(screen.getByText('Oct 17, 2:00 PM')).toBeInTheDocument();
  1666. });
  1667. it('displays absolute date value correctly (with UTC time)', function () {
  1668. render(
  1669. <SearchQueryBuilder
  1670. {...defaultProps}
  1671. initialQuery="age:>=2017-10-17T14:00:00Z"
  1672. />
  1673. );
  1674. expect(screen.getByText('is on or after')).toBeInTheDocument();
  1675. expect(screen.getByText('Oct 17, 2:00 PM UTC')).toBeInTheDocument();
  1676. });
  1677. });
  1678. });
  1679. describe('disallowLogicalOperators', function () {
  1680. it('should mark AND invalid', async function () {
  1681. render(
  1682. <SearchQueryBuilder
  1683. {...defaultProps}
  1684. disallowLogicalOperators
  1685. initialQuery="and"
  1686. />
  1687. );
  1688. expect(screen.getByRole('row', {name: 'and'})).toHaveAttribute(
  1689. 'aria-invalid',
  1690. 'true'
  1691. );
  1692. await userEvent.click(screen.getByRole('row', {name: 'and'}));
  1693. expect(
  1694. await screen.findByText('The AND operator is not allowed in this search')
  1695. ).toBeInTheDocument();
  1696. });
  1697. it('should mark OR invalid', async function () {
  1698. render(
  1699. <SearchQueryBuilder
  1700. {...defaultProps}
  1701. disallowLogicalOperators
  1702. initialQuery="or"
  1703. />
  1704. );
  1705. expect(screen.getByRole('row', {name: 'or'})).toHaveAttribute(
  1706. 'aria-invalid',
  1707. 'true'
  1708. );
  1709. await userEvent.click(screen.getByRole('row', {name: 'or'}));
  1710. expect(
  1711. await screen.findByText('The OR operator is not allowed in this search')
  1712. ).toBeInTheDocument();
  1713. });
  1714. it('should mark parens invalid', async function () {
  1715. render(
  1716. <SearchQueryBuilder
  1717. {...defaultProps}
  1718. disallowLogicalOperators
  1719. initialQuery="()"
  1720. />
  1721. );
  1722. expect(screen.getByRole('row', {name: '('})).toHaveAttribute(
  1723. 'aria-invalid',
  1724. 'true'
  1725. );
  1726. expect(screen.getByRole('row', {name: ')'})).toHaveAttribute(
  1727. 'aria-invalid',
  1728. 'true'
  1729. );
  1730. await userEvent.click(screen.getByRole('row', {name: '('}));
  1731. expect(
  1732. await screen.findByText('Parentheses are not supported in this search')
  1733. ).toBeInTheDocument();
  1734. });
  1735. });
  1736. describe('disallowWildcard', function () {
  1737. it('should mark tokens with wildcards invalid', async function () {
  1738. render(
  1739. <SearchQueryBuilder
  1740. {...defaultProps}
  1741. disallowWildcard
  1742. initialQuery="browser.name:Firefox*"
  1743. />
  1744. );
  1745. expect(screen.getByRole('row', {name: 'browser.name:Firefox*'})).toHaveAttribute(
  1746. 'aria-invalid',
  1747. 'true'
  1748. );
  1749. // Put focus into token, should show error message
  1750. await userEvent.click(getLastInput());
  1751. await userEvent.keyboard('{ArrowLeft}');
  1752. expect(
  1753. await screen.findByText('Wildcards not supported in search')
  1754. ).toBeInTheDocument();
  1755. });
  1756. it('should mark free text with wildcards invalid', async function () {
  1757. render(
  1758. <SearchQueryBuilder {...defaultProps} disallowWildcard initialQuery="foo*" />
  1759. );
  1760. expect(screen.getByRole('row', {name: 'foo*'})).toHaveAttribute(
  1761. 'aria-invalid',
  1762. 'true'
  1763. );
  1764. await userEvent.click(getLastInput());
  1765. expect(
  1766. await screen.findByText('Wildcards not supported in search')
  1767. ).toBeInTheDocument();
  1768. });
  1769. });
  1770. describe('disallowFreeText', function () {
  1771. it('should mark free text invalid', async function () {
  1772. render(
  1773. <SearchQueryBuilder {...defaultProps} disallowFreeText initialQuery="foo" />
  1774. );
  1775. expect(screen.getByRole('row', {name: 'foo'})).toHaveAttribute(
  1776. 'aria-invalid',
  1777. 'true'
  1778. );
  1779. await userEvent.click(getLastInput());
  1780. expect(
  1781. await screen.findByText('Free text is not supported in this search')
  1782. ).toBeInTheDocument();
  1783. });
  1784. });
  1785. describe('highlightUnsupportedFilters', function () {
  1786. it('should mark unsupported filters as invalid', async function () {
  1787. render(
  1788. <SearchQueryBuilder
  1789. {...defaultProps}
  1790. disallowUnsupportedFilters
  1791. initialQuery="foo:bar"
  1792. />
  1793. );
  1794. expect(screen.getByRole('row', {name: 'foo:bar'})).toHaveAttribute(
  1795. 'aria-invalid',
  1796. 'true'
  1797. );
  1798. await userEvent.click(getLastInput());
  1799. await userEvent.keyboard('{ArrowLeft}');
  1800. expect(
  1801. await screen.findByText('Invalid key. "foo" is not a supported search key.')
  1802. ).toBeInTheDocument();
  1803. });
  1804. });
  1805. describe('invalidMessages', function () {
  1806. it('should customize invalid messages', async function () {
  1807. render(
  1808. <SearchQueryBuilder
  1809. {...defaultProps}
  1810. initialQuery="foo:"
  1811. invalidMessages={{
  1812. [InvalidReason.FILTER_MUST_HAVE_VALUE]: 'foo bar baz',
  1813. }}
  1814. />
  1815. );
  1816. expect(screen.getByRole('row', {name: 'foo:'})).toHaveAttribute(
  1817. 'aria-invalid',
  1818. 'true'
  1819. );
  1820. await userEvent.click(getLastInput());
  1821. await userEvent.keyboard('{ArrowLeft}');
  1822. expect(await screen.findByText('foo bar baz')).toBeInTheDocument();
  1823. });
  1824. });
  1825. });