widgetViewerModal.tsx 37 KB

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