widgetViewerModal.tsx 42 KB

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