tableView.spec.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520
  1. import {LocationFixture} from 'sentry-fixture/locationFixture';
  2. import {OrganizationFixture} from 'sentry-fixture/organization';
  3. import {initializeOrg} from 'sentry-test/initializeOrg';
  4. import {act, render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary';
  5. import ProjectsStore from 'sentry/stores/projectsStore';
  6. import TagStore from 'sentry/stores/tagStore';
  7. import type {TableData} from 'sentry/utils/discover/discoverQuery';
  8. import EventView from 'sentry/utils/discover/eventView';
  9. import {SavedQueryDatasets} from 'sentry/utils/discover/types';
  10. import TableView from 'sentry/views/discover/table/tableView';
  11. describe('TableView > CellActions', function () {
  12. let initialData: ReturnType<typeof initializeOrg>;
  13. let rows: any;
  14. let onChangeShowTags: jest.Mock;
  15. const location = LocationFixture({
  16. pathname: '/organizations/org-slug/discover/results/',
  17. query: {
  18. id: '42',
  19. name: 'best query',
  20. field: [
  21. 'title',
  22. 'transaction',
  23. 'count()',
  24. 'timestamp',
  25. 'release',
  26. 'equation|count() + 100',
  27. ],
  28. sort: ['title'],
  29. query: '',
  30. project: ['123'],
  31. statsPeriod: '14d',
  32. environment: ['staging'],
  33. yAxis: 'p95',
  34. },
  35. });
  36. const eventView = EventView.fromLocation(location);
  37. function renderComponent(
  38. context: ReturnType<typeof initializeOrg>,
  39. tableData: TableData,
  40. view: EventView
  41. ) {
  42. return render(
  43. <TableView
  44. organization={context.organization}
  45. location={location}
  46. eventView={view}
  47. isLoading={false}
  48. tableData={tableData}
  49. onChangeShowTags={onChangeShowTags}
  50. error={null}
  51. isFirstPage
  52. measurementKeys={null}
  53. showTags={false}
  54. title=""
  55. queryDataset={SavedQueryDatasets.TRANSACTIONS}
  56. />,
  57. {router: context.router}
  58. );
  59. }
  60. async function openContextMenu(cellIndex: number) {
  61. const firstRow = screen.getAllByRole('row')[1]!;
  62. const emptyValueCell = within(firstRow).getAllByRole('cell')[cellIndex]!;
  63. await userEvent.click(within(emptyValueCell).getByRole('button', {name: 'Actions'}));
  64. }
  65. beforeEach(function () {
  66. const organization = OrganizationFixture({
  67. features: ['discover-basic'],
  68. });
  69. initialData = initializeOrg({
  70. organization,
  71. router: {location},
  72. });
  73. act(() => {
  74. ProjectsStore.loadInitialData(initialData.projects);
  75. TagStore.reset();
  76. TagStore.loadTagsSuccess([
  77. {name: 'size', key: 'size'},
  78. {name: 'shape', key: 'shape'},
  79. {name: 'direction', key: 'direction'},
  80. ]);
  81. });
  82. onChangeShowTags = jest.fn();
  83. rows = {
  84. meta: {
  85. title: 'string',
  86. transaction: 'string',
  87. 'count()': 'integer',
  88. timestamp: 'date',
  89. release: 'string',
  90. 'equation[0]': 'integer',
  91. },
  92. data: [
  93. {
  94. title: 'some title',
  95. transaction: '/organizations/',
  96. 'count()': 9,
  97. timestamp: '2019-05-23T22:12:48+00:00',
  98. release: 'v1.0.2',
  99. 'equation[0]': 109,
  100. },
  101. ],
  102. };
  103. MockApiClient.addMockResponse({
  104. url: '/organizations/org-slug/dynamic-sampling/custom-rules/',
  105. method: 'GET',
  106. statusCode: 204,
  107. body: '',
  108. });
  109. });
  110. afterEach(() => {
  111. ProjectsStore.reset();
  112. });
  113. it('updates sort order on equation fields', function () {
  114. const view = eventView.clone();
  115. renderComponent(initialData, rows, view);
  116. const equationCell = screen.getByRole('columnheader', {name: 'count() + 100'});
  117. const sortLink = within(equationCell).getByRole('link');
  118. expect(sortLink).toHaveAttribute(
  119. 'href',
  120. '/organizations/org-slug/discover/results/?environment=staging&field=title&field=transaction&field=count%28%29&field=timestamp&field=release&field=equation%7Ccount%28%29%20%2B%20100&id=42&name=best%20query&project=123&query=&sort=-equation%7Ccount%28%29%20%2B%20100&statsPeriod=14d&yAxis=p95'
  121. );
  122. });
  123. it('updates sort order on non-equation fields', function () {
  124. const view = eventView.clone();
  125. renderComponent(initialData, rows, view);
  126. const transactionCell = screen.getByRole('columnheader', {name: 'transaction'});
  127. const sortLink = within(transactionCell).getByRole('link');
  128. expect(sortLink).toHaveAttribute(
  129. 'href',
  130. '/organizations/org-slug/discover/results/?environment=staging&field=title&field=transaction&field=count%28%29&field=timestamp&field=release&field=equation%7Ccount%28%29%20%2B%20100&id=42&name=best%20query&project=123&query=&sort=-transaction&statsPeriod=14d&yAxis=p95'
  131. );
  132. });
  133. it('handles add cell action on null value', async function () {
  134. rows.data[0].title = null;
  135. renderComponent(initialData, rows, eventView);
  136. await openContextMenu(1);
  137. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  138. expect(initialData.router.push).toHaveBeenCalledWith({
  139. pathname: location.pathname,
  140. query: expect.objectContaining({
  141. query: '!has:title',
  142. }),
  143. });
  144. });
  145. it('handles add cell action on null value replace has condition', async function () {
  146. rows.data[0].title = null;
  147. const view = eventView.clone();
  148. view.query = 'tag:value has:title';
  149. renderComponent(initialData, rows, view);
  150. await openContextMenu(1);
  151. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  152. expect(initialData.router.push).toHaveBeenCalledWith({
  153. pathname: location.pathname,
  154. query: expect.objectContaining({
  155. query: 'tag:value !has:title',
  156. }),
  157. });
  158. });
  159. it('handles add cell action on string value replace negation', async function () {
  160. const view = eventView.clone();
  161. view.query = 'tag:value !title:nope';
  162. renderComponent(initialData, rows, view);
  163. await openContextMenu(1);
  164. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  165. expect(initialData.router.push).toHaveBeenCalledWith({
  166. pathname: location.pathname,
  167. query: expect.objectContaining({
  168. query: 'tag:value title:"some title"',
  169. }),
  170. });
  171. });
  172. it('handles add cell action with multiple y axis', async function () {
  173. location.query.yAxis = ['count()', 'failure_count()'];
  174. renderComponent(initialData, rows, eventView);
  175. await openContextMenu(1);
  176. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'}));
  177. expect(initialData.router.push).toHaveBeenCalledWith({
  178. pathname: location.pathname,
  179. query: expect.objectContaining({
  180. query: 'title:"some title"',
  181. yAxis: ['count()', 'failure_count()'],
  182. }),
  183. });
  184. });
  185. it('handles exclude cell action on string value', async function () {
  186. renderComponent(initialData, rows, eventView);
  187. await openContextMenu(1);
  188. await userEvent.click(
  189. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  190. );
  191. expect(initialData.router.push).toHaveBeenCalledWith({
  192. pathname: location.pathname,
  193. query: expect.objectContaining({
  194. query: '!title:"some title"',
  195. }),
  196. });
  197. });
  198. it('handles exclude cell action on string value replace inclusion', async function () {
  199. const view = eventView.clone();
  200. view.query = 'tag:value title:nope';
  201. renderComponent(initialData, rows, view);
  202. await openContextMenu(1);
  203. await userEvent.click(
  204. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  205. );
  206. expect(initialData.router.push).toHaveBeenCalledWith({
  207. pathname: location.pathname,
  208. query: expect.objectContaining({
  209. query: 'tag:value !title:"some title"',
  210. }),
  211. });
  212. });
  213. it('handles exclude cell action on null value', async function () {
  214. rows.data[0].title = null;
  215. renderComponent(initialData, rows, eventView);
  216. await openContextMenu(1);
  217. await userEvent.click(
  218. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  219. );
  220. expect(initialData.router.push).toHaveBeenCalledWith({
  221. pathname: location.pathname,
  222. query: expect.objectContaining({
  223. query: 'has:title',
  224. }),
  225. });
  226. });
  227. it('handles exclude cell action on null value replace condition', async function () {
  228. const view = eventView.clone();
  229. view.query = 'tag:value !has:title';
  230. rows.data[0].title = null;
  231. renderComponent(initialData, rows, view);
  232. await openContextMenu(1);
  233. await userEvent.click(
  234. screen.getByRole('menuitemradio', {name: 'Exclude from filter'})
  235. );
  236. expect(initialData.router.push).toHaveBeenCalledWith({
  237. pathname: location.pathname,
  238. query: expect.objectContaining({
  239. query: 'tag:value has:title',
  240. }),
  241. });
  242. });
  243. it('handles greater than cell action on number value', async function () {
  244. renderComponent(initialData, rows, eventView);
  245. await openContextMenu(3);
  246. await userEvent.click(
  247. screen.getByRole('menuitemradio', {name: 'Show values greater than'})
  248. );
  249. expect(initialData.router.push).toHaveBeenCalledWith({
  250. pathname: location.pathname,
  251. query: expect.objectContaining({
  252. query: 'count():>9',
  253. }),
  254. });
  255. });
  256. it('handles less than cell action on number value', async function () {
  257. renderComponent(initialData, rows, eventView);
  258. await openContextMenu(3);
  259. await userEvent.click(
  260. screen.getByRole('menuitemradio', {name: 'Show values less than'})
  261. );
  262. expect(initialData.router.push).toHaveBeenCalledWith({
  263. pathname: location.pathname,
  264. query: expect.objectContaining({
  265. query: 'count():<9',
  266. }),
  267. });
  268. });
  269. it('renders transaction summary link', function () {
  270. rows.data[0].project = 'project-slug';
  271. renderComponent(initialData, rows, eventView);
  272. const firstRow = screen.getAllByRole('row')[1]!;
  273. const link = within(firstRow).getByTestId('tableView-transaction-link');
  274. expect(link).toHaveAttribute(
  275. 'href',
  276. expect.stringMatching(
  277. RegExp(
  278. '/organizations/org-slug/performance/summary/?.*project=2&referrer=performance-transaction-summary.*transaction=%2.*'
  279. )
  280. )
  281. );
  282. });
  283. it('renders trace view link', function () {
  284. const org = OrganizationFixture({
  285. features: [
  286. 'discover-basic',
  287. 'performance-discover-dataset-selector',
  288. 'trace-view-v1',
  289. ],
  290. });
  291. rows = {
  292. meta: {
  293. trace: 'string',
  294. id: 'string',
  295. transaction: 'string',
  296. timestamp: 'date',
  297. project: 'string',
  298. },
  299. data: [
  300. {
  301. trace: '7fdf8efed85a4f9092507063ced1995b',
  302. id: '509663014077465b8981b65225bdec0f',
  303. transaction: '/organizations/',
  304. timestamp: '2019-05-23T22:12:48+00:00',
  305. project: 'project-slug',
  306. },
  307. ],
  308. };
  309. const loc = LocationFixture({
  310. pathname: '/organizations/org-slug/discover/results/',
  311. query: {
  312. id: '42',
  313. name: 'best query',
  314. field: ['id', 'transaction', 'timestamp'],
  315. queryDataset: 'transaction-like',
  316. sort: ['transaction'],
  317. query: '',
  318. project: ['123'],
  319. statsPeriod: '14d',
  320. environment: ['staging'],
  321. yAxis: 'p95',
  322. },
  323. });
  324. initialData = initializeOrg({
  325. organization: org,
  326. router: {location: loc},
  327. });
  328. renderComponent(initialData, rows, EventView.fromLocation(loc));
  329. const firstRow = screen.getAllByRole('row')[1]!;
  330. const link = within(firstRow).getByTestId('view-event');
  331. expect(link).toHaveAttribute(
  332. 'href',
  333. expect.stringMatching(
  334. RegExp(
  335. '/organizations/org-slug/discover/trace/7fdf8efed85a4f9092507063ced1995b/?.*'
  336. )
  337. )
  338. );
  339. });
  340. it('handles go to release', async function () {
  341. renderComponent(initialData, rows, eventView);
  342. await openContextMenu(5);
  343. await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'}));
  344. expect(initialData.router.push).toHaveBeenCalledWith({
  345. pathname: '/organizations/org-slug/releases/v1.0.2/',
  346. query: expect.objectContaining({
  347. environment: eventView.environment,
  348. }),
  349. });
  350. });
  351. it('has title on integer value greater than 999', function () {
  352. rows.data[0]['count()'] = 1000;
  353. renderComponent(initialData, rows, eventView);
  354. const firstRow = screen.getAllByRole('row')[1]!;
  355. const emptyValueCell = within(firstRow).getAllByRole('cell')[3]!;
  356. expect(within(emptyValueCell).getByText('1k')).toHaveAttribute('title', '1,000');
  357. });
  358. it('renders size columns correctly', function () {
  359. const orgWithFeature = OrganizationFixture();
  360. render(
  361. <TableView
  362. organization={orgWithFeature}
  363. location={location}
  364. eventView={EventView.fromLocation({
  365. ...location,
  366. query: {
  367. ...location.query,
  368. field: [
  369. 'title',
  370. 'p99(measurements.custom.kibibyte)',
  371. 'p99(measurements.custom.kilobyte)',
  372. ],
  373. },
  374. })}
  375. isLoading={false}
  376. tableData={{
  377. data: [
  378. {
  379. id: '1',
  380. title: '/random/transaction/name',
  381. 'p99(measurements.custom.kibibyte)': 222.3,
  382. 'p99(measurements.custom.kilobyte)': 444.3,
  383. },
  384. ],
  385. meta: {
  386. title: 'string',
  387. 'p99(measurements.custom.kibibyte)': 'size',
  388. 'p99(measurements.custom.kilobyte)': 'size',
  389. units: {
  390. 'p99(measurements.custom.kibibyte)': 'kibibyte',
  391. 'p99(measurements.custom.kilobyte)': 'kilobyte',
  392. },
  393. },
  394. }}
  395. onChangeShowTags={onChangeShowTags}
  396. error={null}
  397. isFirstPage
  398. measurementKeys={null}
  399. showTags={false}
  400. title=""
  401. />
  402. );
  403. expect(screen.getByText('222.3 KiB')).toBeInTheDocument();
  404. expect(screen.getByText('444.3 KB')).toBeInTheDocument();
  405. });
  406. it('shows events with value less than selected custom performance metric', async function () {
  407. const orgWithFeature = OrganizationFixture();
  408. render(
  409. <TableView
  410. organization={orgWithFeature}
  411. location={location}
  412. eventView={EventView.fromLocation({
  413. ...location,
  414. query: {
  415. ...location.query,
  416. field: ['title', 'p99(measurements.custom.kilobyte)'],
  417. },
  418. })}
  419. isLoading={false}
  420. tableData={{
  421. data: [
  422. {
  423. id: '1',
  424. title: '/random/transaction/name',
  425. 'p99(measurements.custom.kilobyte)': 444.3,
  426. },
  427. ],
  428. meta: {
  429. title: 'string',
  430. 'p99(measurements.custom.kilobyte)': 'size',
  431. units: {'p99(measurements.custom.kilobyte)': 'kilobyte'},
  432. },
  433. }}
  434. onChangeShowTags={onChangeShowTags}
  435. error={null}
  436. isFirstPage
  437. measurementKeys={null}
  438. showTags={false}
  439. title=""
  440. />,
  441. {router: initialData.router}
  442. );
  443. await userEvent.hover(screen.getByText('444.3 KB'));
  444. const buttons = screen.getAllByRole('button');
  445. await userEvent.click(buttons[buttons.length - 1]!);
  446. await userEvent.click(screen.getByText('Show values less than'));
  447. expect(initialData.router.push).toHaveBeenCalledWith({
  448. pathname: location.pathname,
  449. query: expect.objectContaining({
  450. query: 'p99(measurements.custom.kilobyte):<444300',
  451. }),
  452. });
  453. });
  454. });