table.tsx 13 KB

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