detail.tsx 38 KB

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