table.tsx 13 KB

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