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