detail.tsx 32 KB

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