detail.tsx 31 KB

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