index.spec.tsx 93 KB

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