detail.tsx 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044
  1. import {cloneElement, Component, isValidElement} from 'react';
  2. import type {PlainRoute, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import isEqual from 'lodash/isEqual';
  6. import isEqualWith from 'lodash/isEqualWith';
  7. import omit from 'lodash/omit';
  8. import {
  9. createDashboard,
  10. deleteDashboard,
  11. updateDashboard,
  12. } from 'sentry/actionCreators/dashboards';
  13. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  14. import {openWidgetViewerModal} from 'sentry/actionCreators/modal';
  15. import type {Client} from 'sentry/api';
  16. import {Breadcrumbs} from 'sentry/components/breadcrumbs';
  17. import HookOrDefault from 'sentry/components/hookOrDefault';
  18. import * as Layout from 'sentry/components/layouts/thirds';
  19. import {
  20. isWidgetViewerPath,
  21. WidgetViewerQueryField,
  22. } from 'sentry/components/modals/widgetViewerModal/utils';
  23. import NoProjectMessage from 'sentry/components/noProjectMessage';
  24. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  25. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  26. import {USING_CUSTOMER_DOMAIN} from 'sentry/constants';
  27. import {t} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import type {Organization, PageFilters, Project} from 'sentry/types';
  30. import {defined} from 'sentry/utils';
  31. import {trackAnalytics} from 'sentry/utils/analytics';
  32. import {browserHistory} from 'sentry/utils/browserHistory';
  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 {defaultMetricWidget} from 'sentry/views/dashboards/metrics/utils';
  44. import {
  45. cloneDashboard,
  46. getCurrentPageFilters,
  47. getDashboardFiltersFromURL,
  48. hasUnsavedFilterChanges,
  49. isWidgetUsingTransactionName,
  50. resetPageFilters,
  51. } from 'sentry/views/dashboards/utils';
  52. import {DataSet} from 'sentry/views/dashboards/widgetBuilder/utils';
  53. import {MetricsDashboardContextProvider} from 'sentry/views/dashboards/widgetCard/metricsContext';
  54. import {MetricsDataSwitcherAlert} from 'sentry/views/performance/landing/metricsDataSwitcherAlert';
  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 null;
  420. }
  421. return 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. }
  440. return newDashboard;
  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 widgetCopy = cloneDeep(
  454. assignTempId({
  455. layout,
  456. ...defaultMetricWidget(),
  457. })
  458. );
  459. openWidgetViewerModal({
  460. organization: this.props.organization,
  461. widget: widgetCopy,
  462. onMetricWidgetEdit: widget => {
  463. const nextList = generateWidgetsAfterCompaction([
  464. ...this.props.dashboard.widgets,
  465. widget,
  466. ]);
  467. this.onUpdateWidget(nextList);
  468. this.handleUpdateWidgetList(nextList);
  469. },
  470. });
  471. };
  472. onAddWidget = (dataset: DataSet) => {
  473. const {
  474. organization,
  475. dashboard,
  476. router,
  477. location,
  478. params: {dashboardId},
  479. } = this.props;
  480. if (dataset === DataSet.METRICS) {
  481. this.handleAddMetricWidget();
  482. return;
  483. }
  484. this.setState(
  485. {
  486. modifiedDashboard: cloneDashboard(dashboard),
  487. },
  488. () => {
  489. if (dashboardId) {
  490. router.push(
  491. normalizeUrl({
  492. pathname: `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
  493. query: {
  494. ...location.query,
  495. source: DashboardWidgetSource.DASHBOARDS,
  496. dataset,
  497. },
  498. })
  499. );
  500. }
  501. }
  502. );
  503. };
  504. onCommit = () => {
  505. const {api, organization, location, dashboard, onDashboardUpdate} = this.props;
  506. const {modifiedDashboard, dashboardState} = this.state;
  507. switch (dashboardState) {
  508. case DashboardState.PREVIEW:
  509. case DashboardState.CREATE: {
  510. if (modifiedDashboard) {
  511. if (this.isPreview) {
  512. trackAnalytics('dashboards_manage.templates.add', {
  513. organization,
  514. dashboard_id: dashboard.id,
  515. dashboard_title: dashboard.title,
  516. was_previewed: true,
  517. });
  518. }
  519. const newModifiedDashboard = {
  520. ...cloneDashboard(modifiedDashboard),
  521. ...getCurrentPageFilters(location),
  522. filters: getDashboardFiltersFromURL(location) ?? modifiedDashboard.filters,
  523. };
  524. createDashboard(
  525. api,
  526. organization.slug,
  527. newModifiedDashboard,
  528. this.isPreview
  529. ).then(
  530. (newDashboard: DashboardDetails) => {
  531. addSuccessMessage(t('Dashboard created'));
  532. trackAnalytics('dashboards2.create.complete', {organization});
  533. this.setState(
  534. {
  535. dashboardState: DashboardState.VIEW,
  536. },
  537. () => {
  538. // redirect to new dashboard
  539. browserHistory.replace(
  540. normalizeUrl({
  541. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  542. query: {
  543. query: omit(location.query, Object.values(DashboardFilterKeys)),
  544. },
  545. })
  546. );
  547. }
  548. );
  549. },
  550. () => undefined
  551. );
  552. }
  553. break;
  554. }
  555. case DashboardState.EDIT: {
  556. // only update the dashboard if there are changes
  557. if (modifiedDashboard) {
  558. if (isEqual(dashboard, modifiedDashboard)) {
  559. this.setState({
  560. dashboardState: DashboardState.VIEW,
  561. modifiedDashboard: null,
  562. });
  563. return;
  564. }
  565. updateDashboard(api, organization.slug, modifiedDashboard).then(
  566. (newDashboard: DashboardDetails) => {
  567. if (onDashboardUpdate) {
  568. onDashboardUpdate(newDashboard);
  569. }
  570. addSuccessMessage(t('Dashboard updated'));
  571. trackAnalytics('dashboards2.edit.complete', {organization});
  572. this.setState(
  573. {
  574. dashboardState: DashboardState.VIEW,
  575. modifiedDashboard: null,
  576. },
  577. () => {
  578. if (dashboard && newDashboard.id !== dashboard.id) {
  579. browserHistory.replace(
  580. normalizeUrl({
  581. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  582. query: {
  583. ...location.query,
  584. },
  585. })
  586. );
  587. }
  588. }
  589. );
  590. },
  591. // `updateDashboard` does its own error handling
  592. () => undefined
  593. );
  594. return;
  595. }
  596. this.setState({
  597. dashboardState: DashboardState.VIEW,
  598. modifiedDashboard: null,
  599. });
  600. break;
  601. }
  602. case DashboardState.VIEW:
  603. default: {
  604. this.setState({
  605. dashboardState: DashboardState.VIEW,
  606. modifiedDashboard: null,
  607. });
  608. break;
  609. }
  610. }
  611. };
  612. setModifiedDashboard = (dashboard: DashboardDetails) => {
  613. this.setState({
  614. modifiedDashboard: dashboard,
  615. });
  616. };
  617. onUpdateWidget = (widgets: Widget[]) => {
  618. this.setState((state: State) => ({
  619. ...state,
  620. widgetLimitReached: widgets.length >= MAX_WIDGETS,
  621. modifiedDashboard: {
  622. ...(state.modifiedDashboard || this.props.dashboard),
  623. widgets,
  624. },
  625. }));
  626. };
  627. renderWidgetBuilder() {
  628. const {children, dashboard} = this.props;
  629. const {modifiedDashboard} = this.state;
  630. return isValidElement(children)
  631. ? cloneElement<any>(children, {
  632. dashboard: modifiedDashboard ?? dashboard,
  633. onSave: this.isEditingDashboard
  634. ? this.onUpdateWidget
  635. : this.handleUpdateWidgetList,
  636. })
  637. : children;
  638. }
  639. renderDefaultDashboardDetail() {
  640. const {organization, dashboard, dashboards, params, router, location} = this.props;
  641. const {modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
  642. const {dashboardId} = params;
  643. return (
  644. <PageFiltersContainer
  645. disablePersistence
  646. defaultSelection={{
  647. datetime: {
  648. start: null,
  649. end: null,
  650. utc: false,
  651. period: DEFAULT_STATS_PERIOD,
  652. },
  653. }}
  654. >
  655. <Layout.Page withPadding>
  656. <OnDemandControlProvider location={location}>
  657. <MetricsDashboardContextProvider>
  658. <MetricsResultsMetaProvider>
  659. <NoProjectMessage organization={organization}>
  660. <StyledPageHeader>
  661. <Layout.Title>
  662. <DashboardTitle
  663. dashboard={modifiedDashboard ?? dashboard}
  664. onUpdate={this.setModifiedDashboard}
  665. isEditingDashboard={this.isEditingDashboard}
  666. />
  667. </Layout.Title>
  668. <Controls
  669. organization={organization}
  670. dashboards={dashboards}
  671. onEdit={this.onEdit}
  672. onCancel={this.onCancel}
  673. onCommit={this.onCommit}
  674. onAddWidget={this.onAddWidget}
  675. onDelete={this.onDelete(dashboard)}
  676. dashboardState={dashboardState}
  677. widgetLimitReached={widgetLimitReached}
  678. />
  679. </StyledPageHeader>
  680. <HookHeader organization={organization} />
  681. <FiltersBar
  682. filters={{}} // Default Dashboards don't have filters set
  683. location={location}
  684. hasUnsavedChanges={false}
  685. isEditingDashboard={false}
  686. isPreview={false}
  687. onDashboardFilterChange={this.handleChangeFilter}
  688. />
  689. <MetricsCardinalityProvider
  690. organization={organization}
  691. location={location}
  692. >
  693. <MetricsDataSwitcher
  694. organization={organization}
  695. eventView={EventView.fromLocation(location)}
  696. location={location}
  697. >
  698. {metricsDataSide => (
  699. <MEPSettingProvider
  700. location={location}
  701. forceTransactions={metricsDataSide.forceTransactionsOnly}
  702. >
  703. <Dashboard
  704. paramDashboardId={dashboardId}
  705. dashboard={modifiedDashboard ?? dashboard}
  706. organization={organization}
  707. isEditingDashboard={this.isEditingDashboard}
  708. widgetLimitReached={widgetLimitReached}
  709. onUpdate={this.onUpdateWidget}
  710. handleUpdateWidgetList={this.handleUpdateWidgetList}
  711. handleAddCustomWidget={this.handleAddCustomWidget}
  712. handleAddMetricWidget={this.handleAddMetricWidget}
  713. isPreview={this.isPreview}
  714. router={router}
  715. location={location}
  716. />
  717. </MEPSettingProvider>
  718. )}
  719. </MetricsDataSwitcher>
  720. </MetricsCardinalityProvider>
  721. </NoProjectMessage>
  722. </MetricsResultsMetaProvider>
  723. </MetricsDashboardContextProvider>
  724. </OnDemandControlProvider>
  725. </Layout.Page>
  726. </PageFiltersContainer>
  727. );
  728. }
  729. getBreadcrumbLabel() {
  730. const {dashboardState} = this.state;
  731. let label = this.dashboardTitle;
  732. if (dashboardState === DashboardState.CREATE) {
  733. label = t('Create Dashboard');
  734. } else if (this.isPreview) {
  735. label = t('Preview Dashboard');
  736. }
  737. return label;
  738. }
  739. renderDashboardDetail() {
  740. const {
  741. api,
  742. organization,
  743. dashboard,
  744. dashboards,
  745. params,
  746. router,
  747. location,
  748. newWidget,
  749. onSetNewWidget,
  750. onDashboardUpdate,
  751. projects,
  752. } = this.props;
  753. const {modifiedDashboard, dashboardState, widgetLimitReached, seriesData, setData} =
  754. this.state;
  755. const {dashboardId} = params;
  756. const hasUnsavedFilters =
  757. dashboard.id !== 'default-overview' &&
  758. dashboardState !== DashboardState.CREATE &&
  759. hasUnsavedFilterChanges(dashboard, location);
  760. const eventView = generatePerformanceEventView(location, projects, {}, organization);
  761. const isDashboardUsingTransaction = dashboard.widgets.some(
  762. isWidgetUsingTransactionName
  763. );
  764. return (
  765. <SentryDocumentTitle title={dashboard.title} orgSlug={organization.slug}>
  766. <PageFiltersContainer
  767. disablePersistence
  768. defaultSelection={{
  769. datetime: {
  770. start: null,
  771. end: null,
  772. utc: false,
  773. period: DEFAULT_STATS_PERIOD,
  774. },
  775. }}
  776. >
  777. <Layout.Page>
  778. <OnDemandControlProvider location={location}>
  779. <MetricsDashboardContextProvider>
  780. <MetricsResultsMetaProvider>
  781. <NoProjectMessage organization={organization}>
  782. <Layout.Header>
  783. <Layout.HeaderContent>
  784. <Breadcrumbs
  785. crumbs={[
  786. {
  787. label: t('Dashboards'),
  788. to: `/organizations/${organization.slug}/dashboards/`,
  789. },
  790. {
  791. label: this.getBreadcrumbLabel(),
  792. },
  793. ]}
  794. />
  795. <Layout.Title>
  796. <DashboardTitle
  797. dashboard={modifiedDashboard ?? dashboard}
  798. onUpdate={this.setModifiedDashboard}
  799. isEditingDashboard={this.isEditingDashboard}
  800. />
  801. </Layout.Title>
  802. </Layout.HeaderContent>
  803. <Layout.HeaderActions>
  804. <Controls
  805. organization={organization}
  806. dashboards={dashboards}
  807. hasUnsavedFilters={hasUnsavedFilters}
  808. onEdit={this.onEdit}
  809. onCancel={this.onCancel}
  810. onCommit={this.onCommit}
  811. onAddWidget={this.onAddWidget}
  812. onDelete={this.onDelete(dashboard)}
  813. dashboardState={dashboardState}
  814. widgetLimitReached={widgetLimitReached}
  815. />
  816. </Layout.HeaderActions>
  817. </Layout.Header>
  818. <Layout.Body>
  819. <Layout.Main fullWidth>
  820. <MetricsCardinalityProvider
  821. organization={organization}
  822. location={location}
  823. >
  824. <MetricsDataSwitcher
  825. organization={organization}
  826. eventView={eventView}
  827. location={location}
  828. >
  829. {metricsDataSide => (
  830. <MEPSettingProvider
  831. location={location}
  832. forceTransactions={metricsDataSide.forceTransactionsOnly}
  833. >
  834. {isDashboardUsingTransaction ? (
  835. <MetricsDataSwitcherAlert
  836. organization={organization}
  837. eventView={eventView}
  838. projects={projects}
  839. location={location}
  840. router={router}
  841. source={DiscoverQueryPageSource.DISCOVER}
  842. {...metricsDataSide}
  843. />
  844. ) : null}
  845. <FiltersBar
  846. filters={(modifiedDashboard ?? dashboard).filters}
  847. location={location}
  848. hasUnsavedChanges={hasUnsavedFilters}
  849. isEditingDashboard={
  850. dashboardState !== DashboardState.CREATE &&
  851. this.isEditingDashboard
  852. }
  853. isPreview={this.isPreview}
  854. onDashboardFilterChange={this.handleChangeFilter}
  855. onCancel={() => {
  856. resetPageFilters(dashboard, location);
  857. this.setState({
  858. modifiedDashboard: {
  859. ...(modifiedDashboard ?? dashboard),
  860. filters: dashboard.filters,
  861. },
  862. });
  863. }}
  864. onSave={() => {
  865. const newModifiedDashboard = {
  866. ...cloneDashboard(modifiedDashboard ?? dashboard),
  867. ...getCurrentPageFilters(location),
  868. filters:
  869. getDashboardFiltersFromURL(location) ??
  870. (modifiedDashboard ?? dashboard).filters,
  871. };
  872. updateDashboard(
  873. api,
  874. organization.slug,
  875. newModifiedDashboard
  876. ).then(
  877. (newDashboard: DashboardDetails) => {
  878. addSuccessMessage(t('Dashboard filters updated'));
  879. const navigateToDashboard = () => {
  880. browserHistory.replace(
  881. normalizeUrl({
  882. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  883. query: omit(
  884. location.query,
  885. Object.values(DashboardFilterKeys)
  886. ),
  887. })
  888. );
  889. };
  890. if (onDashboardUpdate) {
  891. onDashboardUpdate(newDashboard);
  892. this.setState(
  893. {
  894. modifiedDashboard: null,
  895. },
  896. () => {
  897. // Wait for modifiedDashboard state to update before navigating
  898. navigateToDashboard();
  899. }
  900. );
  901. return;
  902. }
  903. navigateToDashboard();
  904. },
  905. // `updateDashboard` does its own error handling
  906. () => undefined
  907. );
  908. }}
  909. />
  910. <WidgetViewerContext.Provider
  911. value={{seriesData, setData}}
  912. >
  913. <Dashboard
  914. paramDashboardId={dashboardId}
  915. dashboard={modifiedDashboard ?? dashboard}
  916. organization={organization}
  917. isEditingDashboard={this.isEditingDashboard}
  918. widgetLimitReached={widgetLimitReached}
  919. onUpdate={this.onUpdateWidget}
  920. handleUpdateWidgetList={this.handleUpdateWidgetList}
  921. handleAddCustomWidget={this.handleAddCustomWidget}
  922. handleAddMetricWidget={this.handleAddMetricWidget}
  923. router={router}
  924. location={location}
  925. newWidget={newWidget}
  926. onSetNewWidget={onSetNewWidget}
  927. isPreview={this.isPreview}
  928. />
  929. </WidgetViewerContext.Provider>
  930. </MEPSettingProvider>
  931. )}
  932. </MetricsDataSwitcher>
  933. </MetricsCardinalityProvider>
  934. </Layout.Main>
  935. </Layout.Body>
  936. </NoProjectMessage>
  937. </MetricsResultsMetaProvider>
  938. </MetricsDashboardContextProvider>
  939. </OnDemandControlProvider>
  940. </Layout.Page>
  941. </PageFiltersContainer>
  942. </SentryDocumentTitle>
  943. );
  944. }
  945. render() {
  946. const {organization} = this.props;
  947. if (this.isWidgetBuilderRouter) {
  948. return this.renderWidgetBuilder();
  949. }
  950. if (organization.features.includes('dashboards-edit')) {
  951. return this.renderDashboardDetail();
  952. }
  953. return this.renderDefaultDashboardDetail();
  954. }
  955. }
  956. const StyledPageHeader = styled('div')`
  957. display: grid;
  958. grid-template-columns: minmax(0, 1fr);
  959. grid-row-gap: ${space(2)};
  960. align-items: center;
  961. margin-bottom: ${space(2)};
  962. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  963. grid-template-columns: minmax(0, 1fr) max-content;
  964. grid-column-gap: ${space(2)};
  965. height: 40px;
  966. }
  967. `;
  968. export default withPageFilters(withProjects(withApi(withOrganization(DashboardDetail))));