transactionsTable.tsx 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import {Fragment, PureComponent} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location, LocationDescriptor} from 'history';
  4. import {LinkButton} from 'sentry/components/button';
  5. import SortLink from 'sentry/components/gridEditable/sortLink';
  6. import Link from 'sentry/components/links/link';
  7. import LoadingIndicator from 'sentry/components/loadingIndicator';
  8. import {PanelTable} from 'sentry/components/panels/panelTable';
  9. import QuestionTooltip from 'sentry/components/questionTooltip';
  10. import {IconProfiling} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import {space} from 'sentry/styles/space';
  13. import type {Organization} from 'sentry/types/organization';
  14. import {trackAnalytics} from 'sentry/utils/analytics';
  15. import type {TableData, TableDataRow} from 'sentry/utils/discover/discoverQuery';
  16. import type {MetaType} from 'sentry/utils/discover/eventView';
  17. import type EventView from 'sentry/utils/discover/eventView';
  18. import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
  19. import type {Alignments} from 'sentry/utils/discover/fields';
  20. import {fieldAlignment, getAggregateAlias} from 'sentry/utils/discover/fields';
  21. import ViewReplayLink from 'sentry/utils/discover/viewReplayLink';
  22. import {isEmptyObject} from 'sentry/utils/object/isEmptyObject';
  23. import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
  24. import type {Actions} from 'sentry/views/discover/table/cellAction';
  25. import CellAction from 'sentry/views/discover/table/cellAction';
  26. import type {TableColumn} from 'sentry/views/discover/table/types';
  27. import {GridCell, GridCellNumber} from 'sentry/views/performance/styles';
  28. import type {TrendsDataEvents} from 'sentry/views/performance/trends/types';
  29. type Props = {
  30. columnOrder: TableColumn<React.ReactText>[];
  31. eventView: EventView;
  32. isLoading: boolean;
  33. location: Location;
  34. organization: Organization;
  35. tableData: TableData | TrendsDataEvents | null;
  36. useAggregateAlias: boolean;
  37. generateLink?: Record<
  38. string,
  39. (
  40. organization: Organization,
  41. tableRow: TableDataRow,
  42. location: Location
  43. ) => LocationDescriptor
  44. >;
  45. handleCellAction?: (
  46. c: TableColumn<React.ReactText>
  47. ) => (a: Actions, v: React.ReactText) => void;
  48. referrer?: string;
  49. titles?: string[];
  50. };
  51. class TransactionsTable extends PureComponent<Props> {
  52. getTitles() {
  53. const {eventView, titles} = this.props;
  54. return titles ?? eventView.getFields();
  55. }
  56. renderHeader() {
  57. const {tableData, columnOrder} = this.props;
  58. const tableMeta = tableData?.meta;
  59. const generateSortLink = () => undefined;
  60. const tableTitles = this.getTitles();
  61. const headers = tableTitles.map((title, index) => {
  62. const column = columnOrder[index];
  63. const align: Alignments = fieldAlignment(column.name, column.type, tableMeta);
  64. if (column.key === 'span_ops_breakdown.relative') {
  65. return (
  66. <HeadCellContainer key={index}>
  67. <SortLink
  68. align={align}
  69. title={
  70. title === t('operation duration') ? (
  71. <Fragment>
  72. {title}
  73. <StyledIconQuestion
  74. size="xs"
  75. position="top"
  76. title={t(
  77. `Span durations are summed over the course of an entire transaction. Any overlapping spans are only counted once.`
  78. )}
  79. />
  80. </Fragment>
  81. ) : (
  82. title
  83. )
  84. }
  85. direction={undefined}
  86. canSort={false}
  87. generateSortLink={generateSortLink}
  88. />
  89. </HeadCellContainer>
  90. );
  91. }
  92. return (
  93. <HeadCellContainer key={index}>
  94. <SortLink
  95. align={align}
  96. title={title}
  97. direction={undefined}
  98. canSort={false}
  99. generateSortLink={generateSortLink}
  100. />
  101. </HeadCellContainer>
  102. );
  103. });
  104. return headers;
  105. }
  106. renderRow(
  107. row: TableDataRow,
  108. rowIndex: number,
  109. columnOrder: TableColumn<React.ReactText>[],
  110. tableMeta: MetaType
  111. ): React.ReactNode[] {
  112. const {
  113. eventView,
  114. organization,
  115. location,
  116. generateLink,
  117. handleCellAction,
  118. titles,
  119. useAggregateAlias,
  120. referrer,
  121. } = this.props;
  122. const fields = eventView.getFields();
  123. if (titles?.length) {
  124. // Slice to match length of given titles
  125. columnOrder = columnOrder.slice(0, titles.length);
  126. }
  127. const resultsRow = columnOrder.map((column, index) => {
  128. const field = String(column.key);
  129. // TODO add a better abstraction for this in fieldRenderers.
  130. const fieldName = useAggregateAlias ? getAggregateAlias(field) : field;
  131. const fieldType = tableMeta[fieldName];
  132. const fieldRenderer = getFieldRenderer(field, tableMeta, useAggregateAlias);
  133. let rendered = fieldRenderer(row, {organization, location});
  134. const target = generateLink?.[field]?.(organization, row, location);
  135. if (fields[index] === 'profile.id') {
  136. rendered = (
  137. <LinkButton
  138. data-test-id={`view-${fields[index]}`}
  139. disabled={!target || isEmptyObject(target)}
  140. to={target || {}}
  141. onClick={getProfileAnalyticsHandler(organization, referrer)}
  142. size="xs"
  143. >
  144. <IconProfiling size="xs" />
  145. </LinkButton>
  146. );
  147. } else if (target && !isEmptyObject(target)) {
  148. if (fields[index] === 'replayId') {
  149. rendered = (
  150. <ViewReplayLink replayId={row.replayId} to={target}>
  151. {rendered}
  152. </ViewReplayLink>
  153. );
  154. } else {
  155. rendered = (
  156. <Link data-test-id={`view-${fields[index]}`} to={target}>
  157. {rendered}
  158. </Link>
  159. );
  160. }
  161. }
  162. const isNumeric = ['integer', 'number', 'duration'].includes(fieldType);
  163. const key = `${rowIndex}:${column.key}:${index}`;
  164. rendered = isNumeric ? (
  165. <GridCellNumber data-test-id="grid-cell">{rendered}</GridCellNumber>
  166. ) : (
  167. <GridCell data-test-id="grid-cell">{rendered}</GridCell>
  168. );
  169. if (handleCellAction) {
  170. rendered = (
  171. <CellAction
  172. column={column}
  173. dataRow={row}
  174. handleCellAction={handleCellAction(column)}
  175. >
  176. {rendered}
  177. </CellAction>
  178. );
  179. }
  180. return <BodyCellContainer key={key}>{rendered}</BodyCellContainer>;
  181. });
  182. return resultsRow;
  183. }
  184. renderResults() {
  185. const {isLoading, tableData, columnOrder} = this.props;
  186. let cells: React.ReactNode[] = [];
  187. if (isLoading) {
  188. return cells;
  189. }
  190. if (!tableData || !tableData.meta || !tableData.data) {
  191. return cells;
  192. }
  193. tableData.data.forEach((row, i: number) => {
  194. // Another check to appease tsc
  195. if (!tableData.meta) {
  196. return;
  197. }
  198. cells = cells.concat(this.renderRow(row, i, columnOrder, tableData.meta));
  199. });
  200. return cells;
  201. }
  202. render() {
  203. const {isLoading, tableData} = this.props;
  204. const hasResults = tableData?.meta && tableData.data?.length > 0;
  205. // Custom set the height so we don't have layout shift when results are loaded.
  206. const loader = <LoadingIndicator style={{margin: '70px auto'}} />;
  207. return (
  208. <VisuallyCompleteWithData
  209. id="TransactionsTable"
  210. hasData={hasResults}
  211. isLoading={isLoading}
  212. >
  213. <PanelTable
  214. data-test-id="transactions-table"
  215. isEmpty={!hasResults}
  216. emptyMessage={t('No transactions found')}
  217. headers={this.renderHeader()}
  218. isLoading={isLoading}
  219. disablePadding
  220. loader={loader}
  221. >
  222. {this.renderResults()}
  223. </PanelTable>
  224. </VisuallyCompleteWithData>
  225. );
  226. }
  227. }
  228. function getProfileAnalyticsHandler(organization: Organization, referrer?: string) {
  229. return () => {
  230. let source;
  231. if (referrer === 'performance.transactions_summary') {
  232. source = 'performance.transactions_summary.overview';
  233. } else {
  234. source = 'discover.transactions_table';
  235. }
  236. trackAnalytics('profiling_views.go_to_flamegraph', {
  237. organization,
  238. source,
  239. });
  240. };
  241. }
  242. const HeadCellContainer = styled('div')`
  243. padding: ${space(2)};
  244. `;
  245. const BodyCellContainer = styled('div')`
  246. padding: ${space(1)} ${space(2)};
  247. ${p => p.theme.overflowEllipsis};
  248. `;
  249. const StyledIconQuestion = styled(QuestionTooltip)`
  250. position: relative;
  251. top: 1px;
  252. left: 4px;
  253. `;
  254. export default TransactionsTable;