diff --git a/package.json b/package.json index 9d3291c88a..6d3ca83085 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "react-dom": "17.0.2", "react-final-form": "^6.5.3", "react-final-form-listeners": "^1.0.2", + "react-grid-layout": "^1.3.4", "react-gtm-module": "^2.0.11", "react-hook-form": "^7.29.0", "react-intersection-observer": "^8.32.0", diff --git a/src/components/Dashboard/CustomizableDashboard.tsx b/src/components/Dashboard/CustomizableDashboard.tsx new file mode 100644 index 0000000000..03ded3ed68 --- /dev/null +++ b/src/components/Dashboard/CustomizableDashboard.tsx @@ -0,0 +1,139 @@ +import { ReactElement, useState } from 'react' +import styled from 'styled-components' +import SettingsIcon from '@material-ui/icons/Settings' +import { IconButton } from '@material-ui/core' +import { Responsive, WidthProvider } from 'react-grid-layout' +import { Tooltip, Button, Card } from '@gnosis.pm/safe-react-components' + +const ResponsiveGridLayout = WidthProvider(Responsive) + +import { black500, extraLargeFontSize } from 'src/theme/variables' +import SafeWidget, { ROW_HEIGHT } from './SafeWidget/SafeWidget' +import useSafeWidgets from 'src/logic/hooks/useSafeWidgets' + +const CustomizableDashboard = (): ReactElement => { + const { widgets, updateWidgets } = useSafeWidgets() + + const [isEditMode, setIsEditMode] = useState(false) + const [editedWidgets, setEditedWidgets] = useState(widgets) + + const onSaveEditDashboard = () => { + updateWidgets(editedWidgets) + setIsEditMode(false) + } + + const onCancelEditDashboard = () => { + setEditedWidgets([...widgets]) + setIsEditMode(false) + } + + const onLayoutChange = (layouts) => { + const newWidgets = editedWidgets.map((currentWidget) => { + const layout = layouts.find(({ i }) => i === currentWidget.widgetId) + return { + ...currentWidget, + widgetLayout: { + row: layout.y, + column: layout.x, + width: layout.w, + minWidth: layout.minW, + maxWidth: layout.maxW, + minHeight: layout.minH, + height: layout.h, + maxHeight: layout.maxH, + }, + } + }) + + setEditedWidgets(newWidgets) + } + + return ( + + + customizable Dashboard POC + + {!isEditMode && ( + + setIsEditMode(true)}> + + + + )} + + {isEditMode && ( + <> + Widget Catalog + + Save + + + Cancel + + + )} + ({ + i: widget.widgetId, + x: widget.widgetLayout.column, + y: widget.widgetLayout.row, + minW: widget.widgetLayout.minWidth, + w: widget.widgetLayout.width, + maxW: widget.widgetLayout.maxWidth, + minH: widget.widgetLayout.minHeight, + h: widget.widgetLayout.height, + maxH: widget.widgetLayout.maxHeight, + })), + }} + isDraggable={isEditMode} + isResizable={isEditMode} + > + {widgets.map((widget) => { + return ( +
+ +
+ ) + })} +
+
+ ) +} + +export default CustomizableDashboard + +const StyledGridContainer = styled.div` + max-width: 1300px; + margin: 0 auto; +` + +const DashboardHeader = styled.div` + display: flex; + justify-content: space-between; + padding-right: 12px; +` + +const StyledIcon = styled(IconButton)` + height: 42px; + width: 42px; +` + +export const DashboardTitle = styled.h1` + color: ${black500}; + font-size: ${extraLargeFontSize}; +` + +const WidgetCatalog = styled(Card)` + margin: 8px; +` + +const StyledBtn = styled(Button)` + margin-left: 8px; +` diff --git a/src/components/Dashboard/SafeWidget/SafeWidget.tsx b/src/components/Dashboard/SafeWidget/SafeWidget.tsx new file mode 100644 index 0000000000..ea4fd00206 --- /dev/null +++ b/src/components/Dashboard/SafeWidget/SafeWidget.tsx @@ -0,0 +1,205 @@ +import axios from 'axios' +import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react' +import { useSelector } from 'react-redux' +import styled from 'styled-components' +import { Tooltip, Card } from '@gnosis.pm/safe-react-components' +import DragIndicatorIcon from '@material-ui/icons/DragIndicator' + +import { getChainById } from 'src/config' +import { currentChainId } from 'src/logic/config/store/selectors' +import { currentSafeLoaded, currentSafeWithNames } from 'src/logic/safe/store/selectors' +import GasPriceWidget from 'src/widgets/GasPriceWidget' +import ClaimTokenWidget from 'src/widgets/ClaimTokenWidget' +import AppFrame from 'src/routes/safe/components/Apps/components/AppFrame' +import { nftLoadedSelector, nftTokensSelector } from 'src/logic/collectibles/store/selectors' +import OverviewSafeWidget from 'src/widgets/OverviewSafeWidget' +import { black300 } from 'src/theme/variables' + +type SafeWidgetProps = { + widget: WidgetType + isEditWidgetEnabled: boolean +} + +export const ROW_HEIGHT = 10 + +type WidgetLayout = { + column: number + row: number + + minWidth?: number + width: number + maxWidth?: number + + minHeight?: number + height: number + maxHeight?: number +} + +// TODO: Create different types for iframeWidgetType & componentWidgetType +export type WidgetType = { + widgetId: string + widgetType: string + widgetLayout: WidgetLayout + widgetIframeUrl?: string + widgetEndointUrl?: string + pollingTime?: number + widgetProps?: Record +} + +export type SafeWidgetComponentProps = { + widget: WidgetType + // TODO: refine safeInfo type + safeInfo: Record + data: Record + appUrl?: string + isLoading: boolean +} + +const availableWidgets = { + overviewSafeWidget: OverviewSafeWidget, + gasPriceWidget: GasPriceWidget, + claimTokensWidget: ClaimTokenWidget, + iframe: AppFrame, +} + +const SafeWidget = ({ widget, isEditWidgetEnabled }: SafeWidgetProps): ReactElement => { + const { widgetType, widgetIframeUrl, widgetEndointUrl, pollingTime } = widget + + const [data, setData] = useState({}) + const [isLoading, setIsLoading] = useState(!!widgetEndointUrl) + + // TODO: Refine safeInfo + const { address, name, owners, threshold, balances } = useSelector(currentSafeWithNames) + const chainId = useSelector(currentChainId) + const { shortName } = getChainById(chainId) + const loaded = useSelector(currentSafeLoaded) + const nftTokens = useSelector(nftTokensSelector) + const nftLoaded = useSelector(nftLoadedSelector) + + const safeInfo = { + address, + name, + owners, + threshold, + balances, + nftTokens, + chain: { + chainId, + shortName, + }, + loaded, + nftLoaded, + } + + const callEndpoint = useCallback(async () => { + if (widgetEndointUrl) { + // --------------- REMOVE THIS FAKE CALL + + if (widgetEndointUrl === 'http://localhost:3001/api') { + setData({ + totalAmount: 1200, + claimedAmount: 30, + }) + return + } + + if (widgetEndointUrl === 'http://localhost:3002/api') { + setData({ + totalAmount: 3200, + claimedAmount: 0, + }) + return + } + + // -------------------- + + const { data } = await axios.get(widgetEndointUrl) + setData(data) + setIsLoading(false) + } + }, [widgetEndointUrl]) + + // call on mount widget + useEffect(() => { + callEndpoint() + }, [callEndpoint]) + + // polling calls + useEffect(() => { + let intervalId + if (pollingTime) { + intervalId = setInterval(() => { + callEndpoint() + }, pollingTime) + } + + return () => { + intervalId && clearInterval(intervalId) + } + }, [callEndpoint, pollingTime]) + + const WidgetComponent = useMemo(() => availableWidgets[widgetType], [widgetType]) + + return ( + + {isEditWidgetEnabled && ( + + + + + + )} + + + + + ) +} + +export default SafeWidget + +const WidgetCard = styled(Card)` + height: 100%; + margin-top: 0; + padding: 0; + position: relative; +` + +const WidgetHeader = styled.div` + position: absolute; + padding: 8px; + cursor: grab; + z-index: 1000; +` + +const DragAndDropIndicatorIcon = styled(DragIndicatorIcon)` + color: ${black300}; +` + +const WidgetBody = styled.div<{ widgetType: string }>` + height: 100%; + ${({ widgetType }) => + widgetType === 'iframe' && + ` + padding: 0; + + && > div { + margin: 0; + height: 100%; + } + + && > div > div { + border-radius: 8px; + } + + && > div > div > iframe { + border-radius: 8px; + } +`} +` diff --git a/src/components/Dashboard/styled.tsx b/src/components/Dashboard/styled.tsx index 5a608b76f3..b26fbe6f0d 100644 --- a/src/components/Dashboard/styled.tsx +++ b/src/components/Dashboard/styled.tsx @@ -7,7 +7,6 @@ import { xs, lg, black500, extraLargeFontSize, largeFontSize } from 'src/theme/v export const WidgetContainer = styled.section` display: flex; flex-direction: column; - height: 100%; ` export const DashboardTitle = styled.h1` diff --git a/src/index.tsx b/src/index.tsx index de06e550a3..92e4f4cb27 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,6 +6,9 @@ import Root from 'src/components/Root' import { SENTRY_DSN } from './utils/constants' import { disableMMAutoRefreshWarning } from './utils/mm_warnings' +import 'react-grid-layout/css/styles.css' +import 'react-resizable/css/styles.css' + disableMMAutoRefreshWarning() BigNumber.set({ EXPONENTIAL_AT: [-7, 255] }) diff --git a/src/logic/hooks/useSafeWidgets.tsx b/src/logic/hooks/useSafeWidgets.tsx new file mode 100644 index 0000000000..f395363d4d --- /dev/null +++ b/src/logic/hooks/useSafeWidgets.tsx @@ -0,0 +1,151 @@ +import { useCallback, useEffect, useState } from 'react' + +import { WidgetType } from 'src/components/Dashboard/SafeWidget/SafeWidget' +import { loadFromStorage, saveToStorage } from 'src/utils/storage' + +const overviewSafeWidget: WidgetType = { + widgetId: '0', + widgetType: 'overviewSafeWidget', + widgetLayout: { + column: 0, // x + row: 0, // y + width: 6, // w + minWidth: 6, + height: 12, // h + minHeight: 12, + }, +} + +const gasPriceWidget: WidgetType = { + widgetId: '1', + widgetType: 'gasPriceWidget', + widgetEndointUrl: + 'https://api.etherscan.io/api?module=gastracker&action=gasoracle&apikey=W7N7ISIDY1JFPYUI2D2HWVMMD3RF88QCCD', + pollingTime: 14000, + widgetLayout: { + column: 8, // x + row: 0, // y + width: 2, // w + height: 6, // h + minWidth: 2, + minHeight: 6, + }, +} + +const claimCowTokens: WidgetType = { + widgetId: '2', + widgetType: 'claimTokensWidget', + widgetEndointUrl: 'http://localhost:3001/api', + widgetLayout: { + column: 6, // x + row: 0, // y + width: 2, // w + height: 9, // h + minWidth: 2, + minHeight: 9, + }, + widgetProps: { + iconUrl: 'https://cowswap.exchange/static/media/cow_v2.00b93700.svg', + tokenName: 'CoW Protocol Token', + tokenSymbol: 'COW', + tokenAddress: '0xDEf1CA1fb7FBcDC777520aa7f396b4E015F497aB', + }, +} + +const claimSafeTokens: WidgetType = { + widgetId: '3', + widgetType: 'claimTokensWidget', + widgetEndointUrl: 'http://localhost:3002/api', + widgetLayout: { + column: 10, // x + row: 0, // y + width: 2, // w + height: 9, // h + minWidth: 2, + minHeight: 9, + }, + widgetProps: { + iconUrl: + 'https://safe-transaction-assets.gnosis-safe.io/tokens/logos/0x5aFE3855358E112B5647B952709E6165e1c1eEEe.png', + tokenName: 'Safe Token', + tokenSymbol: 'SAFE', + tokenAddress: '0x5aFE3855358E112B5647B952709E6165e1c1eEEe', + }, +} + +const cowSwapWidget: WidgetType = { + widgetId: '4', + widgetType: 'iframe', + widgetIframeUrl: 'https://cowswap.exchange/?widget=1', + widgetLayout: { + row: 12, // y + column: 0, // x + width: 4, // w + height: 43, // h + minHeight: 43, + }, +} + +const uniSwapWidget: WidgetType = { + widgetId: '5', + widgetType: 'iframe', + widgetIframeUrl: 'https://app.uniswap.org', + widgetLayout: { + row: 12, // y + column: 4, // x + width: 4, // w + height: 24, // h + minHeight: 24, + }, +} + +const rampWidget: WidgetType = { + widgetId: '6', + widgetType: 'iframe', + widgetIframeUrl: 'https://apps.gnosis-safe.io/ramp-network', + widgetLayout: { + row: 12, // y + column: 8, // x + width: 4, // w + height: 30, // h + minHeight: 30, + }, +} + +const defaultWidgets: WidgetType[] = [ + overviewSafeWidget, + gasPriceWidget, + claimCowTokens, + claimSafeTokens, + cowSwapWidget, + uniSwapWidget, + rampWidget, +] + +const SAFE_WIDGETS = 'SAFE_WIDGETS' + +type ReturnType = { + widgets: WidgetType[] + updateWidgets: (newWidgets: WidgetType[]) => void +} + +const useSafeWidgets = (): ReturnType => { + const [widgets, setWidgets] = useState(defaultWidgets) + + useEffect(() => { + const safeWidgets = loadFromStorage(SAFE_WIDGETS) || defaultWidgets + setWidgets([...safeWidgets]) + }, []) + + const updateWidgets = useCallback((newWidgets: WidgetType[]) => { + setWidgets([...newWidgets]) + saveToStorage(SAFE_WIDGETS, newWidgets) + }, []) + + return { + widgets, + updateWidgets, + } +} + +export default useSafeWidgets diff --git a/src/routes/Home/index.tsx b/src/routes/Home/index.tsx index 473aa778ff..da30a54f79 100644 --- a/src/routes/Home/index.tsx +++ b/src/routes/Home/index.tsx @@ -2,11 +2,13 @@ import { ReactElement } from 'react' import Page from 'src/components/layout/Page' import { Box } from '@material-ui/core' import Dashboard from 'src/components/Dashboard' +import CustomizableDashboard from 'src/components/Dashboard/CustomizableDashboard' const Home = (): ReactElement => { return ( + diff --git a/src/widgets/ClaimTokenWidget.tsx b/src/widgets/ClaimTokenWidget.tsx new file mode 100644 index 0000000000..b6458d27d6 --- /dev/null +++ b/src/widgets/ClaimTokenWidget.tsx @@ -0,0 +1,56 @@ +import { ReactElement } from 'react' +import styled from 'styled-components' +import { Button } from '@gnosis.pm/safe-react-components' + +import { black500, extraLargeFontSize, largeFontSize } from 'src/theme/variables' +import { SafeWidgetComponentProps } from 'src/components/Dashboard/SafeWidget/SafeWidget' + +const ClaimTokenWidget = ({ data, widget }: SafeWidgetComponentProps): ReactElement => { + const { iconUrl, tokenSymbol } = widget.widgetProps as any + const { totalAmount } = data || {} + return ( + +
+ +
+ + {totalAmount} {tokenSymbol} + +
+ +
+
+ ) +} + +export default ClaimTokenWidget + +const WidgetCard = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + + text-align: center; + color: ${black500}; + font-size: ${largeFontSize}; +` + +export const AmountLabel = styled.p` + color: ${black500}; + font-size: ${extraLargeFontSize}; + margin-top: 0; + margin-bottom: 8px; +` + +export const TokenLogo = styled.img` + height: 60px; + margin-bottom: 8px; +` diff --git a/src/widgets/GasPriceWidget.tsx b/src/widgets/GasPriceWidget.tsx new file mode 100644 index 0000000000..3c4f60b0ac --- /dev/null +++ b/src/widgets/GasPriceWidget.tsx @@ -0,0 +1,65 @@ +import { ReactElement } from 'react' +import styled from 'styled-components' +import LocalGasStationIcon from '@material-ui/icons/LocalGasStation' + +import { black400, black500, extraLargeFontSize, largeFontSize, mediumFontSize } from 'src/theme/variables' +import { SafeWidgetComponentProps } from 'src/components/Dashboard/SafeWidget/SafeWidget' + +const GasPriceWidget = ({ data, isLoading }: SafeWidgetComponentProps): ReactElement => { + const result = data?.result || {} + const { suggestBaseFee, ProposeGasPrice, SafeGasPrice, FastGasPrice } = result + + return ( + + Gas Price + {isLoading ? ( + 'Loading...' + ) : ( + <> + + {~~suggestBaseFee} Gwei + + + Low: {SafeGasPrice} | Avg: {ProposeGasPrice} | High: {FastGasPrice} + + + )} + + ) +} + +export default GasPriceWidget + +const WidgetCard = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + height: 100%; + + text-align: center; + color: ${black500}; + font-size: ${largeFontSize}; +` + +const WidgetTitle = styled.h2` + color: ${black500}; + font-size: ${largeFontSize}; + margin: 0; +` + +const GasPriceLabel = styled.div` + display: flex; + justify-content: center; + font-size: ${extraLargeFontSize}; + margin: 8px 0; +` + +const GasStationIcon = styled(LocalGasStationIcon)` + margin-right: 4px; +` + +const GasPriceInfoLabel = styled.p` + color: ${black400}; + font-size: ${mediumFontSize}; + margin: 0; +` diff --git a/src/widgets/OverviewSafeWidget.tsx b/src/widgets/OverviewSafeWidget.tsx new file mode 100644 index 0000000000..b6c13c5fd1 --- /dev/null +++ b/src/widgets/OverviewSafeWidget.tsx @@ -0,0 +1,151 @@ +import { ReactElement, useMemo } from 'react' +import { Skeleton } from '@material-ui/lab' +import styled from 'styled-components' +import { Link } from 'react-router-dom' +import { Text, Identicon } from '@gnosis.pm/safe-react-components' +import { Box, Grid } from '@material-ui/core' + +import { SafeWidgetComponentProps } from 'src/components/Dashboard/SafeWidget/SafeWidget' +import NetworkLabel from 'src/components/NetworkLabel/NetworkLabel' +import Threshold from 'src/components/AppLayout/Sidebar/Threshold' +import PrefixedEthHashInfo from 'src/components/PrefixedEthHashInfo' +import { Card, WidgetBody, WidgetContainer } from 'src/components/Dashboard/styled' +import Button from 'src/components/layout/Button' +import { md, lg } from 'src/theme/variables' +import { generateSafeRoute, SAFE_ROUTES } from 'src/routes/routes' + +const OverviewSafeWidget = ({ safeInfo }: SafeWidgetComponentProps): ReactElement => { + const { loaded, name, address, chain, owners, threshold, balances, nftTokens, nftLoaded } = safeInfo + const { shortName } = chain + + const assetsLink = generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES, { safeAddress: address, shortName }) + const nftsLink = generateSafeRoute(SAFE_ROUTES.ASSETS_BALANCES_COLLECTIBLES, { safeAddress: address, shortName }) + + // Native token is always returned even when its balance is 0 + const tokenCount = useMemo(() => balances.filter((token) => token.tokenBalance !== '0').length, [balances]) + + return ( + + + {!loaded ? ( + SkeletonOverview + ) : ( + + + + + + + + + + {name} + + + + + + + + + + + + + + Tokens + + {tokenCount} + + + + + + + NFTs + + {nftTokens && {nftLoaded ? nftTokens.length : ValueSkeleton}} + + + + + + + + + + + + + )} + + + ) +} + +export default OverviewSafeWidget + +const ValueSkeleton = + +const IdenticonContainer = styled.div` + position: relative; + margin-bottom: ${md}; +` + +const StyledText = styled(Text)` + margin-top: 8px; + font-size: 24px; + font-weight: bold; +` + +const StyledLink = styled(Link)` + text-decoration: none; +` + +const NetworkLabelContainer = styled.div` + position: absolute; + top: ${lg}; + right: ${lg}; + + & span { + bottom: auto; + } +` + +const SkeletonOverview = ( + + + + + + + + + + + + + + + + + + + + + + Tokens + + {ValueSkeleton} + + + + NFTs + + {ValueSkeleton} + + + +) diff --git a/yarn.lock b/yarn.lock index 7d2eb912e2..89ee50b311 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5842,6 +5842,11 @@ clsx@^1.0.4, clsx@^1.1.0: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +clsx@^1.1.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -11839,6 +11844,11 @@ lodash.defaults@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= +lodash.isequal@^4.0.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" + integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ== + lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -14372,6 +14382,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" +prop-types@15.x, prop-types@^15.8.1: + version "15.8.1" + resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" + integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== + dependencies: + loose-envify "^1.4.0" + object-assign "^4.1.1" + react-is "^16.13.1" + prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" @@ -14753,6 +14772,14 @@ react-dom@17.0.2, react-dom@^17.0.1: object-assign "^4.1.1" scheduler "^0.20.2" +react-draggable@^4.0.0, react-draggable@^4.0.3: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.5.tgz#9e37fe7ce1a4cf843030f521a0a4cc41886d7e7c" + integrity sha512-OMHzJdyJbYTZo4uQE393fHcqqPYsEtkjfMgvCHr6rejT+Ezn4OZbNyGH50vv+SunC1RMvwOTSWkEODQLzw1M9g== + dependencies: + clsx "^1.1.1" + prop-types "^15.8.1" + react-error-boundary@^3.1.0: version "3.1.3" resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.3.tgz#276bfa05de8ac17b863587c9e0647522c25e2a0b" @@ -14784,6 +14811,17 @@ react-final-form@^6.5.3: dependencies: "@babel/runtime" "^7.15.4" +react-grid-layout@^1.3.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/react-grid-layout/-/react-grid-layout-1.3.4.tgz#4fa819be24a1ba9268aa11b82d63afc4762a32ff" + integrity sha512-sB3rNhorW77HUdOjB4JkelZTdJGQKuXLl3gNg+BI8gJkTScspL1myfZzW/EM0dLEn+1eH+xW+wNqk0oIM9o7cw== + dependencies: + clsx "^1.1.1" + lodash.isequal "^4.0.0" + prop-types "^15.8.1" + react-draggable "^4.0.0" + react-resizable "^3.0.4" + react-gtm-module@^2.0.11: version "2.0.11" resolved "https://registry.yarnpkg.com/react-gtm-module/-/react-gtm-module-2.0.11.tgz#14484dac8257acd93614e347c32da9c5ac524206" @@ -14868,6 +14906,14 @@ react-refresh@^0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-resizable@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.0.4.tgz#aa20108eff28c52c6fddaa49abfbef8abf5e581b" + integrity sha512-StnwmiESiamNzdRHbSSvA65b0ZQJ7eVQpPusrSmcpyGKzC0gojhtO62xxH6YOBmepk9dQTBi9yxidL3W4s3EBA== + dependencies: + prop-types "15.x" + react-draggable "^4.0.3" + react-router-dom@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662"