detail.tsx 36 KB

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