widgetViewerModal.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239
  1. import {Fragment, memo, useEffect, useMemo, useRef, useState} from 'react';
  2. import {components} from 'react-select';
  3. import {css} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import * as Sentry from '@sentry/react';
  6. import {truncate} from '@sentry/utils';
  7. import type {DataZoomComponentOption} from 'echarts';
  8. import type {Location} from 'history';
  9. import cloneDeep from 'lodash/cloneDeep';
  10. import isEqual from 'lodash/isEqual';
  11. import trimStart from 'lodash/trimStart';
  12. import moment from 'moment';
  13. import {fetchTotalCount} from 'sentry/actionCreators/events';
  14. import type {ModalRenderProps} from 'sentry/actionCreators/modal';
  15. import type {Client} from 'sentry/api';
  16. import {Alert} from 'sentry/components/alert';
  17. import {Button} from 'sentry/components/button';
  18. import ButtonBar from 'sentry/components/buttonBar';
  19. import SelectControl from 'sentry/components/forms/controls/selectControl';
  20. import Option from 'sentry/components/forms/controls/selectOption';
  21. import type {GridColumnOrder} from 'sentry/components/gridEditable';
  22. import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  23. import Pagination from 'sentry/components/pagination';
  24. import QuestionTooltip from 'sentry/components/questionTooltip';
  25. import {parseSearch} from 'sentry/components/searchSyntax/parser';
  26. import HighlightQuery from 'sentry/components/searchSyntax/renderer';
  27. import {Tooltip} from 'sentry/components/tooltip';
  28. import {t, tct} from 'sentry/locale';
  29. import {space} from 'sentry/styles/space';
  30. import type {Organization, PageFilters, SelectValue} from 'sentry/types';
  31. import type {Series} from 'sentry/types/echarts';
  32. import {defined} from 'sentry/utils';
  33. import {trackAnalytics} from 'sentry/utils/analytics';
  34. import {getUtcDateString} from 'sentry/utils/dates';
  35. import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
  36. import type EventView from 'sentry/utils/discover/eventView';
  37. import type {AggregationOutputType} from 'sentry/utils/discover/fields';
  38. import {
  39. isAggregateField,
  40. isEquation,
  41. isEquationAlias,
  42. } from 'sentry/utils/discover/fields';
  43. import {
  44. createOnDemandFilterWarning,
  45. shouldDisplayOnDemandWidgetWarning,
  46. } from 'sentry/utils/onDemandMetrics';
  47. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  48. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  49. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  50. import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
  51. import useApi from 'sentry/utils/useApi';
  52. import {useLocation} from 'sentry/utils/useLocation';
  53. import useProjects from 'sentry/utils/useProjects';
  54. import useRouter from 'sentry/utils/useRouter';
  55. import withPageFilters from 'sentry/utils/withPageFilters';
  56. import type {DashboardFilters, Widget} from 'sentry/views/dashboards/types';
  57. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  58. import {
  59. dashboardFiltersToString,
  60. eventViewFromWidget,
  61. getColoredWidgetIndicator,
  62. getFieldsFromEquations,
  63. getNumEquations,
  64. getWidgetDiscoverUrl,
  65. getWidgetIssueUrl,
  66. getWidgetMetricsUrl,
  67. getWidgetReleasesUrl,
  68. } from 'sentry/views/dashboards/utils';
  69. import {
  70. SESSION_DURATION_ALERT,
  71. WidgetDescription,
  72. } from 'sentry/views/dashboards/widgetCard';
  73. import type {AugmentedEChartDataZoomHandler} from 'sentry/views/dashboards/widgetCard/chart';
  74. import WidgetCardChart, {SLIDER_HEIGHT} from 'sentry/views/dashboards/widgetCard/chart';
  75. import {
  76. DashboardsMEPConsumer,
  77. DashboardsMEPProvider,
  78. useDashboardsMEPContext,
  79. } from 'sentry/views/dashboards/widgetCard/dashboardsMEPContext';
  80. import type {GenericWidgetQueriesChildrenProps} from 'sentry/views/dashboards/widgetCard/genericWidgetQueries';
  81. import IssueWidgetQueries from 'sentry/views/dashboards/widgetCard/issueWidgetQueries';
  82. import ReleaseWidgetQueries from 'sentry/views/dashboards/widgetCard/releaseWidgetQueries';
  83. import {WidgetCardChartContainer} from 'sentry/views/dashboards/widgetCard/widgetCardChartContainer';
  84. import WidgetQueries from 'sentry/views/dashboards/widgetCard/widgetQueries';
  85. import {decodeColumnOrder} from 'sentry/views/discover/utils';
  86. import {OrganizationContext} from 'sentry/views/organizationContext';
  87. import {MetricsDataSwitcher} from 'sentry/views/performance/landing/metricsDataSwitcher';
  88. import {WidgetViewerQueryField} from './widgetViewerModal/utils';
  89. import {
  90. renderDiscoverGridHeaderCell,
  91. renderGridBodyCell,
  92. renderIssueGridHeaderCell,
  93. renderReleaseGridHeaderCell,
  94. } from './widgetViewerModal/widgetViewerTableCell';
  95. export interface WidgetViewerModalOptions {
  96. organization: Organization;
  97. widget: Widget;
  98. dashboardFilters?: DashboardFilters;
  99. onEdit?: () => void;
  100. onMetricWidgetEdit?: (widget: Widget) => void;
  101. pageLinks?: string;
  102. seriesData?: Series[];
  103. seriesResultsType?: Record<string, AggregationOutputType>;
  104. tableData?: TableDataWithTitle[];
  105. totalIssuesCount?: string;
  106. }
  107. interface Props extends ModalRenderProps, WidgetViewerModalOptions {
  108. organization: Organization;
  109. selection: PageFilters;
  110. }
  111. const FULL_TABLE_ITEM_LIMIT = 20;
  112. const HALF_TABLE_ITEM_LIMIT = 10;
  113. const HALF_CONTAINER_HEIGHT = 300;
  114. const EMPTY_QUERY_NAME = '(Empty Query Condition)';
  115. const shouldWidgetCardChartMemo = (prevProps, props) => {
  116. const selectionMatches = props.selection === prevProps.selection;
  117. const sortMatches =
  118. props.location.query[WidgetViewerQueryField.SORT] ===
  119. prevProps.location.query[WidgetViewerQueryField.SORT];
  120. const chartZoomOptionsMatches = isEqual(
  121. props.chartZoomOptions,
  122. prevProps.chartZoomOptions
  123. );
  124. const isNotTopNWidget =
  125. props.widget.displayType !== DisplayType.TOP_N && !defined(props.widget.limit);
  126. return selectionMatches && chartZoomOptionsMatches && (sortMatches || isNotTopNWidget);
  127. };
  128. // WidgetCardChartContainer and WidgetCardChart rerenders if selection was changed.
  129. // This is required because we want to prevent ECharts interactions from causing
  130. // unnecessary rerenders which can break legends and zoom functionality.
  131. const MemoizedWidgetCardChartContainer = memo(
  132. WidgetCardChartContainer,
  133. shouldWidgetCardChartMemo
  134. );
  135. const MemoizedWidgetCardChart = memo(WidgetCardChart, shouldWidgetCardChartMemo);
  136. async function fetchDiscoverTotal(
  137. api: Client,
  138. organization: Organization,
  139. location: Location,
  140. eventView: EventView
  141. ): Promise<string | undefined> {
  142. if (!eventView.isValid()) {
  143. return undefined;
  144. }
  145. try {
  146. const total = await fetchTotalCount(
  147. api,
  148. organization.slug,
  149. eventView.getEventsAPIPayload(location)
  150. );
  151. return total.toLocaleString();
  152. } catch (err) {
  153. Sentry.captureException(err);
  154. return undefined;
  155. }
  156. }
  157. function WidgetViewerModal(props: Props) {
  158. const {
  159. organization,
  160. widget,
  161. selection,
  162. Footer,
  163. Body,
  164. Header,
  165. closeModal,
  166. onEdit,
  167. seriesData,
  168. tableData,
  169. totalIssuesCount,
  170. pageLinks: defaultPageLinks,
  171. seriesResultsType,
  172. dashboardFilters,
  173. } = props;
  174. const location = useLocation();
  175. const {projects} = useProjects();
  176. const router = useRouter();
  177. const shouldShowSlider = organization.features.includes('widget-viewer-modal-minimap');
  178. // TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
  179. // let widgetContentLoadingStatus: boolean | undefined = undefined;
  180. // Get widget zoom from location
  181. // We use the start and end query params for just the initial state
  182. const start = decodeScalar(location.query[WidgetViewerQueryField.START]);
  183. const end = decodeScalar(location.query[WidgetViewerQueryField.END]);
  184. const isTableWidget = widget.displayType === DisplayType.TABLE;
  185. const hasSessionDuration = widget.queries.some(query =>
  186. query.aggregates.some(aggregate => aggregate.includes('session.duration'))
  187. );
  188. const locationPageFilter = useMemo(
  189. () =>
  190. start && end
  191. ? {
  192. ...selection,
  193. datetime: {start, end, period: null, utc: null},
  194. }
  195. : selection,
  196. [start, end, selection]
  197. );
  198. const [chartUnmodified, setChartUnmodified] = useState<boolean>(true);
  199. const [chartZoomOptions, setChartZoomOptions] = useState<DataZoomComponentOption>({
  200. start: 0,
  201. end: 100,
  202. });
  203. // We wrap the modalChartSelection in a useRef because we do not want to recalculate this value
  204. // (which would cause an unnecessary rerender on calculation) except for the initial load.
  205. // We use this for when a user visit a widget viewer url directly.
  206. const [modalTableSelection, setModalTableSelection] =
  207. useState<PageFilters>(locationPageFilter);
  208. const modalChartSelection = useRef(modalTableSelection);
  209. // Detect when a user clicks back and set the PageFilter state to match the location
  210. // We need to use useEffect to prevent infinite looping rerenders due to the setModalTableSelection call
  211. useEffect(() => {
  212. if (location.action === 'POP') {
  213. setModalTableSelection(locationPageFilter);
  214. if (start && end) {
  215. setChartZoomOptions({
  216. startValue: moment.utc(start).unix() * 1000,
  217. endValue: moment.utc(end).unix() * 1000,
  218. });
  219. } else {
  220. setChartZoomOptions({start: 0, end: 100});
  221. }
  222. }
  223. }, [end, location, locationPageFilter, start]);
  224. // Get legends toggle settings from location
  225. // We use the legend query params for just the initial state
  226. const [disabledLegends, setDisabledLegends] = useState<{[key: string]: boolean}>(
  227. decodeList(location.query[WidgetViewerQueryField.LEGEND]).reduce((acc, legend) => {
  228. acc[legend] = false;
  229. return acc;
  230. }, {})
  231. );
  232. const [totalResults, setTotalResults] = useState<string | undefined>();
  233. // Get query selection settings from location
  234. const selectedQueryIndex =
  235. decodeInteger(location.query[WidgetViewerQueryField.QUERY]) ?? 0;
  236. // Get pagination settings from location
  237. const page = decodeInteger(location.query[WidgetViewerQueryField.PAGE]) ?? 0;
  238. const cursor = decodeScalar(location.query[WidgetViewerQueryField.CURSOR]);
  239. // Get table column widths from location
  240. const widths = decodeList(location.query[WidgetViewerQueryField.WIDTH]);
  241. // Get table sort settings from location
  242. const sort = decodeScalar(location.query[WidgetViewerQueryField.SORT]);
  243. const sortedQueries = cloneDeep(
  244. sort ? widget.queries.map(query => ({...query, orderby: sort})) : widget.queries
  245. );
  246. // Top N widget charts (including widgets with limits) results rely on the sorting of the query
  247. // Set the orderby of the widget chart to match the location query params
  248. const primaryWidget =
  249. widget.displayType === DisplayType.TOP_N || widget.limit !== undefined
  250. ? {...widget, queries: sortedQueries}
  251. : widget;
  252. const api = useApi();
  253. // Create Table widget
  254. const tableWidget = {
  255. ...cloneDeep({...widget, queries: [sortedQueries[selectedQueryIndex]]}),
  256. displayType: DisplayType.TABLE,
  257. };
  258. const {aggregates, columns} = tableWidget.queries[0];
  259. const {orderby} = widget.queries[0];
  260. const order = orderby.startsWith('-');
  261. const rawOrderby = trimStart(orderby, '-');
  262. const fields = defined(tableWidget.queries[0].fields)
  263. ? tableWidget.queries[0].fields
  264. : [...columns, ...aggregates];
  265. // Some Discover Widgets (Line, Area, Bar) allow the user to specify an orderby
  266. // that is not explicitly selected as an aggregate or column. We need to explicitly
  267. // include the orderby in the table widget aggregates and columns otherwise
  268. // eventsv2 will complain about sorting on an unselected field.
  269. if (
  270. widget.widgetType === WidgetType.DISCOVER &&
  271. orderby &&
  272. !isEquationAlias(rawOrderby) &&
  273. !fields.includes(rawOrderby)
  274. ) {
  275. fields.push(rawOrderby);
  276. [tableWidget, primaryWidget].forEach(aggregatesAndColumns => {
  277. if (isAggregateField(rawOrderby) || isEquation(rawOrderby)) {
  278. aggregatesAndColumns.queries.forEach(query => {
  279. if (!query.aggregates.includes(rawOrderby)) {
  280. query.aggregates.push(rawOrderby);
  281. }
  282. });
  283. } else {
  284. aggregatesAndColumns.queries.forEach(query => {
  285. if (!query.columns.includes(rawOrderby)) {
  286. query.columns.push(rawOrderby);
  287. }
  288. });
  289. }
  290. });
  291. }
  292. // Need to set the orderby of the eventsv2 query to equation[index] format
  293. // since eventsv2 does not accept the raw equation as a valid sort payload
  294. if (isEquation(rawOrderby) && tableWidget.queries[0].orderby === orderby) {
  295. tableWidget.queries[0].orderby = `${order ? '-' : ''}equation[${
  296. getNumEquations(fields) - 1
  297. }]`;
  298. }
  299. // Default table columns for visualizations that don't have a column setting
  300. const shouldReplaceTableColumns =
  301. [
  302. DisplayType.AREA,
  303. DisplayType.LINE,
  304. DisplayType.BIG_NUMBER,
  305. DisplayType.BAR,
  306. ].includes(widget.displayType) &&
  307. widget.widgetType &&
  308. [WidgetType.DISCOVER, WidgetType.RELEASE].includes(widget.widgetType) &&
  309. !defined(widget.limit);
  310. // Updates fields by adding any individual terms from equation fields as a column
  311. if (!isTableWidget) {
  312. const equationFields = getFieldsFromEquations(fields);
  313. equationFields.forEach(term => {
  314. if (isAggregateField(term) && !aggregates.includes(term)) {
  315. aggregates.unshift(term);
  316. }
  317. if (!isAggregateField(term) && !columns.includes(term)) {
  318. columns.unshift(term);
  319. }
  320. });
  321. }
  322. // Add any group by columns into table fields if missing
  323. columns.forEach(column => {
  324. if (!fields.includes(column)) {
  325. fields.unshift(column);
  326. }
  327. });
  328. if (shouldReplaceTableColumns) {
  329. switch (widget.widgetType) {
  330. case WidgetType.DISCOVER:
  331. if (fields.length === 1) {
  332. tableWidget.queries[0].orderby =
  333. tableWidget.queries[0].orderby || `-${fields[0]}`;
  334. }
  335. fields.unshift('title');
  336. columns.unshift('title');
  337. break;
  338. case WidgetType.RELEASE:
  339. fields.unshift('release');
  340. columns.unshift('release');
  341. break;
  342. default:
  343. break;
  344. }
  345. }
  346. const eventView = eventViewFromWidget(
  347. tableWidget.title,
  348. tableWidget.queries[0],
  349. modalTableSelection
  350. );
  351. let columnOrder = decodeColumnOrder(
  352. fields.map(field => ({
  353. field,
  354. }))
  355. );
  356. const columnSortBy = eventView.getSorts();
  357. columnOrder = columnOrder.map((column, index) => ({
  358. ...column,
  359. width: parseInt(widths[index], 10) || -1,
  360. }));
  361. const getOnDemandFilterWarning = createOnDemandFilterWarning(
  362. t(
  363. 'We don’t routinely collect metrics from this property. As such, historical data may be limited.'
  364. )
  365. );
  366. const queryOptions = sortedQueries.map((query, index) => {
  367. const {name, conditions} = query;
  368. // Creates the highlighted query elements to be used in the Query Select
  369. const dashboardFiltersString = dashboardFiltersToString(dashboardFilters);
  370. const parsedQuery =
  371. !name && !!conditions
  372. ? parseSearch(
  373. conditions +
  374. (dashboardFiltersString === '' ? '' : ` ${dashboardFiltersString}`),
  375. {
  376. getFilterTokenWarning: shouldDisplayOnDemandWidgetWarning(
  377. query,
  378. widget.widgetType ?? WidgetType.DISCOVER,
  379. organization
  380. )
  381. ? getOnDemandFilterWarning
  382. : undefined,
  383. }
  384. )
  385. : null;
  386. const getHighlightedQuery = (
  387. highlightedContainerProps: React.ComponentProps<typeof HighlightContainer>
  388. ) => {
  389. return parsedQuery !== null ? (
  390. <HighlightContainer {...highlightedContainerProps}>
  391. <HighlightQuery parsedQuery={parsedQuery} />
  392. </HighlightContainer>
  393. ) : undefined;
  394. };
  395. return {
  396. label: truncate(name || conditions, 120),
  397. value: index,
  398. getHighlightedQuery,
  399. };
  400. });
  401. const onResizeColumn = (columnIndex: number, nextColumn: GridColumnOrder) => {
  402. const newWidth = nextColumn.width ? Number(nextColumn.width) : COL_WIDTH_UNDEFINED;
  403. const newWidths: number[] = new Array(Math.max(columnIndex, widths.length)).fill(
  404. COL_WIDTH_UNDEFINED
  405. );
  406. widths.forEach((width, index) => (newWidths[index] = parseInt(width, 10)));
  407. newWidths[columnIndex] = newWidth;
  408. router.replace({
  409. pathname: location.pathname,
  410. query: {
  411. ...location.query,
  412. [WidgetViewerQueryField.WIDTH]: newWidths,
  413. },
  414. });
  415. };
  416. // Get discover result totals
  417. useEffect(() => {
  418. const getDiscoverTotals = async () => {
  419. if (widget.widgetType === WidgetType.DISCOVER) {
  420. setTotalResults(await fetchDiscoverTotal(api, organization, location, eventView));
  421. }
  422. };
  423. getDiscoverTotals();
  424. // Disabling this for now since this effect should only run on initial load and query index changes
  425. // Including all exhaustive deps would cause fetchDiscoverTotal on nearly every update
  426. // eslint-disable-next-line react-hooks/exhaustive-deps
  427. }, [selectedQueryIndex]);
  428. function onLegendSelectChanged({selected}: {selected: Record<string, boolean>}) {
  429. setDisabledLegends(selected);
  430. router.replace({
  431. pathname: location.pathname,
  432. query: {
  433. ...location.query,
  434. [WidgetViewerQueryField.LEGEND]: Object.keys(selected).filter(
  435. key => !selected[key]
  436. ),
  437. },
  438. });
  439. trackAnalytics('dashboards_views.widget_viewer.toggle_legend', {
  440. organization,
  441. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  442. display_type: widget.displayType,
  443. });
  444. }
  445. function DiscoverTable({
  446. tableResults,
  447. loading,
  448. pageLinks,
  449. }: GenericWidgetQueriesChildrenProps) {
  450. const {isMetricsData} = useDashboardsMEPContext();
  451. const links = parseLinkHeader(pageLinks ?? null);
  452. const isFirstPage = links.previous?.results === false;
  453. return (
  454. <Fragment>
  455. <GridEditable
  456. isLoading={loading}
  457. data={tableResults?.[0]?.data ?? []}
  458. columnOrder={columnOrder}
  459. columnSortBy={columnSortBy}
  460. grid={{
  461. renderHeadCell: renderDiscoverGridHeaderCell({
  462. ...props,
  463. location,
  464. widget: tableWidget,
  465. tableData: tableResults?.[0],
  466. onHeaderClick: () => {
  467. if (
  468. [DisplayType.TOP_N, DisplayType.TABLE].includes(widget.displayType) ||
  469. defined(widget.limit)
  470. ) {
  471. setChartUnmodified(false);
  472. }
  473. },
  474. isMetricsData,
  475. }) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
  476. renderBodyCell: renderGridBodyCell({
  477. ...props,
  478. location,
  479. tableData: tableResults?.[0],
  480. isFirstPage,
  481. projects,
  482. eventView,
  483. }),
  484. onResizeColumn,
  485. }}
  486. />
  487. {(links?.previous?.results || links?.next?.results) && (
  488. <Pagination
  489. pageLinks={pageLinks}
  490. onCursor={newCursor => {
  491. router.replace({
  492. pathname: location.pathname,
  493. query: {
  494. ...location.query,
  495. [WidgetViewerQueryField.CURSOR]: newCursor,
  496. },
  497. });
  498. if (widget.displayType === DisplayType.TABLE) {
  499. setChartUnmodified(false);
  500. }
  501. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  502. organization,
  503. widget_type: WidgetType.DISCOVER,
  504. display_type: widget.displayType,
  505. });
  506. }}
  507. />
  508. )}
  509. </Fragment>
  510. );
  511. }
  512. const renderIssuesTable = ({
  513. tableResults,
  514. loading,
  515. pageLinks,
  516. totalCount,
  517. }: GenericWidgetQueriesChildrenProps) => {
  518. if (totalResults === undefined && totalCount) {
  519. setTotalResults(totalCount);
  520. }
  521. const links = parseLinkHeader(pageLinks ?? null);
  522. return (
  523. <Fragment>
  524. <GridEditable
  525. isLoading={loading}
  526. data={tableResults?.[0]?.data ?? []}
  527. columnOrder={columnOrder}
  528. columnSortBy={columnSortBy}
  529. grid={{
  530. renderHeadCell: renderIssueGridHeaderCell({
  531. location,
  532. organization,
  533. selection,
  534. widget: tableWidget,
  535. onHeaderClick: () => {
  536. setChartUnmodified(false);
  537. },
  538. }) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
  539. renderBodyCell: renderGridBodyCell({
  540. location,
  541. organization,
  542. selection,
  543. widget: tableWidget,
  544. }),
  545. onResizeColumn,
  546. }}
  547. />
  548. {(links?.previous?.results || links?.next?.results) && (
  549. <Pagination
  550. pageLinks={pageLinks}
  551. onCursor={(nextCursor, _path, _query, delta) => {
  552. let nextPage = isNaN(page) ? delta : page + delta;
  553. let newCursor = nextCursor;
  554. // unset cursor and page when we navigate back to the first page
  555. // also reset cursor if somehow the previous button is enabled on
  556. // first page and user attempts to go backwards
  557. if (nextPage <= 0) {
  558. newCursor = undefined;
  559. nextPage = 0;
  560. }
  561. router.replace({
  562. pathname: location.pathname,
  563. query: {
  564. ...location.query,
  565. [WidgetViewerQueryField.CURSOR]: newCursor,
  566. [WidgetViewerQueryField.PAGE]: nextPage,
  567. },
  568. });
  569. if (widget.displayType === DisplayType.TABLE) {
  570. setChartUnmodified(false);
  571. }
  572. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  573. organization,
  574. widget_type: WidgetType.ISSUE,
  575. display_type: widget.displayType,
  576. });
  577. }}
  578. />
  579. )}
  580. </Fragment>
  581. );
  582. };
  583. const renderReleaseTable: ReleaseWidgetQueries['props']['children'] = ({
  584. tableResults,
  585. loading,
  586. pageLinks,
  587. }) => {
  588. const links = parseLinkHeader(pageLinks ?? null);
  589. const isFirstPage = links.previous?.results === false;
  590. return (
  591. <Fragment>
  592. <GridEditable
  593. isLoading={loading}
  594. data={tableResults?.[0]?.data ?? []}
  595. columnOrder={columnOrder}
  596. columnSortBy={columnSortBy}
  597. grid={{
  598. renderHeadCell: renderReleaseGridHeaderCell({
  599. ...props,
  600. location,
  601. widget: tableWidget,
  602. tableData: tableResults?.[0],
  603. onHeaderClick: () => {
  604. if (
  605. [DisplayType.TOP_N, DisplayType.TABLE].includes(widget.displayType) ||
  606. defined(widget.limit)
  607. ) {
  608. setChartUnmodified(false);
  609. }
  610. },
  611. }) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
  612. renderBodyCell: renderGridBodyCell({
  613. ...props,
  614. location,
  615. tableData: tableResults?.[0],
  616. isFirstPage,
  617. }),
  618. onResizeColumn,
  619. }}
  620. />
  621. {!tableWidget.queries[0].orderby.match(/^-?release$/) &&
  622. (links?.previous?.results || links?.next?.results) && (
  623. <Pagination
  624. pageLinks={pageLinks}
  625. onCursor={newCursor => {
  626. router.replace({
  627. pathname: location.pathname,
  628. query: {
  629. ...location.query,
  630. [WidgetViewerQueryField.CURSOR]: newCursor,
  631. },
  632. });
  633. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  634. organization,
  635. widget_type: WidgetType.RELEASE,
  636. display_type: widget.displayType,
  637. });
  638. }}
  639. />
  640. )}
  641. </Fragment>
  642. );
  643. };
  644. const onZoom: AugmentedEChartDataZoomHandler = (evt, chart) => {
  645. // @ts-expect-error getModel() is private but we need this to retrieve datetime values of zoomed in region
  646. const model = chart.getModel();
  647. const {seriesStart, seriesEnd} = evt;
  648. let startValue, endValue;
  649. startValue = model._payload.batch?.[0].startValue;
  650. endValue = model._payload.batch?.[0].endValue;
  651. const seriesStartTime = seriesStart ? new Date(seriesStart).getTime() : undefined;
  652. const seriesEndTime = seriesEnd ? new Date(seriesEnd).getTime() : undefined;
  653. // Slider zoom events don't contain the raw date time value, only the percentage
  654. // We use the percentage with the start and end of the series to calculate the adjusted zoom
  655. if (startValue === undefined || endValue === undefined) {
  656. if (seriesStartTime && seriesEndTime) {
  657. const diff = seriesEndTime - seriesStartTime;
  658. startValue = diff * model._payload.start * 0.01 + seriesStartTime;
  659. endValue = diff * model._payload.end * 0.01 + seriesStartTime;
  660. } else {
  661. return;
  662. }
  663. }
  664. setChartZoomOptions({startValue, endValue});
  665. const newStart = getUtcDateString(moment.utc(startValue));
  666. const newEnd = getUtcDateString(moment.utc(endValue));
  667. setModalTableSelection({
  668. ...modalTableSelection,
  669. datetime: {
  670. ...modalTableSelection.datetime,
  671. start: newStart,
  672. end: newEnd,
  673. period: null,
  674. },
  675. });
  676. router.push({
  677. pathname: location.pathname,
  678. query: {
  679. ...location.query,
  680. [WidgetViewerQueryField.START]: newStart,
  681. [WidgetViewerQueryField.END]: newEnd,
  682. },
  683. });
  684. trackAnalytics('dashboards_views.widget_viewer.zoom', {
  685. organization,
  686. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  687. display_type: widget.displayType,
  688. });
  689. };
  690. function renderWidgetViewerTable() {
  691. switch (widget.widgetType) {
  692. case WidgetType.ISSUE:
  693. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  694. return renderIssuesTable({
  695. tableResults: tableData,
  696. loading: false,
  697. errorMessage: undefined,
  698. pageLinks: defaultPageLinks,
  699. totalCount: totalIssuesCount,
  700. });
  701. }
  702. return (
  703. <IssueWidgetQueries
  704. api={api}
  705. organization={organization}
  706. widget={tableWidget}
  707. selection={modalTableSelection}
  708. limit={
  709. widget.displayType === DisplayType.TABLE
  710. ? FULL_TABLE_ITEM_LIMIT
  711. : HALF_TABLE_ITEM_LIMIT
  712. }
  713. cursor={cursor}
  714. dashboardFilters={dashboardFilters}
  715. >
  716. {renderIssuesTable}
  717. </IssueWidgetQueries>
  718. );
  719. case WidgetType.RELEASE:
  720. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  721. return renderReleaseTable({
  722. tableResults: tableData,
  723. loading: false,
  724. pageLinks: defaultPageLinks,
  725. });
  726. }
  727. return (
  728. <ReleaseWidgetQueries
  729. api={api}
  730. organization={organization}
  731. widget={tableWidget}
  732. selection={modalTableSelection}
  733. limit={
  734. widget.displayType === DisplayType.TABLE
  735. ? FULL_TABLE_ITEM_LIMIT
  736. : HALF_TABLE_ITEM_LIMIT
  737. }
  738. cursor={cursor}
  739. dashboardFilters={dashboardFilters}
  740. >
  741. {renderReleaseTable}
  742. </ReleaseWidgetQueries>
  743. );
  744. case WidgetType.DISCOVER:
  745. default:
  746. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  747. return (
  748. <DiscoverTable
  749. tableResults={tableData}
  750. loading={false}
  751. pageLinks={defaultPageLinks}
  752. />
  753. );
  754. }
  755. return (
  756. <WidgetQueries
  757. api={api}
  758. organization={organization}
  759. widget={tableWidget}
  760. selection={modalTableSelection}
  761. limit={
  762. widget.displayType === DisplayType.TABLE
  763. ? FULL_TABLE_ITEM_LIMIT
  764. : HALF_TABLE_ITEM_LIMIT
  765. }
  766. cursor={cursor}
  767. dashboardFilters={dashboardFilters}
  768. >
  769. {({tableResults, loading, pageLinks}) => {
  770. // TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
  771. // small hack that improves the concurrency render of the warning triangle
  772. // widgetContentLoadingStatus = loading;
  773. return (
  774. <DiscoverTable
  775. tableResults={tableResults}
  776. loading={loading}
  777. pageLinks={pageLinks}
  778. />
  779. );
  780. }}
  781. </WidgetQueries>
  782. );
  783. }
  784. }
  785. function renderWidgetViewer() {
  786. return (
  787. <Fragment>
  788. {hasSessionDuration && SESSION_DURATION_ALERT}
  789. {widget.displayType !== DisplayType.TABLE && (
  790. <Container
  791. height={
  792. widget.displayType !== DisplayType.BIG_NUMBER
  793. ? HALF_CONTAINER_HEIGHT +
  794. (shouldShowSlider &&
  795. [
  796. DisplayType.AREA,
  797. DisplayType.LINE,
  798. DisplayType.BAR,
  799. DisplayType.TOP_N,
  800. ].includes(widget.displayType)
  801. ? SLIDER_HEIGHT
  802. : 0)
  803. : null
  804. }
  805. >
  806. {(!!seriesData || !!tableData) && chartUnmodified ? (
  807. <MemoizedWidgetCardChart
  808. timeseriesResults={seriesData}
  809. timeseriesResultsTypes={seriesResultsType}
  810. tableResults={tableData}
  811. errorMessage={undefined}
  812. loading={false}
  813. location={location}
  814. widget={widget}
  815. selection={selection}
  816. router={router}
  817. organization={organization}
  818. onZoom={onZoom}
  819. onLegendSelectChanged={onLegendSelectChanged}
  820. legendOptions={{selected: disabledLegends}}
  821. expandNumbers
  822. showSlider={shouldShowSlider}
  823. noPadding
  824. chartZoomOptions={chartZoomOptions}
  825. />
  826. ) : (
  827. <MemoizedWidgetCardChartContainer
  828. location={location}
  829. api={api}
  830. organization={organization}
  831. selection={modalChartSelection.current}
  832. dashboardFilters={dashboardFilters}
  833. // Top N charts rely on the orderby of the table
  834. widget={primaryWidget}
  835. onZoom={onZoom}
  836. onLegendSelectChanged={onLegendSelectChanged}
  837. legendOptions={{selected: disabledLegends}}
  838. expandNumbers
  839. showSlider={shouldShowSlider}
  840. noPadding
  841. chartZoomOptions={chartZoomOptions}
  842. />
  843. )}
  844. </Container>
  845. )}
  846. {widget.queries.length > 1 && (
  847. <Alert type="info" showIcon>
  848. {t(
  849. 'This widget was built with multiple queries. Table data can only be displayed for one query at a time. To edit any of the queries, edit the widget.'
  850. )}
  851. </Alert>
  852. )}
  853. {(widget.queries.length > 1 || widget.queries[0].conditions) && (
  854. <QueryContainer>
  855. <SelectControl
  856. value={selectedQueryIndex}
  857. options={queryOptions}
  858. onChange={(option: SelectValue<number>) => {
  859. router.replace({
  860. pathname: location.pathname,
  861. query: {
  862. ...location.query,
  863. [WidgetViewerQueryField.QUERY]: option.value,
  864. [WidgetViewerQueryField.PAGE]: undefined,
  865. [WidgetViewerQueryField.CURSOR]: undefined,
  866. },
  867. });
  868. trackAnalytics('dashboards_views.widget_viewer.select_query', {
  869. organization,
  870. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  871. display_type: widget.displayType,
  872. });
  873. }}
  874. components={{
  875. // Replaces the displayed selected value
  876. SingleValue: containerProps => {
  877. return (
  878. <components.SingleValue
  879. {...containerProps}
  880. // Overwrites some of the default styling that interferes with highlighted query text
  881. getStyles={() => ({
  882. wordBreak: 'break-word',
  883. flex: 1,
  884. display: 'flex',
  885. padding: `0 ${space(0.5)}`,
  886. })}
  887. >
  888. {queryOptions[selectedQueryIndex].getHighlightedQuery({
  889. display: 'block',
  890. }) ??
  891. (queryOptions[selectedQueryIndex].label || (
  892. <EmptyQueryContainer>{EMPTY_QUERY_NAME}</EmptyQueryContainer>
  893. ))}
  894. </components.SingleValue>
  895. );
  896. },
  897. // Replaces the dropdown options
  898. Option: containerProps => {
  899. const highlightedQuery = containerProps.data.getHighlightedQuery({
  900. display: 'flex',
  901. });
  902. return (
  903. <Option
  904. {...(highlightedQuery
  905. ? {
  906. ...containerProps,
  907. label: highlightedQuery,
  908. }
  909. : containerProps.label
  910. ? containerProps
  911. : {
  912. ...containerProps,
  913. label: (
  914. <EmptyQueryContainer>
  915. {EMPTY_QUERY_NAME}
  916. </EmptyQueryContainer>
  917. ),
  918. })}
  919. />
  920. );
  921. },
  922. // Hide the dropdown indicator if there is only one option
  923. ...(widget.queries.length < 2 ? {IndicatorsContainer: _ => null} : {}),
  924. }}
  925. isSearchable={false}
  926. isDisabled={widget.queries.length < 2}
  927. />
  928. {widget.queries.length === 1 && (
  929. <StyledQuestionTooltip
  930. title={t('To edit this query, you must edit the widget.')}
  931. size="sm"
  932. />
  933. )}
  934. </QueryContainer>
  935. )}
  936. {renderWidgetViewerTable()}
  937. </Fragment>
  938. );
  939. }
  940. return (
  941. <Fragment>
  942. <OrganizationContext.Provider value={organization}>
  943. <DashboardsMEPProvider>
  944. <MetricsCardinalityProvider organization={organization} location={location}>
  945. <MetricsDataSwitcher
  946. organization={organization}
  947. eventView={eventView}
  948. location={location}
  949. hideLoadingIndicator
  950. >
  951. {metricsDataSide => (
  952. <MEPSettingProvider
  953. location={location}
  954. forceTransactions={metricsDataSide.forceTransactionsOnly}
  955. >
  956. <Header closeButton>
  957. <WidgetHeader>
  958. <WidgetTitleRow>
  959. <h3>{widget.title}</h3>
  960. {widget.thresholds &&
  961. tableData &&
  962. organization.features.includes('dashboard-widget-indicators') &&
  963. getColoredWidgetIndicator(widget.thresholds, tableData)}
  964. </WidgetTitleRow>
  965. {widget.description && (
  966. <Tooltip
  967. title={widget.description}
  968. containerDisplayMode="grid"
  969. showOnlyOnOverflow
  970. isHoverable
  971. position="bottom"
  972. >
  973. <WidgetDescription>{widget.description}</WidgetDescription>
  974. </Tooltip>
  975. )}
  976. <DashboardsMEPConsumer>
  977. {({}) => {
  978. // TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
  979. // if (
  980. // widgetContentLoadingStatus === false &&
  981. // widget.widgetType === WidgetType.DISCOVER &&
  982. // isMetricsData === false
  983. // ) {
  984. // return (
  985. // <Tooltip
  986. // containerDisplayMode="inline-flex"
  987. // title={t(
  988. // 'Based on your search criteria, the sampled events available may be limited and may not be representative of all events.'
  989. // )}
  990. // >
  991. // <IconWarning color="warningText" size="md" />
  992. // </Tooltip>
  993. // );
  994. // }
  995. return null;
  996. }}
  997. </DashboardsMEPConsumer>
  998. </WidgetHeader>
  999. </Header>
  1000. <Body>{renderWidgetViewer()}</Body>
  1001. <Footer>
  1002. <ResultsContainer>
  1003. {renderTotalResults(totalResults, widget.widgetType)}
  1004. <ButtonBar gap={1}>
  1005. {onEdit && widget.id && (
  1006. <Button
  1007. onClick={() => {
  1008. closeModal();
  1009. onEdit();
  1010. trackAnalytics('dashboards_views.widget_viewer.edit', {
  1011. organization,
  1012. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  1013. display_type: widget.displayType,
  1014. });
  1015. }}
  1016. >
  1017. {t('Edit Widget')}
  1018. </Button>
  1019. )}
  1020. {widget.widgetType && (
  1021. <OpenButton
  1022. widget={primaryWidget}
  1023. organization={organization}
  1024. selection={modalTableSelection}
  1025. selectedQueryIndex={selectedQueryIndex}
  1026. />
  1027. )}
  1028. </ButtonBar>
  1029. </ResultsContainer>
  1030. </Footer>
  1031. </MEPSettingProvider>
  1032. )}
  1033. </MetricsDataSwitcher>
  1034. </MetricsCardinalityProvider>
  1035. </DashboardsMEPProvider>
  1036. </OrganizationContext.Provider>
  1037. </Fragment>
  1038. );
  1039. }
  1040. interface OpenButtonProps {
  1041. organization: Organization;
  1042. selectedQueryIndex: number;
  1043. selection: PageFilters;
  1044. widget: Widget;
  1045. }
  1046. function OpenButton({
  1047. widget,
  1048. selection,
  1049. organization,
  1050. selectedQueryIndex,
  1051. }: OpenButtonProps) {
  1052. let openLabel: string;
  1053. let path: string;
  1054. const {isMetricsData} = useDashboardsMEPContext();
  1055. switch (widget.widgetType) {
  1056. case WidgetType.ISSUE:
  1057. openLabel = t('Open in Issues');
  1058. path = getWidgetIssueUrl(widget, selection, organization);
  1059. break;
  1060. case WidgetType.RELEASE:
  1061. openLabel = t('Open in Releases');
  1062. path = getWidgetReleasesUrl(widget, selection, organization);
  1063. break;
  1064. case WidgetType.METRICS:
  1065. openLabel = t('Open in Metrics');
  1066. path = getWidgetMetricsUrl(widget, selection, organization);
  1067. break;
  1068. case WidgetType.DISCOVER:
  1069. default:
  1070. openLabel = t('Open in Discover');
  1071. path = getWidgetDiscoverUrl(
  1072. {...widget, queries: [widget.queries[selectedQueryIndex]]},
  1073. selection,
  1074. organization,
  1075. 0,
  1076. isMetricsData
  1077. );
  1078. break;
  1079. }
  1080. return (
  1081. <Button
  1082. to={path}
  1083. priority="primary"
  1084. onClick={() => {
  1085. trackAnalytics('dashboards_views.widget_viewer.open_source', {
  1086. organization,
  1087. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  1088. display_type: widget.displayType,
  1089. });
  1090. }}
  1091. >
  1092. {openLabel}
  1093. </Button>
  1094. );
  1095. }
  1096. function renderTotalResults(totalResults?: string, widgetType?: WidgetType) {
  1097. if (totalResults === undefined) {
  1098. return <span />;
  1099. }
  1100. switch (widgetType) {
  1101. case WidgetType.ISSUE:
  1102. return (
  1103. <span>
  1104. {tct('[description:Total Issues:] [total]', {
  1105. description: <strong />,
  1106. total: totalResults === '1000' ? '1000+' : totalResults,
  1107. })}
  1108. </span>
  1109. );
  1110. case WidgetType.DISCOVER:
  1111. return (
  1112. <span>
  1113. {tct('[description:Sampled Events:] [total]', {
  1114. description: <strong />,
  1115. total: totalResults,
  1116. })}
  1117. </span>
  1118. );
  1119. default:
  1120. return <span />;
  1121. }
  1122. }
  1123. export const modalCss = css`
  1124. width: 100%;
  1125. max-width: 1200px;
  1126. `;
  1127. const Container = styled('div')<{height?: number | null}>`
  1128. height: ${p => (p.height ? `${p.height}px` : 'auto')};
  1129. position: relative;
  1130. padding-bottom: ${space(3)};
  1131. `;
  1132. const QueryContainer = styled('div')`
  1133. margin-bottom: ${space(2)};
  1134. position: relative;
  1135. `;
  1136. const StyledQuestionTooltip = styled(QuestionTooltip)`
  1137. position: absolute;
  1138. top: ${space(1.5)};
  1139. right: ${space(2)};
  1140. `;
  1141. const HighlightContainer = styled('span')<{display?: 'block' | 'flex'}>`
  1142. display: ${p => p.display};
  1143. gap: ${space(1)};
  1144. font-family: ${p => p.theme.text.familyMono};
  1145. font-size: ${p => p.theme.fontSizeSmall};
  1146. line-height: 2;
  1147. flex: 1;
  1148. `;
  1149. const ResultsContainer = styled('div')`
  1150. display: flex;
  1151. flex-grow: 1;
  1152. flex-direction: column;
  1153. gap: ${space(1)};
  1154. @media (min-width: ${p => p.theme.breakpoints.small}) {
  1155. align-items: center;
  1156. flex-direction: row;
  1157. justify-content: space-between;
  1158. }
  1159. `;
  1160. const EmptyQueryContainer = styled('span')`
  1161. color: ${p => p.theme.disabled};
  1162. `;
  1163. const WidgetHeader = styled('div')`
  1164. display: flex;
  1165. flex-direction: column;
  1166. gap: ${space(1)};
  1167. `;
  1168. const WidgetTitleRow = styled('div')`
  1169. display: flex;
  1170. align-items: center;
  1171. gap: ${space(0.75)};
  1172. `;
  1173. export default withPageFilters(WidgetViewerModal);