widgetViewerModal.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242
  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. getWidgetDDMUrl,
  65. getWidgetDiscoverUrl,
  66. getWidgetIssueUrl,
  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. location={location}
  487. />
  488. {(links?.previous?.results || links?.next?.results) && (
  489. <Pagination
  490. pageLinks={pageLinks}
  491. onCursor={newCursor => {
  492. router.replace({
  493. pathname: location.pathname,
  494. query: {
  495. ...location.query,
  496. [WidgetViewerQueryField.CURSOR]: newCursor,
  497. },
  498. });
  499. if (widget.displayType === DisplayType.TABLE) {
  500. setChartUnmodified(false);
  501. }
  502. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  503. organization,
  504. widget_type: WidgetType.DISCOVER,
  505. display_type: widget.displayType,
  506. });
  507. }}
  508. />
  509. )}
  510. </Fragment>
  511. );
  512. }
  513. const renderIssuesTable = ({
  514. tableResults,
  515. loading,
  516. pageLinks,
  517. totalCount,
  518. }: GenericWidgetQueriesChildrenProps) => {
  519. if (totalResults === undefined && totalCount) {
  520. setTotalResults(totalCount);
  521. }
  522. const links = parseLinkHeader(pageLinks ?? null);
  523. return (
  524. <Fragment>
  525. <GridEditable
  526. isLoading={loading}
  527. data={tableResults?.[0]?.data ?? []}
  528. columnOrder={columnOrder}
  529. columnSortBy={columnSortBy}
  530. grid={{
  531. renderHeadCell: renderIssueGridHeaderCell({
  532. location,
  533. organization,
  534. selection,
  535. widget: tableWidget,
  536. onHeaderClick: () => {
  537. setChartUnmodified(false);
  538. },
  539. }) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
  540. renderBodyCell: renderGridBodyCell({
  541. location,
  542. organization,
  543. selection,
  544. widget: tableWidget,
  545. }),
  546. onResizeColumn,
  547. }}
  548. location={location}
  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. router.replace({
  564. pathname: location.pathname,
  565. query: {
  566. ...location.query,
  567. [WidgetViewerQueryField.CURSOR]: newCursor,
  568. [WidgetViewerQueryField.PAGE]: nextPage,
  569. },
  570. });
  571. if (widget.displayType === DisplayType.TABLE) {
  572. setChartUnmodified(false);
  573. }
  574. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  575. organization,
  576. widget_type: WidgetType.ISSUE,
  577. display_type: widget.displayType,
  578. });
  579. }}
  580. />
  581. )}
  582. </Fragment>
  583. );
  584. };
  585. const renderReleaseTable: ReleaseWidgetQueries['props']['children'] = ({
  586. tableResults,
  587. loading,
  588. pageLinks,
  589. }) => {
  590. const links = parseLinkHeader(pageLinks ?? null);
  591. const isFirstPage = links.previous?.results === false;
  592. return (
  593. <Fragment>
  594. <GridEditable
  595. isLoading={loading}
  596. data={tableResults?.[0]?.data ?? []}
  597. columnOrder={columnOrder}
  598. columnSortBy={columnSortBy}
  599. grid={{
  600. renderHeadCell: renderReleaseGridHeaderCell({
  601. ...props,
  602. location,
  603. widget: tableWidget,
  604. tableData: tableResults?.[0],
  605. onHeaderClick: () => {
  606. if (
  607. [DisplayType.TOP_N, DisplayType.TABLE].includes(widget.displayType) ||
  608. defined(widget.limit)
  609. ) {
  610. setChartUnmodified(false);
  611. }
  612. },
  613. }) as (column: GridColumnOrder, columnIndex: number) => React.ReactNode,
  614. renderBodyCell: renderGridBodyCell({
  615. ...props,
  616. location,
  617. tableData: tableResults?.[0],
  618. isFirstPage,
  619. }),
  620. onResizeColumn,
  621. }}
  622. location={location}
  623. />
  624. {!tableWidget.queries[0].orderby.match(/^-?release$/) &&
  625. (links?.previous?.results || links?.next?.results) && (
  626. <Pagination
  627. pageLinks={pageLinks}
  628. onCursor={newCursor => {
  629. router.replace({
  630. pathname: location.pathname,
  631. query: {
  632. ...location.query,
  633. [WidgetViewerQueryField.CURSOR]: newCursor,
  634. },
  635. });
  636. trackAnalytics('dashboards_views.widget_viewer.paginate', {
  637. organization,
  638. widget_type: WidgetType.RELEASE,
  639. display_type: widget.displayType,
  640. });
  641. }}
  642. />
  643. )}
  644. </Fragment>
  645. );
  646. };
  647. const onZoom: AugmentedEChartDataZoomHandler = (evt, chart) => {
  648. // @ts-expect-error getModel() is private but we need this to retrieve datetime values of zoomed in region
  649. const model = chart.getModel();
  650. const {seriesStart, seriesEnd} = evt;
  651. let startValue, endValue;
  652. startValue = model._payload.batch?.[0].startValue;
  653. endValue = model._payload.batch?.[0].endValue;
  654. const seriesStartTime = seriesStart ? new Date(seriesStart).getTime() : undefined;
  655. const seriesEndTime = seriesEnd ? new Date(seriesEnd).getTime() : undefined;
  656. // Slider zoom events don't contain the raw date time value, only the percentage
  657. // We use the percentage with the start and end of the series to calculate the adjusted zoom
  658. if (startValue === undefined || endValue === undefined) {
  659. if (seriesStartTime && seriesEndTime) {
  660. const diff = seriesEndTime - seriesStartTime;
  661. startValue = diff * model._payload.start * 0.01 + seriesStartTime;
  662. endValue = diff * model._payload.end * 0.01 + seriesStartTime;
  663. } else {
  664. return;
  665. }
  666. }
  667. setChartZoomOptions({startValue, endValue});
  668. const newStart = getUtcDateString(moment.utc(startValue));
  669. const newEnd = getUtcDateString(moment.utc(endValue));
  670. setModalTableSelection({
  671. ...modalTableSelection,
  672. datetime: {
  673. ...modalTableSelection.datetime,
  674. start: newStart,
  675. end: newEnd,
  676. period: null,
  677. },
  678. });
  679. router.push({
  680. pathname: location.pathname,
  681. query: {
  682. ...location.query,
  683. [WidgetViewerQueryField.START]: newStart,
  684. [WidgetViewerQueryField.END]: newEnd,
  685. },
  686. });
  687. trackAnalytics('dashboards_views.widget_viewer.zoom', {
  688. organization,
  689. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  690. display_type: widget.displayType,
  691. });
  692. };
  693. function renderWidgetViewerTable() {
  694. switch (widget.widgetType) {
  695. case WidgetType.ISSUE:
  696. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  697. return renderIssuesTable({
  698. tableResults: tableData,
  699. loading: false,
  700. errorMessage: undefined,
  701. pageLinks: defaultPageLinks,
  702. totalCount: totalIssuesCount,
  703. });
  704. }
  705. return (
  706. <IssueWidgetQueries
  707. api={api}
  708. organization={organization}
  709. widget={tableWidget}
  710. selection={modalTableSelection}
  711. limit={
  712. widget.displayType === DisplayType.TABLE
  713. ? FULL_TABLE_ITEM_LIMIT
  714. : HALF_TABLE_ITEM_LIMIT
  715. }
  716. cursor={cursor}
  717. dashboardFilters={dashboardFilters}
  718. >
  719. {renderIssuesTable}
  720. </IssueWidgetQueries>
  721. );
  722. case WidgetType.RELEASE:
  723. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  724. return renderReleaseTable({
  725. tableResults: tableData,
  726. loading: false,
  727. pageLinks: defaultPageLinks,
  728. });
  729. }
  730. return (
  731. <ReleaseWidgetQueries
  732. api={api}
  733. organization={organization}
  734. widget={tableWidget}
  735. selection={modalTableSelection}
  736. limit={
  737. widget.displayType === DisplayType.TABLE
  738. ? FULL_TABLE_ITEM_LIMIT
  739. : HALF_TABLE_ITEM_LIMIT
  740. }
  741. cursor={cursor}
  742. dashboardFilters={dashboardFilters}
  743. >
  744. {renderReleaseTable}
  745. </ReleaseWidgetQueries>
  746. );
  747. case WidgetType.DISCOVER:
  748. default:
  749. if (tableData && chartUnmodified && widget.displayType === DisplayType.TABLE) {
  750. return (
  751. <DiscoverTable
  752. tableResults={tableData}
  753. loading={false}
  754. pageLinks={defaultPageLinks}
  755. />
  756. );
  757. }
  758. return (
  759. <WidgetQueries
  760. api={api}
  761. organization={organization}
  762. widget={tableWidget}
  763. selection={modalTableSelection}
  764. limit={
  765. widget.displayType === DisplayType.TABLE
  766. ? FULL_TABLE_ITEM_LIMIT
  767. : HALF_TABLE_ITEM_LIMIT
  768. }
  769. cursor={cursor}
  770. dashboardFilters={dashboardFilters}
  771. >
  772. {({tableResults, loading, pageLinks}) => {
  773. // TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
  774. // small hack that improves the concurrency render of the warning triangle
  775. // widgetContentLoadingStatus = loading;
  776. return (
  777. <DiscoverTable
  778. tableResults={tableResults}
  779. loading={loading}
  780. pageLinks={pageLinks}
  781. />
  782. );
  783. }}
  784. </WidgetQueries>
  785. );
  786. }
  787. }
  788. function renderWidgetViewer() {
  789. return (
  790. <Fragment>
  791. {hasSessionDuration && SESSION_DURATION_ALERT}
  792. {widget.displayType !== DisplayType.TABLE && (
  793. <Container
  794. height={
  795. widget.displayType !== DisplayType.BIG_NUMBER
  796. ? HALF_CONTAINER_HEIGHT +
  797. (shouldShowSlider &&
  798. [
  799. DisplayType.AREA,
  800. DisplayType.LINE,
  801. DisplayType.BAR,
  802. DisplayType.TOP_N,
  803. ].includes(widget.displayType)
  804. ? SLIDER_HEIGHT
  805. : 0)
  806. : null
  807. }
  808. >
  809. {(!!seriesData || !!tableData) && chartUnmodified ? (
  810. <MemoizedWidgetCardChart
  811. timeseriesResults={seriesData}
  812. timeseriesResultsTypes={seriesResultsType}
  813. tableResults={tableData}
  814. errorMessage={undefined}
  815. loading={false}
  816. location={location}
  817. widget={widget}
  818. selection={selection}
  819. router={router}
  820. organization={organization}
  821. onZoom={onZoom}
  822. onLegendSelectChanged={onLegendSelectChanged}
  823. legendOptions={{selected: disabledLegends}}
  824. expandNumbers
  825. showSlider={shouldShowSlider}
  826. noPadding
  827. chartZoomOptions={chartZoomOptions}
  828. />
  829. ) : (
  830. <MemoizedWidgetCardChartContainer
  831. location={location}
  832. api={api}
  833. organization={organization}
  834. selection={modalChartSelection.current}
  835. dashboardFilters={dashboardFilters}
  836. // Top N charts rely on the orderby of the table
  837. widget={primaryWidget}
  838. onZoom={onZoom}
  839. onLegendSelectChanged={onLegendSelectChanged}
  840. legendOptions={{selected: disabledLegends}}
  841. expandNumbers
  842. showSlider={shouldShowSlider}
  843. noPadding
  844. chartZoomOptions={chartZoomOptions}
  845. />
  846. )}
  847. </Container>
  848. )}
  849. {widget.queries.length > 1 && (
  850. <Alert type="info" showIcon>
  851. {t(
  852. '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.'
  853. )}
  854. </Alert>
  855. )}
  856. {(widget.queries.length > 1 || widget.queries[0].conditions) && (
  857. <QueryContainer>
  858. <SelectControl
  859. value={selectedQueryIndex}
  860. options={queryOptions}
  861. onChange={(option: SelectValue<number>) => {
  862. router.replace({
  863. pathname: location.pathname,
  864. query: {
  865. ...location.query,
  866. [WidgetViewerQueryField.QUERY]: option.value,
  867. [WidgetViewerQueryField.PAGE]: undefined,
  868. [WidgetViewerQueryField.CURSOR]: undefined,
  869. },
  870. });
  871. trackAnalytics('dashboards_views.widget_viewer.select_query', {
  872. organization,
  873. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  874. display_type: widget.displayType,
  875. });
  876. }}
  877. components={{
  878. // Replaces the displayed selected value
  879. SingleValue: containerProps => {
  880. return (
  881. <components.SingleValue
  882. {...containerProps}
  883. // Overwrites some of the default styling that interferes with highlighted query text
  884. getStyles={() => ({
  885. wordBreak: 'break-word',
  886. flex: 1,
  887. display: 'flex',
  888. padding: `0 ${space(0.5)}`,
  889. })}
  890. >
  891. {queryOptions[selectedQueryIndex].getHighlightedQuery({
  892. display: 'block',
  893. }) ??
  894. (queryOptions[selectedQueryIndex].label || (
  895. <EmptyQueryContainer>{EMPTY_QUERY_NAME}</EmptyQueryContainer>
  896. ))}
  897. </components.SingleValue>
  898. );
  899. },
  900. // Replaces the dropdown options
  901. Option: containerProps => {
  902. const highlightedQuery = containerProps.data.getHighlightedQuery({
  903. display: 'flex',
  904. });
  905. return (
  906. <Option
  907. {...(highlightedQuery
  908. ? {
  909. ...containerProps,
  910. label: highlightedQuery,
  911. }
  912. : containerProps.label
  913. ? containerProps
  914. : {
  915. ...containerProps,
  916. label: (
  917. <EmptyQueryContainer>
  918. {EMPTY_QUERY_NAME}
  919. </EmptyQueryContainer>
  920. ),
  921. })}
  922. />
  923. );
  924. },
  925. // Hide the dropdown indicator if there is only one option
  926. ...(widget.queries.length < 2 ? {IndicatorsContainer: _ => null} : {}),
  927. }}
  928. isSearchable={false}
  929. isDisabled={widget.queries.length < 2}
  930. />
  931. {widget.queries.length === 1 && (
  932. <StyledQuestionTooltip
  933. title={t('To edit this query, you must edit the widget.')}
  934. size="sm"
  935. />
  936. )}
  937. </QueryContainer>
  938. )}
  939. {renderWidgetViewerTable()}
  940. </Fragment>
  941. );
  942. }
  943. return (
  944. <Fragment>
  945. <OrganizationContext.Provider value={organization}>
  946. <DashboardsMEPProvider>
  947. <MetricsCardinalityProvider organization={organization} location={location}>
  948. <MetricsDataSwitcher
  949. organization={organization}
  950. eventView={eventView}
  951. location={location}
  952. hideLoadingIndicator
  953. >
  954. {metricsDataSide => (
  955. <MEPSettingProvider
  956. location={location}
  957. forceTransactions={metricsDataSide.forceTransactionsOnly}
  958. >
  959. <Header closeButton>
  960. <WidgetHeader>
  961. <WidgetTitleRow>
  962. <h3>{widget.title}</h3>
  963. {widget.thresholds &&
  964. tableData &&
  965. organization.features.includes('dashboard-widget-indicators') &&
  966. getColoredWidgetIndicator(widget.thresholds, tableData)}
  967. </WidgetTitleRow>
  968. {widget.description && (
  969. <Tooltip
  970. title={widget.description}
  971. containerDisplayMode="grid"
  972. showOnlyOnOverflow
  973. isHoverable
  974. position="bottom"
  975. >
  976. <WidgetDescription>{widget.description}</WidgetDescription>
  977. </Tooltip>
  978. )}
  979. <DashboardsMEPConsumer>
  980. {({}) => {
  981. // TODO(Tele-Team): Re-enable this when we have a better way to determine if the data is transaction only
  982. // if (
  983. // widgetContentLoadingStatus === false &&
  984. // widget.widgetType === WidgetType.DISCOVER &&
  985. // isMetricsData === false
  986. // ) {
  987. // return (
  988. // <Tooltip
  989. // containerDisplayMode="inline-flex"
  990. // title={t(
  991. // 'Based on your search criteria, the sampled events available may be limited and may not be representative of all events.'
  992. // )}
  993. // >
  994. // <IconWarning color="warningText" size="md" />
  995. // </Tooltip>
  996. // );
  997. // }
  998. return null;
  999. }}
  1000. </DashboardsMEPConsumer>
  1001. </WidgetHeader>
  1002. </Header>
  1003. <Body>{renderWidgetViewer()}</Body>
  1004. <Footer>
  1005. <ResultsContainer>
  1006. {renderTotalResults(totalResults, widget.widgetType)}
  1007. <ButtonBar gap={1}>
  1008. {onEdit && widget.id && (
  1009. <Button
  1010. onClick={() => {
  1011. closeModal();
  1012. onEdit();
  1013. trackAnalytics('dashboards_views.widget_viewer.edit', {
  1014. organization,
  1015. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  1016. display_type: widget.displayType,
  1017. });
  1018. }}
  1019. >
  1020. {t('Edit Widget')}
  1021. </Button>
  1022. )}
  1023. {widget.widgetType && (
  1024. <OpenButton
  1025. widget={primaryWidget}
  1026. organization={organization}
  1027. selection={modalTableSelection}
  1028. selectedQueryIndex={selectedQueryIndex}
  1029. />
  1030. )}
  1031. </ButtonBar>
  1032. </ResultsContainer>
  1033. </Footer>
  1034. </MEPSettingProvider>
  1035. )}
  1036. </MetricsDataSwitcher>
  1037. </MetricsCardinalityProvider>
  1038. </DashboardsMEPProvider>
  1039. </OrganizationContext.Provider>
  1040. </Fragment>
  1041. );
  1042. }
  1043. interface OpenButtonProps {
  1044. organization: Organization;
  1045. selectedQueryIndex: number;
  1046. selection: PageFilters;
  1047. widget: Widget;
  1048. }
  1049. function OpenButton({
  1050. widget,
  1051. selection,
  1052. organization,
  1053. selectedQueryIndex,
  1054. }: OpenButtonProps) {
  1055. let openLabel: string;
  1056. let path: string;
  1057. const {isMetricsData} = useDashboardsMEPContext();
  1058. switch (widget.widgetType) {
  1059. case WidgetType.ISSUE:
  1060. openLabel = t('Open in Issues');
  1061. path = getWidgetIssueUrl(widget, selection, organization);
  1062. break;
  1063. case WidgetType.RELEASE:
  1064. openLabel = t('Open in Releases');
  1065. path = getWidgetReleasesUrl(widget, selection, organization);
  1066. break;
  1067. case WidgetType.METRICS:
  1068. openLabel = t('Open in Metrics');
  1069. path = getWidgetDDMUrl(widget, selection, organization);
  1070. break;
  1071. case WidgetType.DISCOVER:
  1072. default:
  1073. openLabel = t('Open in Discover');
  1074. path = getWidgetDiscoverUrl(
  1075. {...widget, queries: [widget.queries[selectedQueryIndex]]},
  1076. selection,
  1077. organization,
  1078. 0,
  1079. isMetricsData
  1080. );
  1081. break;
  1082. }
  1083. return (
  1084. <Button
  1085. to={path}
  1086. priority="primary"
  1087. onClick={() => {
  1088. trackAnalytics('dashboards_views.widget_viewer.open_source', {
  1089. organization,
  1090. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  1091. display_type: widget.displayType,
  1092. });
  1093. }}
  1094. >
  1095. {openLabel}
  1096. </Button>
  1097. );
  1098. }
  1099. function renderTotalResults(totalResults?: string, widgetType?: WidgetType) {
  1100. if (totalResults === undefined) {
  1101. return <span />;
  1102. }
  1103. switch (widgetType) {
  1104. case WidgetType.ISSUE:
  1105. return (
  1106. <span>
  1107. {tct('[description:Total Issues:] [total]', {
  1108. description: <strong />,
  1109. total: totalResults === '1000' ? '1000+' : totalResults,
  1110. })}
  1111. </span>
  1112. );
  1113. case WidgetType.DISCOVER:
  1114. return (
  1115. <span>
  1116. {tct('[description:Sampled Events:] [total]', {
  1117. description: <strong />,
  1118. total: totalResults,
  1119. })}
  1120. </span>
  1121. );
  1122. default:
  1123. return <span />;
  1124. }
  1125. }
  1126. export const modalCss = css`
  1127. width: 100%;
  1128. max-width: 1200px;
  1129. `;
  1130. const Container = styled('div')<{height?: number | null}>`
  1131. height: ${p => (p.height ? `${p.height}px` : 'auto')};
  1132. position: relative;
  1133. padding-bottom: ${space(3)};
  1134. `;
  1135. const QueryContainer = styled('div')`
  1136. margin-bottom: ${space(2)};
  1137. position: relative;
  1138. `;
  1139. const StyledQuestionTooltip = styled(QuestionTooltip)`
  1140. position: absolute;
  1141. top: ${space(1.5)};
  1142. right: ${space(2)};
  1143. `;
  1144. const HighlightContainer = styled('span')<{display?: 'block' | 'flex'}>`
  1145. display: ${p => p.display};
  1146. gap: ${space(1)};
  1147. font-family: ${p => p.theme.text.familyMono};
  1148. font-size: ${p => p.theme.fontSizeSmall};
  1149. line-height: 2;
  1150. flex: 1;
  1151. `;
  1152. const ResultsContainer = styled('div')`
  1153. display: flex;
  1154. flex-grow: 1;
  1155. flex-direction: column;
  1156. gap: ${space(1)};
  1157. @media (min-width: ${p => p.theme.breakpoints.small}) {
  1158. align-items: center;
  1159. flex-direction: row;
  1160. justify-content: space-between;
  1161. }
  1162. `;
  1163. const EmptyQueryContainer = styled('span')`
  1164. color: ${p => p.theme.disabled};
  1165. `;
  1166. const WidgetHeader = styled('div')`
  1167. display: flex;
  1168. flex-direction: column;
  1169. gap: ${space(1)};
  1170. `;
  1171. const WidgetTitleRow = styled('div')`
  1172. display: flex;
  1173. align-items: center;
  1174. gap: ${space(0.75)};
  1175. `;
  1176. export default withPageFilters(WidgetViewerModal);