widgetViewerModal.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import {components} from 'react-select';
  4. import {css} from '@emotion/react';
  5. import styled from '@emotion/styled';
  6. import * as Sentry from '@sentry/react';
  7. import {truncate} from '@sentry/utils';
  8. import {Location} from 'history';
  9. import cloneDeep from 'lodash/cloneDeep';
  10. import moment from 'moment';
  11. import {fetchTotalCount} from 'sentry/actionCreators/events';
  12. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  13. import {Client} from 'sentry/api';
  14. import Alert from 'sentry/components/alert';
  15. import Button from 'sentry/components/button';
  16. import ButtonBar from 'sentry/components/buttonBar';
  17. import FeatureBadge from 'sentry/components/featureBadge';
  18. import SelectControl from 'sentry/components/forms/selectControl';
  19. import Option from 'sentry/components/forms/selectOption';
  20. import GridEditable, {
  21. COL_WIDTH_UNDEFINED,
  22. GridColumnOrder,
  23. } from 'sentry/components/gridEditable';
  24. import Pagination from 'sentry/components/pagination';
  25. import {parseSearch} from 'sentry/components/searchSyntax/parser';
  26. import HighlightQuery from 'sentry/components/searchSyntax/renderer';
  27. import Tooltip from 'sentry/components/tooltip';
  28. import {IconInfo, IconSearch} from 'sentry/icons';
  29. import {t, tct} from 'sentry/locale';
  30. import space from 'sentry/styles/space';
  31. import {Organization, PageFilters, SelectValue} from 'sentry/types';
  32. import {defined} from 'sentry/utils';
  33. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  34. import {getUtcDateString} from 'sentry/utils/dates';
  35. import EventView from 'sentry/utils/discover/eventView';
  36. import {getAggregateAlias, isAggregateField} from 'sentry/utils/discover/fields';
  37. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  38. import {decodeInteger, decodeList, decodeScalar} from 'sentry/utils/queryString';
  39. import useApi from 'sentry/utils/useApi';
  40. import withPageFilters from 'sentry/utils/withPageFilters';
  41. import {DisplayType, Widget, WidgetType} from 'sentry/views/dashboardsV2/types';
  42. import {
  43. eventViewFromWidget,
  44. getFieldsFromEquations,
  45. getWidgetDiscoverUrl,
  46. getWidgetIssueUrl,
  47. } from 'sentry/views/dashboardsV2/utils';
  48. import IssueWidgetQueries from 'sentry/views/dashboardsV2/widgetCard/issueWidgetQueries';
  49. import {WidgetCardChartContainer} from 'sentry/views/dashboardsV2/widgetCard/widgetCardChartContainer';
  50. import WidgetQueries from 'sentry/views/dashboardsV2/widgetCard/widgetQueries';
  51. import {WidgetViewerQueryField} from './widgetViewerModal/utils';
  52. import {
  53. renderDiscoverGridHeaderCell,
  54. renderGridBodyCell,
  55. renderIssueGridHeaderCell,
  56. } from './widgetViewerModal/widgetViewerTableCell';
  57. export type WidgetViewerModalOptions = {
  58. organization: Organization;
  59. widget: Widget;
  60. onEdit?: () => void;
  61. };
  62. type Props = ModalRenderProps &
  63. WithRouterProps &
  64. WidgetViewerModalOptions & {
  65. organization: Organization;
  66. selection: PageFilters;
  67. };
  68. const FULL_TABLE_ITEM_LIMIT = 20;
  69. const HALF_TABLE_ITEM_LIMIT = 10;
  70. const GEO_COUNTRY_CODE = 'geo.country_code';
  71. const HALF_CONTAINER_HEIGHT = 300;
  72. const EMPTY_QUERY_NAME = '(Empty Query Condition)';
  73. // WidgetCardChartContainer rerenders if selection was changed.
  74. // This is required because we want to prevent ECharts interactions from
  75. // causing unnecessary rerenders which can break persistent legends functionality.
  76. const MemoizedWidgetCardChartContainer = React.memo(
  77. WidgetCardChartContainer,
  78. (props, prevProps) => {
  79. return (
  80. props.selection === prevProps.selection &&
  81. props.location.query[WidgetViewerQueryField.QUERY] ===
  82. prevProps.location.query[WidgetViewerQueryField.QUERY] &&
  83. props.location.query[WidgetViewerQueryField.SORT] ===
  84. prevProps.location.query[WidgetViewerQueryField.SORT] &&
  85. props.location.query[WidgetViewerQueryField.WIDTH] ===
  86. prevProps.location.query[WidgetViewerQueryField.WIDTH]
  87. );
  88. }
  89. );
  90. async function fetchDiscoverTotal(
  91. api: Client,
  92. organization: Organization,
  93. location: Location,
  94. eventView: EventView
  95. ): Promise<string | undefined> {
  96. if (!eventView.isValid()) {
  97. return undefined;
  98. }
  99. try {
  100. const total = await fetchTotalCount(
  101. api,
  102. organization.slug,
  103. eventView.getEventsAPIPayload(location)
  104. );
  105. return total.toLocaleString();
  106. } catch (err) {
  107. Sentry.captureException(err);
  108. return undefined;
  109. }
  110. }
  111. function WidgetViewerModal(props: Props) {
  112. const {
  113. organization,
  114. widget,
  115. selection,
  116. location,
  117. Footer,
  118. Body,
  119. Header,
  120. closeModal,
  121. onEdit,
  122. router,
  123. routes,
  124. params,
  125. } = props;
  126. const isTableWidget = widget.displayType === DisplayType.TABLE;
  127. const [modalSelection, setModalSelection] = React.useState<PageFilters>(selection);
  128. const [totalResults, setTotalResults] = React.useState<string | undefined>();
  129. // Get query selection settings from location
  130. const selectedQueryIndex =
  131. decodeInteger(location.query[WidgetViewerQueryField.QUERY]) ?? 0;
  132. // Get legends toggle settings from location
  133. const disabledLegends = decodeList(
  134. location.query[WidgetViewerQueryField.LEGEND]
  135. ).reduce((acc, legend) => {
  136. acc[legend] = false;
  137. return acc;
  138. }, {});
  139. // Get pagination settings from location
  140. const page = decodeInteger(location.query[WidgetViewerQueryField.PAGE]) ?? 0;
  141. const cursor = decodeScalar(location.query[WidgetViewerQueryField.CURSOR]);
  142. // Get table column widths from location
  143. const widths = decodeList(location.query[WidgetViewerQueryField.WIDTH]);
  144. // Get table sort settings from location
  145. const sort = decodeScalar(location.query[WidgetViewerQueryField.SORT]);
  146. const sortedQueries = sort
  147. ? widget.queries.map(query => ({...query, orderby: sort}))
  148. : widget.queries;
  149. // Top N widget charts results rely on the sorting of the query
  150. const primaryWidget =
  151. widget.displayType === DisplayType.TOP_N
  152. ? {...widget, queries: sortedQueries}
  153. : widget;
  154. const api = useApi();
  155. // Create Table widget
  156. const tableWidget = {
  157. ...cloneDeep({...widget, queries: [sortedQueries[selectedQueryIndex]]}),
  158. displayType: DisplayType.TABLE,
  159. };
  160. const {aggregates, columns} = tableWidget.queries[0];
  161. const fields = defined(tableWidget.queries[0].fields)
  162. ? tableWidget.queries[0].fields
  163. : [...columns, ...aggregates];
  164. // World Map view should always have geo.country in the table chart
  165. if (
  166. widget.displayType === DisplayType.WORLD_MAP &&
  167. !columns.includes(GEO_COUNTRY_CODE)
  168. ) {
  169. fields.unshift(GEO_COUNTRY_CODE);
  170. columns.unshift(GEO_COUNTRY_CODE);
  171. }
  172. // Default table columns for visualizations that don't have a column setting
  173. const shouldReplaceTableColumns = [
  174. DisplayType.AREA,
  175. DisplayType.LINE,
  176. DisplayType.BIG_NUMBER,
  177. DisplayType.BAR,
  178. ].includes(widget.displayType);
  179. if (shouldReplaceTableColumns) {
  180. if (fields.length === 1) {
  181. tableWidget.queries[0].orderby =
  182. tableWidget.queries[0].orderby || `-${getAggregateAlias(fields[0])}`;
  183. }
  184. fields.unshift('title');
  185. columns.unshift('title');
  186. }
  187. let equationFieldsCount = 0;
  188. // Updates fields by adding any individual terms from equation fields as a column
  189. if (!isTableWidget) {
  190. const equationFields = getFieldsFromEquations(fields);
  191. equationFields.forEach(term => {
  192. if (Array.isArray(fields) && !fields.includes(term)) {
  193. equationFieldsCount++;
  194. fields.unshift(term);
  195. }
  196. if (isAggregateField(term) && !aggregates.includes(term)) {
  197. aggregates.unshift(term);
  198. }
  199. if (!isAggregateField(term) && !columns.includes(term)) {
  200. columns.unshift(term);
  201. }
  202. });
  203. }
  204. const eventView = eventViewFromWidget(
  205. tableWidget.title,
  206. tableWidget.queries[0],
  207. modalSelection,
  208. tableWidget.displayType
  209. );
  210. // Update field widths
  211. widths.forEach((width, index) => {
  212. if (eventView.fields[index]) {
  213. eventView.fields[index].width = parseInt(width, 10);
  214. }
  215. });
  216. let columnOrder = eventView.getColumns();
  217. const columnSortBy = eventView.getSorts();
  218. // Filter out equation terms from columnOrder so we don't clutter the table
  219. if (shouldReplaceTableColumns && equationFieldsCount) {
  220. columnOrder = columnOrder.filter(
  221. (_, index) => index === 0 || index > equationFieldsCount
  222. );
  223. }
  224. const queryOptions = sortedQueries.map(({name, conditions}, index) => {
  225. // Creates the highlighted query elements to be used in the Query Select
  226. const parsedQuery = !!!name && !!conditions ? parseSearch(conditions) : null;
  227. const getHighlightedQuery = (
  228. highlightedContainerProps: React.ComponentProps<typeof HighlightContainer>
  229. ) => {
  230. return parsedQuery !== null ? (
  231. <HighlightContainer {...highlightedContainerProps}>
  232. <HighlightQuery parsedQuery={parsedQuery} />
  233. </HighlightContainer>
  234. ) : undefined;
  235. };
  236. return {
  237. label: truncate(name || conditions, 120),
  238. value: index,
  239. getHighlightedQuery,
  240. };
  241. });
  242. const onResizeColumn = (columnIndex: number, nextColumn: GridColumnOrder) => {
  243. const newWidth = nextColumn.width ? Number(nextColumn.width) : COL_WIDTH_UNDEFINED;
  244. const newWidths: number[] = new Array(Math.max(columnIndex, widths.length)).fill(
  245. COL_WIDTH_UNDEFINED
  246. );
  247. widths.forEach((width, index) => (newWidths[index] = parseInt(width, 10)));
  248. newWidths[columnIndex] = newWidth;
  249. router.replace({
  250. pathname: location.pathname,
  251. query: {
  252. ...location.query,
  253. [WidgetViewerQueryField.WIDTH]: newWidths,
  254. },
  255. });
  256. };
  257. // Get discover result totals
  258. React.useEffect(() => {
  259. const getDiscoverTotals = async () => {
  260. if (widget.widgetType !== WidgetType.ISSUE) {
  261. setTotalResults(await fetchDiscoverTotal(api, organization, location, eventView));
  262. }
  263. };
  264. getDiscoverTotals();
  265. }, [selectedQueryIndex]);
  266. function renderWidgetViewer() {
  267. return (
  268. <React.Fragment>
  269. {widget.displayType !== DisplayType.TABLE && (
  270. <Container
  271. height={
  272. widget.displayType !== DisplayType.BIG_NUMBER ? HALF_CONTAINER_HEIGHT : null
  273. }
  274. >
  275. <MemoizedWidgetCardChartContainer
  276. location={location}
  277. router={router}
  278. routes={routes}
  279. params={params}
  280. api={api}
  281. organization={organization}
  282. selection={modalSelection}
  283. // Top N charts rely on the orderby of the table
  284. widget={primaryWidget}
  285. onZoom={(_evt, chart) => {
  286. // @ts-ignore getModel() is private but we need this to retrieve datetime values of zoomed in region
  287. const model = chart.getModel();
  288. const {startValue, endValue} = model._payload.batch[0];
  289. const start = getUtcDateString(moment.utc(startValue));
  290. const end = getUtcDateString(moment.utc(endValue));
  291. setModalSelection({
  292. ...modalSelection,
  293. datetime: {...modalSelection.datetime, start, end, period: null},
  294. });
  295. trackAdvancedAnalyticsEvent('dashboards_views.widget_viewer.zoom', {
  296. organization,
  297. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  298. display_type: widget.displayType,
  299. });
  300. }}
  301. onLegendSelectChanged={({selected}) => {
  302. router.replace({
  303. pathname: location.pathname,
  304. query: {
  305. ...location.query,
  306. [WidgetViewerQueryField.LEGEND]: Object.keys(selected).filter(
  307. key => !selected[key]
  308. ),
  309. },
  310. });
  311. trackAdvancedAnalyticsEvent(
  312. 'dashboards_views.widget_viewer.toggle_legend',
  313. {
  314. organization,
  315. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  316. display_type: widget.displayType,
  317. }
  318. );
  319. }}
  320. legendOptions={{selected: disabledLegends}}
  321. expandNumbers
  322. />
  323. </Container>
  324. )}
  325. {widget.queries.length > 1 && (
  326. <StyledAlert type="info" icon={<IconInfo />}>
  327. {t(
  328. 'This widget was built with multiple queries. Table data can only be displayed for one query at a time.'
  329. )}
  330. </StyledAlert>
  331. )}
  332. {(widget.queries.length > 1 || widget.queries[0].conditions) && (
  333. <StyledSelectControl
  334. value={selectedQueryIndex}
  335. options={queryOptions}
  336. onChange={(option: SelectValue<number>) => {
  337. router.replace({
  338. pathname: location.pathname,
  339. query: {
  340. ...location.query,
  341. [WidgetViewerQueryField.QUERY]: option.value,
  342. [WidgetViewerQueryField.PAGE]: undefined,
  343. [WidgetViewerQueryField.CURSOR]: undefined,
  344. },
  345. });
  346. trackAdvancedAnalyticsEvent('dashboards_views.widget_viewer.select_query', {
  347. organization,
  348. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  349. display_type: widget.displayType,
  350. });
  351. }}
  352. components={{
  353. // Replaces the displayed selected value
  354. SingleValue: containerProps => {
  355. return (
  356. <components.SingleValue
  357. {...containerProps}
  358. // Overwrites some of the default styling that interferes with highlighted query text
  359. getStyles={() => ({
  360. wordBreak: 'break-word',
  361. flex: 1,
  362. display: 'flex',
  363. })}
  364. >
  365. <StyledIconSearch />
  366. {queryOptions[selectedQueryIndex].getHighlightedQuery({
  367. display: 'block',
  368. }) ??
  369. (queryOptions[selectedQueryIndex].label || (
  370. <EmptyQueryContainer>{EMPTY_QUERY_NAME}</EmptyQueryContainer>
  371. ))}
  372. </components.SingleValue>
  373. );
  374. },
  375. // Replaces the dropdown options
  376. Option: containerProps => {
  377. const highlightedQuery = containerProps.data.getHighlightedQuery({
  378. display: 'flex',
  379. });
  380. return (
  381. <Option
  382. {...(highlightedQuery
  383. ? {
  384. ...containerProps,
  385. label: highlightedQuery,
  386. }
  387. : containerProps.label
  388. ? containerProps
  389. : {
  390. ...containerProps,
  391. label: (
  392. <EmptyQueryContainer>{EMPTY_QUERY_NAME}</EmptyQueryContainer>
  393. ),
  394. })}
  395. />
  396. );
  397. },
  398. // Hide the dropdown indicator if there is only one option
  399. ...(widget.queries.length < 2 ? {IndicatorsContainer: _ => null} : {}),
  400. }}
  401. isSearchable={false}
  402. isDisabled={widget.queries.length < 2}
  403. />
  404. )}
  405. <TableContainer>
  406. {widget.widgetType === WidgetType.ISSUE ? (
  407. <IssueWidgetQueries
  408. api={api}
  409. organization={organization}
  410. widget={tableWidget}
  411. selection={modalSelection}
  412. limit={
  413. widget.displayType === DisplayType.TABLE
  414. ? FULL_TABLE_ITEM_LIMIT
  415. : HALF_TABLE_ITEM_LIMIT
  416. }
  417. cursor={cursor}
  418. >
  419. {({transformedResults, loading, pageLinks, totalCount}) => {
  420. if (totalResults === undefined) {
  421. setTotalResults(totalCount);
  422. }
  423. return (
  424. <React.Fragment>
  425. <GridEditable
  426. isLoading={loading}
  427. data={transformedResults}
  428. columnOrder={columnOrder}
  429. columnSortBy={columnSortBy}
  430. grid={{
  431. renderHeadCell: renderIssueGridHeaderCell({
  432. ...props,
  433. widget: tableWidget,
  434. }) as (
  435. column: GridColumnOrder,
  436. columnIndex: number
  437. ) => React.ReactNode,
  438. renderBodyCell: renderGridBodyCell({
  439. ...props,
  440. widget: tableWidget,
  441. }),
  442. onResizeColumn,
  443. }}
  444. location={location}
  445. />
  446. <StyledPagination
  447. pageLinks={pageLinks}
  448. onCursor={(nextCursor, _path, _query, delta) => {
  449. let nextPage = isNaN(page) ? delta : page + delta;
  450. let newCursor = nextCursor;
  451. // unset cursor and page when we navigate back to the first page
  452. // also reset cursor if somehow the previous button is enabled on
  453. // first page and user attempts to go backwards
  454. if (nextPage <= 0) {
  455. newCursor = undefined;
  456. nextPage = 0;
  457. }
  458. router.replace({
  459. pathname: location.pathname,
  460. query: {
  461. ...location.query,
  462. [WidgetViewerQueryField.CURSOR]: newCursor,
  463. [WidgetViewerQueryField.PAGE]: nextPage,
  464. },
  465. });
  466. trackAdvancedAnalyticsEvent(
  467. 'dashboards_views.widget_viewer.paginate',
  468. {
  469. organization,
  470. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  471. display_type: widget.displayType,
  472. }
  473. );
  474. }}
  475. />
  476. </React.Fragment>
  477. );
  478. }}
  479. </IssueWidgetQueries>
  480. ) : (
  481. <WidgetQueries
  482. api={api}
  483. organization={organization}
  484. widget={tableWidget}
  485. selection={modalSelection}
  486. limit={
  487. widget.displayType === DisplayType.TABLE
  488. ? FULL_TABLE_ITEM_LIMIT
  489. : HALF_TABLE_ITEM_LIMIT
  490. }
  491. pagination
  492. cursor={cursor}
  493. >
  494. {({tableResults, loading, pageLinks}) => {
  495. const isFirstPage = pageLinks
  496. ? parseLinkHeader(pageLinks).previous.results === false
  497. : false;
  498. return (
  499. <React.Fragment>
  500. <GridEditable
  501. isLoading={loading}
  502. data={tableResults?.[0]?.data ?? []}
  503. columnOrder={columnOrder}
  504. columnSortBy={columnSortBy}
  505. grid={{
  506. renderHeadCell: renderDiscoverGridHeaderCell({
  507. ...props,
  508. widget: tableWidget,
  509. tableData: tableResults?.[0],
  510. }) as (
  511. column: GridColumnOrder,
  512. columnIndex: number
  513. ) => React.ReactNode,
  514. renderBodyCell: renderGridBodyCell({
  515. ...props,
  516. tableData: tableResults?.[0],
  517. isFirstPage,
  518. }),
  519. onResizeColumn,
  520. }}
  521. location={location}
  522. />
  523. <StyledPagination
  524. pageLinks={pageLinks}
  525. onCursor={newCursor => {
  526. router.replace({
  527. pathname: location.pathname,
  528. query: {
  529. ...location.query,
  530. [WidgetViewerQueryField.CURSOR]: newCursor,
  531. },
  532. });
  533. trackAdvancedAnalyticsEvent(
  534. 'dashboards_views.widget_viewer.paginate',
  535. {
  536. organization,
  537. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  538. display_type: widget.displayType,
  539. }
  540. );
  541. }}
  542. />
  543. </React.Fragment>
  544. );
  545. }}
  546. </WidgetQueries>
  547. )}
  548. </TableContainer>
  549. </React.Fragment>
  550. );
  551. }
  552. const StyledHeader = styled(Header)`
  553. ${headerCss}
  554. `;
  555. const StyledFooter = styled(Footer)`
  556. ${footerCss}
  557. `;
  558. let openLabel: string;
  559. let path: string;
  560. switch (widget.widgetType) {
  561. case WidgetType.ISSUE:
  562. openLabel = t('Open in Issues');
  563. path = getWidgetIssueUrl(primaryWidget, modalSelection, organization);
  564. break;
  565. case WidgetType.DISCOVER:
  566. default:
  567. openLabel = t('Open in Discover');
  568. path = getWidgetDiscoverUrl(
  569. {...primaryWidget, queries: [primaryWidget.queries[selectedQueryIndex]]},
  570. modalSelection,
  571. organization
  572. );
  573. break;
  574. }
  575. return (
  576. <React.Fragment>
  577. <StyledHeader closeButton>
  578. <Tooltip title={widget.title} showOnlyOnOverflow>
  579. <WidgetTitle>{widget.title}</WidgetTitle>
  580. </Tooltip>
  581. <FeatureBadge type="beta" />
  582. </StyledHeader>
  583. <Body>{renderWidgetViewer()}</Body>
  584. <StyledFooter>
  585. <TotalResultsContainer>
  586. {totalResults &&
  587. (widget.widgetType === WidgetType.ISSUE ? (
  588. <span>
  589. {tct('[description:Total Issues:] [total]', {
  590. description: <strong />,
  591. total: totalResults === '1000' ? '1000+' : totalResults,
  592. })}
  593. </span>
  594. ) : (
  595. <span>
  596. {tct('[description:Total Events:] [total]', {
  597. description: <strong />,
  598. total: totalResults,
  599. })}
  600. </span>
  601. ))}
  602. </TotalResultsContainer>
  603. <ButtonBarContainer>
  604. <StyledButtonBar gap={1}>
  605. {onEdit && widget.id && (
  606. <Button
  607. type="button"
  608. onClick={() => {
  609. closeModal();
  610. onEdit();
  611. trackAdvancedAnalyticsEvent('dashboards_views.widget_viewer.edit', {
  612. organization,
  613. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  614. display_type: widget.displayType,
  615. });
  616. }}
  617. >
  618. {t('Edit Widget')}
  619. </Button>
  620. )}
  621. <Button
  622. to={path}
  623. priority="primary"
  624. type="button"
  625. onClick={() => {
  626. trackAdvancedAnalyticsEvent(
  627. 'dashboards_views.widget_viewer.open_source',
  628. {
  629. organization,
  630. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  631. display_type: widget.displayType,
  632. }
  633. );
  634. }}
  635. >
  636. {openLabel}
  637. </Button>
  638. </StyledButtonBar>
  639. </ButtonBarContainer>
  640. </StyledFooter>
  641. </React.Fragment>
  642. );
  643. }
  644. export const modalCss = css`
  645. width: 100%;
  646. max-width: 1400px;
  647. `;
  648. const headerCss = css`
  649. margin: -${space(4)} -${space(4)} 0px -${space(4)};
  650. line-height: normal;
  651. display: flex;
  652. `;
  653. const footerCss = css`
  654. margin: 0px -${space(4)} -${space(4)};
  655. flex-wrap: wrap;
  656. `;
  657. const Container = styled('div')<{height?: number | null}>`
  658. height: ${p => (p.height ? `${p.height}px` : 'auto')};
  659. max-height: ${HALF_CONTAINER_HEIGHT}px;
  660. position: relative;
  661. & > div {
  662. padding: ${space(1.5)} 0px;
  663. }
  664. `;
  665. const StyledAlert = styled(Alert)`
  666. margin: ${space(1)} 0 0 0;
  667. `;
  668. const StyledSelectControl = styled(SelectControl)`
  669. margin-top: ${space(2)};
  670. display: flex;
  671. & > div {
  672. width: 100%;
  673. }
  674. & input {
  675. height: 0;
  676. }
  677. `;
  678. // Table Container allows Table display to work around parent padding and fill full modal width
  679. const TableContainer = styled('div')`
  680. max-width: 1400px;
  681. position: relative;
  682. margin: ${space(2)} 0;
  683. & > div {
  684. margin: 0;
  685. }
  686. & td:first-child {
  687. padding: ${space(1)} ${space(2)};
  688. }
  689. `;
  690. const WidgetTitle = styled('h4')`
  691. text-overflow: ellipsis;
  692. white-space: nowrap;
  693. overflow: hidden;
  694. `;
  695. const StyledPagination = styled(Pagination)`
  696. padding-top: ${space(2)};
  697. `;
  698. const HighlightContainer = styled('span')<{display?: 'block' | 'flex'}>`
  699. flex: 1;
  700. display: ${p => p.display};
  701. gap: ${space(1)};
  702. font-family: ${p => p.theme.text.familyMono};
  703. font-size: ${space(1.5)};
  704. line-height: 2;
  705. `;
  706. const TotalResultsContainer = styled('span')`
  707. margin-top: auto;
  708. margin-bottom: ${space(1)};
  709. font-size: 0.875rem;
  710. text-align: right;
  711. `;
  712. const ButtonBarContainer = styled('span')`
  713. display: flex;
  714. flex-grow: 1;
  715. flex-direction: row-reverse;
  716. `;
  717. const StyledButtonBar = styled(ButtonBar)`
  718. width: fit-content;
  719. `;
  720. const EmptyQueryContainer = styled('span')`
  721. color: ${p => p.theme.disabled};
  722. `;
  723. const StyledIconSearch = styled(IconSearch)`
  724. margin: auto ${space(1.5)} auto 0;
  725. `;
  726. export default withRouter(withPageFilters(WidgetViewerModal));