detail.tsx 22 KB

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