table.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. import {Component, Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location, LocationDescriptorObject} from 'history';
  5. import GridEditable, {
  6. COL_WIDTH_UNDEFINED,
  7. GridColumn,
  8. } from 'sentry/components/gridEditable';
  9. import SortLink from 'sentry/components/gridEditable/sortLink';
  10. import Link from 'sentry/components/links/link';
  11. import Pagination from 'sentry/components/pagination';
  12. import Tag from 'sentry/components/tag';
  13. import {IconStar} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {Organization, Project} from 'sentry/types';
  16. import {trackAnalyticsEvent} from 'sentry/utils/analytics';
  17. import EventView, {
  18. EventData,
  19. EventsMetaType,
  20. isFieldSortable,
  21. } from 'sentry/utils/discover/eventView';
  22. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  23. import {fieldAlignment, getAggregateAlias, Sort} from 'sentry/utils/discover/fields';
  24. import {WebVital} from 'sentry/utils/fields';
  25. import VitalsDetailsTableQuery, {
  26. TableData,
  27. TableDataRow,
  28. } from 'sentry/utils/performance/vitals/vitalsDetailsTableQuery';
  29. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  30. import CellAction, {Actions, updateQuery} from 'sentry/views/eventsV2/table/cellAction';
  31. import {TableColumn} from 'sentry/views/eventsV2/table/types';
  32. import {DisplayModes} from '../transactionSummary/transactionOverview/charts';
  33. import {
  34. normalizeSearchConditionsWithTransactionName,
  35. TransactionFilterOptions,
  36. transactionSummaryRouteWithQuery,
  37. } from '../transactionSummary/utils';
  38. import {getSelectedProjectPlatforms} from '../utils';
  39. import {
  40. getVitalDetailTableMehStatusFunction,
  41. getVitalDetailTablePoorStatusFunction,
  42. vitalAbbreviations,
  43. vitalNameFromLocation,
  44. VitalState,
  45. vitalStateColors,
  46. } from './utils';
  47. const COLUMN_TITLES = ['Transaction', 'Project', 'Unique Users', 'Count'];
  48. const getTableColumnTitle = (index: number, vitalName: WebVital) => {
  49. const abbrev = vitalAbbreviations[vitalName];
  50. const titles = [
  51. ...COLUMN_TITLES,
  52. `p50(${abbrev})`,
  53. `p75(${abbrev})`,
  54. `p95(${abbrev})`,
  55. `Status`,
  56. ];
  57. return titles[index];
  58. };
  59. export function getProjectID(
  60. eventData: EventData,
  61. projects: Project[]
  62. ): string | undefined {
  63. const projectSlug = (eventData?.project as string) || undefined;
  64. if (typeof projectSlug === undefined) {
  65. return undefined;
  66. }
  67. const project = projects.find(currentProject => currentProject.slug === projectSlug);
  68. if (!project) {
  69. return undefined;
  70. }
  71. return project.id;
  72. }
  73. type Props = {
  74. eventView: EventView;
  75. location: Location;
  76. organization: Organization;
  77. projects: Project[];
  78. setError: (msg: string | undefined) => void;
  79. summaryConditions: string;
  80. };
  81. type State = {
  82. widths: number[];
  83. };
  84. class Table extends Component<Props, State> {
  85. state: State = {
  86. widths: [],
  87. };
  88. handleCellAction = (column: TableColumn<keyof TableDataRow>) => {
  89. return (action: Actions, value: React.ReactText) => {
  90. const {eventView, location, organization} = this.props;
  91. trackAnalyticsEvent({
  92. eventKey: 'performance_views.overview.cellaction',
  93. eventName: 'Performance Views: Cell Action Clicked',
  94. organization_id: parseInt(organization.id, 10),
  95. action,
  96. });
  97. const searchConditions = normalizeSearchConditionsWithTransactionName(
  98. eventView.query
  99. );
  100. updateQuery(searchConditions, action, column, value);
  101. browserHistory.push({
  102. pathname: location.pathname,
  103. query: {
  104. ...location.query,
  105. cursor: undefined,
  106. query: searchConditions.formatString(),
  107. },
  108. });
  109. };
  110. };
  111. renderBodyCell(
  112. tableData: TableData | null,
  113. column: TableColumn<keyof TableDataRow>,
  114. dataRow: TableDataRow,
  115. vitalName: WebVital
  116. ): React.ReactNode {
  117. const {eventView, organization, projects, location, summaryConditions} = this.props;
  118. if (!tableData || !tableData.meta?.fields) {
  119. return dataRow[column.key];
  120. }
  121. const tableMeta = tableData.meta?.fields;
  122. const field = String(column.key);
  123. if (field === getVitalDetailTablePoorStatusFunction(vitalName)) {
  124. if (dataRow[field]) {
  125. return (
  126. <UniqueTagCell>
  127. <PoorTag>{t('Poor')}</PoorTag>
  128. </UniqueTagCell>
  129. );
  130. }
  131. if (dataRow[getVitalDetailTableMehStatusFunction(vitalName)]) {
  132. return (
  133. <UniqueTagCell>
  134. <MehTag>{t('Meh')}</MehTag>
  135. </UniqueTagCell>
  136. );
  137. }
  138. return (
  139. <UniqueTagCell>
  140. <GoodTag>{t('Good')}</GoodTag>
  141. </UniqueTagCell>
  142. );
  143. }
  144. const fieldRenderer = getFieldRenderer(field, tableMeta, false);
  145. const rendered = fieldRenderer(dataRow, {organization, location});
  146. const allowActions = [
  147. Actions.ADD,
  148. Actions.EXCLUDE,
  149. Actions.SHOW_GREATER_THAN,
  150. Actions.SHOW_LESS_THAN,
  151. ];
  152. if (field === 'transaction') {
  153. const projectID = getProjectID(dataRow, projects);
  154. const summaryView = eventView.clone();
  155. const conditions = new MutableSearch(summaryConditions);
  156. conditions.addFilterValues('has', [`${vitalName}`]);
  157. summaryView.query = conditions.formatString();
  158. const transaction = String(dataRow.transaction) || '';
  159. const target = transactionSummaryRouteWithQuery({
  160. orgSlug: organization.slug,
  161. transaction,
  162. query: summaryView.generateQueryStringObject(),
  163. projectID,
  164. showTransactions: TransactionFilterOptions.RECENT,
  165. display: DisplayModes.VITALS,
  166. });
  167. return (
  168. <CellAction
  169. column={column}
  170. dataRow={dataRow}
  171. handleCellAction={this.handleCellAction(column)}
  172. allowActions={allowActions}
  173. >
  174. <Link
  175. to={target}
  176. aria-label={t('See transaction summary of the transaction %s', transaction)}
  177. onClick={this.handleSummaryClick}
  178. >
  179. {rendered}
  180. </Link>
  181. </CellAction>
  182. );
  183. }
  184. if (field.startsWith('team_key_transaction')) {
  185. return rendered;
  186. }
  187. return (
  188. <CellAction
  189. column={column}
  190. dataRow={dataRow}
  191. handleCellAction={this.handleCellAction(column)}
  192. allowActions={allowActions}
  193. >
  194. {rendered}
  195. </CellAction>
  196. );
  197. }
  198. renderBodyCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
  199. return (
  200. column: TableColumn<keyof TableDataRow>,
  201. dataRow: TableDataRow
  202. ): React.ReactNode => this.renderBodyCell(tableData, column, dataRow, vitalName);
  203. };
  204. renderHeadCell(
  205. column: TableColumn<keyof TableDataRow>,
  206. title: React.ReactNode,
  207. tableMeta?: EventsMetaType['fields']
  208. ): React.ReactNode {
  209. const {eventView, location} = this.props;
  210. // TODO: Need to map table meta keys to aggregate alias since eventView sorting still expects
  211. // aggregate aliases for now. We'll need to refactor event view to get rid of all aggregate
  212. // alias references and then we can remove this.
  213. const aggregateAliasTableMeta: EventsMetaType['fields'] | undefined = tableMeta
  214. ? {}
  215. : undefined;
  216. if (tableMeta) {
  217. Object.keys(tableMeta).forEach(key => {
  218. aggregateAliasTableMeta![getAggregateAlias(key)] = tableMeta[key];
  219. });
  220. }
  221. const align = fieldAlignment(column.name, column.type, aggregateAliasTableMeta);
  222. const field = {field: column.name, width: column.width};
  223. function generateSortLink(): LocationDescriptorObject | undefined {
  224. if (!aggregateAliasTableMeta) {
  225. return undefined;
  226. }
  227. const nextEventView = eventView.sortOnField(field, aggregateAliasTableMeta);
  228. const queryStringObject = nextEventView.generateQueryStringObject();
  229. return {
  230. ...location,
  231. query: {...location.query, sort: queryStringObject.sort},
  232. };
  233. }
  234. const currentSort = eventView.sortForField(field, aggregateAliasTableMeta);
  235. const canSort = isFieldSortable(field, aggregateAliasTableMeta);
  236. return (
  237. <SortLink
  238. align={align}
  239. title={title || field.field}
  240. direction={currentSort ? currentSort.kind : undefined}
  241. canSort={canSort}
  242. generateSortLink={generateSortLink}
  243. />
  244. );
  245. }
  246. renderHeadCellWithMeta = (
  247. vitalName: WebVital,
  248. tableMeta?: EventsMetaType['fields']
  249. ) => {
  250. return (column: TableColumn<keyof TableDataRow>, index: number): React.ReactNode =>
  251. this.renderHeadCell(column, getTableColumnTitle(index, vitalName), tableMeta);
  252. };
  253. renderPrependCellWithData = (tableData: TableData | null, vitalName: WebVital) => {
  254. const {eventView} = this.props;
  255. const teamKeyTransactionColumn = eventView
  256. .getColumns()
  257. .find((col: TableColumn<React.ReactText>) => col.name === 'team_key_transaction');
  258. return (isHeader: boolean, dataRow?: any) => {
  259. if (teamKeyTransactionColumn) {
  260. if (isHeader) {
  261. const star = (
  262. <IconStar
  263. key="keyTransaction"
  264. color="yellow300"
  265. isSolid
  266. data-test-id="key-transaction-header"
  267. />
  268. );
  269. return [
  270. this.renderHeadCell(teamKeyTransactionColumn, star, tableData?.meta?.fields),
  271. ];
  272. }
  273. return [
  274. this.renderBodyCell(tableData, teamKeyTransactionColumn, dataRow, vitalName),
  275. ];
  276. }
  277. return [];
  278. };
  279. };
  280. handleSummaryClick = () => {
  281. const {organization, projects, location} = this.props;
  282. trackAnalyticsEvent({
  283. eventKey: 'performance_views.overview.navigate.summary',
  284. eventName: 'Performance Views: Overview view summary',
  285. organization_id: parseInt(organization.id, 10),
  286. project_platforms: getSelectedProjectPlatforms(location, projects),
  287. });
  288. };
  289. handleResizeColumn = (columnIndex: number, nextColumn: GridColumn) => {
  290. const widths: number[] = [...this.state.widths];
  291. widths[columnIndex] = nextColumn.width
  292. ? Number(nextColumn.width)
  293. : COL_WIDTH_UNDEFINED;
  294. this.setState({widths});
  295. };
  296. getSortedEventView(vitalName: WebVital) {
  297. const {eventView} = this.props;
  298. const aggregateFieldPoor = getAggregateAlias(
  299. getVitalDetailTablePoorStatusFunction(vitalName)
  300. );
  301. const aggregateFieldMeh = getAggregateAlias(
  302. getVitalDetailTableMehStatusFunction(vitalName)
  303. );
  304. const isSortingByStatus = eventView.sorts.some(
  305. sort =>
  306. sort.field.includes(aggregateFieldPoor) || sort.field.includes(aggregateFieldMeh)
  307. );
  308. const additionalSorts: Sort[] = isSortingByStatus
  309. ? []
  310. : [
  311. {
  312. field: 'team_key_transaction',
  313. kind: 'desc',
  314. },
  315. {
  316. field: aggregateFieldPoor,
  317. kind: 'desc',
  318. },
  319. {
  320. field: aggregateFieldMeh,
  321. kind: 'desc',
  322. },
  323. ];
  324. return eventView.withSorts([...additionalSorts, ...eventView.sorts]);
  325. }
  326. render() {
  327. const {eventView, organization, location} = this.props;
  328. const {widths} = this.state;
  329. const fakeColumnView = eventView.clone();
  330. fakeColumnView.fields = [...eventView.fields];
  331. const columnOrder = fakeColumnView
  332. .getColumns()
  333. // remove key_transactions from the column order as we'll be rendering it
  334. // via a prepended column
  335. .filter((col: TableColumn<React.ReactText>) => col.name !== 'team_key_transaction')
  336. .slice(0, -1)
  337. .map((col: TableColumn<React.ReactText>, i: number) => {
  338. if (typeof widths[i] === 'number') {
  339. return {...col, width: widths[i]};
  340. }
  341. return col;
  342. });
  343. const vitalName = vitalNameFromLocation(location);
  344. const sortedEventView = this.getSortedEventView(vitalName);
  345. const columnSortBy = sortedEventView.getSorts();
  346. return (
  347. <div>
  348. <VitalsDetailsTableQuery
  349. eventView={sortedEventView}
  350. orgSlug={organization.slug}
  351. location={location}
  352. limit={10}
  353. referrer="api.performance.vital-detail"
  354. >
  355. {({pageLinks, isLoading, tableData}) => (
  356. <Fragment>
  357. <GridEditable
  358. isLoading={isLoading}
  359. data={tableData ? tableData.data : []}
  360. columnOrder={columnOrder}
  361. columnSortBy={columnSortBy}
  362. grid={{
  363. onResizeColumn: this.handleResizeColumn,
  364. renderHeadCell: this.renderHeadCellWithMeta(
  365. vitalName,
  366. tableData?.meta?.fields
  367. ) as any,
  368. renderBodyCell: this.renderBodyCellWithData(
  369. tableData,
  370. vitalName
  371. ) as any,
  372. renderPrependColumns: this.renderPrependCellWithData(
  373. tableData,
  374. vitalName
  375. ) as any,
  376. prependColumnWidths: ['max-content'],
  377. }}
  378. location={location}
  379. />
  380. <Pagination pageLinks={pageLinks} />
  381. </Fragment>
  382. )}
  383. </VitalsDetailsTableQuery>
  384. </div>
  385. );
  386. }
  387. }
  388. const UniqueTagCell = styled('div')`
  389. text-align: right;
  390. justify-self: flex-end;
  391. flex-grow: 1;
  392. `;
  393. const GoodTag = styled(Tag)`
  394. div {
  395. background-color: ${p => p.theme[vitalStateColors[VitalState.GOOD]]};
  396. }
  397. span {
  398. color: ${p => p.theme.white};
  399. }
  400. `;
  401. const MehTag = styled(Tag)`
  402. div {
  403. background-color: ${p => p.theme[vitalStateColors[VitalState.MEH]]};
  404. }
  405. span {
  406. color: ${p => p.theme.white};
  407. }
  408. `;
  409. const PoorTag = styled(Tag)`
  410. div {
  411. background-color: ${p => p.theme[vitalStateColors[VitalState.POOR]]};
  412. }
  413. span {
  414. color: ${p => p.theme.white};
  415. }
  416. `;
  417. export default Table;