Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC: Safe bundles #4710

Draft
wants to merge 5 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
71 changes: 71 additions & 0 deletions apps/web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# dependencies
/node_modules
**/node_modules/*
/.pnp
.pnp.js

# testing
/coverage

# types
/src/types/contracts/

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem
.idea

# Yarn v4
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

# expo
**/.expo/*

# tamagui
**/.tamagui/*


# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# local env files

.env
.env.local
.env.local*
.env.development.local
.env.test.local
.env.production.local
.env.production

# vercel
.vercel

# typescript
*.tsbuildinfo

# yalc
.yalc
yalc.lock

certificates
*storybook.log

# os
THUMBS_DB
thumbs.db
6 changes: 1 addition & 5 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,15 +102,11 @@ const withMDX = createMDX({
extension: /\.(md|mdx)?$/,
jsx: true,
options: {
remarkPlugins: [
remarkFrontmatter,
[remarkMdxFrontmatter, { name: 'metadata' }],
remarkHeadingId, remarkGfm],
remarkPlugins: [remarkFrontmatter, [remarkMdxFrontmatter, { name: 'metadata' }], remarkHeadingId, remarkGfm],
rehypePlugins: [],
},
})


export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(withPWA(withMDX(nextConfig)))
2 changes: 2 additions & 0 deletions apps/web/src/config/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const AppRoutes = {
imprint: '/imprint',
home: '/home',
cookie: '/cookie',
bundle: '/bundle',
bridge: '/bridge',
addressBook: '/address-book',
addOwner: '/addOwner',
Expand Down Expand Up @@ -57,6 +58,7 @@ export const AppRoutes = {
},
welcome: {
index: '/welcome',
bundles: '/welcome/bundles',
accounts: '/welcome/accounts',
},
}
84 changes: 84 additions & 0 deletions apps/web/src/features/bundle/AddSafeInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import useAllSafes from '@/features/myAccounts/hooks/useAllSafes'
import { shortenAddress } from '@/utils/formatters'
import { createFilterOptions } from '@mui/material/Autocomplete'
import uniqBy from 'lodash/uniqBy'
import { useMemo } from 'react'
import { Autocomplete, TextField, Typography } from '@mui/material'
import { Controller, useFormContext } from 'react-hook-form'
import css from './styles.module.css'
import EthHashInfo from '@/components/common/EthHashInfo'

const filter = createFilterOptions<{ address: string; chainId: string }>()

const AddSafeInput = ({ name }: { name: string }) => {
const allSafes = useAllSafes()

const { control } = useFormContext()

const options = useMemo(() => {
const safes = []
const uniqueSafes = uniqBy(allSafes, 'address')
const safeOptions = uniqueSafes.map((safe) => ({ address: safe.address, chainId: safe.chainId }))

if (safeOptions) {
safes.push(...safeOptions)
}

return safes
}, [allSafes])

return (
<Controller
name={name}
control={control}
render={({ field: { ref, ...field } }) => (
<Autocomplete
{...field}
multiple
disableCloseOnSelect
className={css.autocomplete}
options={options}
onChange={(_e, newValue) => field.onChange(newValue)}
getOptionLabel={(option) => shortenAddress(option.address)}
isOptionEqualToValue={(option, value) => option.address === value.address}
filterOptions={(options, params) => {
const filtered = filter(options, params)

const { inputValue } = params
const isExisting = options.some((option) => inputValue === option.address)

if (inputValue !== '' && !isExisting) {
filtered.push({ address: inputValue, chainId: '1' })
}

return filtered
}}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
inputRef={ref}
className={css.input}
placeholder="Safe Accounts"
/>
)}
renderOption={(props, option) =>
option ? (
<Typography component="li" variant="body2" className={css.option} {...props}>
<EthHashInfo
address={option.address || ''}
shortAddress={true}
avatarSize={24}
showPrefix={false}
copyAddress={false}
/>
</Typography>
) : null
}
/>
)}
/>
)
}

export default AddSafeInput
19 changes: 19 additions & 0 deletions apps/web/src/features/bundle/CreateBundleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import CreateBundle from '@/features/bundle/components/CreateBundle'
import { useState } from 'react'
import { Button } from '@mui/material'

const CreateBundleButton = () => {
const [open, setOpen] = useState<boolean>(false)

return (
<>
<Button size="small" variant="contained" onClick={() => setOpen(true)}>
Create Bundle
</Button>

<CreateBundle open={open} setOpen={setOpen} />
</>
)
}

export default CreateBundleButton
49 changes: 49 additions & 0 deletions apps/web/src/features/bundle/bundleSlice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '@/store'

export type SafeEntry = {
address: string
chainId: string
}

export type Bundle = {
id: string
name: string
safes: SafeEntry[]
}

export type BundleState = {
[id: string]: Bundle
}

const initialState: BundleState = {}

export const bundleSlice = createSlice({
name: 'bundles',
initialState,
reducers: {
setBundles: (_, action: PayloadAction<BundleState>) => {
return action.payload
},
addBundle: (state, { payload }: PayloadAction<Bundle>) => {
const { id, name, safes } = payload

state[id] = {
id,
name,
safes,
}
},
removeBundle: (state, { payload }: PayloadAction<Bundle>) => {
const { id } = payload

delete state[id]
},
},
})

export const { addBundle, removeBundle } = bundleSlice.actions

export const selectAllBundles = (state: RootState): BundleState => {
return state[bundleSlice.name]
}
64 changes: 64 additions & 0 deletions apps/web/src/features/bundle/components/BundleItem/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import CreateBundle from '@/features/bundle/components/CreateBundle'
import EditIcon from '@/public/images/common/edit.svg'
import { useAppDispatch } from '@/store'
import type { MouseEvent } from 'react'
import React, { useState } from 'react'
import Link from 'next/link'
import Identicon from '@/components/common/Identicon'
import { type Bundle, removeBundle } from '@/features/bundle/bundleSlice'
import { createBundleLink } from '@/features/bundle/utils'
import css from '@/features/myAccounts/components/AccountItems/styles.module.css'
import DeleteIcon from '@/public/images/common/delete.svg'
import { Box, IconButton, Stack, SvgIcon, Typography } from '@mui/material'
import ListItemButton from '@mui/material/ListItemButton'

const BundleItem = ({ bundle }: { bundle: Bundle }) => {
const [open, setOpen] = useState(false)
const dispatch = useAppDispatch()
const MAX_NUM_VISIBLE_SAFES = 4
const visibleSafes = bundle.safes.slice(0, MAX_NUM_VISIBLE_SAFES)

const deleteBundle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
dispatch(removeBundle(bundle))
}

const editBundle = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
setOpen(true)
}

return (
<ListItemButton className={css.listItem} disableRipple>
<Link href={createBundleLink(bundle)} passHref>
<Stack direction="row" px={2} py={2} alignItems="center">
<Stack direction="row" flexWrap="wrap" maxWidth="52px" mr={2}>
{visibleSafes.map((safe) => {
return (
<Box key={safe.address} ml="-2px" mt="-2px" sx={{ border: '2px solid white', borderRadius: '50%' }}>
<Identicon address={safe.address} size={24} />
</Box>
)
})}
</Stack>

<Box>
<Typography fontWeight="bold">{bundle.name}</Typography>
<Typography>{bundle.safes.length} Safe Accounts</Typography>
</Box>

<IconButton onClick={editBundle} sx={{ ml: 'auto' }}>
<SvgIcon component={EditIcon} inheritViewBox fontSize="small" />
</IconButton>

<IconButton onClick={deleteBundle} title="Remove bundle">
<SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" />
</IconButton>
</Stack>
</Link>
<CreateBundle open={open} setOpen={setOpen} bundle={bundle} />
</ListItemButton>
)
}

export default BundleItem
66 changes: 66 additions & 0 deletions apps/web/src/features/bundle/components/CreateBundle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { BaseSyntheticEvent, Dispatch, SetStateAction } from 'react'
import AddSafeInput from '@/features/bundle/AddSafeInput'
import ModalDialog from '@/components/common/ModalDialog'
import NameInput from '@/components/common/NameInput'
import { addBundle, type Bundle } from '@/features/bundle/bundleSlice'
import { useAppDispatch } from '@/store'
import { Box, Button, DialogActions, DialogContent } from '@mui/material'
import { FormProvider, useForm } from 'react-hook-form'

const CreateBundle = ({
open,
setOpen,
bundle,
}: {
open: boolean
setOpen: Dispatch<SetStateAction<boolean>>
bundle?: Bundle
}) => {
const dispatch = useAppDispatch()

const methods = useForm<Bundle>({
mode: 'onChange',
defaultValues: {
id: bundle?.id,
name: bundle?.name,
safes: bundle?.safes,
},
})

const { handleSubmit, reset } = methods

const submitCallback = handleSubmit((data: Bundle) => {
const id = data.id || crypto.randomUUID()
dispatch(addBundle({ id, name: data.name, safes: data.safes }))
reset()
setOpen(false)
})

const onSubmit = (e: BaseSyntheticEvent) => {
e.stopPropagation()
submitCallback(e)
}
return (
<ModalDialog open={open} dialogTitle="Create Bundle" hideChainIndicator onClose={() => setOpen(false)}>
<FormProvider {...methods}>
<form onSubmit={onSubmit}>
<DialogContent>
<Box mb={2}>
<NameInput label="Bundle name" autoFocus name="name" required />
</Box>
<Box>
<AddSafeInput name="safes" />
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" type="submit">
Submit
</Button>
</DialogActions>
</form>
</FormProvider>
</ModalDialog>
)
}

export default CreateBundle
Loading
Loading