detail.tsx 37 KB

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