detail.tsx 41 KB

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