detail.tsx 39 KB

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