Feature: Fullscreen Safe apps (#2183)

* Change delete button to be in app list card

* add remove modal

* show button on hover
This commit is contained in:
Mikhail Mikheev 2021-04-21 12:45:09 +03:00 committed by GitHub
parent 072d9d9980
commit 1f7d27e75e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 99 additions and 84 deletions

View File

@ -44,7 +44,7 @@ const SidebarWrapper = styled.aside`
box-shadow: 0 2px 4px 0 rgba(40, 54, 61, 0.18); box-shadow: 0 2px 4px 0 rgba(40, 54, 61, 0.18);
` `
const ContentWrapper = styled.section` const ContentWrapper = styled.div`
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,5 +1,5 @@
import { isAppManifestValid } from '../utils' import { isAppManifestValid } from '../utils'
import { SafeApp, SAFE_APP_FETCH_STATUS } from '../types.d' import { SafeApp } from '../types.d'
describe('SafeApp manifest', () => { describe('SafeApp manifest', () => {
it('It should return true given a manifest with mandatory values supplied', async () => { it('It should return true given a manifest with mandatory values supplied', async () => {

View File

@ -41,7 +41,7 @@ const AppDocsInfo = styled.div`
} }
` `
export interface AddAppFormValues { interface AddAppFormValues {
appUrl: string appUrl: string
agreementAccepted: boolean agreementAccepted: boolean
} }

View File

@ -1,18 +1,8 @@
import React, { useState, useRef, useCallback, useEffect } from 'react' import React, { useState, useRef, useCallback, useEffect } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { import { FixedIcon, Loader, Title, Card } from '@gnosis.pm/safe-react-components'
FixedIcon,
Loader,
Title,
Text,
Card,
GenericModal,
ModalFooterConfirmation,
Menu,
ButtonLink,
} from '@gnosis.pm/safe-react-components'
import { MethodToResponse, RPCPayload } from '@gnosis.pm/safe-apps-sdk' import { MethodToResponse, RPCPayload } from '@gnosis.pm/safe-apps-sdk'
import { useHistory, useRouteMatch } from 'react-router-dom' import { useHistory } from 'react-router-dom'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1' import { INTERFACE_MESSAGES, Transaction, RequestId, LowercaseNetworks } from '@gnosis.pm/safe-apps-sdk-v1'
@ -26,7 +16,6 @@ import { getNetworkName, getTxServiceUrl } from 'src/config'
import { SAFELIST_ADDRESS } from 'src/routes/routes' import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { isSameURL } from 'src/utils/url' import { isSameURL } from 'src/utils/url'
import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics' import { useAnalytics, SAFE_NAVIGATION_EVENT } from 'src/utils/googleAnalytics'
import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { useAppList } from '../hooks/useAppList' import { useAppList } from '../hooks/useAppList'
import { LoadingContainer } from 'src/components/LoaderContainer/index' import { LoadingContainer } from 'src/components/LoaderContainer/index'
import { TIMEOUT } from 'src/utils/constants' import { TIMEOUT } from 'src/utils/constants'
@ -36,8 +25,8 @@ import { ConfirmTxModal } from '../components/ConfirmTxModal'
import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler' import { useIframeMessageHandler } from '../hooks/useIframeMessageHandler'
import { useLegalConsent } from '../hooks/useLegalConsent' import { useLegalConsent } from '../hooks/useLegalConsent'
import LegalDisclaimer from './LegalDisclaimer' import LegalDisclaimer from './LegalDisclaimer'
import { APPS_STORAGE_KEY, getAppInfoFromUrl } from '../utils' import { getAppInfoFromUrl } from '../utils'
import { SafeApp, StoredSafeApp } from '../types.d' import { SafeApp } from '../types.d'
import { useAppCommunicator } from '../communicator' import { useAppCommunicator } from '../communicator'
const OwnerDisclaimer = styled.div` const OwnerDisclaimer = styled.div`
@ -51,12 +40,14 @@ const OwnerDisclaimer = styled.div`
const AppWrapper = styled.div` const AppWrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: calc(100% + 59px);
margin: 0 -16px;
` `
const StyledCard = styled(Card)` const StyledCard = styled(Card)`
flex-grow: 1; flex-grow: 1;
padding: 0; padding: 0;
border-radius: 0;
` `
const StyledIframe = styled.iframe<{ isLoading: boolean }>` const StyledIframe = styled.iframe<{ isLoading: boolean }>`
@ -67,10 +58,6 @@ const StyledIframe = styled.iframe<{ isLoading: boolean }>`
display: ${({ isLoading }) => (isLoading ? 'none' : 'block')}; display: ${({ isLoading }) => (isLoading ? 'none' : 'block')};
` `
const Breadcrumb = styled.div`
height: 51px;
`
export type TransactionParams = { export type TransactionParams = {
safeTxGas?: number safeTxGas?: number
} }
@ -105,16 +92,12 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
const { consentReceived, onConsentReceipt } = useLegalConsent() const { consentReceived, onConsentReceipt } = useLegalConsent()
const { staticAppsList } = useAppList() const { staticAppsList } = useAppList()
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null)
const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>( const [confirmTransactionModal, setConfirmTransactionModal] = useState<ConfirmTransactionModalState>(
INITIAL_CONFIRM_TX_MODAL_STATE, INITIAL_CONFIRM_TX_MODAL_STATE,
) )
const [appIsLoading, setAppIsLoading] = useState<boolean>(true) const [appIsLoading, setAppIsLoading] = useState<boolean>(true)
const [safeApp, setSafeApp] = useState<SafeApp | undefined>() const [safeApp, setSafeApp] = useState<SafeApp | undefined>()
const [isRemoveModalOpen, setIsRemoveModalOpen] = useState(false)
const [isAppDeletable, setIsAppDeletable] = useState<boolean | undefined>()
const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`) const redirectToBalance = () => history.push(`${SAFELIST_ADDRESS}/${safeAddress}/balances`)
const timer = useRef<number>() const timer = useRef<number>()
@ -247,25 +230,10 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
communicator?.send('Transaction was rejected', confirmTransactionModal.requestId, true) communicator?.send('Transaction was rejected', confirmTransactionModal.requestId, true)
} }
const openRemoveModal = () => setIsRemoveModalOpen(true)
const closeRemoveModal = () => setIsRemoveModalOpen(false)
const removeApp = async () => {
const persistedAppList = (await loadFromStorage<StoredSafeApp[]>(APPS_STORAGE_KEY)) || []
const filteredList = persistedAppList.filter((a) => a.url !== safeApp?.url)
saveToStorage(APPS_STORAGE_KEY, filteredList)
const goToApp = `${matchSafeWithAddress?.url}/apps`
history.push(goToApp)
}
useEffect(() => { useEffect(() => {
const loadApp = async () => { const loadApp = async () => {
const app = await getAppInfoFromUrl(appUrl) const app = await getAppInfoFromUrl(appUrl)
const existsStaticApp = staticAppsList.some((staticApp) => staticApp.url === app.url)
setIsAppDeletable(!existsStaticApp)
setSafeApp(app) setSafeApp(app)
} }
if (staticAppsList.length) { if (staticAppsList.length) {
@ -307,21 +275,12 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
return ( return (
<AppWrapper> <AppWrapper>
<Menu>
<Breadcrumb />
{isAppDeletable && (
<ButtonLink color="error" iconType="delete" onClick={openRemoveModal}>
Remove app
</ButtonLink>
)}
</Menu>
<StyledCard> <StyledCard>
{appIsLoading && ( {appIsLoading && (
<LoadingContainer style={{ flexDirection: 'column' }}> <LoadingContainer style={{ flexDirection: 'column' }}>
{appTimeout && ( {appTimeout && (
<Title size="xs"> <Title size="xs">
The safe-app is taking longer than usual to load. There might be a problem with the safe-app provider. The safe app is taking longer than usual to load. There might be a problem with the app provider.
</Title> </Title>
)} )}
<Loader size="md" /> <Loader size="md" />
@ -339,26 +298,6 @@ const AppFrame = ({ appUrl }: Props): React.ReactElement => {
/> />
</StyledCard> </StyledCard>
{isRemoveModalOpen && (
<GenericModal
title={
<Title size="sm" withoutMargin>
Remove app
</Title>
}
body={<Text size="md">This action will remove {safeApp.name} from the interface</Text>}
footer={
<ModalFooterConfirmation
cancelText="Cancel"
handleCancel={closeRemoveModal}
handleOk={removeApp}
okText="Remove"
/>
}
onClose={closeRemoveModal}
/>
)}
<ConfirmTxModal <ConfirmTxModal
isOpen={confirmTransactionModal.isOpen} isOpen={confirmTransactionModal.isOpen}
app={safeApp as SafeApp} app={safeApp as SafeApp}

View File

@ -1,7 +1,16 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import styled, { css } from 'styled-components' import styled, { css } from 'styled-components'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { GenericModal, IconText, Loader, Menu } from '@gnosis.pm/safe-react-components' import {
GenericModal,
IconText,
Loader,
Menu,
Icon,
ModalFooterConfirmation,
Text,
} from '@gnosis.pm/safe-react-components'
import IconButton from '@material-ui/core/IconButton'
import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors' import { safeParamAddressFromStateSelector } from 'src/logic/safe/store/selectors'
import AppCard from 'src/routes/safe/components/Apps/components/AppCard' import AppCard from 'src/routes/safe/components/Apps/components/AppCard'
@ -12,6 +21,7 @@ import { SAFELIST_ADDRESS } from 'src/routes/routes'
import { useAppList } from '../hooks/useAppList' import { useAppList } from '../hooks/useAppList'
import { SAFE_APP_FETCH_STATUS, SafeApp } from '../types.d' import { SAFE_APP_FETCH_STATUS, SafeApp } from '../types.d'
import AddAppForm from './AddAppForm' import AddAppForm from './AddAppForm'
import { AppData } from '../api/fetchSafeAppsList'
const Wrapper = styled.div` const Wrapper = styled.div`
height: 100%; height: 100%;
@ -56,18 +66,41 @@ const Breadcrumb = styled.div`
height: 51px; height: 51px;
` `
const IconBtn = styled(IconButton)`
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
padding: 5px;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`
const AppContainer = styled.div`
position: relative;
&:hover {
${IconBtn} {
opacity: 1;
}
}
`
const isAppLoading = (app: SafeApp) => SAFE_APP_FETCH_STATUS.LOADING === app.fetchStatus
const isCustomApp = (appUrl: string, staticAppsList: AppData[]) => !staticAppsList.some(({ url }) => url === appUrl)
const AppsList = (): React.ReactElement => { const AppsList = (): React.ReactElement => {
const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` }) const matchSafeWithAddress = useRouteMatch<{ safeAddress: string }>({ path: `${SAFELIST_ADDRESS}/:safeAddress` })
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useSelector(safeParamAddressFromStateSelector)
const { appList } = useAppList() const { appList, removeApp, staticAppsList } = useAppList()
const [isAddAppModalOpen, setIsAddAppModalOpen] = useState<boolean>(false) const [isAddAppModalOpen, setIsAddAppModalOpen] = useState<boolean>(false)
const [appToRemove, setAppToRemove] = useState<SafeApp | null>(null)
const openAddAppModal = () => setIsAddAppModalOpen(true) const openAddAppModal = () => setIsAddAppModalOpen(true)
const closeAddAppModal = () => setIsAddAppModalOpen(false) const closeAddAppModal = () => setIsAddAppModalOpen(false)
const isAppLoading = (app: SafeApp) => SAFE_APP_FETCH_STATUS.LOADING === app.fetchStatus
if (!appList.length || !safeAddress) { if (!appList.length || !safeAddress) {
return ( return (
<LoadingContainer> <LoadingContainer>
@ -90,9 +123,23 @@ const AppsList = (): React.ReactElement => {
{appList {appList
.filter((a) => a.fetchStatus !== SAFE_APP_FETCH_STATUS.ERROR) .filter((a) => a.fetchStatus !== SAFE_APP_FETCH_STATUS.ERROR)
.map((a) => ( .map((a) => (
<StyledLink key={a.url} to={`${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(a.url)}`}> <AppContainer key={a.url}>
<AppCard isLoading={isAppLoading(a)} iconUrl={a.iconUrl} name={a.name} description={a.description} /> <StyledLink key={a.url} to={`${matchSafeWithAddress?.url}/apps?appUrl=${encodeURI(a.url)}`}>
</StyledLink> <AppCard isLoading={isAppLoading(a)} iconUrl={a.iconUrl} name={a.name} description={a.description} />
</StyledLink>
{isCustomApp(a.url, staticAppsList) && (
<IconBtn
title="Remove"
onClick={(e) => {
e.stopPropagation()
setAppToRemove(a)
}}
>
<Icon size="sm" type="delete" color="error" />
</IconBtn>
)}
</AppContainer>
))} ))}
</CardsWrapper> </CardsWrapper>
@ -112,6 +159,25 @@ const AppsList = (): React.ReactElement => {
onClose={closeAddAppModal} onClose={closeAddAppModal}
/> />
)} )}
{appToRemove && (
<GenericModal
title="Remove app"
body={<Text size="md">This action will remove {appToRemove.name} from the interface</Text>}
footer={
<ModalFooterConfirmation
cancelText="Cancel"
handleCancel={() => setAppToRemove(null)}
handleOk={() => {
removeApp(appToRemove.url)
setAppToRemove(null)
}}
okText="Remove"
/>
}
onClose={() => setAppToRemove(null)}
/>
)}
</Wrapper> </Wrapper>
) )
} }

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { loadFromStorage } from 'src/utils/storage' import { loadFromStorage, saveToStorage } from 'src/utils/storage'
import { APPS_STORAGE_KEY, getAppInfoFromUrl, getAppsList, getEmptySafeApp } from '../utils' import { APPS_STORAGE_KEY, getAppInfoFromUrl, getAppsList, getEmptySafeApp } from '../utils'
import { AppData } from '../api/fetchSafeAppsList' import { AppData } from '../api/fetchSafeAppsList'
import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d' import { SafeApp, StoredSafeApp, SAFE_APP_FETCH_STATUS } from '../types.d'
@ -7,6 +7,7 @@ import { getNetworkId } from 'src/config'
type UseAppListReturnType = { type UseAppListReturnType = {
appList: SafeApp[] appList: SafeApp[]
removeApp: (appUrl: string) => void
staticAppsList: AppData[] staticAppsList: AppData[]
} }
@ -73,14 +74,23 @@ const useAppList = (): UseAppListReturnType => {
}) })
} }
if (staticAppsList.length) { loadApps()
loadApps()
}
}, [staticAppsList]) }, [staticAppsList])
const removeApp = useCallback((appUrl: string): void => {
setAppList((list) => {
const newList = list.filter(({ url }) => url !== appUrl)
const persistedAppList = newList.map(({ url, disabled }) => ({ url, disabled }))
saveToStorage(APPS_STORAGE_KEY, persistedAppList)
return newList
})
}, [])
return { return {
appList, appList,
staticAppsList, staticAppsList,
removeApp,
} }
} }