widgetViewerModal.tsx 42 KB

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