detail.tsx 37 KB

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