detail.tsx 22 KB

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