detail.tsx 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928
  1. import {cloneElement, Component, isValidElement} from 'react';
  2. import {browserHistory, PlainRoute, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import omit from 'lodash/omit';
  6. import {
  7. createDashboard,
  8. deleteDashboard,
  9. updateDashboard,
  10. } from 'sentry/actionCreators/dashboards';
  11. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  12. import {openWidgetViewerModal} from 'sentry/actionCreators/modal';
  13. import {Client} from 'sentry/api';
  14. import Breadcrumbs from 'sentry/components/breadcrumbs';
  15. import DatePageFilter from 'sentry/components/datePageFilter';
  16. import EnvironmentPageFilter from 'sentry/components/environmentPageFilter';
  17. import HookOrDefault from 'sentry/components/hookOrDefault';
  18. import * as Layout from 'sentry/components/layouts/thirds';
  19. import {
  20. isWidgetViewerPath,
  21. WidgetViewerQueryField,
  22. } from 'sentry/components/modals/widgetViewerModal/utils';
  23. import NoProjectMessage from 'sentry/components/noProjectMessage';
  24. import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
  25. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  26. import ProjectPageFilter from 'sentry/components/projectPageFilter';
  27. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  28. import {t} from 'sentry/locale';
  29. import {PageContent} from 'sentry/styles/organization';
  30. import space from 'sentry/styles/space';
  31. import {Organization, Project} from 'sentry/types';
  32. import {defined} from 'sentry/utils';
  33. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  34. import EventView from 'sentry/utils/discover/eventView';
  35. import {MetricsCardinalityProvider} from 'sentry/utils/performance/contexts/metricsCardinality';
  36. import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
  37. import withApi from 'sentry/utils/withApi';
  38. import withOrganization from 'sentry/utils/withOrganization';
  39. import withProjects from 'sentry/utils/withProjects';
  40. import {
  41. cloneDashboard,
  42. getCurrentPageFilters,
  43. getDashboardFiltersFromURL,
  44. hasUnsavedFilterChanges,
  45. isWidgetUsingTransactionName,
  46. resetPageFilters,
  47. } from 'sentry/views/dashboardsV2/utils';
  48. import {MetricsDataSwitcherAlert} from 'sentry/views/performance/landing/metricsDataSwitcherAlert';
  49. import {generatePerformanceEventView} from '../performance/data';
  50. import {MetricsDataSwitcher} from '../performance/landing/metricsDataSwitcher';
  51. import {DiscoverQueryPageSource} from '../performance/utils';
  52. import {
  53. WidgetViewerContext,
  54. WidgetViewerContextProps,
  55. } from './widgetViewer/widgetViewerContext';
  56. import Controls from './controls';
  57. import Dashboard from './dashboard';
  58. import {DEFAULT_STATS_PERIOD} from './data';
  59. import FiltersBar from './filtersBar';
  60. import {
  61. assignDefaultLayout,
  62. calculateColumnDepths,
  63. getDashboardLayout,
  64. } from './layoutUtils';
  65. import DashboardTitle from './title';
  66. import {
  67. DashboardDetails,
  68. DashboardFilterKeys,
  69. DashboardFilters,
  70. DashboardListItem,
  71. DashboardState,
  72. DashboardWidgetSource,
  73. MAX_WIDGETS,
  74. Widget,
  75. WidgetType,
  76. } from './types';
  77. const UNSAVED_MESSAGE = t('You have unsaved changes, are you sure you want to leave?');
  78. export const UNSAVED_FILTERS_MESSAGE = t(
  79. 'You have unsaved dashboard filters. You can save or discard them.'
  80. );
  81. const HookHeader = HookOrDefault({hookName: 'component:dashboards-header'});
  82. type RouteParams = {
  83. orgId: string;
  84. dashboardId?: string;
  85. widgetId?: number;
  86. widgetIndex?: number;
  87. };
  88. type Props = RouteComponentProps<RouteParams, {}> & {
  89. api: Client;
  90. dashboard: DashboardDetails;
  91. dashboards: DashboardListItem[];
  92. initialState: DashboardState;
  93. organization: Organization;
  94. projects: Project[];
  95. route: PlainRoute;
  96. newWidget?: Widget;
  97. onDashboardUpdate?: (updatedDashboard: DashboardDetails) => void;
  98. onSetNewWidget?: () => void;
  99. };
  100. type State = {
  101. dashboardState: DashboardState;
  102. modifiedDashboard: DashboardDetails | null;
  103. widgetLimitReached: boolean;
  104. } & WidgetViewerContextProps;
  105. class DashboardDetail extends Component<Props, State> {
  106. state: State = {
  107. dashboardState: this.props.initialState,
  108. modifiedDashboard: this.updateModifiedDashboard(this.props.initialState),
  109. widgetLimitReached: this.props.dashboard.widgets.length >= MAX_WIDGETS,
  110. setData: data => {
  111. this.setState(data);
  112. },
  113. };
  114. componentDidMount() {
  115. const {route, router} = this.props;
  116. router.setRouteLeaveHook(route, this.onRouteLeave);
  117. window.addEventListener('beforeunload', this.onUnload);
  118. this.checkIfShouldMountWidgetViewerModal();
  119. }
  120. componentDidUpdate(prevProps: Props) {
  121. this.checkIfShouldMountWidgetViewerModal();
  122. if (prevProps.initialState !== this.props.initialState) {
  123. // Widget builder can toggle Edit state when saving
  124. this.setState({dashboardState: this.props.initialState});
  125. }
  126. }
  127. componentWillUnmount() {
  128. window.removeEventListener('beforeunload', this.onUnload);
  129. }
  130. checkIfShouldMountWidgetViewerModal() {
  131. const {
  132. params: {widgetId, dashboardId},
  133. organization,
  134. dashboard,
  135. location,
  136. router,
  137. } = this.props;
  138. const {seriesData, tableData, pageLinks, totalIssuesCount, seriesResultsType} =
  139. this.state;
  140. if (isWidgetViewerPath(location.pathname)) {
  141. const widget =
  142. defined(widgetId) &&
  143. (dashboard.widgets.find(({id}) => id === String(widgetId)) ??
  144. dashboard.widgets[widgetId]);
  145. if (widget) {
  146. openWidgetViewerModal({
  147. organization,
  148. widget,
  149. seriesData,
  150. seriesResultsType,
  151. tableData,
  152. pageLinks,
  153. totalIssuesCount,
  154. onClose: () => {
  155. // Filter out Widget Viewer Modal query params when exiting the Modal
  156. const query = omit(location.query, Object.values(WidgetViewerQueryField));
  157. router.push({
  158. pathname: location.pathname.replace(/widget\/[0-9]+\/$/, ''),
  159. query,
  160. });
  161. },
  162. onEdit: () => {
  163. const widgetIndex = dashboard.widgets.indexOf(widget);
  164. if (dashboardId) {
  165. router.push({
  166. pathname: `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetIndex}/edit/`,
  167. query: {
  168. ...location.query,
  169. source: DashboardWidgetSource.DASHBOARDS,
  170. },
  171. });
  172. return;
  173. }
  174. },
  175. });
  176. trackAdvancedAnalyticsEvent('dashboards_views.widget_viewer.open', {
  177. organization,
  178. widget_type: widget.widgetType ?? WidgetType.DISCOVER,
  179. display_type: widget.displayType,
  180. });
  181. } else {
  182. // Replace the URL if the widget isn't found and raise an error in toast
  183. router.replace({
  184. pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
  185. query: location.query,
  186. });
  187. addErrorMessage(t('Widget not found'));
  188. }
  189. }
  190. }
  191. updateModifiedDashboard(dashboardState: DashboardState) {
  192. const {dashboard} = this.props;
  193. switch (dashboardState) {
  194. case DashboardState.PREVIEW:
  195. case DashboardState.CREATE:
  196. case DashboardState.EDIT:
  197. return cloneDashboard(dashboard);
  198. default: {
  199. return null;
  200. }
  201. }
  202. }
  203. get isPreview() {
  204. const {dashboardState} = this.state;
  205. return DashboardState.PREVIEW === dashboardState;
  206. }
  207. get isEditing() {
  208. const {dashboardState} = this.state;
  209. return [
  210. DashboardState.EDIT,
  211. DashboardState.CREATE,
  212. DashboardState.PENDING_DELETE,
  213. ].includes(dashboardState);
  214. }
  215. get isWidgetBuilderRouter() {
  216. const {location, params, organization} = this.props;
  217. const {dashboardId, widgetIndex} = params;
  218. const widgetBuilderRoutes = [
  219. `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  220. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
  221. `/organizations/${organization.slug}/dashboards/new/widget/${widgetIndex}/edit/`,
  222. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetIndex}/edit/`,
  223. ];
  224. return widgetBuilderRoutes.includes(location.pathname);
  225. }
  226. get dashboardTitle() {
  227. const {dashboard} = this.props;
  228. const {modifiedDashboard} = this.state;
  229. return modifiedDashboard ? modifiedDashboard.title : dashboard.title;
  230. }
  231. onEdit = () => {
  232. const {dashboard, organization} = this.props;
  233. trackAdvancedAnalyticsEvent('dashboards2.edit.start', {organization});
  234. this.setState({
  235. dashboardState: DashboardState.EDIT,
  236. modifiedDashboard: cloneDashboard(dashboard),
  237. });
  238. };
  239. onRouteLeave = () => {
  240. const {dashboard} = this.props;
  241. const {modifiedDashboard} = this.state;
  242. if (
  243. ![
  244. DashboardState.VIEW,
  245. DashboardState.PENDING_DELETE,
  246. DashboardState.PREVIEW,
  247. ].includes(this.state.dashboardState) &&
  248. !isEqual(modifiedDashboard, dashboard)
  249. ) {
  250. return UNSAVED_MESSAGE;
  251. }
  252. return undefined;
  253. };
  254. onUnload = (event: BeforeUnloadEvent) => {
  255. const {dashboard} = this.props;
  256. const {modifiedDashboard} = this.state;
  257. if (
  258. [
  259. DashboardState.VIEW,
  260. DashboardState.PENDING_DELETE,
  261. DashboardState.PREVIEW,
  262. ].includes(this.state.dashboardState) ||
  263. isEqual(modifiedDashboard, dashboard)
  264. ) {
  265. return;
  266. }
  267. event.preventDefault();
  268. event.returnValue = UNSAVED_MESSAGE;
  269. };
  270. onDelete = (dashboard: State['modifiedDashboard']) => () => {
  271. const {api, organization, location} = this.props;
  272. if (!dashboard?.id) {
  273. return;
  274. }
  275. const previousDashboardState = this.state.dashboardState;
  276. this.setState({dashboardState: DashboardState.PENDING_DELETE}, () => {
  277. deleteDashboard(api, organization.slug, dashboard.id)
  278. .then(() => {
  279. addSuccessMessage(t('Dashboard deleted'));
  280. trackAdvancedAnalyticsEvent('dashboards2.delete', {organization});
  281. browserHistory.replace({
  282. pathname: `/organizations/${organization.slug}/dashboards/`,
  283. query: location.query,
  284. });
  285. })
  286. .catch(() => {
  287. this.setState({
  288. dashboardState: previousDashboardState,
  289. });
  290. });
  291. });
  292. };
  293. onCancel = () => {
  294. const {organization, dashboard, location, params} = this.props;
  295. const {modifiedDashboard} = this.state;
  296. let hasDashboardChanged = !isEqual(modifiedDashboard, dashboard);
  297. // If a dashboard has every layout undefined, then ignore the layout field
  298. // when checking equality because it is a dashboard from before the grid feature
  299. const isLegacyLayout = dashboard.widgets.every(({layout}) => !defined(layout));
  300. if (isLegacyLayout) {
  301. hasDashboardChanged = !isEqual(
  302. {
  303. ...modifiedDashboard,
  304. widgets: modifiedDashboard?.widgets.map(widget => omit(widget, 'layout')),
  305. },
  306. {...dashboard, widgets: dashboard.widgets.map(widget => omit(widget, 'layout'))}
  307. );
  308. }
  309. // Don't confirm preview cancellation regardless of dashboard state
  310. if (hasDashboardChanged && !this.isPreview) {
  311. // Ignore no-alert here, so that the confirm on cancel matches onUnload & onRouteLeave
  312. /* eslint no-alert:0 */
  313. if (!confirm(UNSAVED_MESSAGE)) {
  314. return;
  315. }
  316. }
  317. if (params.dashboardId) {
  318. trackAdvancedAnalyticsEvent('dashboards2.edit.cancel', {organization});
  319. this.setState({
  320. dashboardState: DashboardState.VIEW,
  321. modifiedDashboard: null,
  322. });
  323. return;
  324. }
  325. trackAdvancedAnalyticsEvent('dashboards2.create.cancel', {organization});
  326. browserHistory.replace({
  327. pathname: `/organizations/${organization.slug}/dashboards/`,
  328. query: location.query,
  329. });
  330. };
  331. handleChangeFilter = (activeFilters: DashboardFilters) => {
  332. const {dashboard, location} = this.props;
  333. const {modifiedDashboard} = this.state;
  334. const newModifiedDashboard = modifiedDashboard || dashboard;
  335. if (
  336. Object.keys(activeFilters).every(
  337. key => !newModifiedDashboard.filters?.[key] && activeFilters[key].length === 0
  338. )
  339. ) {
  340. return;
  341. }
  342. const filterParams: DashboardFilters = {};
  343. Object.keys(activeFilters).forEach(key => {
  344. filterParams[key] = activeFilters[key].length ? activeFilters[key] : '';
  345. });
  346. if (!isEqual(activeFilters, dashboard.filters)) {
  347. browserHistory.push({
  348. ...location,
  349. query: {
  350. ...location.query,
  351. ...filterParams,
  352. },
  353. });
  354. }
  355. };
  356. handleUpdateWidgetList = (widgets: Widget[]) => {
  357. const {organization, dashboard, api, onDashboardUpdate, location} = this.props;
  358. const {modifiedDashboard} = this.state;
  359. // Use the new widgets for calculating layout because widgets has
  360. // the most up to date information in edit state
  361. const currentLayout = getDashboardLayout(widgets);
  362. const layoutColumnDepths = calculateColumnDepths(currentLayout);
  363. const newModifiedDashboard = {
  364. ...cloneDashboard(modifiedDashboard || dashboard),
  365. widgets: assignDefaultLayout(widgets, layoutColumnDepths),
  366. };
  367. this.setState({
  368. modifiedDashboard: newModifiedDashboard,
  369. widgetLimitReached: widgets.length >= MAX_WIDGETS,
  370. });
  371. if (this.isEditing || this.isPreview) {
  372. return;
  373. }
  374. updateDashboard(api, organization.slug, newModifiedDashboard).then(
  375. (newDashboard: DashboardDetails) => {
  376. if (onDashboardUpdate) {
  377. onDashboardUpdate(newDashboard);
  378. this.setState({
  379. modifiedDashboard: null,
  380. });
  381. }
  382. addSuccessMessage(t('Dashboard updated'));
  383. if (dashboard && newDashboard.id !== dashboard.id) {
  384. browserHistory.replace({
  385. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  386. query: {
  387. ...location.query,
  388. },
  389. });
  390. return;
  391. }
  392. },
  393. () => undefined
  394. );
  395. };
  396. handleAddCustomWidget = (widget: Widget) => {
  397. const {dashboard} = this.props;
  398. const {modifiedDashboard} = this.state;
  399. const newModifiedDashboard = modifiedDashboard || dashboard;
  400. this.onUpdateWidget([...newModifiedDashboard.widgets, widget]);
  401. };
  402. onAddWidget = () => {
  403. const {
  404. organization,
  405. dashboard,
  406. router,
  407. location,
  408. params: {dashboardId},
  409. } = this.props;
  410. this.setState({
  411. modifiedDashboard: cloneDashboard(dashboard),
  412. });
  413. if (dashboardId) {
  414. router.push({
  415. pathname: `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
  416. query: {
  417. ...location.query,
  418. source: DashboardWidgetSource.DASHBOARDS,
  419. },
  420. });
  421. return;
  422. }
  423. };
  424. onCommit = () => {
  425. const {api, organization, location, dashboard, onDashboardUpdate} = this.props;
  426. const {modifiedDashboard, dashboardState} = this.state;
  427. switch (dashboardState) {
  428. case DashboardState.PREVIEW:
  429. case DashboardState.CREATE: {
  430. if (modifiedDashboard) {
  431. if (this.isPreview) {
  432. trackAdvancedAnalyticsEvent('dashboards_manage.templates.add', {
  433. organization,
  434. dashboard_id: dashboard.id,
  435. dashboard_title: dashboard.title,
  436. was_previewed: true,
  437. });
  438. }
  439. let newModifiedDashboard = modifiedDashboard;
  440. if (organization.features.includes('dashboards-top-level-filter')) {
  441. newModifiedDashboard = {
  442. ...cloneDashboard(modifiedDashboard),
  443. ...getCurrentPageFilters(location),
  444. filters: getDashboardFiltersFromURL(location) ?? modifiedDashboard.filters,
  445. };
  446. }
  447. createDashboard(
  448. api,
  449. organization.slug,
  450. newModifiedDashboard,
  451. this.isPreview
  452. ).then(
  453. (newDashboard: DashboardDetails) => {
  454. addSuccessMessage(t('Dashboard created'));
  455. trackAdvancedAnalyticsEvent('dashboards2.create.complete', {organization});
  456. this.setState({
  457. dashboardState: DashboardState.VIEW,
  458. });
  459. // redirect to new dashboard
  460. browserHistory.replace({
  461. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  462. query: {
  463. query: omit(location.query, Object.values(DashboardFilterKeys)),
  464. },
  465. });
  466. },
  467. () => undefined
  468. );
  469. }
  470. break;
  471. }
  472. case DashboardState.EDIT: {
  473. // only update the dashboard if there are changes
  474. if (modifiedDashboard) {
  475. if (isEqual(dashboard, modifiedDashboard)) {
  476. this.setState({
  477. dashboardState: DashboardState.VIEW,
  478. modifiedDashboard: null,
  479. });
  480. return;
  481. }
  482. updateDashboard(api, organization.slug, modifiedDashboard).then(
  483. (newDashboard: DashboardDetails) => {
  484. if (onDashboardUpdate) {
  485. onDashboardUpdate(newDashboard);
  486. }
  487. addSuccessMessage(t('Dashboard updated'));
  488. trackAdvancedAnalyticsEvent('dashboards2.edit.complete', {organization});
  489. this.setState({
  490. dashboardState: DashboardState.VIEW,
  491. modifiedDashboard: null,
  492. });
  493. if (dashboard && newDashboard.id !== dashboard.id) {
  494. browserHistory.replace({
  495. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  496. query: {
  497. ...location.query,
  498. },
  499. });
  500. return;
  501. }
  502. },
  503. () => undefined
  504. );
  505. return;
  506. }
  507. this.setState({
  508. dashboardState: DashboardState.VIEW,
  509. modifiedDashboard: null,
  510. });
  511. break;
  512. }
  513. case DashboardState.VIEW:
  514. default: {
  515. this.setState({
  516. dashboardState: DashboardState.VIEW,
  517. modifiedDashboard: null,
  518. });
  519. break;
  520. }
  521. }
  522. };
  523. setModifiedDashboard = (dashboard: DashboardDetails) => {
  524. this.setState({
  525. modifiedDashboard: dashboard,
  526. });
  527. };
  528. onUpdateWidget = (widgets: Widget[]) => {
  529. this.setState((state: State) => ({
  530. ...state,
  531. widgetLimitReached: widgets.length >= MAX_WIDGETS,
  532. modifiedDashboard: {
  533. ...(state.modifiedDashboard || this.props.dashboard),
  534. widgets,
  535. },
  536. }));
  537. };
  538. renderWidgetBuilder() {
  539. const {children, dashboard} = this.props;
  540. const {modifiedDashboard} = this.state;
  541. return isValidElement(children)
  542. ? cloneElement<any>(children, {
  543. dashboard: modifiedDashboard ?? dashboard,
  544. onSave: this.isEditing ? this.onUpdateWidget : this.handleUpdateWidgetList,
  545. })
  546. : children;
  547. }
  548. renderDefaultDashboardDetail() {
  549. const {organization, dashboard, dashboards, params, router, location} = this.props;
  550. const {modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
  551. const {dashboardId} = params;
  552. return (
  553. <PageFiltersContainer
  554. desyncedAlertMessage='Using filter values saved to this dashboard. To edit saved filters, click "Edit Dashboard".'
  555. hideDesyncRevertButton
  556. defaultSelection={{
  557. datetime: {
  558. start: null,
  559. end: null,
  560. utc: false,
  561. period: DEFAULT_STATS_PERIOD,
  562. },
  563. }}
  564. >
  565. <PageContent>
  566. <NoProjectMessage organization={organization}>
  567. <StyledPageHeader>
  568. <StyledTitle>
  569. <DashboardTitle
  570. dashboard={modifiedDashboard ?? dashboard}
  571. onUpdate={this.setModifiedDashboard}
  572. isEditing={this.isEditing}
  573. />
  574. </StyledTitle>
  575. <Controls
  576. organization={organization}
  577. dashboards={dashboards}
  578. onEdit={this.onEdit}
  579. onCancel={this.onCancel}
  580. onCommit={this.onCommit}
  581. onAddWidget={this.onAddWidget}
  582. onDelete={this.onDelete(dashboard)}
  583. dashboardState={dashboardState}
  584. widgetLimitReached={widgetLimitReached}
  585. />
  586. </StyledPageHeader>
  587. <HookHeader organization={organization} />
  588. <StyledPageFilterBar condensed>
  589. <ProjectPageFilter />
  590. <EnvironmentPageFilter />
  591. <DatePageFilter alignDropdown="left" />
  592. </StyledPageFilterBar>
  593. <MetricsCardinalityProvider organization={organization} location={location}>
  594. <MetricsDataSwitcher
  595. organization={organization}
  596. eventView={EventView.fromLocation(location)}
  597. location={location}
  598. hideLoadingIndicator
  599. >
  600. {metricsDataSide => (
  601. <MEPSettingProvider
  602. location={location}
  603. forceTransactions={metricsDataSide.forceTransactionsOnly}
  604. >
  605. <Dashboard
  606. paramDashboardId={dashboardId}
  607. dashboard={modifiedDashboard ?? dashboard}
  608. organization={organization}
  609. isEditing={this.isEditing}
  610. widgetLimitReached={widgetLimitReached}
  611. onUpdate={this.onUpdateWidget}
  612. handleUpdateWidgetList={this.handleUpdateWidgetList}
  613. handleAddCustomWidget={this.handleAddCustomWidget}
  614. isPreview={this.isPreview}
  615. router={router}
  616. location={location}
  617. />
  618. </MEPSettingProvider>
  619. )}
  620. </MetricsDataSwitcher>
  621. </MetricsCardinalityProvider>
  622. </NoProjectMessage>
  623. </PageContent>
  624. </PageFiltersContainer>
  625. );
  626. }
  627. getBreadcrumbLabel() {
  628. const {dashboardState} = this.state;
  629. let label = this.dashboardTitle;
  630. if (dashboardState === DashboardState.CREATE) {
  631. label = t('Create Dashboard');
  632. } else if (this.isPreview) {
  633. label = t('Preview Dashboard');
  634. }
  635. return label;
  636. }
  637. renderDashboardDetail() {
  638. const {
  639. api,
  640. organization,
  641. dashboard,
  642. dashboards,
  643. params,
  644. router,
  645. location,
  646. newWidget,
  647. onSetNewWidget,
  648. onDashboardUpdate,
  649. projects,
  650. } = this.props;
  651. const {modifiedDashboard, dashboardState, widgetLimitReached, seriesData, setData} =
  652. this.state;
  653. const {dashboardId} = params;
  654. const hasUnsavedFilters =
  655. organization.features.includes('dashboards-top-level-filter') &&
  656. dashboard.id !== 'default-overview' &&
  657. dashboardState !== DashboardState.CREATE &&
  658. hasUnsavedFilterChanges(dashboard, location);
  659. const eventView = generatePerformanceEventView(location, projects);
  660. const isDashboardUsingTransaction = dashboard.widgets.some(
  661. isWidgetUsingTransactionName
  662. );
  663. return (
  664. <SentryDocumentTitle title={dashboard.title} orgSlug={organization.slug}>
  665. <PageFiltersContainer
  666. desyncedAlertMessage='Using filter values saved to this dashboard. To edit saved filters, click "Edit Dashboard".'
  667. hideDesyncRevertButton
  668. defaultSelection={{
  669. datetime: {
  670. start: null,
  671. end: null,
  672. utc: false,
  673. period: DEFAULT_STATS_PERIOD,
  674. },
  675. }}
  676. >
  677. <StyledPageContent>
  678. <NoProjectMessage organization={organization}>
  679. <Layout.Header>
  680. <Layout.HeaderContent>
  681. <Breadcrumbs
  682. crumbs={[
  683. {
  684. label: t('Dashboards'),
  685. to: `/organizations/${organization.slug}/dashboards/`,
  686. },
  687. {
  688. label: this.getBreadcrumbLabel(),
  689. },
  690. ]}
  691. />
  692. <Layout.Title>
  693. <DashboardTitle
  694. dashboard={modifiedDashboard ?? dashboard}
  695. onUpdate={this.setModifiedDashboard}
  696. isEditing={this.isEditing}
  697. />
  698. </Layout.Title>
  699. </Layout.HeaderContent>
  700. <Layout.HeaderActions>
  701. <Controls
  702. organization={organization}
  703. dashboards={dashboards}
  704. hasUnsavedFilters={hasUnsavedFilters}
  705. onEdit={this.onEdit}
  706. onCancel={this.onCancel}
  707. onCommit={this.onCommit}
  708. onAddWidget={this.onAddWidget}
  709. onDelete={this.onDelete(dashboard)}
  710. dashboardState={dashboardState}
  711. widgetLimitReached={widgetLimitReached}
  712. />
  713. </Layout.HeaderActions>
  714. </Layout.Header>
  715. <Layout.Body>
  716. <Layout.Main fullWidth>
  717. <MetricsCardinalityProvider
  718. organization={organization}
  719. location={location}
  720. >
  721. <MetricsDataSwitcher
  722. organization={organization}
  723. eventView={eventView}
  724. location={location}
  725. hideLoadingIndicator
  726. >
  727. {metricsDataSide => (
  728. <MEPSettingProvider
  729. location={location}
  730. forceTransactions={metricsDataSide.forceTransactionsOnly}
  731. >
  732. {isDashboardUsingTransaction ? (
  733. <MetricsDataSwitcherAlert
  734. organization={organization}
  735. eventView={eventView}
  736. projects={projects}
  737. location={location}
  738. router={router}
  739. source={DiscoverQueryPageSource.DISCOVER}
  740. {...metricsDataSide}
  741. />
  742. ) : null}
  743. <FiltersBar
  744. filters={(modifiedDashboard ?? dashboard).filters}
  745. location={location}
  746. hasUnsavedChanges={hasUnsavedFilters}
  747. isEditingDashboard={
  748. dashboardState !== DashboardState.CREATE && this.isEditing
  749. }
  750. isPreview={this.isPreview}
  751. onDashboardFilterChange={this.handleChangeFilter}
  752. onCancel={() => {
  753. resetPageFilters(dashboard, location);
  754. this.setState({
  755. modifiedDashboard: {
  756. ...(modifiedDashboard ?? dashboard),
  757. filters: dashboard.filters,
  758. },
  759. });
  760. }}
  761. onSave={() => {
  762. const newModifiedDashboard = {
  763. ...cloneDashboard(modifiedDashboard ?? dashboard),
  764. ...getCurrentPageFilters(location),
  765. filters:
  766. getDashboardFiltersFromURL(location) ??
  767. (modifiedDashboard ?? dashboard).filters,
  768. };
  769. updateDashboard(
  770. api,
  771. organization.slug,
  772. newModifiedDashboard
  773. ).then(
  774. (newDashboard: DashboardDetails) => {
  775. if (onDashboardUpdate) {
  776. onDashboardUpdate(newDashboard);
  777. this.setState({
  778. modifiedDashboard: null,
  779. });
  780. }
  781. addSuccessMessage(t('Dashboard filters updated'));
  782. browserHistory.replace({
  783. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  784. query: omit(
  785. location.query,
  786. Object.values(DashboardFilterKeys)
  787. ),
  788. });
  789. },
  790. () => undefined
  791. );
  792. }}
  793. />
  794. <WidgetViewerContext.Provider value={{seriesData, setData}}>
  795. <Dashboard
  796. paramDashboardId={dashboardId}
  797. dashboard={modifiedDashboard ?? dashboard}
  798. organization={organization}
  799. isEditing={this.isEditing}
  800. widgetLimitReached={widgetLimitReached}
  801. onUpdate={this.onUpdateWidget}
  802. handleUpdateWidgetList={this.handleUpdateWidgetList}
  803. handleAddCustomWidget={this.handleAddCustomWidget}
  804. router={router}
  805. location={location}
  806. newWidget={newWidget}
  807. onSetNewWidget={onSetNewWidget}
  808. isPreview={this.isPreview}
  809. />
  810. </WidgetViewerContext.Provider>
  811. </MEPSettingProvider>
  812. )}
  813. </MetricsDataSwitcher>
  814. </MetricsCardinalityProvider>
  815. </Layout.Main>
  816. </Layout.Body>
  817. </NoProjectMessage>
  818. </StyledPageContent>
  819. </PageFiltersContainer>
  820. </SentryDocumentTitle>
  821. );
  822. }
  823. render() {
  824. const {organization} = this.props;
  825. if (this.isWidgetBuilderRouter) {
  826. return this.renderWidgetBuilder();
  827. }
  828. if (organization.features.includes('dashboards-edit')) {
  829. return this.renderDashboardDetail();
  830. }
  831. return this.renderDefaultDashboardDetail();
  832. }
  833. }
  834. const StyledPageHeader = styled('div')`
  835. display: grid;
  836. grid-template-columns: minmax(0, 1fr);
  837. grid-row-gap: ${space(2)};
  838. align-items: center;
  839. margin-bottom: ${space(2)};
  840. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  841. grid-template-columns: minmax(0, 1fr) max-content;
  842. grid-column-gap: ${space(2)};
  843. height: 40px;
  844. }
  845. `;
  846. const StyledTitle = styled(Layout.Title)`
  847. margin-top: 0;
  848. `;
  849. const StyledPageContent = styled(PageContent)`
  850. padding: 0;
  851. `;
  852. const StyledPageFilterBar = styled(PageFilterBar)`
  853. margin-bottom: ${space(2)};
  854. `;
  855. export default withProjects(withApi(withOrganization(DashboardDetail)));