widgetViewerModal.tsx 41 KB

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