dashboard.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683
  1. import 'react-grid-layout/css/styles.css';
  2. import 'react-resizable/css/styles.css';
  3. import {Component} from 'react';
  4. import {Layouts, Responsive, WidthProvider} from 'react-grid-layout';
  5. import {forceCheck} from 'react-lazyload';
  6. import {InjectedRouter} from 'react-router';
  7. import {closestCenter, DndContext} from '@dnd-kit/core';
  8. import {arrayMove, rectSortingStrategy, SortableContext} from '@dnd-kit/sortable';
  9. import styled from '@emotion/styled';
  10. import {Location} from 'history';
  11. import cloneDeep from 'lodash/cloneDeep';
  12. import debounce from 'lodash/debounce';
  13. import isEqual from 'lodash/isEqual';
  14. import {validateWidget} from 'sentry/actionCreators/dashboards';
  15. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  16. import {fetchOrgMembers} from 'sentry/actionCreators/members';
  17. import {openAddDashboardWidgetModal} from 'sentry/actionCreators/modal';
  18. import {loadOrganizationTags} from 'sentry/actionCreators/tags';
  19. import {Client} from 'sentry/api';
  20. import Button from 'sentry/components/button';
  21. import {IconResize} from 'sentry/icons';
  22. import {t} from 'sentry/locale';
  23. import space from 'sentry/styles/space';
  24. import {Organization, PageFilters} from 'sentry/types';
  25. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  26. import theme from 'sentry/utils/theme';
  27. import withApi from 'sentry/utils/withApi';
  28. import withPageFilters from 'sentry/utils/withPageFilters';
  29. import AddWidget, {ADD_WIDGET_BUTTON_DRAG_ID} from './addWidget';
  30. import {
  31. assignDefaultLayout,
  32. assignTempId,
  33. calculateColumnDepths,
  34. constructGridItemKey,
  35. DEFAULT_WIDGET_WIDTH,
  36. enforceWidgetHeightValues,
  37. generateWidgetId,
  38. generateWidgetsAfterCompaction,
  39. getDashboardLayout,
  40. getDefaultWidgetHeight,
  41. getMobileLayout,
  42. getNextAvailablePosition,
  43. pickDefinedStoreKeys,
  44. Position,
  45. } from './layoutUtils';
  46. import SortableWidget from './sortableWidget';
  47. import {DashboardDetails, DashboardWidgetSource, Widget, WidgetType} from './types';
  48. export const DRAG_HANDLE_CLASS = 'widget-drag';
  49. const DRAG_RESIZE_CLASS = 'widget-resize';
  50. const DESKTOP = 'desktop';
  51. const MOBILE = 'mobile';
  52. export const NUM_DESKTOP_COLS = 6;
  53. const NUM_MOBILE_COLS = 2;
  54. const ROW_HEIGHT = 120;
  55. const WIDGET_MARGINS: [number, number] = [16, 16];
  56. const BOTTOM_MOBILE_VIEW_POSITION = {
  57. x: 0,
  58. y: Number.MAX_SAFE_INTEGER,
  59. };
  60. const MOBILE_BREAKPOINT = parseInt(theme.breakpoints.small, 10);
  61. const BREAKPOINTS = {[MOBILE]: 0, [DESKTOP]: MOBILE_BREAKPOINT};
  62. const COLUMNS = {[MOBILE]: NUM_MOBILE_COLS, [DESKTOP]: NUM_DESKTOP_COLS};
  63. type Props = {
  64. api: Client;
  65. dashboard: DashboardDetails;
  66. handleAddCustomWidget: (widget: Widget) => void;
  67. handleUpdateWidgetList: (widgets: Widget[]) => void;
  68. isEditing: boolean;
  69. location: Location;
  70. /**
  71. * Fired when widgets are added/removed/sorted.
  72. */
  73. onUpdate: (widgets: Widget[]) => void;
  74. organization: Organization;
  75. router: InjectedRouter;
  76. selection: PageFilters;
  77. widgetLimitReached: boolean;
  78. isPreview?: boolean;
  79. newWidget?: Widget;
  80. onSetNewWidget?: () => void;
  81. paramDashboardId?: string;
  82. paramTemplateId?: string;
  83. };
  84. type State = {
  85. isMobile: boolean;
  86. layouts: Layouts;
  87. windowWidth: number;
  88. };
  89. class Dashboard extends Component<Props, State> {
  90. constructor(props: Props) {
  91. super(props);
  92. const {dashboard, organization} = props;
  93. const isUsingGrid = organization.features.includes('dashboard-grid-layout');
  94. const desktopLayout = getDashboardLayout(dashboard.widgets);
  95. this.state = {
  96. isMobile: false,
  97. layouts: {
  98. [DESKTOP]: isUsingGrid ? desktopLayout : [],
  99. [MOBILE]: isUsingGrid ? getMobileLayout(desktopLayout, dashboard.widgets) : [],
  100. },
  101. windowWidth: window.innerWidth,
  102. };
  103. }
  104. static getDerivedStateFromProps(props, state) {
  105. if (props.organization.features.includes('dashboard-grid-layout')) {
  106. if (state.isMobile) {
  107. // Don't need to recalculate any layout state from props in the mobile view
  108. // because we want to force different positions (i.e. new widgets added
  109. // at the bottom)
  110. return null;
  111. }
  112. // If the user clicks "Cancel" and the dashboard resets,
  113. // recalculate the layout to revert to the unmodified state
  114. const dashboardLayout = getDashboardLayout(props.dashboard.widgets);
  115. if (
  116. !isEqual(
  117. dashboardLayout.map(pickDefinedStoreKeys),
  118. state.layouts[DESKTOP].map(pickDefinedStoreKeys)
  119. )
  120. ) {
  121. return {
  122. ...state,
  123. layouts: {
  124. [DESKTOP]: dashboardLayout,
  125. [MOBILE]: getMobileLayout(dashboardLayout, props.dashboard.widgets),
  126. },
  127. };
  128. }
  129. }
  130. return null;
  131. }
  132. componentDidMount() {
  133. const {organization, newWidget} = this.props;
  134. if (organization.features.includes('dashboard-grid-layout')) {
  135. window.addEventListener('resize', this.debouncedHandleResize);
  136. }
  137. // Always load organization tags on dashboards
  138. this.fetchTags();
  139. if (newWidget) {
  140. this.addNewWidget();
  141. }
  142. // Get member list data for issue widgets
  143. this.fetchMemberList();
  144. }
  145. componentDidUpdate(prevProps: Props) {
  146. const {selection, newWidget} = this.props;
  147. if (newWidget && newWidget !== prevProps.newWidget) {
  148. this.addNewWidget();
  149. }
  150. if (!isEqual(prevProps.selection.projects, selection.projects)) {
  151. this.fetchMemberList();
  152. }
  153. }
  154. componentWillUnmount() {
  155. if (this.props.organization.features.includes('dashboard-grid-layout')) {
  156. window.removeEventListener('resize', this.debouncedHandleResize);
  157. }
  158. window.clearTimeout(this.forceCheckTimeout);
  159. }
  160. forceCheckTimeout: number | undefined = undefined;
  161. debouncedHandleResize = debounce(() => {
  162. this.setState({
  163. windowWidth: window.innerWidth,
  164. });
  165. }, 250);
  166. fetchMemberList() {
  167. const {api, selection} = this.props;
  168. // Stores MemberList in MemberListStore for use in modals and sets state for use is child components
  169. fetchOrgMembers(
  170. api,
  171. this.props.organization.slug,
  172. selection.projects?.map(projectId => String(projectId))
  173. );
  174. }
  175. async addNewWidget() {
  176. const {api, organization, newWidget, handleAddCustomWidget, onSetNewWidget} =
  177. this.props;
  178. if (newWidget) {
  179. try {
  180. await validateWidget(api, organization.slug, newWidget);
  181. handleAddCustomWidget(newWidget);
  182. onSetNewWidget?.();
  183. } catch (error) {
  184. // Don't do anything, widget isn't valid
  185. addErrorMessage(error);
  186. }
  187. }
  188. }
  189. fetchTags() {
  190. const {api, organization, selection} = this.props;
  191. loadOrganizationTags(api, organization.slug, selection);
  192. }
  193. handleStartAdd = () => {
  194. const {
  195. organization,
  196. dashboard,
  197. selection,
  198. handleUpdateWidgetList,
  199. handleAddCustomWidget,
  200. router,
  201. location,
  202. paramDashboardId,
  203. } = this.props;
  204. if (organization.features.includes('new-widget-builder-experience-design')) {
  205. if (paramDashboardId) {
  206. router.push({
  207. pathname: `/organizations/${organization.slug}/dashboard/${paramDashboardId}/widget/new/`,
  208. query: {
  209. ...location.query,
  210. source: DashboardWidgetSource.DASHBOARDS,
  211. },
  212. });
  213. return;
  214. }
  215. router.push({
  216. pathname: `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  217. query: {
  218. ...location.query,
  219. source: DashboardWidgetSource.DASHBOARDS,
  220. },
  221. });
  222. return;
  223. }
  224. trackAdvancedAnalyticsEvent('dashboards_views.add_widget_modal.opened', {
  225. organization,
  226. });
  227. trackAdvancedAnalyticsEvent('dashboards_views.widget_library.opened', {
  228. organization,
  229. });
  230. openAddDashboardWidgetModal({
  231. organization,
  232. dashboard,
  233. selection,
  234. onAddWidget: handleAddCustomWidget,
  235. onAddLibraryWidget: (widgets: Widget[]) => handleUpdateWidgetList(widgets),
  236. source: DashboardWidgetSource.LIBRARY,
  237. });
  238. };
  239. handleUpdateComplete = (prevWidget: Widget) => (nextWidget: Widget) => {
  240. const {isEditing, onUpdate, handleUpdateWidgetList} = this.props;
  241. let nextList = [...this.props.dashboard.widgets];
  242. const updateIndex = nextList.indexOf(prevWidget);
  243. const nextWidgetData = {
  244. ...nextWidget,
  245. tempId: prevWidget.tempId,
  246. };
  247. // Only modify and re-compact if the default height has changed
  248. if (
  249. getDefaultWidgetHeight(prevWidget.displayType) !==
  250. getDefaultWidgetHeight(nextWidget.displayType)
  251. ) {
  252. nextList[updateIndex] = enforceWidgetHeightValues(nextWidgetData);
  253. nextList = generateWidgetsAfterCompaction(nextList);
  254. } else {
  255. nextList[updateIndex] = nextWidgetData;
  256. }
  257. onUpdate(nextList);
  258. if (!!!isEditing) {
  259. handleUpdateWidgetList(nextList);
  260. }
  261. };
  262. handleDeleteWidget = (widgetToDelete: Widget) => () => {
  263. const {dashboard, onUpdate, isEditing, handleUpdateWidgetList} = this.props;
  264. let nextList = dashboard.widgets.filter(widget => widget !== widgetToDelete);
  265. nextList = generateWidgetsAfterCompaction(nextList);
  266. onUpdate(nextList);
  267. if (!!!isEditing) {
  268. handleUpdateWidgetList(nextList);
  269. }
  270. };
  271. handleDuplicateWidget = (widget: Widget, index: number) => () => {
  272. const {dashboard, onUpdate, isEditing, handleUpdateWidgetList} = this.props;
  273. const widgetCopy = cloneDeep(
  274. assignTempId({...widget, id: undefined, tempId: undefined})
  275. );
  276. let nextList = [...dashboard.widgets];
  277. nextList.splice(index, 0, widgetCopy);
  278. nextList = generateWidgetsAfterCompaction(nextList);
  279. onUpdate(nextList);
  280. if (!!!isEditing) {
  281. handleUpdateWidgetList(nextList);
  282. }
  283. };
  284. handleEditWidget = (widget: Widget, index: number) => () => {
  285. const {
  286. organization,
  287. dashboard,
  288. selection,
  289. router,
  290. location,
  291. paramDashboardId,
  292. handleAddCustomWidget,
  293. isEditing,
  294. } = this.props;
  295. if (
  296. organization.features.includes('new-widget-builder-experience-design') &&
  297. (!organization.features.includes('new-widget-builder-experience-modal-access') ||
  298. isEditing)
  299. ) {
  300. if (paramDashboardId) {
  301. router.push({
  302. pathname: `/organizations/${organization.slug}/dashboard/${paramDashboardId}/widget/${index}/edit/`,
  303. query: {
  304. ...location.query,
  305. source: DashboardWidgetSource.DASHBOARDS,
  306. },
  307. });
  308. return;
  309. }
  310. router.push({
  311. pathname: `/organizations/${organization.slug}/dashboards/new/widget/${index}/edit/`,
  312. query: {
  313. ...location.query,
  314. source: DashboardWidgetSource.DASHBOARDS,
  315. },
  316. });
  317. return;
  318. }
  319. trackAdvancedAnalyticsEvent('dashboards_views.edit_widget_modal.opened', {
  320. organization,
  321. });
  322. const modalProps = {
  323. organization,
  324. widget,
  325. selection,
  326. onAddWidget: handleAddCustomWidget,
  327. onUpdateWidget: this.handleUpdateComplete(widget),
  328. };
  329. openAddDashboardWidgetModal({
  330. ...modalProps,
  331. dashboard,
  332. source: DashboardWidgetSource.DASHBOARDS,
  333. });
  334. };
  335. getWidgetIds() {
  336. return [
  337. ...this.props.dashboard.widgets.map((widget, index): string => {
  338. return generateWidgetId(widget, index);
  339. }),
  340. ADD_WIDGET_BUTTON_DRAG_ID,
  341. ];
  342. }
  343. renderWidget(widget: Widget, index: number) {
  344. const {isMobile, windowWidth} = this.state;
  345. const {isEditing, organization, widgetLimitReached, isPreview} = this.props;
  346. const widgetProps = {
  347. widget,
  348. isEditing,
  349. widgetLimitReached,
  350. onDelete: this.handleDeleteWidget(widget),
  351. onEdit: this.handleEditWidget(widget, index),
  352. onDuplicate: this.handleDuplicateWidget(widget, index),
  353. isPreview,
  354. };
  355. if (organization.features.includes('dashboard-grid-layout')) {
  356. const key = constructGridItemKey(widget);
  357. const dragId = key;
  358. return (
  359. <div key={key} data-grid={widget.layout}>
  360. <SortableWidget
  361. {...widgetProps}
  362. dragId={dragId}
  363. isMobile={isMobile}
  364. windowWidth={windowWidth}
  365. index={String(index)}
  366. />
  367. </div>
  368. );
  369. }
  370. const key = generateWidgetId(widget, index);
  371. const dragId = key;
  372. return (
  373. <SortableWidget {...widgetProps} key={key} dragId={dragId} index={String(index)} />
  374. );
  375. }
  376. handleLayoutChange = (_, allLayouts: Layouts) => {
  377. const {isMobile} = this.state;
  378. const {dashboard, onUpdate} = this.props;
  379. const isNotAddButton = ({i}) => i !== ADD_WIDGET_BUTTON_DRAG_ID;
  380. const newLayouts = {
  381. [DESKTOP]: allLayouts[DESKTOP].filter(isNotAddButton),
  382. [MOBILE]: allLayouts[MOBILE].filter(isNotAddButton),
  383. };
  384. // Generate a new list of widgets where the layouts are associated
  385. let columnDepths = calculateColumnDepths(newLayouts[DESKTOP]);
  386. const newWidgets = dashboard.widgets.map(widget => {
  387. const gridKey = constructGridItemKey(widget);
  388. let matchingLayout = newLayouts[DESKTOP].find(({i}) => i === gridKey);
  389. if (!matchingLayout) {
  390. const height = getDefaultWidgetHeight(widget.displayType);
  391. const defaultWidgetParams = {
  392. w: DEFAULT_WIDGET_WIDTH,
  393. h: height,
  394. minH: height,
  395. i: gridKey,
  396. };
  397. // Calculate the available position
  398. const [nextPosition, nextColumnDepths] = getNextAvailablePosition(
  399. columnDepths,
  400. height
  401. );
  402. columnDepths = nextColumnDepths;
  403. // Set the position for the desktop layout
  404. matchingLayout = {
  405. ...defaultWidgetParams,
  406. ...nextPosition,
  407. };
  408. if (isMobile) {
  409. // This is a new widget and it's on the mobile page so we keep it at the bottom
  410. const mobileLayout = newLayouts[MOBILE].filter(({i}) => i !== gridKey);
  411. mobileLayout.push({
  412. ...defaultWidgetParams,
  413. ...BOTTOM_MOBILE_VIEW_POSITION,
  414. });
  415. newLayouts[MOBILE] = mobileLayout;
  416. }
  417. }
  418. return {
  419. ...widget,
  420. layout: pickDefinedStoreKeys(matchingLayout),
  421. };
  422. });
  423. this.setState({
  424. layouts: newLayouts,
  425. });
  426. onUpdate(newWidgets);
  427. // Force check lazyLoad elements that might have shifted into view after (re)moving an upper widget
  428. // Unfortunately need to use window.setTimeout since React Grid Layout animates widgets into view when layout changes
  429. // RGL doesn't provide a handler for post animation layout change
  430. window.clearTimeout(this.forceCheckTimeout);
  431. this.forceCheckTimeout = window.setTimeout(forceCheck, 400);
  432. };
  433. handleBreakpointChange = (newBreakpoint: string) => {
  434. const {layouts} = this.state;
  435. const {
  436. dashboard: {widgets},
  437. } = this.props;
  438. if (newBreakpoint === MOBILE) {
  439. this.setState({
  440. isMobile: true,
  441. layouts: {
  442. ...layouts,
  443. [MOBILE]: getMobileLayout(layouts[DESKTOP], widgets),
  444. },
  445. });
  446. return;
  447. }
  448. this.setState({isMobile: false});
  449. };
  450. get addWidgetLayout() {
  451. const {isMobile, layouts} = this.state;
  452. let position: Position = BOTTOM_MOBILE_VIEW_POSITION;
  453. if (!isMobile) {
  454. const columnDepths = calculateColumnDepths(layouts[DESKTOP]);
  455. const [nextPosition] = getNextAvailablePosition(columnDepths, 1);
  456. position = nextPosition;
  457. }
  458. return {
  459. ...position,
  460. w: DEFAULT_WIDGET_WIDTH,
  461. h: 1,
  462. isResizable: false,
  463. };
  464. }
  465. renderGridDashboard() {
  466. const {layouts, isMobile} = this.state;
  467. const {isEditing, dashboard, organization, widgetLimitReached} = this.props;
  468. let {widgets} = dashboard;
  469. // Filter out any issue/release widgets if the user does not have the feature flag
  470. widgets = widgets.filter(({widgetType}) => {
  471. if (widgetType === WidgetType.RELEASE) {
  472. return organization.features.includes('dashboards-releases');
  473. }
  474. return true;
  475. });
  476. const columnDepths = calculateColumnDepths(layouts[DESKTOP]);
  477. const widgetsWithLayout = assignDefaultLayout(widgets, columnDepths);
  478. const canModifyLayout = !isMobile && isEditing;
  479. return (
  480. <GridLayout
  481. breakpoints={BREAKPOINTS}
  482. cols={COLUMNS}
  483. rowHeight={ROW_HEIGHT}
  484. margin={WIDGET_MARGINS}
  485. draggableHandle={`.${DRAG_HANDLE_CLASS}`}
  486. draggableCancel={`.${DRAG_RESIZE_CLASS}`}
  487. layouts={layouts}
  488. onLayoutChange={this.handleLayoutChange}
  489. onBreakpointChange={this.handleBreakpointChange}
  490. isDraggable={canModifyLayout}
  491. isResizable={canModifyLayout}
  492. resizeHandle={
  493. <ResizeHandle
  494. aria-label={t('Resize Widget')}
  495. data-test-id="custom-resize-handle"
  496. className={DRAG_RESIZE_CLASS}
  497. size="xs"
  498. borderless
  499. icon={<IconResize size="xs" />}
  500. />
  501. }
  502. useCSSTransforms={false}
  503. isBounded
  504. >
  505. {widgetsWithLayout.map((widget, index) => this.renderWidget(widget, index))}
  506. {isEditing && !!!widgetLimitReached && (
  507. <AddWidgetWrapper
  508. key={ADD_WIDGET_BUTTON_DRAG_ID}
  509. data-grid={this.addWidgetLayout}
  510. >
  511. <AddWidget onAddWidget={this.handleStartAdd} />
  512. </AddWidgetWrapper>
  513. )}
  514. </GridLayout>
  515. );
  516. }
  517. renderDndDashboard = () => {
  518. const {isEditing, onUpdate, dashboard, organization, widgetLimitReached} = this.props;
  519. let {widgets} = dashboard;
  520. // Filter out any issue/release widgets if the user does not have the feature flag
  521. widgets = widgets.filter(({widgetType}) => {
  522. if (widgetType === WidgetType.RELEASE) {
  523. return organization.features.includes('dashboards-releases');
  524. }
  525. return true;
  526. });
  527. const items = this.getWidgetIds();
  528. return (
  529. <DndContext
  530. collisionDetection={closestCenter}
  531. onDragEnd={({over, active}) => {
  532. const activeDragId = active.id;
  533. const getIndex = items.indexOf.bind(items);
  534. const activeIndex = activeDragId ? getIndex(activeDragId) : -1;
  535. if (over && over.id !== ADD_WIDGET_BUTTON_DRAG_ID) {
  536. const overIndex = getIndex(over.id);
  537. if (activeIndex !== overIndex) {
  538. onUpdate(arrayMove(widgets, activeIndex, overIndex));
  539. }
  540. }
  541. }}
  542. >
  543. <WidgetContainer>
  544. <SortableContext items={items} strategy={rectSortingStrategy}>
  545. {widgets.map((widget, index) => this.renderWidget(widget, index))}
  546. {isEditing && !!!widgetLimitReached && (
  547. <AddWidget onAddWidget={this.handleStartAdd} />
  548. )}
  549. </SortableContext>
  550. </WidgetContainer>
  551. </DndContext>
  552. );
  553. };
  554. render() {
  555. const {organization} = this.props;
  556. if (organization.features.includes('dashboard-grid-layout')) {
  557. return this.renderGridDashboard();
  558. }
  559. return this.renderDndDashboard();
  560. }
  561. }
  562. export default withApi(withPageFilters(Dashboard));
  563. const WidgetContainer = styled('div')`
  564. display: grid;
  565. grid-template-columns: repeat(2, minmax(0, 1fr));
  566. grid-auto-flow: row dense;
  567. gap: ${space(2)};
  568. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  569. grid-template-columns: repeat(4, minmax(0, 1fr));
  570. }
  571. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  572. grid-template-columns: repeat(6, minmax(0, 1fr));
  573. }
  574. @media (min-width: ${p => p.theme.breakpoints.xxlarge}) {
  575. grid-template-columns: repeat(8, minmax(0, 1fr));
  576. }
  577. `;
  578. // A widget being dragged has a z-index of 3
  579. // Allow the Add Widget tile to show above widgets when moved
  580. const AddWidgetWrapper = styled('div')`
  581. z-index: 5;
  582. background-color: ${p => p.theme.background};
  583. `;
  584. const GridLayout = styled(WidthProvider(Responsive))`
  585. margin: -${space(2)};
  586. .react-grid-item.react-grid-placeholder {
  587. background: ${p => p.theme.purple200};
  588. border-radius: ${p => p.theme.borderRadius};
  589. }
  590. `;
  591. const ResizeHandle = styled(Button)`
  592. position: absolute;
  593. z-index: 2;
  594. bottom: ${space(0.5)};
  595. right: ${space(0.5)};
  596. color: ${p => p.theme.subText};
  597. cursor: nwse-resize;
  598. .react-resizable-hide & {
  599. display: none;
  600. }
  601. `;