detail.tsx 26 KB

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