widgetViewerModal.tsx 44 KB

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