detail.tsx 32 KB

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