detail.tsx 22 KB

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