detail.tsx 32 KB

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