detail.tsx 37 KB

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