detail.tsx 27 KB

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