detail.tsx 40 KB

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