widgetViewerModal.tsx 42 KB

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