detail.tsx 31 KB

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