cellAction.spec.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. import {Location} from 'history';
  2. import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
  3. import {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  4. import EventView from 'sentry/utils/discover/eventView';
  5. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  6. import CellAction, {Actions, updateQuery} from 'sentry/views/discover/table/cellAction';
  7. import {TableColumn} from 'sentry/views/discover/table/types';
  8. const defaultData: TableDataRow = {
  9. transaction: 'best-transaction',
  10. count: 19,
  11. timestamp: '2020-06-09T01:46:25+00:00',
  12. release: 'F2520C43515BD1F0E8A6BD46233324641A370BF6',
  13. 'measurements.fcp': 1234,
  14. 'percentile(measurements.fcp, 0.5)': 1234,
  15. // TODO: Fix this type
  16. // @ts-ignore
  17. 'error.handled': [null],
  18. // TODO: Fix this type
  19. // @ts-ignore
  20. 'error.type': [
  21. 'ServerException',
  22. 'ClickhouseError',
  23. 'QueryException',
  24. 'QueryException',
  25. ],
  26. id: '42',
  27. };
  28. function renderComponent({
  29. eventView,
  30. handleCellAction = jest.fn(),
  31. columnIndex = 0,
  32. data = defaultData,
  33. }: {
  34. eventView: EventView;
  35. columnIndex?: number;
  36. data?: TableDataRow;
  37. handleCellAction?: (
  38. action: Actions,
  39. value: React.ReactText | null[] | string[] | null
  40. ) => void;
  41. }) {
  42. return render(
  43. <CellAction
  44. dataRow={data}
  45. column={eventView.getColumns()[columnIndex]}
  46. handleCellAction={handleCellAction}
  47. >
  48. <strong>some content</strong>
  49. </CellAction>
  50. );
  51. }
  52. describe('Discover -> CellAction', function () {
  53. const location: Location = TestStubs.location({
  54. query: {
  55. id: '42',
  56. name: 'best query',
  57. field: [
  58. 'transaction',
  59. 'count()',
  60. 'timestamp',
  61. 'release',
  62. 'nullValue',
  63. 'measurements.fcp',
  64. 'percentile(measurements.fcp, 0.5)',
  65. 'error.handled',
  66. 'error.type',
  67. ],
  68. widths: ['437', '647', '416', '905'],
  69. sort: ['title'],
  70. query: 'event.type:transaction',
  71. project: ['123'],
  72. start: '2019-10-01T00:00:00',
  73. end: '2019-10-02T00:00:00',
  74. statsPeriod: '14d',
  75. environment: ['staging'],
  76. yAxis: 'p95',
  77. },
  78. });
  79. const view = EventView.fromLocation(location);
  80. async function openMenu() {
  81. await userEvent.click(screen.getByRole('button', {name: 'Actions'}));
  82. }
  83. describe('hover menu button', function () {
  84. it('shows no menu by default', function () {
  85. renderComponent({eventView: view});
  86. expect(screen.queryByRole('button', {name: 'Actions'})).toBeInTheDocument();
  87. });
  88. });
  89. describe('opening the menu', function () {
  90. it('toggles the menu on click', async function () {
  91. renderComponent({eventView: view});
  92. await openMenu();
  93. expect(
  94. screen.getByRole('menuitemradio', {name: 'Add to filter'})
  95. ).toBeInTheDocument();
  96. });
  97. });
  98. describe('per cell actions', function () {
  99. let handleCellAction;
  100. beforeEach(function () {
  101. handleCellAction = jest.fn();
  102. });
  103. it('add button appends condition', async function () {
  104. renderComponent({eventView: view, handleCellAction});
  105. await openMenu();
  106. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  107. expect(handleCellAction).toHaveBeenCalledWith('add', 'best-transaction');
  108. });
  109. it('exclude button adds condition', async function () {
  110. renderComponent({eventView: view, handleCellAction});
  111. await openMenu();
  112. await userEvent.click(
  113. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  114. );
  115. expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction');
  116. });
  117. it('exclude button appends exclusions', async function () {
  118. const excludeView = EventView.fromLocation(
  119. TestStubs.location({
  120. query: {...location.query, query: '!transaction:nope'},
  121. })
  122. );
  123. renderComponent({eventView: excludeView, handleCellAction});
  124. await openMenu();
  125. await userEvent.click(
  126. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  127. );
  128. expect(handleCellAction).toHaveBeenCalledWith('exclude', 'best-transaction');
  129. });
  130. it('go to release button goes to release health page', async function () {
  131. renderComponent({eventView: view, handleCellAction, columnIndex: 3});
  132. await openMenu();
  133. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'}));
  134. expect(handleCellAction).toHaveBeenCalledWith(
  135. 'release',
  136. 'F2520C43515BD1F0E8A6BD46233324641A370BF6'
  137. );
  138. });
  139. it('greater than button adds condition', async function () {
  140. renderComponent({eventView: view, handleCellAction, columnIndex: 2});
  141. await openMenu();
  142. await userEvent.click(
  143. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  144. );
  145. expect(handleCellAction).toHaveBeenCalledWith(
  146. 'show_greater_than',
  147. '2020-06-09T01:46:25+00:00'
  148. );
  149. });
  150. it('less than button adds condition', async function () {
  151. renderComponent({eventView: view, handleCellAction, columnIndex: 2});
  152. await openMenu();
  153. await userEvent.click(
  154. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  155. );
  156. expect(handleCellAction).toHaveBeenCalledWith(
  157. 'show_less_than',
  158. '2020-06-09T01:46:25+00:00'
  159. );
  160. });
  161. it('error.handled with null adds condition', async function () {
  162. renderComponent({
  163. eventView: view,
  164. handleCellAction,
  165. columnIndex: 7,
  166. data: defaultData,
  167. });
  168. await openMenu();
  169. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  170. expect(handleCellAction).toHaveBeenCalledWith('add', 1);
  171. });
  172. it('error.type with array values adds condition', async function () {
  173. renderComponent({
  174. eventView: view,
  175. handleCellAction,
  176. columnIndex: 8,
  177. data: defaultData,
  178. });
  179. await openMenu();
  180. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  181. expect(handleCellAction).toHaveBeenCalledWith('add', [
  182. 'ServerException',
  183. 'ClickhouseError',
  184. 'QueryException',
  185. 'QueryException',
  186. ]);
  187. });
  188. it('error.handled with 0 adds condition', async function () {
  189. renderComponent({
  190. eventView: view,
  191. handleCellAction,
  192. columnIndex: 7,
  193. data: {
  194. ...defaultData,
  195. // TODO: Fix this type
  196. // @ts-ignore
  197. 'error.handled': ['0'],
  198. },
  199. });
  200. await openMenu();
  201. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  202. expect(handleCellAction).toHaveBeenCalledWith('add', ['0']);
  203. });
  204. it('show appropriate actions for string cells', async function () {
  205. renderComponent({eventView: view, handleCellAction, columnIndex: 0});
  206. await openMenu();
  207. expect(
  208. screen.getByRole('menuitemradio', {name: 'Add to filter'})
  209. ).toBeInTheDocument();
  210. expect(
  211. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  212. ).toBeInTheDocument();
  213. expect(
  214. screen.queryByRole('menuitemradio', {name: 'Show values greater than'})
  215. ).not.toBeInTheDocument();
  216. expect(
  217. screen.queryByRole('menuitemradio', {name: 'Show values less than'})
  218. ).not.toBeInTheDocument();
  219. });
  220. it('show appropriate actions for string cells with null values', async function () {
  221. renderComponent({eventView: view, handleCellAction, columnIndex: 4});
  222. await openMenu();
  223. expect(
  224. screen.getByRole('menuitemradio', {name: 'Add to filter'})
  225. ).toBeInTheDocument();
  226. expect(
  227. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  228. ).toBeInTheDocument();
  229. });
  230. it('show appropriate actions for number cells', async function () {
  231. renderComponent({eventView: view, handleCellAction, columnIndex: 1});
  232. await openMenu();
  233. expect(
  234. screen.queryByRole('menuitemradio', {name: 'Add to filter'})
  235. ).not.toBeInTheDocument();
  236. expect(
  237. screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
  238. ).not.toBeInTheDocument();
  239. expect(
  240. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  241. ).toBeInTheDocument();
  242. expect(
  243. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  244. ).toBeInTheDocument();
  245. });
  246. it('show appropriate actions for date cells', async function () {
  247. renderComponent({eventView: view, handleCellAction, columnIndex: 2});
  248. await openMenu();
  249. expect(
  250. screen.getByRole('menuitemradio', {name: 'Add to filter'})
  251. ).toBeInTheDocument();
  252. expect(
  253. screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
  254. ).not.toBeInTheDocument();
  255. expect(
  256. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  257. ).toBeInTheDocument();
  258. expect(
  259. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  260. ).toBeInTheDocument();
  261. });
  262. it('show appropriate actions for release cells', async function () {
  263. renderComponent({eventView: view, handleCellAction, columnIndex: 3});
  264. await openMenu();
  265. expect(
  266. screen.getByRole('menuitemradio', {name: 'Go to release'})
  267. ).toBeInTheDocument();
  268. });
  269. it('show appropriate actions for empty release cells', async function () {
  270. renderComponent({
  271. eventView: view,
  272. handleCellAction,
  273. columnIndex: 3,
  274. // TODO: Fix this type
  275. // @ts-ignore
  276. data: {...defaultData, release: null},
  277. });
  278. await openMenu();
  279. expect(
  280. screen.queryByRole('menuitemradio', {name: 'Go to release'})
  281. ).not.toBeInTheDocument();
  282. });
  283. it('show appropriate actions for measurement cells', async function () {
  284. renderComponent({eventView: view, handleCellAction, columnIndex: 5});
  285. await openMenu();
  286. expect(
  287. screen.queryByRole('menuitemradio', {name: 'Add to filter'})
  288. ).not.toBeInTheDocument();
  289. expect(
  290. screen.queryByRole('menuitemradio', {name: 'Exclude from filter'})
  291. ).not.toBeInTheDocument();
  292. expect(
  293. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  294. ).toBeInTheDocument();
  295. expect(
  296. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  297. ).toBeInTheDocument();
  298. });
  299. it('show appropriate actions for empty measurement cells', async function () {
  300. renderComponent({
  301. eventView: view,
  302. handleCellAction,
  303. columnIndex: 5,
  304. data: {
  305. ...defaultData,
  306. // TODO: Fix this type
  307. // @ts-ignore
  308. 'measurements.fcp': null,
  309. },
  310. });
  311. await openMenu();
  312. expect(
  313. screen.getByRole('menuitemradio', {name: 'Add to filter'})
  314. ).toBeInTheDocument();
  315. expect(
  316. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  317. ).toBeInTheDocument();
  318. expect(
  319. screen.queryByRole('menuitemradio', {name: 'Show values greater than'})
  320. ).not.toBeInTheDocument();
  321. expect(
  322. screen.queryByRole('menuitemradio', {name: 'Show values less than'})
  323. ).not.toBeInTheDocument();
  324. });
  325. it('show appropriate actions for numeric function cells', async function () {
  326. renderComponent({eventView: view, handleCellAction, columnIndex: 6});
  327. await openMenu();
  328. expect(
  329. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  330. ).toBeInTheDocument();
  331. expect(
  332. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  333. ).toBeInTheDocument();
  334. });
  335. it('show appropriate actions for empty numeric function cells', function () {
  336. renderComponent({
  337. eventView: view,
  338. handleCellAction,
  339. columnIndex: 6,
  340. data: {
  341. ...defaultData,
  342. // TODO: Fix this type
  343. // @ts-ignore
  344. 'percentile(measurements.fcp, 0.5)': null,
  345. },
  346. });
  347. expect(screen.queryByRole('button', {name: 'Actions'})).not.toBeInTheDocument();
  348. });
  349. });
  350. });
  351. describe('updateQuery()', function () {
  352. const columnA: TableColumn<keyof TableDataRow> = {
  353. key: 'a',
  354. name: 'a',
  355. type: 'number',
  356. isSortable: false,
  357. column: {
  358. kind: 'field',
  359. field: 'a',
  360. },
  361. width: -1,
  362. };
  363. const columnB: TableColumn<keyof TableDataRow> = {
  364. key: 'b',
  365. name: 'b',
  366. type: 'number',
  367. isSortable: false,
  368. column: {
  369. kind: 'field',
  370. field: 'b',
  371. },
  372. width: -1,
  373. };
  374. it('modifies the query with has/!has', function () {
  375. let results = new MutableSearch([]);
  376. // TODO: Fix this type
  377. // @ts-ignore
  378. updateQuery(results, Actions.ADD, columnA, null);
  379. expect(results.formatString()).toEqual('!has:a');
  380. // TODO: Fix this type
  381. // @ts-ignore
  382. updateQuery(results, Actions.EXCLUDE, columnA, null);
  383. expect(results.formatString()).toEqual('has:a');
  384. // TODO: Fix this type
  385. // @ts-ignore
  386. updateQuery(results, Actions.ADD, columnA, null);
  387. expect(results.formatString()).toEqual('!has:a');
  388. results = new MutableSearch([]);
  389. // TODO: Fix this type
  390. // @ts-ignore
  391. updateQuery(results, Actions.ADD, columnA, [null]);
  392. expect(results.formatString()).toEqual('!has:a');
  393. });
  394. it('modifies the query with additions', function () {
  395. const results = new MutableSearch([]);
  396. updateQuery(results, Actions.ADD, columnA, '1');
  397. expect(results.formatString()).toEqual('a:1');
  398. updateQuery(results, Actions.ADD, columnB, '1');
  399. expect(results.formatString()).toEqual('a:1 b:1');
  400. updateQuery(results, Actions.ADD, columnA, '2');
  401. expect(results.formatString()).toEqual('b:1 a:2');
  402. updateQuery(results, Actions.ADD, columnA, ['1', '2', '3']);
  403. expect(results.formatString()).toEqual('b:1 a:2 a:1 a:3');
  404. });
  405. it('modifies the query with exclusions', function () {
  406. const results = new MutableSearch([]);
  407. updateQuery(results, Actions.EXCLUDE, columnA, '1');
  408. expect(results.formatString()).toEqual('!a:1');
  409. updateQuery(results, Actions.EXCLUDE, columnB, '1');
  410. expect(results.formatString()).toEqual('!a:1 !b:1');
  411. updateQuery(results, Actions.EXCLUDE, columnA, '2');
  412. expect(results.formatString()).toEqual('!b:1 !a:1 !a:2');
  413. updateQuery(results, Actions.EXCLUDE, columnA, ['1', '2', '3']);
  414. expect(results.formatString()).toEqual('!b:1 !a:1 !a:2 !a:3');
  415. });
  416. it('modifies the query with a mix of additions and exclusions', function () {
  417. const results = new MutableSearch([]);
  418. updateQuery(results, Actions.ADD, columnA, '1');
  419. expect(results.formatString()).toEqual('a:1');
  420. updateQuery(results, Actions.ADD, columnB, '2');
  421. expect(results.formatString()).toEqual('a:1 b:2');
  422. updateQuery(results, Actions.EXCLUDE, columnA, '3');
  423. expect(results.formatString()).toEqual('b:2 !a:3');
  424. updateQuery(results, Actions.EXCLUDE, columnB, '4');
  425. expect(results.formatString()).toEqual('!a:3 !b:4');
  426. results.addFilterValues('!a', ['*dontescapeme*'], false);
  427. expect(results.formatString()).toEqual('!a:3 !b:4 !a:*dontescapeme*');
  428. updateQuery(results, Actions.EXCLUDE, columnA, '*escapeme*');
  429. expect(results.formatString()).toEqual(
  430. '!b:4 !a:3 !a:*dontescapeme* !a:"\\*escapeme\\*"'
  431. );
  432. updateQuery(results, Actions.ADD, columnA, '5');
  433. expect(results.formatString()).toEqual('!b:4 a:5');
  434. updateQuery(results, Actions.ADD, columnB, '6');
  435. expect(results.formatString()).toEqual('a:5 b:6');
  436. });
  437. it('modifies the query with greater/less than', function () {
  438. const results = new MutableSearch([]);
  439. updateQuery(results, Actions.SHOW_GREATER_THAN, columnA, 1);
  440. expect(results.formatString()).toEqual('a:>1');
  441. updateQuery(results, Actions.SHOW_GREATER_THAN, columnA, 2);
  442. expect(results.formatString()).toEqual('a:>2');
  443. updateQuery(results, Actions.SHOW_LESS_THAN, columnA, 3);
  444. expect(results.formatString()).toEqual('a:<3');
  445. updateQuery(results, Actions.SHOW_LESS_THAN, columnA, 4);
  446. expect(results.formatString()).toEqual('a:<4');
  447. });
  448. it('modifies the query with greater/less than on duration fields', function () {
  449. const columnADuration: TableColumn<keyof TableDataRow> = {
  450. ...columnA,
  451. type: 'duration',
  452. };
  453. const results = new MutableSearch([]);
  454. updateQuery(results, Actions.SHOW_GREATER_THAN, columnADuration, 1);
  455. expect(results.formatString()).toEqual('a:>1.00ms');
  456. updateQuery(results, Actions.SHOW_GREATER_THAN, columnADuration, 2);
  457. expect(results.formatString()).toEqual('a:>2.00ms');
  458. updateQuery(results, Actions.SHOW_LESS_THAN, columnADuration, 3);
  459. expect(results.formatString()).toEqual('a:<3.00ms');
  460. updateQuery(results, Actions.SHOW_LESS_THAN, columnADuration, 4.1234);
  461. expect(results.formatString()).toEqual('a:<4.12ms');
  462. });
  463. it('does not error for special actions', function () {
  464. const results = new MutableSearch([]);
  465. updateQuery(results, Actions.RELEASE, columnA, '');
  466. updateQuery(results, Actions.DRILLDOWN, columnA, '');
  467. });
  468. it('errors for unknown actions', function () {
  469. const results = new MutableSearch([]);
  470. // TODO: Fix this type
  471. // @ts-ignore
  472. expect(() => updateQuery(results, 'unknown', columnA, '')).toThrow();
  473. });
  474. });