detail.tsx 37 KB

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