detail.tsx 46 KB

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