Skip to content
This repository was archived by the owner on Nov 10, 2023. It is now read-only.

Customizable Dashboard & Safe widgets POC #4035

Draft
wants to merge 8 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
139 changes: 139 additions & 0 deletions src/components/Dashboard/CustomizableDashboard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<StyledGridContainer>
<DashboardHeader>
<DashboardTitle>customizable Dashboard POC</DashboardTitle>

{!isEditMode && (
<Tooltip placement="top" title="Customize your dashboard!" backgroundColor="primary" textColor="white" arrow>
<StyledIcon onClick={() => setIsEditMode(true)}>
<SettingsIcon />
</StyledIcon>
</Tooltip>
)}
</DashboardHeader>
{isEditMode && (
<>
<WidgetCatalog>Widget Catalog</WidgetCatalog>
<StyledBtn size={'md'} onClick={onSaveEditDashboard}>
Save
</StyledBtn>
<StyledBtn color={'secondary'} variant="bordered" size={'md'} onClick={onCancelEditDashboard}>
Cancel
</StyledBtn>
</>
)}
<ResponsiveGridLayout
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }}
onLayoutChange={onLayoutChange}
key={isEditMode}
rowHeight={ROW_HEIGHT}
compactType={null}
layouts={{
lg: widgets.map((widget) => ({
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 (
<div key={widget.widgetId}>
<SafeWidget widget={widget} isEditWidgetEnabled={isEditMode} />
</div>
)
})}
</ResponsiveGridLayout>
</StyledGridContainer>
)
}

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;
`
205 changes: 205 additions & 0 deletions src/components/Dashboard/SafeWidget/SafeWidget.tsx
Original file line number Diff line number Diff line change
@@ -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<string, any>
}

export type SafeWidgetComponentProps = {
widget: WidgetType
// TODO: refine safeInfo type
safeInfo: Record<string, any>
data: Record<string, any>
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 (
<WidgetCard>
{isEditWidgetEnabled && (
<WidgetHeader>
<Tooltip placement="top" title="Drag & Drop your widget!" backgroundColor="primary" textColor="white" arrow>
<DragAndDropIndicatorIcon fontSize="small" />
</Tooltip>
</WidgetHeader>
)}
<WidgetBody widgetType={widgetType}>
<WidgetComponent
widget={widget}
safeInfo={safeInfo}
data={data || {}}
isLoading={isLoading}
appUrl={widgetIframeUrl}
/>
</WidgetBody>
</WidgetCard>
)
}

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;
}
`}
`
1 change: 0 additions & 1 deletion src/components/Dashboard/styled.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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] })
Expand Down
Loading