detail.tsx 50 KB

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