widgetViewerModal.tsx 37 KB

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