detail.tsx 26 KB

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