detail.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  1. import {cloneElement, Component, isValidElement} from 'react';
  2. import {browserHistory, PlainRoute, RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import isEqual from 'lodash/isEqual';
  5. import {
  6. createDashboard,
  7. deleteDashboard,
  8. updateDashboard,
  9. } from 'app/actionCreators/dashboards';
  10. import {addSuccessMessage} from 'app/actionCreators/indicator';
  11. import {Client} from 'app/api';
  12. import Breadcrumbs from 'app/components/breadcrumbs';
  13. import HookOrDefault from 'app/components/hookOrDefault';
  14. import * as Layout from 'app/components/layouts/thirds';
  15. import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
  16. import GlobalSelectionHeader from 'app/components/organizations/globalSelectionHeader';
  17. import {t} from 'app/locale';
  18. import {PageContent} from 'app/styles/organization';
  19. import space from 'app/styles/space';
  20. import {Organization} from 'app/types';
  21. import {trackAnalyticsEvent} from 'app/utils/analytics';
  22. import withApi from 'app/utils/withApi';
  23. import withOrganization from 'app/utils/withOrganization';
  24. import Controls from './controls';
  25. import Dashboard from './dashboard';
  26. import {DEFAULT_STATS_PERIOD, EMPTY_DASHBOARD} from './data';
  27. import DashboardTitle from './title';
  28. import {DashboardDetails, DashboardListItem, DashboardState, Widget} from './types';
  29. import {cloneDashboard} from './utils';
  30. const UNSAVED_MESSAGE = t('You have unsaved changes, are you sure you want to leave?');
  31. const HookHeader = HookOrDefault({hookName: 'component:dashboards-header'});
  32. type RouteParams = {
  33. orgId: string;
  34. dashboardId?: string;
  35. widgetId?: number;
  36. };
  37. type Props = RouteComponentProps<RouteParams, {}> & {
  38. api: Client;
  39. organization: Organization;
  40. initialState: DashboardState;
  41. dashboard: DashboardDetails;
  42. dashboards: DashboardListItem[];
  43. route: PlainRoute;
  44. reloadData?: () => void;
  45. };
  46. type State = {
  47. dashboardState: DashboardState;
  48. modifiedDashboard: DashboardDetails | null;
  49. widgetToBeUpdated?: Widget;
  50. };
  51. class DashboardDetail extends Component<Props, State> {
  52. state: State = {
  53. dashboardState: this.props.initialState,
  54. modifiedDashboard: this.updateModifiedDashboard(this.props.initialState),
  55. };
  56. componentDidMount() {
  57. const {route, router} = this.props;
  58. this.checkStateRoute();
  59. router.setRouteLeaveHook(route, this.onRouteLeave);
  60. window.addEventListener('beforeunload', this.onUnload);
  61. }
  62. componentDidUpdate(prevProps: Props) {
  63. if (prevProps.location.pathname !== this.props.location.pathname) {
  64. this.checkStateRoute();
  65. }
  66. }
  67. componentWillUnmount() {
  68. window.removeEventListener('beforeunload', this.onUnload);
  69. }
  70. checkStateRoute() {
  71. const {router, organization, params} = this.props;
  72. const {dashboardId} = params;
  73. const dashboardDetailsRoute = `/organizations/${organization.slug}/dashboard/${dashboardId}/`;
  74. if (this.isWidgetBuilderRouter && !this.isEditing) {
  75. router.replace(dashboardDetailsRoute);
  76. }
  77. if (location.pathname === dashboardDetailsRoute && !!this.state.widgetToBeUpdated) {
  78. this.onSetWidgetToBeUpdated(undefined);
  79. }
  80. }
  81. updateRouteAfterSavingWidget() {
  82. if (this.isWidgetBuilderRouter) {
  83. const {router, organization, params} = this.props;
  84. const {dashboardId} = params;
  85. if (dashboardId) {
  86. router.replace(`/organizations/${organization.slug}/dashboard/${dashboardId}/`);
  87. return;
  88. }
  89. router.replace(`/organizations/${organization.slug}/dashboards/new/`);
  90. }
  91. }
  92. updateModifiedDashboard(dashboardState: DashboardState) {
  93. const {dashboard} = this.props;
  94. switch (dashboardState) {
  95. case DashboardState.CREATE:
  96. return cloneDashboard(EMPTY_DASHBOARD);
  97. case DashboardState.EDIT:
  98. return cloneDashboard(dashboard);
  99. default: {
  100. return null;
  101. }
  102. }
  103. }
  104. get isEditing() {
  105. const {dashboardState} = this.state;
  106. return [
  107. DashboardState.EDIT,
  108. DashboardState.CREATE,
  109. DashboardState.PENDING_DELETE,
  110. ].includes(dashboardState);
  111. }
  112. get isWidgetBuilderRouter() {
  113. const {location, params, organization} = this.props;
  114. const {dashboardId} = params;
  115. const newWidgetRoutes = [
  116. `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  117. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
  118. ];
  119. return newWidgetRoutes.includes(location.pathname) || this.isWidgetBuilderEditRouter;
  120. }
  121. get isWidgetBuilderEditRouter() {
  122. const {location, params, organization} = this.props;
  123. const {dashboardId, widgetId} = params;
  124. const widgetEditRoutes = [
  125. `/organizations/${organization.slug}/dashboards/new/widget/${widgetId}/edit/`,
  126. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetId}/edit/`,
  127. ];
  128. return widgetEditRoutes.includes(location.pathname);
  129. }
  130. get dashboardTitle() {
  131. const {dashboard} = this.props;
  132. const {modifiedDashboard} = this.state;
  133. return modifiedDashboard ? modifiedDashboard.title : dashboard.title;
  134. }
  135. onEdit = () => {
  136. const {dashboard} = this.props;
  137. trackAnalyticsEvent({
  138. eventKey: 'dashboards2.edit.start',
  139. eventName: 'Dashboards2: Edit start',
  140. organization_id: parseInt(this.props.organization.id, 10),
  141. });
  142. this.setState({
  143. dashboardState: DashboardState.EDIT,
  144. modifiedDashboard: cloneDashboard(dashboard),
  145. });
  146. };
  147. onRouteLeave = () => {
  148. if (
  149. ![DashboardState.VIEW, DashboardState.PENDING_DELETE].includes(
  150. this.state.dashboardState
  151. )
  152. ) {
  153. return UNSAVED_MESSAGE;
  154. }
  155. return undefined;
  156. };
  157. onUnload = (event: BeforeUnloadEvent) => {
  158. if (
  159. [DashboardState.VIEW, DashboardState.PENDING_DELETE].includes(
  160. this.state.dashboardState
  161. )
  162. ) {
  163. return;
  164. }
  165. event.preventDefault();
  166. event.returnValue = UNSAVED_MESSAGE;
  167. };
  168. onDelete = (dashboard: State['modifiedDashboard']) => () => {
  169. const {api, organization, location} = this.props;
  170. if (!dashboard?.id) {
  171. return;
  172. }
  173. const previousDashboardState = this.state.dashboardState;
  174. this.setState({dashboardState: DashboardState.PENDING_DELETE}, () => {
  175. deleteDashboard(api, organization.slug, dashboard.id)
  176. .then(() => {
  177. addSuccessMessage(t('Dashboard deleted'));
  178. trackAnalyticsEvent({
  179. eventKey: 'dashboards2.delete',
  180. eventName: 'Dashboards2: Delete',
  181. organization_id: parseInt(this.props.organization.id, 10),
  182. });
  183. browserHistory.replace({
  184. pathname: `/organizations/${organization.slug}/dashboards/`,
  185. query: location.query,
  186. });
  187. })
  188. .catch(() => {
  189. this.setState({
  190. dashboardState: previousDashboardState,
  191. });
  192. });
  193. });
  194. };
  195. onCancel = () => {
  196. const {organization, location, params} = this.props;
  197. if (params.dashboardId) {
  198. trackAnalyticsEvent({
  199. eventKey: 'dashboards2.edit.cancel',
  200. eventName: 'Dashboards2: Edit cancel',
  201. organization_id: parseInt(this.props.organization.id, 10),
  202. });
  203. this.setState({
  204. dashboardState: DashboardState.VIEW,
  205. modifiedDashboard: null,
  206. });
  207. return;
  208. }
  209. trackAnalyticsEvent({
  210. eventKey: 'dashboards2.create.cancel',
  211. eventName: 'Dashboards2: Create cancel',
  212. organization_id: parseInt(this.props.organization.id, 10),
  213. });
  214. browserHistory.replace({
  215. pathname: `/organizations/${organization.slug}/dashboards/`,
  216. query: location.query,
  217. });
  218. };
  219. onCommit = () => {
  220. const {api, organization, location, dashboard, reloadData} = this.props;
  221. const {modifiedDashboard, dashboardState} = this.state;
  222. switch (dashboardState) {
  223. case DashboardState.CREATE: {
  224. if (modifiedDashboard) {
  225. createDashboard(api, organization.slug, modifiedDashboard).then(
  226. (newDashboard: DashboardDetails) => {
  227. addSuccessMessage(t('Dashboard created'));
  228. trackAnalyticsEvent({
  229. eventKey: 'dashboards2.create.complete',
  230. eventName: 'Dashboards2: Create complete',
  231. organization_id: parseInt(organization.id, 10),
  232. });
  233. this.setState({
  234. dashboardState: DashboardState.VIEW,
  235. modifiedDashboard: null,
  236. });
  237. // redirect to new dashboard
  238. browserHistory.replace({
  239. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  240. query: {
  241. ...location.query,
  242. },
  243. });
  244. }
  245. );
  246. }
  247. break;
  248. }
  249. case DashboardState.EDIT: {
  250. // only update the dashboard if there are changes
  251. if (modifiedDashboard) {
  252. if (isEqual(dashboard, modifiedDashboard)) {
  253. this.setState({
  254. dashboardState: DashboardState.VIEW,
  255. modifiedDashboard: null,
  256. });
  257. return;
  258. }
  259. updateDashboard(api, organization.slug, modifiedDashboard).then(
  260. (newDashboard: DashboardDetails) => {
  261. addSuccessMessage(t('Dashboard updated'));
  262. trackAnalyticsEvent({
  263. eventKey: 'dashboards2.edit.complete',
  264. eventName: 'Dashboards2: Edit complete',
  265. organization_id: parseInt(organization.id, 10),
  266. });
  267. this.setState({
  268. dashboardState: DashboardState.VIEW,
  269. modifiedDashboard: null,
  270. });
  271. if (dashboard && newDashboard.id !== dashboard.id) {
  272. browserHistory.replace({
  273. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  274. query: {
  275. ...location.query,
  276. },
  277. });
  278. return;
  279. }
  280. if (reloadData) {
  281. reloadData();
  282. }
  283. }
  284. );
  285. return;
  286. }
  287. this.setState({
  288. dashboardState: DashboardState.VIEW,
  289. modifiedDashboard: null,
  290. });
  291. break;
  292. }
  293. case DashboardState.VIEW:
  294. default: {
  295. this.setState({
  296. dashboardState: DashboardState.VIEW,
  297. modifiedDashboard: null,
  298. });
  299. break;
  300. }
  301. }
  302. };
  303. setModifiedDashboard = (dashboard: DashboardDetails) => {
  304. this.setState({
  305. modifiedDashboard: dashboard,
  306. });
  307. };
  308. onSetWidgetToBeUpdated = (widget?: Widget) => {
  309. this.setState({widgetToBeUpdated: widget});
  310. };
  311. onUpdateWidget = (widgets: Widget[]) => {
  312. const {modifiedDashboard} = this.state;
  313. if (modifiedDashboard === null) {
  314. return;
  315. }
  316. this.setState(
  317. (state: State) => ({
  318. ...state,
  319. widgetToBeUpdated: undefined,
  320. modifiedDashboard: {
  321. ...state.modifiedDashboard!,
  322. widgets,
  323. },
  324. }),
  325. this.updateRouteAfterSavingWidget
  326. );
  327. };
  328. renderWidgetBuilder(dashboard: DashboardDetails) {
  329. const {children} = this.props;
  330. const {modifiedDashboard, widgetToBeUpdated} = this.state;
  331. return isValidElement(children)
  332. ? cloneElement(children, {
  333. dashboard: modifiedDashboard ?? dashboard,
  334. onSave: this.onUpdateWidget,
  335. widget: widgetToBeUpdated,
  336. })
  337. : children;
  338. }
  339. renderDefaultDashboardDetail() {
  340. const {organization, dashboard, dashboards, params, router, location} = this.props;
  341. const {modifiedDashboard, dashboardState} = this.state;
  342. const {dashboardId} = params;
  343. return (
  344. <GlobalSelectionHeader
  345. skipLoadLastUsed={organization.features.includes('global-views')}
  346. defaultSelection={{
  347. datetime: {
  348. start: null,
  349. end: null,
  350. utc: false,
  351. period: DEFAULT_STATS_PERIOD,
  352. },
  353. }}
  354. >
  355. <PageContent>
  356. <LightWeightNoProjectMessage organization={organization}>
  357. <StyledPageHeader>
  358. <DashboardTitle
  359. dashboard={modifiedDashboard ?? dashboard}
  360. onUpdate={this.setModifiedDashboard}
  361. isEditing={this.isEditing}
  362. />
  363. <Controls
  364. organization={organization}
  365. dashboards={dashboards}
  366. onEdit={this.onEdit}
  367. onCancel={this.onCancel}
  368. onCommit={this.onCommit}
  369. onDelete={this.onDelete(dashboard)}
  370. dashboardState={dashboardState}
  371. />
  372. </StyledPageHeader>
  373. <HookHeader organization={organization} />
  374. <Dashboard
  375. paramDashboardId={dashboardId}
  376. dashboard={modifiedDashboard ?? dashboard}
  377. organization={organization}
  378. isEditing={this.isEditing}
  379. onUpdate={this.onUpdateWidget}
  380. onSetWidgetToBeUpdated={this.onSetWidgetToBeUpdated}
  381. router={router}
  382. location={location}
  383. />
  384. </LightWeightNoProjectMessage>
  385. </PageContent>
  386. </GlobalSelectionHeader>
  387. );
  388. }
  389. renderDashboardDetail() {
  390. const {organization, dashboard, dashboards, params, router, location} = this.props;
  391. const {modifiedDashboard, dashboardState} = this.state;
  392. const {dashboardId} = params;
  393. return (
  394. <GlobalSelectionHeader
  395. skipLoadLastUsed={organization.features.includes('global-views')}
  396. defaultSelection={{
  397. datetime: {
  398. start: null,
  399. end: null,
  400. utc: false,
  401. period: DEFAULT_STATS_PERIOD,
  402. },
  403. }}
  404. >
  405. <LightWeightNoProjectMessage organization={organization}>
  406. <Layout.Header>
  407. <Layout.HeaderContent>
  408. <Breadcrumbs
  409. crumbs={[
  410. {
  411. label: t('Dashboards'),
  412. to: `/organizations/${organization.slug}/dashboards/`,
  413. },
  414. {
  415. label:
  416. dashboardState === DashboardState.CREATE
  417. ? t('Create Dashboard')
  418. : organization.features.includes('dashboards-edit') &&
  419. dashboard.id === 'default-overview'
  420. ? 'Default Dashboard'
  421. : this.dashboardTitle,
  422. },
  423. ]}
  424. />
  425. <Layout.Title>
  426. <DashboardTitle
  427. dashboard={modifiedDashboard ?? dashboard}
  428. onUpdate={this.setModifiedDashboard}
  429. isEditing={this.isEditing}
  430. />
  431. </Layout.Title>
  432. </Layout.HeaderContent>
  433. <Layout.HeaderActions>
  434. <Controls
  435. organization={organization}
  436. dashboards={dashboards}
  437. onEdit={this.onEdit}
  438. onCancel={this.onCancel}
  439. onCommit={this.onCommit}
  440. onDelete={this.onDelete(dashboard)}
  441. dashboardState={dashboardState}
  442. />
  443. </Layout.HeaderActions>
  444. </Layout.Header>
  445. <Layout.Body>
  446. <Layout.Main fullWidth>
  447. <Dashboard
  448. paramDashboardId={dashboardId}
  449. dashboard={modifiedDashboard ?? dashboard}
  450. organization={organization}
  451. isEditing={this.isEditing}
  452. onUpdate={this.onUpdateWidget}
  453. onSetWidgetToBeUpdated={this.onSetWidgetToBeUpdated}
  454. router={router}
  455. location={location}
  456. />
  457. </Layout.Main>
  458. </Layout.Body>
  459. </LightWeightNoProjectMessage>
  460. </GlobalSelectionHeader>
  461. );
  462. }
  463. render() {
  464. const {organization, dashboard} = this.props;
  465. if (this.isEditing && this.isWidgetBuilderRouter) {
  466. return this.renderWidgetBuilder(dashboard);
  467. }
  468. if (organization.features.includes('dashboards-edit')) {
  469. return this.renderDashboardDetail();
  470. }
  471. return this.renderDefaultDashboardDetail();
  472. }
  473. }
  474. const StyledPageHeader = styled('div')`
  475. display: grid;
  476. grid-template-columns: minmax(0, 1fr);
  477. grid-row-gap: ${space(2)};
  478. align-items: center;
  479. font-size: ${p => p.theme.headerFontSize};
  480. color: ${p => p.theme.textColor};
  481. margin-bottom: ${space(2)};
  482. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  483. grid-template-columns: minmax(0, 1fr) max-content;
  484. grid-column-gap: ${space(2)};
  485. height: 40px;
  486. }
  487. `;
  488. export default withApi(withOrganization(DashboardDetail));