detail.tsx 33 KB

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