detail.tsx 32 KB

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