detail.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. import {cloneElement, Component, isValidElement} from 'react';
  2. import type {Layout as RGLLayout} from 'react-grid-layout';
  3. import {browserHistory, PlainRoute, RouteComponentProps} from 'react-router';
  4. import styled from '@emotion/styled';
  5. import isEqual from 'lodash/isEqual';
  6. import {
  7. createDashboard,
  8. deleteDashboard,
  9. updateDashboard,
  10. } from 'sentry/actionCreators/dashboards';
  11. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  12. import {openDashboardWidgetLibraryModal} from 'sentry/actionCreators/modal';
  13. import {Client} from 'sentry/api';
  14. import Breadcrumbs from 'sentry/components/breadcrumbs';
  15. import HookOrDefault from 'sentry/components/hookOrDefault';
  16. import * as Layout from 'sentry/components/layouts/thirds';
  17. import NoProjectMessage from 'sentry/components/noProjectMessage';
  18. import GlobalSelectionHeader from 'sentry/components/organizations/globalSelectionHeader';
  19. import {t} from 'sentry/locale';
  20. import {PageContent} from 'sentry/styles/organization';
  21. import space from 'sentry/styles/space';
  22. import {Organization} from 'sentry/types';
  23. import {trackAnalyticsEvent} from 'sentry/utils/analytics';
  24. import withApi from 'sentry/utils/withApi';
  25. import withOrganization from 'sentry/utils/withOrganization';
  26. import {getDashboardLayout, saveDashboardLayout} from './gridLayout/utils';
  27. import Controls from './controls';
  28. import Dashboard, {assignTempId, constructGridItemKey} from './dashboard';
  29. import {DEFAULT_STATS_PERIOD, EMPTY_DASHBOARD} from './data';
  30. import DashboardTitle from './title';
  31. import {
  32. DashboardDetails,
  33. DashboardListItem,
  34. DashboardState,
  35. MAX_WIDGETS,
  36. Widget,
  37. } from './types';
  38. import {cloneDashboard} from './utils';
  39. const UNSAVED_MESSAGE = t('You have unsaved changes, are you sure you want to leave?');
  40. const HookHeader = HookOrDefault({hookName: 'component:dashboards-header'});
  41. type RouteParams = {
  42. orgId: string;
  43. dashboardId?: string;
  44. widgetId?: number;
  45. };
  46. type Props = RouteComponentProps<RouteParams, {}> & {
  47. api: Client;
  48. organization: Organization;
  49. initialState: DashboardState;
  50. dashboard: DashboardDetails;
  51. dashboards: DashboardListItem[];
  52. route: PlainRoute;
  53. onDashboardUpdate?: (updatedDashboard: DashboardDetails) => void;
  54. newWidget?: Widget;
  55. };
  56. type State = {
  57. dashboardState: DashboardState;
  58. modifiedDashboard: DashboardDetails | null;
  59. widgetToBeUpdated?: Widget;
  60. layout: RGLLayout[];
  61. widgetLimitReached: boolean;
  62. };
  63. class DashboardDetail extends Component<Props, State> {
  64. state: State = {
  65. dashboardState: this.props.initialState,
  66. modifiedDashboard: this.updateModifiedDashboard(this.props.initialState),
  67. layout: getDashboardLayout(this.props.organization.id, this.props.dashboard.id),
  68. widgetLimitReached: this.props.dashboard.widgets.length >= MAX_WIDGETS,
  69. };
  70. componentDidMount() {
  71. const {route, router} = this.props;
  72. this.checkStateRoute();
  73. router.setRouteLeaveHook(route, this.onRouteLeave);
  74. window.addEventListener('beforeunload', this.onUnload);
  75. }
  76. componentDidUpdate(prevProps: Props) {
  77. if (prevProps.location.pathname !== this.props.location.pathname) {
  78. this.checkStateRoute();
  79. }
  80. }
  81. componentWillUnmount() {
  82. window.removeEventListener('beforeunload', this.onUnload);
  83. }
  84. checkStateRoute() {
  85. const {router, organization, params} = this.props;
  86. const {dashboardId} = params;
  87. const dashboardDetailsRoute = `/organizations/${organization.slug}/dashboard/${dashboardId}/`;
  88. if (this.isWidgetBuilderRouter && !this.isEditing) {
  89. router.replace(dashboardDetailsRoute);
  90. }
  91. if (location.pathname === dashboardDetailsRoute && !!this.state.widgetToBeUpdated) {
  92. this.onSetWidgetToBeUpdated(undefined);
  93. }
  94. }
  95. updateRouteAfterSavingWidget() {
  96. if (this.isWidgetBuilderRouter) {
  97. const {router, organization, params} = this.props;
  98. const {dashboardId} = params;
  99. if (dashboardId) {
  100. router.replace(`/organizations/${organization.slug}/dashboard/${dashboardId}/`);
  101. return;
  102. }
  103. router.replace(`/organizations/${organization.slug}/dashboards/new/`);
  104. }
  105. }
  106. updateModifiedDashboard(dashboardState: DashboardState) {
  107. const {dashboard} = this.props;
  108. switch (dashboardState) {
  109. case DashboardState.CREATE:
  110. return cloneDashboard(EMPTY_DASHBOARD);
  111. case DashboardState.EDIT:
  112. return cloneDashboard(dashboard);
  113. default: {
  114. return null;
  115. }
  116. }
  117. }
  118. get isEditing() {
  119. const {dashboardState} = this.state;
  120. return [
  121. DashboardState.EDIT,
  122. DashboardState.CREATE,
  123. DashboardState.PENDING_DELETE,
  124. ].includes(dashboardState);
  125. }
  126. get isWidgetBuilderRouter() {
  127. const {location, params, organization} = this.props;
  128. const {dashboardId} = params;
  129. const newWidgetRoutes = [
  130. `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  131. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/new/`,
  132. ];
  133. return newWidgetRoutes.includes(location.pathname) || this.isWidgetBuilderEditRouter;
  134. }
  135. get isWidgetBuilderEditRouter() {
  136. const {location, params, organization} = this.props;
  137. const {dashboardId, widgetId} = params;
  138. const widgetEditRoutes = [
  139. `/organizations/${organization.slug}/dashboards/new/widget/${widgetId}/edit/`,
  140. `/organizations/${organization.slug}/dashboard/${dashboardId}/widget/${widgetId}/edit/`,
  141. ];
  142. return widgetEditRoutes.includes(location.pathname);
  143. }
  144. get dashboardTitle() {
  145. const {dashboard} = this.props;
  146. const {modifiedDashboard} = this.state;
  147. return modifiedDashboard ? modifiedDashboard.title : dashboard.title;
  148. }
  149. onEdit = () => {
  150. const {dashboard} = this.props;
  151. trackAnalyticsEvent({
  152. eventKey: 'dashboards2.edit.start',
  153. eventName: 'Dashboards2: Edit start',
  154. organization_id: parseInt(this.props.organization.id, 10),
  155. });
  156. this.setState({
  157. dashboardState: DashboardState.EDIT,
  158. modifiedDashboard: cloneDashboard(dashboard),
  159. });
  160. };
  161. onRouteLeave = () => {
  162. if (
  163. ![DashboardState.VIEW, DashboardState.PENDING_DELETE].includes(
  164. this.state.dashboardState
  165. )
  166. ) {
  167. return UNSAVED_MESSAGE;
  168. }
  169. return undefined;
  170. };
  171. onUnload = (event: BeforeUnloadEvent) => {
  172. if (
  173. [DashboardState.VIEW, DashboardState.PENDING_DELETE].includes(
  174. this.state.dashboardState
  175. )
  176. ) {
  177. return;
  178. }
  179. event.preventDefault();
  180. event.returnValue = UNSAVED_MESSAGE;
  181. };
  182. onDelete = (dashboard: State['modifiedDashboard']) => () => {
  183. const {api, organization, location} = this.props;
  184. if (!dashboard?.id) {
  185. return;
  186. }
  187. const previousDashboardState = this.state.dashboardState;
  188. this.setState({dashboardState: DashboardState.PENDING_DELETE}, () => {
  189. deleteDashboard(api, organization.slug, dashboard.id)
  190. .then(() => {
  191. addSuccessMessage(t('Dashboard deleted'));
  192. trackAnalyticsEvent({
  193. eventKey: 'dashboards2.delete',
  194. eventName: 'Dashboards2: Delete',
  195. organization_id: parseInt(this.props.organization.id, 10),
  196. });
  197. browserHistory.replace({
  198. pathname: `/organizations/${organization.slug}/dashboards/`,
  199. query: location.query,
  200. });
  201. })
  202. .catch(() => {
  203. this.setState({
  204. dashboardState: previousDashboardState,
  205. });
  206. });
  207. });
  208. };
  209. onCancel = () => {
  210. const {organization, dashboard, location, params} = this.props;
  211. if (params.dashboardId) {
  212. trackAnalyticsEvent({
  213. eventKey: 'dashboards2.edit.cancel',
  214. eventName: 'Dashboards2: Edit cancel',
  215. organization_id: parseInt(this.props.organization.id, 10),
  216. });
  217. this.setState({
  218. dashboardState: DashboardState.VIEW,
  219. modifiedDashboard: null,
  220. layout: getDashboardLayout(organization.id, dashboard.id),
  221. });
  222. return;
  223. }
  224. trackAnalyticsEvent({
  225. eventKey: 'dashboards2.create.cancel',
  226. eventName: 'Dashboards2: Create cancel',
  227. organization_id: parseInt(this.props.organization.id, 10),
  228. });
  229. browserHistory.replace({
  230. pathname: `/organizations/${organization.slug}/dashboards/`,
  231. query: location.query,
  232. });
  233. };
  234. handleAddLibraryWidgets = (widgets: Widget[]) => {
  235. const {organization, dashboard, api, onDashboardUpdate, location} = this.props;
  236. const {dashboardState, modifiedDashboard} = this.state;
  237. const newModifiedDashboard = {
  238. ...cloneDashboard(modifiedDashboard || dashboard),
  239. widgets: widgets.map(assignTempId),
  240. };
  241. this.setState({
  242. modifiedDashboard: newModifiedDashboard,
  243. widgetLimitReached: widgets.length >= MAX_WIDGETS,
  244. });
  245. if ([DashboardState.CREATE, DashboardState.EDIT].includes(dashboardState)) {
  246. return;
  247. }
  248. updateDashboard(api, organization.slug, newModifiedDashboard).then(
  249. (newDashboard: DashboardDetails) => {
  250. if (onDashboardUpdate) {
  251. onDashboardUpdate(newDashboard);
  252. }
  253. addSuccessMessage(t('Dashboard updated'));
  254. if (dashboard && newDashboard.id !== dashboard.id) {
  255. browserHistory.replace({
  256. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  257. query: {
  258. ...location.query,
  259. },
  260. });
  261. return;
  262. }
  263. },
  264. () => undefined
  265. );
  266. };
  267. onAddWidget = () => {
  268. const {organization, dashboard} = this.props;
  269. this.setState({
  270. modifiedDashboard: cloneDashboard(dashboard),
  271. });
  272. openDashboardWidgetLibraryModal({
  273. organization,
  274. dashboard,
  275. onAddWidget: (widgets: Widget[]) => this.handleAddLibraryWidgets(widgets),
  276. });
  277. };
  278. /**
  279. * Saves a dashboard layout where the layout keys are replaced with the IDs of new widgets.
  280. *
  281. * If there are more widgets than layout objects, these widgets will be treated as
  282. * new and will get default positioning. This happens when saving in mobile
  283. * view because we don't update the desktop layout to account for the new widget.
  284. *
  285. * Throws an error if we end up in a state where we're trying to save more layouts
  286. * than we have widgets.
  287. */
  288. saveLayoutWithNewWidgets = (organizationId, dashboardId, newWidgets) => {
  289. const {layout} = this.state;
  290. if (layout.length > newWidgets.length) {
  291. throw new Error('Expected layouts to have less length than widgets');
  292. }
  293. const newLayout = layout.map((widgetLayout, index) => ({
  294. ...widgetLayout,
  295. i: constructGridItemKey(newWidgets[index]),
  296. }));
  297. saveDashboardLayout(organizationId, dashboardId, newLayout);
  298. this.setState({layout: newLayout});
  299. };
  300. onCommit = () => {
  301. const {api, organization, location, dashboard, onDashboardUpdate} = this.props;
  302. const {layout, modifiedDashboard, dashboardState} = this.state;
  303. switch (dashboardState) {
  304. case DashboardState.CREATE: {
  305. if (modifiedDashboard) {
  306. createDashboard(api, organization.slug, modifiedDashboard).then(
  307. (newDashboard: DashboardDetails) => {
  308. if (organization.features.includes('dashboard-grid-layout')) {
  309. this.saveLayoutWithNewWidgets(
  310. organization.id,
  311. newDashboard.id,
  312. newDashboard.widgets
  313. );
  314. }
  315. addSuccessMessage(t('Dashboard created'));
  316. trackAnalyticsEvent({
  317. eventKey: 'dashboards2.create.complete',
  318. eventName: 'Dashboards2: Create complete',
  319. organization_id: parseInt(organization.id, 10),
  320. });
  321. this.setState({
  322. dashboardState: DashboardState.VIEW,
  323. });
  324. // redirect to new dashboard
  325. browserHistory.replace({
  326. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  327. query: {
  328. ...location.query,
  329. },
  330. });
  331. },
  332. () => undefined
  333. );
  334. }
  335. break;
  336. }
  337. case DashboardState.EDIT: {
  338. // TODO(nar): This should only fire when there are changes to the layout
  339. // and the dashboard can be successfully saved
  340. if (organization.features.includes('dashboard-grid-layout')) {
  341. saveDashboardLayout(organization.id, dashboard.id, layout);
  342. }
  343. // only update the dashboard if there are changes
  344. if (modifiedDashboard) {
  345. if (isEqual(dashboard, modifiedDashboard)) {
  346. this.setState({
  347. dashboardState: DashboardState.VIEW,
  348. modifiedDashboard: null,
  349. });
  350. return;
  351. }
  352. updateDashboard(api, organization.slug, modifiedDashboard).then(
  353. (newDashboard: DashboardDetails) => {
  354. if (onDashboardUpdate) {
  355. onDashboardUpdate(newDashboard);
  356. }
  357. if (organization.features.includes('dashboard-grid-layout')) {
  358. this.saveLayoutWithNewWidgets(
  359. organization.id,
  360. newDashboard.id,
  361. newDashboard.widgets
  362. );
  363. }
  364. addSuccessMessage(t('Dashboard updated'));
  365. trackAnalyticsEvent({
  366. eventKey: 'dashboards2.edit.complete',
  367. eventName: 'Dashboards2: Edit complete',
  368. organization_id: parseInt(organization.id, 10),
  369. });
  370. this.setState({
  371. dashboardState: DashboardState.VIEW,
  372. modifiedDashboard: null,
  373. });
  374. if (dashboard && newDashboard.id !== dashboard.id) {
  375. browserHistory.replace({
  376. pathname: `/organizations/${organization.slug}/dashboard/${newDashboard.id}/`,
  377. query: {
  378. ...location.query,
  379. },
  380. });
  381. return;
  382. }
  383. },
  384. () => undefined
  385. );
  386. return;
  387. }
  388. this.setState({
  389. dashboardState: DashboardState.VIEW,
  390. modifiedDashboard: null,
  391. });
  392. break;
  393. }
  394. case DashboardState.VIEW:
  395. default: {
  396. this.setState({
  397. dashboardState: DashboardState.VIEW,
  398. modifiedDashboard: null,
  399. });
  400. break;
  401. }
  402. }
  403. };
  404. setModifiedDashboard = (dashboard: DashboardDetails) => {
  405. this.setState({
  406. modifiedDashboard: dashboard,
  407. });
  408. };
  409. onSetWidgetToBeUpdated = (widget?: Widget) => {
  410. this.setState({widgetToBeUpdated: widget});
  411. };
  412. onLayoutChange = (layout: RGLLayout[]) => {
  413. this.setState({layout});
  414. };
  415. onUpdateWidget = (widgets: Widget[]) => {
  416. this.setState(
  417. (state: State) => ({
  418. ...state,
  419. widgetToBeUpdated: undefined,
  420. widgetLimitReached: widgets.length >= MAX_WIDGETS,
  421. modifiedDashboard: {
  422. ...(state.modifiedDashboard || this.props.dashboard),
  423. widgets,
  424. },
  425. }),
  426. this.updateRouteAfterSavingWidget
  427. );
  428. };
  429. renderWidgetBuilder(dashboard: DashboardDetails) {
  430. const {children} = this.props;
  431. const {modifiedDashboard, widgetToBeUpdated} = this.state;
  432. return isValidElement(children)
  433. ? cloneElement(children, {
  434. dashboard: modifiedDashboard ?? dashboard,
  435. onSave: this.onUpdateWidget,
  436. widget: widgetToBeUpdated,
  437. })
  438. : children;
  439. }
  440. renderDefaultDashboardDetail() {
  441. const {organization, dashboard, dashboards, params, router, location} = this.props;
  442. const {layout, modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
  443. const {dashboardId} = params;
  444. return (
  445. <GlobalSelectionHeader
  446. skipLoadLastUsed={organization.features.includes('global-views')}
  447. defaultSelection={{
  448. datetime: {
  449. start: null,
  450. end: null,
  451. utc: false,
  452. period: DEFAULT_STATS_PERIOD,
  453. },
  454. }}
  455. >
  456. <PageContent>
  457. <NoProjectMessage organization={organization}>
  458. <StyledPageHeader>
  459. <DashboardTitle
  460. dashboard={modifiedDashboard ?? dashboard}
  461. onUpdate={this.setModifiedDashboard}
  462. isEditing={this.isEditing}
  463. />
  464. <Controls
  465. organization={organization}
  466. dashboards={dashboards}
  467. onEdit={this.onEdit}
  468. onCancel={this.onCancel}
  469. onCommit={this.onCommit}
  470. onAddWidget={this.onAddWidget}
  471. onDelete={this.onDelete(dashboard)}
  472. dashboardState={dashboardState}
  473. widgetLimitReached={widgetLimitReached}
  474. />
  475. </StyledPageHeader>
  476. <HookHeader organization={organization} />
  477. <Dashboard
  478. paramDashboardId={dashboardId}
  479. dashboard={modifiedDashboard ?? dashboard}
  480. organization={organization}
  481. isEditing={this.isEditing}
  482. widgetLimitReached={widgetLimitReached}
  483. onUpdate={this.onUpdateWidget}
  484. onSetWidgetToBeUpdated={this.onSetWidgetToBeUpdated}
  485. handleAddLibraryWidgets={this.handleAddLibraryWidgets}
  486. router={router}
  487. location={location}
  488. layout={layout}
  489. onLayoutChange={this.onLayoutChange}
  490. />
  491. </NoProjectMessage>
  492. </PageContent>
  493. </GlobalSelectionHeader>
  494. );
  495. }
  496. renderDashboardDetail() {
  497. const {organization, dashboard, dashboards, params, router, location, newWidget} =
  498. this.props;
  499. const {layout, modifiedDashboard, dashboardState, widgetLimitReached} = this.state;
  500. const {dashboardId} = params;
  501. return (
  502. <GlobalSelectionHeader
  503. skipLoadLastUsed={organization.features.includes('global-views')}
  504. defaultSelection={{
  505. datetime: {
  506. start: null,
  507. end: null,
  508. utc: false,
  509. period: DEFAULT_STATS_PERIOD,
  510. },
  511. }}
  512. >
  513. <StyledPageContent>
  514. <NoProjectMessage organization={organization}>
  515. <Layout.Header>
  516. <Layout.HeaderContent>
  517. <Breadcrumbs
  518. crumbs={[
  519. {
  520. label: t('Dashboards'),
  521. to: `/organizations/${organization.slug}/dashboards/`,
  522. },
  523. {
  524. label:
  525. dashboardState === DashboardState.CREATE
  526. ? t('Create Dashboard')
  527. : organization.features.includes('dashboards-edit') &&
  528. dashboard.id === 'default-overview'
  529. ? 'Default Dashboard'
  530. : this.dashboardTitle,
  531. },
  532. ]}
  533. />
  534. <Layout.Title>
  535. <DashboardTitle
  536. dashboard={modifiedDashboard ?? dashboard}
  537. onUpdate={this.setModifiedDashboard}
  538. isEditing={this.isEditing}
  539. />
  540. </Layout.Title>
  541. </Layout.HeaderContent>
  542. <Layout.HeaderActions>
  543. <Controls
  544. organization={organization}
  545. dashboards={dashboards}
  546. onEdit={this.onEdit}
  547. onCancel={this.onCancel}
  548. onCommit={this.onCommit}
  549. onAddWidget={this.onAddWidget}
  550. onDelete={this.onDelete(dashboard)}
  551. dashboardState={dashboardState}
  552. widgetLimitReached={widgetLimitReached}
  553. />
  554. </Layout.HeaderActions>
  555. </Layout.Header>
  556. <Layout.Body>
  557. <Layout.Main fullWidth>
  558. <Dashboard
  559. paramDashboardId={dashboardId}
  560. dashboard={modifiedDashboard ?? dashboard}
  561. organization={organization}
  562. isEditing={this.isEditing}
  563. widgetLimitReached={widgetLimitReached}
  564. onUpdate={this.onUpdateWidget}
  565. handleAddLibraryWidgets={this.handleAddLibraryWidgets}
  566. onSetWidgetToBeUpdated={this.onSetWidgetToBeUpdated}
  567. router={router}
  568. location={location}
  569. newWidget={newWidget}
  570. layout={layout}
  571. onLayoutChange={this.onLayoutChange}
  572. />
  573. </Layout.Main>
  574. </Layout.Body>
  575. </NoProjectMessage>
  576. </StyledPageContent>
  577. </GlobalSelectionHeader>
  578. );
  579. }
  580. render() {
  581. const {organization, dashboard} = this.props;
  582. if (this.isEditing && this.isWidgetBuilderRouter) {
  583. return this.renderWidgetBuilder(dashboard);
  584. }
  585. if (organization.features.includes('dashboards-edit')) {
  586. return this.renderDashboardDetail();
  587. }
  588. return this.renderDefaultDashboardDetail();
  589. }
  590. }
  591. const StyledPageHeader = styled('div')`
  592. display: grid;
  593. grid-template-columns: minmax(0, 1fr);
  594. grid-row-gap: ${space(2)};
  595. align-items: center;
  596. font-size: ${p => p.theme.headerFontSize};
  597. color: ${p => p.theme.textColor};
  598. margin-bottom: ${space(2)};
  599. @media (min-width: ${p => p.theme.breakpoints[1]}) {
  600. grid-template-columns: minmax(0, 1fr) max-content;
  601. grid-column-gap: ${space(2)};
  602. height: 40px;
  603. }
  604. `;
  605. const StyledPageContent = styled(PageContent)`
  606. padding: 0;
  607. `;
  608. export default withApi(withOrganization(DashboardDetail));