detail.tsx 17 KB

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