detail.tsx 33 KB

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