cellAction.spec.tsx 17 KB

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