detail.tsx 41 KB

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