cellAction.spec.tsx 17 KB

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