table.tsx 13 KB

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