diff --git a/.storybook/main.js b/.storybook/main.js new file mode 100644 index 0000000000..195fee2608 --- /dev/null +++ b/.storybook/main.js @@ -0,0 +1,8 @@ +module.exports = { + stories: ['../src/**/*.stories.tsx'], + addons: [ + '@storybook/preset-create-react-app', + '@storybook/addon-actions', + '@storybook/addon-links', + ], +}; diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html new file mode 100644 index 0000000000..a34b447790 --- /dev/null +++ b/.storybook/preview-body.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js new file mode 100644 index 0000000000..58ae9a1a54 --- /dev/null +++ b/.storybook/preview.js @@ -0,0 +1,26 @@ +import React from 'react' +import { MemoryRouter } from 'react-router-dom' +import { addDecorator } from '@storybook/react' +import { ThemeProvider, createGlobalStyle } from 'styled-components' +import { theme } from '@gnosis.pm/safe-react-components' + +import averta from 'src/assets/fonts/Averta-normal.woff2' +import avertaBold from 'src/assets/fonts/Averta-ExtraBold.woff2' + +const GlobalStyles = createGlobalStyle` + @font-face { + font-family: 'Averta'; + src: local('Averta'), local('Averta Bold'), + url(${averta}) format('woff2'), + url(${avertaBold}) format('woff'); + } +` + +addDecorator((storyFn) => ( + + + + {storyFn()} + + +)) diff --git a/package.json b/package.json index 95bd8a21af..bcb633ca9e 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "start": "react-app-rewired start", "test": "NODE_ENV=test && react-app-rewired test --env=jsdom", "test:coverage": "yarn test --coverage --watchAll=false", - "coveralls": "cat ./coverage/lcov.info | coveralls" + "coveralls": "cat ./coverage/lcov.info | coveralls", + "storybook": "start-storybook -p 9009 -s public", + "build-storybook": "build-storybook -s public" }, "husky": { "hooks": { @@ -164,7 +166,7 @@ "dependencies": { "@gnosis.pm/safe-apps-sdk": "https://github.com/gnosis/safe-apps-sdk.git#development", "@gnosis.pm/safe-contracts": "1.1.1-dev.2", - "@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#fd4498f", + "@gnosis.pm/safe-react-components": "https://github.com/gnosis/safe-react-components.git#development", "@gnosis.pm/util-contracts": "2.0.6", "@ledgerhq/hw-transport-node-hid": "5.19.1", "@material-ui/core": "4.11.0", @@ -229,6 +231,11 @@ "web3-utils": "^1.2.11" }, "devDependencies": { + "@storybook/addon-actions": "^5.3.19", + "@storybook/addon-links": "^5.3.19", + "@storybook/addons": "^5.3.19", + "@storybook/preset-create-react-app": "^3.1.4", + "@storybook/react": "^5.3.19", "@testing-library/jest-dom": "5.11.2", "@testing-library/react": "10.4.8", "@testing-library/user-event": "12.1.0", @@ -237,7 +244,7 @@ "@types/jest": "^26.0.9", "@types/lodash.memoize": "^4.1.6", "@types/node": "14.6.0", - "@types/react": "^16.9.44", + "@types/react": "^16.9.47", "@types/react-dom": "^16.9.6", "@types/react-redux": "^7.1.9", "@types/react-router-dom": "^5.1.5", @@ -264,6 +271,7 @@ "node-sass": "^4.14.1", "prettier": "2.0.5", "react-app-rewired": "^2.1.6", + "react-docgen-typescript-loader": "^3.7.2", "truffle": "5.1.36", "typechain": "^2.0.0", "typescript": "3.9.7", diff --git a/src/routes/safe/components/Balances/Receive/index.tsx b/src/components/App/ModalReceive.tsx similarity index 100% rename from src/routes/safe/components/Balances/Receive/index.tsx rename to src/components/App/ModalReceive.tsx diff --git a/src/components/layout/PageFrame/assets/alert.svg b/src/components/App/assets/alert.svg similarity index 100% rename from src/components/layout/PageFrame/assets/alert.svg rename to src/components/App/assets/alert.svg diff --git a/src/components/layout/PageFrame/assets/check.svg b/src/components/App/assets/check.svg similarity index 100% rename from src/components/layout/PageFrame/assets/check.svg rename to src/components/App/assets/check.svg diff --git a/src/components/layout/PageFrame/assets/error.svg b/src/components/App/assets/error.svg similarity index 100% rename from src/components/layout/PageFrame/assets/error.svg rename to src/components/App/assets/error.svg diff --git a/src/components/layout/PageFrame/assets/info.svg b/src/components/App/assets/info.svg similarity index 100% rename from src/components/layout/PageFrame/assets/info.svg rename to src/components/App/assets/info.svg diff --git a/src/components/App/index.tsx b/src/components/App/index.tsx new file mode 100644 index 0000000000..7d889c67e1 --- /dev/null +++ b/src/components/App/index.tsx @@ -0,0 +1,157 @@ +import React, { useContext, useEffect } from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { SnackbarProvider } from 'notistack' +import { useSelector } from 'react-redux' +import { useRouteMatch, useHistory } from 'react-router-dom' +import styled from 'styled-components' + +import AlertIcon from './assets/alert.svg' +import CheckIcon from './assets/check.svg' +import ErrorIcon from './assets/error.svg' +import InfoIcon from './assets/info.svg' + +import AppLayout from 'src/components/AppLayout' +import SafeListSidebarProvider, { SafeListSidebarContext } from 'src/components/SafeListSidebar' +import CookiesBanner from 'src/components/CookiesBanner' +import Notifier from 'src/components/Notifier' +import Backdrop from 'src/components/layout/Backdrop' +import Img from 'src/components/layout/Img' +import { getNetwork } from 'src/config' +import { ETHEREUM_NETWORK } from 'src/logic/wallets/getWeb3' +import { networkSelector } from 'src/logic/wallets/store/selectors' +import { SAFELIST_ADDRESS, WELCOME_ADDRESS } from 'src/routes/routes' +import { safeNameSelector, safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' +import Modal from 'src/components/Modal' +import SendModal from 'src/routes/safe/components/Balances/SendModal' +import { useLoadSafe } from 'src/logic/safe/hooks/useLoadSafe' +import { useSafeScheduledUpdates } from 'src/logic/safe/hooks/useSafeScheduledUpdates' +import useSafeActions from 'src/logic/safe/hooks/useSafeActions' +import { currentCurrencySelector, safeFiatBalancesTotalSelector } from 'src/logic/currencyValues/store/selectors/index' +import { formatAmountInUsFormat } from 'src/logic/tokens/utils/formatAmount' +import { grantedSelector } from 'src/routes/safe/container/selector' + +import Receive from './ModalReceive' +import { useSidebarItems } from 'src/components/AppLayout/Sidebar/useSidebarItems' + +const notificationStyles = { + success: { + background: '#fff', + }, + error: { + background: '#ffe6ea', + }, + warning: { + background: '#fff3e2', + }, + info: { + background: '#fff', + }, +} + +const Frame = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 auto; + max-width: 100%; +` + +const desiredNetwork = getNetwork() + +const useStyles = makeStyles(notificationStyles) + +const App: React.FC = ({ children }) => { + const classes = useStyles() + const currentNetwork = useSelector(networkSelector) + const isWrongNetwork = currentNetwork !== ETHEREUM_NETWORK.UNKNOWN && currentNetwork !== desiredNetwork + const { toggleSidebar } = useContext(SafeListSidebarContext) + const matchSafe = useRouteMatch({ path: `${SAFELIST_ADDRESS}`, strict: false }) + const history = useHistory() + const safeAddress = useSelector(safeParamAddressFromStateSelector) + const safeName = useSelector(safeNameSelector) + const { safeActionsState, onShow, onHide, showSendFunds, hideSendFunds } = useSafeActions() + const currentSafeBalance = useSelector(safeFiatBalancesTotalSelector) + const currentCurrency = useSelector(currentCurrencySelector) + const granted = useSelector(grantedSelector) + const sidebarItems = useSidebarItems() + + useLoadSafe(safeAddress) + useSafeScheduledUpdates(safeAddress) + + const sendFunds = safeActionsState.sendFunds as { isOpen: boolean; selectedToken: string } + const formattedTotalBalance = currentSafeBalance ? formatAmountInUsFormat(currentSafeBalance) : '' + const balance = !!formattedTotalBalance && !!currentCurrency ? `${formattedTotalBalance} ${currentCurrency}` : null + + useEffect(() => { + if (matchSafe?.isExact) { + history.push(WELCOME_ADDRESS) + return + } + }, [matchSafe, history]) + + const onReceiveShow = () => onShow('Receive') + const onReceiveHide = () => onHide('Receive') + + return ( + + + , + info: Info, + success: Success, + warning: Warning, + }} + maxSnack={5} + > + <> + + + showSendFunds('')} + > + {children} + + + + + + + + + + + + ) +} + +const WrapperAppWithSidebar: React.FC = ({ children }) => ( + + {children} + +) + +export default WrapperAppWithSidebar diff --git a/src/components/AppLayout/AppLayout.stories.tsx b/src/components/AppLayout/AppLayout.stories.tsx new file mode 100644 index 0000000000..778e0dadbb --- /dev/null +++ b/src/components/AppLayout/AppLayout.stories.tsx @@ -0,0 +1,61 @@ +import React from 'react' + +import { Icon } from '@gnosis.pm/safe-react-components' +import { ListItemType } from 'src/components/List' +import Layout from '.' + +export default { + title: 'Layout', + component: Layout, + parameters: { + componentSubtitle: 'It provides a custom layout used in Safe Multisig', + }, +} + +const items: ListItemType[] = [ + { + label: 'Assets', + icon: , + href: '#', + }, + { + label: 'Settings', + icon: , + href: '#', + subItems: [ + { + label: 'Safe Details', + href: '#', + }, + { + label: 'Owners', + href: '#', + }, + { + label: 'Policies', + href: '#', + }, + { + label: 'Advanced', + href: '#', + }, + ], + }, +] + +export const Base = (): React.ReactElement => { + return ( + console.log} + onReceiveClick={() => console.log} + onNewTransactionClick={() => console.log} + > +
The content goes here
+
+ ) +} diff --git a/src/components/Footer/index.tsx b/src/components/AppLayout/Footer/index.tsx similarity index 98% rename from src/components/Footer/index.tsx rename to src/components/AppLayout/Footer/index.tsx index 4c82da452a..d5269b617d 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/AppLayout/Footer/index.tsx @@ -44,7 +44,7 @@ const useStyles = makeStyles({ const appVersion = process.env.REACT_APP_APP_VERSION ? `v${process.env.REACT_APP_APP_VERSION} ` : 'Versions' -const Footer = () => { +const Footer = (): React.ReactElement => { const date = new Date() const classes = useStyles() const dispatch = useDispatch() diff --git a/src/components/Header/assets/dotRinkeby.svg b/src/components/AppLayout/Header/assets/dotRinkeby.svg similarity index 100% rename from src/components/Header/assets/dotRinkeby.svg rename to src/components/AppLayout/Header/assets/dotRinkeby.svg diff --git a/src/components/AppLayout/Header/assets/gnosis-safe-multisig-logo.svg b/src/components/AppLayout/Header/assets/gnosis-safe-multisig-logo.svg new file mode 100644 index 0000000000..26f7135173 --- /dev/null +++ b/src/components/AppLayout/Header/assets/gnosis-safe-multisig-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/components/Header/assets/key.svg b/src/components/AppLayout/Header/assets/key.svg similarity index 100% rename from src/components/Header/assets/key.svg rename to src/components/AppLayout/Header/assets/key.svg diff --git a/src/components/Header/assets/triangle.svg b/src/components/AppLayout/Header/assets/triangle.svg similarity index 100% rename from src/components/Header/assets/triangle.svg rename to src/components/AppLayout/Header/assets/triangle.svg diff --git a/src/components/Header/assets/wallet.svg b/src/components/AppLayout/Header/assets/wallet.svg similarity index 100% rename from src/components/Header/assets/wallet.svg rename to src/components/AppLayout/Header/assets/wallet.svg diff --git a/src/components/Header/components/CircleDot.tsx b/src/components/AppLayout/Header/components/CircleDot.tsx similarity index 100% rename from src/components/Header/components/CircleDot.tsx rename to src/components/AppLayout/Header/components/CircleDot.tsx diff --git a/src/components/Header/components/Layout.tsx b/src/components/AppLayout/Header/components/Layout.tsx similarity index 89% rename from src/components/Header/components/Layout.tsx rename to src/components/AppLayout/Header/components/Layout.tsx index ad9345013d..476947ed8c 100644 --- a/src/components/Header/components/Layout.tsx +++ b/src/components/AppLayout/Header/components/Layout.tsx @@ -6,14 +6,11 @@ import { withStyles } from '@material-ui/core/styles' import * as React from 'react' import { Link } from 'react-router-dom' -import NetworkLabel from './NetworkLabel' import Provider from './Provider' -import SafeListHeader from './SafeListHeader' import Spacer from 'src/components/Spacer' import openHoc from 'src/components/hoc/OpenHoc' import Col from 'src/components/layout/Col' -import Divider from 'src/components/layout/Divider' import Img from 'src/components/layout/Img' import Row from 'src/components/layout/Row' import { border, headerHeight, md, screenSm, sm } from 'src/theme/variables' @@ -41,11 +38,12 @@ const styles = () => ({ zIndex: 1301, }, logo: { - flexBasis: '95px', + flexBasis: '114px', flexShrink: '0', flexGrow: '0', maxWidth: '55px', padding: sm, + marginTop: '4px', [`@media (min-width: ${screenSm}px)`]: { maxWidth: 'none', paddingLeft: md, @@ -61,13 +59,9 @@ const Layout = openHoc(({ classes, clickAway, open, providerDetails, providerInf - Gnosis Team Safe + Gnosis Team Safe - - - - ( + ({})} + onReceiveClick={console.log} + onNewTransactionClick={console.log} + /> +) diff --git a/src/components/AppLayout/Sidebar/SafeHeader/index.tsx b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx new file mode 100644 index 0000000000..82df813fcc --- /dev/null +++ b/src/components/AppLayout/Sidebar/SafeHeader/index.tsx @@ -0,0 +1,155 @@ +import React from 'react' +import styled from 'styled-components' +import { + Icon, + FixedIcon, + EthHashInfo, + Text, + Identicon, + Button, + CopyToClipboardBtn, + EtherscanButton, +} from '@gnosis.pm/safe-react-components' + +import { getNetwork } from 'src/config' +import FlexSpacer from 'src/components/FlexSpacer' + +export const TOGGLE_SIDEBAR_BTN_TESTID = 'TOGGLE_SIDEBAR_BTN' + +const Container = styled.div` + max-width: 200px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +` + +const IdenticonContainer = styled.div` + width: 100%; + margin: 8px; + display: flex; + justify-content: space-between; + + div:first-of-type { + width: 32px; + } +` + +const IconContainer = styled.div` + width: 100px; + display: flex; + padding: 8px 0; + justify-content: space-evenly; +` +const StyledButton = styled(Button)` + *:first-child { + margin: 0 4px 0 0; + } +` +const StyledEthHashInfo = styled(EthHashInfo)` + p { + color: ${({ theme }) => theme.colors.placeHolder}; + font-size: 14px; + } +` + +const StyledLabel = styled.div` + background-color: ${({ theme }) => theme.colors.icon}; + margin: 8px 0 0 0 !important; + padding: 4px 8px; + border-radius: 4px; + letter-spacing: 1px; + p { + line-height: 18px; + } +` +const StyledText = styled(Text)` + margin: 8px 0 16px 0; +` +const UnStyledButton = styled.button` + background: none; + color: inherit; + border: none; + padding: 0; + font: inherit; + cursor: pointer; + outline-color: ${({ theme }) => theme.colors.separator}; + display: flex; + align-items: center; +` + +type Props = { + address: string | null + safeName: string + granted: boolean + balance: string | null + onToggleSafeList: () => void + onReceiveClick: () => void + onNewTransactionClick: () => void +} + +const SafeHeader = ({ + address, + safeName, + balance, + granted, + onToggleSafeList, + onReceiveClick, + onNewTransactionClick, +}: Props): React.ReactElement => { + if (!address) { + return ( + + + +
+ +
+ + + +
+
+ ) + } + + return ( + + + + + + + + + + {safeName} + + + + + + + + + + {granted ? null : ( + + + READ ONLY + + + )} + + {balance} + + + + New Transaction + + + + ) +} + +export default SafeHeader diff --git a/src/components/AppLayout/Sidebar/index.stories.tsx b/src/components/AppLayout/Sidebar/index.stories.tsx new file mode 100644 index 0000000000..6929a795b0 --- /dev/null +++ b/src/components/AppLayout/Sidebar/index.stories.tsx @@ -0,0 +1,54 @@ +import React from 'react' + +import Sidebar from './index' +import { ListItemType } from 'src/components/List' +import { Icon } from '@gnosis.pm/safe-react-components' + +export default { + title: 'Layout/Sidebar', + component: Sidebar, +} + +const items: ListItemType[] = [ + { + label: 'Assets', + icon: , + href: '#', + }, + { + label: 'Settings', + icon: , + href: '#', + subItems: [ + { + label: 'Safe Details', + href: '#', + }, + { + label: 'Owners', + href: '#', + }, + { + label: 'Policies', + href: '#', + }, + { + label: 'Advanced', + href: '#', + }, + ], + }, +] + +export const Base = (): React.ReactElement => ( + console.log} + /> +) diff --git a/src/components/AppLayout/Sidebar/index.tsx b/src/components/AppLayout/Sidebar/index.tsx new file mode 100644 index 0000000000..f59c3b0e37 --- /dev/null +++ b/src/components/AppLayout/Sidebar/index.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import styled from 'styled-components' +import { Divider, IconText } from '@gnosis.pm/safe-react-components' + +import List, { ListItemType } from 'src/components/List' +import SafeHeader from './SafeHeader' + +const StyledDivider = styled(Divider)` + margin: 16px -8px 0; +` + +const HelpContainer = styled.div` + height: 58px; +` + +const HelpCenterLink = styled.a` + height: 30px; + width: 166px; + padding: 10px 0 0 16px; + margin: 10px 0px; + text-decoration: none; + display: block; + + &:hover { + border-radius: 8px; + background-color: ${({ theme }) => theme.colors.background}; + box-sizing: content-box; + } + p { + font-family: ${({ theme }) => theme.fonts.fontFamily}; + font-size: 0.76em; + font-weight: 600; + line-height: 1.5; + letter-spacing: 1px; + color: ${({ theme }) => theme.colors.placeHolder}; + text-transform: uppercase; + padding: 0 0 0 4px; + } +` +type Props = { + safeAddress: string | null + safeName: string | null + balance: string | null + granted: boolean + onToggleSafeList: () => void + onReceiveClick: () => void + onNewTransactionClick: () => void + items: ListItemType[] +} + +const Sidebar = ({ + items, + balance, + safeAddress, + safeName, + granted, + onToggleSafeList, + onReceiveClick, + onNewTransactionClick, +}: Props): React.ReactElement => { + return ( + <> + + + {items.length ? ( + <> + + + + ) : null} + + + + + + + + + ) +} + +export default Sidebar diff --git a/src/components/AppLayout/Sidebar/useSidebarItems.tsx b/src/components/AppLayout/Sidebar/useSidebarItems.tsx new file mode 100644 index 0000000000..58439e79a4 --- /dev/null +++ b/src/components/AppLayout/Sidebar/useSidebarItems.tsx @@ -0,0 +1,58 @@ +import React, { useMemo } from 'react' +import { useRouteMatch } from 'react-router-dom' + +import { ListItemType } from 'src/components/List' +import ListIcon from 'src/components/List/ListIcon' +import { SAFELIST_ADDRESS } from 'src/routes/routes' + +const useSidebarItems = (): ListItemType[] => { + const matchSafe = useRouteMatch({ path: `${SAFELIST_ADDRESS}`, strict: false }) + const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` }) + const matchSafeWithAction = useRouteMatch({ path: `${SAFELIST_ADDRESS}/:safeAddress/:safeAction` }) as { + url: string + params: Record + } + + const sidebarItems = useMemo((): ListItemType[] => { + if (!matchSafe || !matchSafeWithAddress) { + return [] + } + + return [ + { + label: 'ASSETS', + icon: , + selected: matchSafeWithAction?.params.safeAction === 'balances', + href: `${matchSafeWithAddress?.url}/balances`, + }, + { + label: 'TRANSACTIONS', + icon: , + selected: matchSafeWithAction?.params.safeAction === 'transactions', + href: `${matchSafeWithAddress?.url}/transactions`, + }, + { + label: 'AddressBook', + icon: , + selected: matchSafeWithAction?.params.safeAction === 'address-book', + href: `${matchSafeWithAddress?.url}/address-book`, + }, + { + label: 'Apps', + icon: , + selected: matchSafeWithAction?.params.safeAction === 'apps', + href: `${matchSafeWithAddress?.url}/apps`, + }, + { + label: 'Settings', + icon: , + selected: matchSafeWithAction?.params.safeAction === 'settings', + href: `${matchSafeWithAddress?.url}/settings`, + }, + ] + }, [matchSafe, matchSafeWithAction, matchSafeWithAddress]) + + return sidebarItems +} + +export { useSidebarItems } diff --git a/src/components/AppLayout/index.tsx b/src/components/AppLayout/index.tsx new file mode 100644 index 0000000000..20bd8c8907 --- /dev/null +++ b/src/components/AppLayout/index.tsx @@ -0,0 +1,108 @@ +import React from 'react' +import styled from 'styled-components' +import { ListItemType } from 'src/components/List' + +import Header from './Header' +import Footer from './Footer' +import Sidebar from './Sidebar' + +const Grid = styled.div` + height: 100%; + overflow: auto; + background-color: ${({ theme }) => theme.colors.background}; + display: grid; + grid-template-columns: 200px 1fr; + grid-template-rows: 54px 1fr; + grid-template-areas: + 'topbar topbar' + 'sidebar body'; +` + +const GridTopbarWrapper = styled.nav` + background-color: white; + box-shadow: 0 2px 4px 0 rgba(212, 212, 211, 0.59); + border-bottom: 2px solid ${({ theme }) => theme.colors.separator}; + z-index: 999; + grid-area: topbar; +` + +const GridSidebarWrapper = styled.aside` + width: 200px; + padding: 8px; + height: 100%; + background-color: ${({ theme }) => theme.colors.white}; + border-right: 2px solid ${({ theme }) => theme.colors.separator}; + display: flex; + flex-direction: column; + box-sizing: border-box; + grid-area: sidebar; + + div:last-of-type { + margin-top: auto; + } +` + +const GridBodyWrapper = styled.section` + margin: 0 16px 0 16px; + grid-area: body; + display: flex; + flex-direction: column; + align-content: stretch; +` + +export const BodyWrapper = styled.div` + flex: 1 100%; +` + +export const FooterWrapper = styled.footer` + margin: 0 16px; +` + +type Props = { + sidebarItems: ListItemType[] + safeAddress: string | null + safeName: string | null + balance: string | null + granted: boolean + onToggleSafeList: () => void + onReceiveClick: () => void + onNewTransactionClick: () => void +} + +const Layout: React.FC = ({ + balance, + safeAddress, + safeName, + granted, + onToggleSafeList, + onReceiveClick, + onNewTransactionClick, + children, + sidebarItems, +}): React.ReactElement => ( + + +
+ + + + + + {children} + +