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