detail.tsx 38 KB

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