detail.tsx 31 KB

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