From 04776f4d945580b886fcaf8056c8ae7b95bfa8b0 Mon Sep 17 00:00:00 2001 From: nicosampler Date: Tue, 16 Jun 2020 13:44:06 -0300 Subject: [PATCH 01/17] Fix: debounce fetch apps --- .../safe/components/Apps/AddAppForm.tsx | 46 +++++++++++-------- .../safe/components/Apps/ManageApps.tsx | 20 ++++---- src/routes/safe/components/Apps/utils.ts | 6 +-- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx index 5eafad7b81..0ed1266614 100644 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ b/src/routes/safe/components/Apps/AddAppForm.tsx @@ -84,23 +84,24 @@ const getUrlFromFormValue = memoize(async (value: string) => { return isUrlValid ? value : ensContent }) -const curriedSafeAppValidator = memoize((appList) => async (value: string) => { - const url = await getUrlFromFormValue(value) +const curriedSafeAppValidator = (appList) => + memoize(async (value: string) => { + const url = await getUrlFromFormValue(value) - if (!url) { - return 'Provide a valid url or ENS name.' - } + if (!url) { + return 'Provide a valid url or ENS name.' + } - const appExistsRes = uniqueAppValidator(appList, url) - if (appExistsRes) { - return appExistsRes - } + const appExistsRes = uniqueAppValidator(appList, url) + if (appExistsRes) { + return appExistsRes + } - const appInfo = await getAppInfoFromUrl(url) - if (appInfo.error) { - return 'This is not a valid Safe app.' - } -}) + const appInfo = await getAppInfoFromUrl(url) + if (appInfo.error) { + return 'This is not a valid Safe app.' + } + }) const composeValidatorsApps = (...validators) => (value, values, meta) => { if (!meta.modified) { @@ -121,6 +122,13 @@ const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabl const [appInfo, setAppInfo] = useState(APP_INFO) const safeAppValidator = curriedSafeAppValidator(appList) + const initialValues = { + appUrl: '', + agreed: false, + } + + const subscription = { submitting: true } + const onFormStatusChange = async ({ pristine, valid, validating, values, errors }) => { if (!pristine) { setIsSubmitDisabled(validating || !valid) @@ -145,12 +153,12 @@ const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabl onAppAdded(appInfo) } + const validators = composeValidatorsApps(required, safeAppValidator) + return ( diff --git a/src/routes/safe/components/Apps/ManageApps.tsx b/src/routes/safe/components/Apps/ManageApps.tsx index b89285168e..06cad0895b 100644 --- a/src/routes/safe/components/Apps/ManageApps.tsx +++ b/src/routes/safe/components/Apps/ManageApps.tsx @@ -38,6 +38,16 @@ const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => { const ButtonLinkAux: any = ButtonLink + const Form = ( + + ) + return ( <> @@ -47,15 +57,7 @@ const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => { - } + formBody={Form} isSubmitFormDisabled={isSubmitDisabled} itemList={getItemList()} onClose={closeModal} diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index 2f99d388ec..26da22d937 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -22,7 +22,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [ { url: `${gnosisAppsUrl}/synthetix`, disabled: false }, ] -export const getAppInfoFromOrigin = (origin) => { +export const getAppInfoFromOrigin = (origin: string): Record | null => { try { return JSON.parse(origin) } catch (error) { @@ -31,10 +31,10 @@ export const getAppInfoFromOrigin = (origin) => { } } -export const getAppInfoFromUrl = async (appUrl: string): Promise => { +export const getAppInfoFromUrl = async (appUrl: string | undefined): Promise => { let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true } - if (!appUrl.length) { + if (!appUrl?.length) { return res } From da85abe355b581d25112812599cc6f2b1a1e380e Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 12:57:11 -0300 Subject: [PATCH 02/17] refactor: fix AddAppForm name and add missing types --- src/routes/safe/components/Apps/ManageApps.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/routes/safe/components/Apps/ManageApps.tsx b/src/routes/safe/components/Apps/ManageApps.tsx index 06cad0895b..404d2e122f 100644 --- a/src/routes/safe/components/Apps/ManageApps.tsx +++ b/src/routes/safe/components/Apps/ManageApps.tsx @@ -2,18 +2,18 @@ import { ButtonLink, ManageListModal } from '@gnosis.pm/safe-react-components' import React, { useState } from 'react' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' -import AddAppFrom from './AddAppForm' +import AddAppForm from './AddAppForm' import { SafeApp } from './types' const FORM_ID = 'add-apps-form' type Props = { appList: Array - onAppAdded: (app: any) => void + onAppAdded: (app: SafeApp) => void onAppToggle: (appId: string, enabled: boolean) => void } -const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => { +const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props): React.ReactElement => { const [isOpen, setIsOpen] = useState(false) const [isSubmitDisabled, setIsSubmitDisabled] = useState(true) @@ -32,14 +32,12 @@ const ManageApps = ({ appList, onAppAdded, onAppToggle }: Props) => { return { ...a, checked: !a.disabled } }) - const onItemToggle = (itemId, checked) => { + const onItemToggle = (itemId: string, checked: boolean): void => { onAppToggle(itemId, checked) } - const ButtonLinkAux: any = ButtonLink - const Form = ( - { return ( <> - + Manage Apps - + {isOpen && ( Date: Thu, 23 Jul 2020 13:00:03 -0300 Subject: [PATCH 03/17] add `use-lodash-debounce` hook to test debounce functionality I'm planning to remove this dependency, as it requires to also install `lodash.debounce`. I prefer to implement it ad-hoc. --- package.json | 2 ++ yarn.lock | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/package.json b/package.json index f8675729b6..a2d5a155e9 100644 --- a/package.json +++ b/package.json @@ -191,6 +191,7 @@ "immortal-db": "^1.0.2", "immutable": "^4.0.0-rc.9", "js-cookie": "^2.2.1", + "lodash.debounce": "^4.0.8", "lodash.memoize": "^4.1.2", "material-ui-search-bar": "^1.0.0-beta.13", "notistack": "https://github.com/gnosis/notistack.git#v0.9.4", @@ -217,6 +218,7 @@ "semver": "7.3.2", "styled-components": "^5.0.1", "truffle-contract": "4.0.31", + "use-lodash-debounce": "^1.1.0", "web3": "1.2.9" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 6a358bb56b..1166f0eea7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10930,6 +10930,11 @@ lodash._reinterpolate@^3.0.0: resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.defaults@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" @@ -16863,6 +16868,11 @@ usb@^1.6.3: nan "2.13.2" prebuild-install "^5.3.3" +use-lodash-debounce@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/use-lodash-debounce/-/use-lodash-debounce-1.1.0.tgz#c31826a7c98788cb1810615e1ad102fc38598fe7" + integrity sha512-VNrIm8hpw4N6JG0cmtxz6w4d4eVGfJM1GkiAEBCqkRBl1GJF05+2ikqOUHqwML+8ppHxin0QnFfJl4jqmK1Fjw== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 2f309771b03018ff9ed6d835920cc25c165923a3 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 13:01:51 -0300 Subject: [PATCH 04/17] refactor AddAppForm to use the observable pattern --- .../safe/components/Apps/AddAppForm.tsx | 236 +++++++++--------- 1 file changed, 119 insertions(+), 117 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx index 7f20185dea..57f753bfd1 100644 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ b/src/routes/safe/components/Apps/AddAppForm.tsx @@ -1,21 +1,23 @@ import { Checkbox, Text, TextField } from '@gnosis.pm/safe-react-components' +import createDecorator from 'final-form-calculate' import memoize from 'lodash.memoize' -import React, { useState } from 'react' -import { FormSpy } from 'react-final-form' +import React from 'react' +import { useField, useFormState } from 'react-final-form' import styled from 'styled-components' +import { useDebounce } from 'use-lodash-debounce' + +import { SafeApp } from './types' +import { getAppInfoFromUrl } from './utils' import Field from 'src/components/forms/Field' -import DebounceValidationField from 'src/components/forms/Field/DebounceValidationField' import GnoForm from 'src/components/forms/GnoForm' -import { required } from 'src/components/forms/validator' +import { composeValidators, required } from 'src/components/forms/validator' import Img from 'src/components/layout/Img' import { getContentFromENS } from 'src/logic/wallets/getWeb3' +import { isValidEnsName } from 'src/logic/wallets/ethAddresses' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' import { isValid as isURLValid } from 'src/utils/url' -import { getAppInfoFromUrl } from './utils' -import { SafeApp } from './types' - const APP_INFO: SafeApp = { id: undefined, url: '', @@ -46,20 +48,7 @@ const StyledCheckbox = styled(Checkbox)` margin: 0; ` -const uniqueAppValidator = memoize((appList, value) => { - const exists = appList.some((a) => { - try { - const currentUrl = new URL(a.url) - const newUrl = new URL(value) - return currentUrl.href === newUrl.href - } catch (error) { - return 'There was a problem trying to validate the URL existence.' - } - }) - return exists ? 'This app is already registered.' : undefined -}) - -const getIpfsLinkFromEns = memoize(async (name) => { +const getIpfsLinkFromEns = memoize(async (name: string) => { try { const content = await getContentFromENS(name) if (content && content.protocolType === 'ipfs') { @@ -71,81 +60,120 @@ const getIpfsLinkFromEns = memoize(async (name) => { } }) -const getUrlFromFormValue = memoize(async (value: string) => { - const isUrlValid = isURLValid(value) - let ensContent - if (!isUrlValid) { - ensContent = await getIpfsLinkFromEns(value) - } - - if (!isUrlValid && ensContent === undefined) { - return undefined - } - return isUrlValid ? value : ensContent -}) - -const curriedSafeAppValidator = (appList) => - memoize(async (value: string) => { - const url = await getUrlFromFormValue(value) +type AddAppFromProps = { + appList: SafeApp[] + closeModal: () => void + formId: string + onAppAdded: (app: SafeApp) => void + setIsSubmitDisabled: (status: boolean) => void +} - if (!url) { - return 'Provide a valid url or ENS name.' +const uniqueApp = (appList: SafeApp[]) => (url: string) => { + const exists = appList.some((a) => { + try { + const currentUrl = new URL(a.url) + const newUrl = new URL(url) + return currentUrl.href === newUrl.href + } catch (error) { + console.error('There was a problem trying to validate the URL existence.', error.message) + return false } + }) + return exists ? 'This app is already registered.' : undefined +} - const appExistsRes = uniqueAppValidator(appList, url) - if (appExistsRes) { - return appExistsRes - } +const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void }): React.ReactElement => { + const { + input: { value: appUrl }, + } = useField('appUrl', { subscription: { value: true } }) + const debouncedValue = useDebounce(appUrl, 500) - const appInfo = await getAppInfoFromUrl(url) - if (appInfo.error) { - return 'This is not a valid Safe app.' + React.useEffect(() => { + const updateAppInfo = async () => { + const appInfo = await getAppInfoFromUrl(debouncedValue) + onAppInfo({ ...appInfo }) } - }) - -const composeValidatorsApps = (...validators) => (value, values, meta) => { - if (!meta.modified) { - return - } - return validators.reduce((error, validator) => error || validator(value), undefined) -} + updateAppInfo() + }, [debouncedValue, onAppInfo]) -type Props = { - formId: string - appList: Array - closeModal: () => void - onAppAdded: (app: SafeApp) => void - setIsSubmitDisabled: (status: boolean) => void + return null } -const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabled }: Props) => { - const [appInfo, setAppInfo] = useState(APP_INFO) - const safeAppValidator = curriedSafeAppValidator(appList) +const appUrlResolver = createDecorator({ + field: 'appUrl', + updates: { + appUrl: async (appUrl: string): Promise => { + const ensContent = !isURLValid(appUrl) && isValidEnsName(appUrl) && (await getIpfsLinkFromEns(appUrl)) - const initialValues = { - appUrl: '', - agreed: false, - } + if (ensContent) { + return ensContent + } - const subscription = { submitting: true } - - const onFormStatusChange = async ({ pristine, valid, validating, values, errors }) => { - if (!pristine) { - setIsSubmitDisabled(validating || !valid) - } + return appUrl + }, + }, +}) - if (validating) { - return +const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL') + +const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => ( + +) + +const AppAgreement = (): React.ReactElement => ( + + This app is not a Gnosis product and I agree to use this app +
+ at my own risk. + } + name="agreement" + type="checkbox" + validate={required} + /> +) + +const SubmitButtonStatus = ({ + appInfo, + isSubmitDisabled, +}: { + appInfo: SafeApp + isSubmitDisabled: (disabled: boolean) => void +}): React.ReactElement => { + const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) + + React.useEffect(() => { + console.log(validating, valid, appInfo.error, appInfo.url, appInfo.name, values) + isSubmitDisabled( + validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', + ) + }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, isSubmitDisabled]) + + return null +} - if (!values.appUrl || !values.appUrl.length || errors.appUrl) { - setAppInfo(APP_INFO) - return - } +const AddApp = ({ + appList, + closeModal, + formId, + onAppAdded, + setIsSubmitDisabled, +}: AddAppFromProps): React.ReactElement => { + const [appInfo, setAppInfo] = React.useState(APP_INFO) - const url = await getUrlFromFormValue(values.appUrl) - const appInfo = await getAppInfoFromUrl(url) - setAppInfo({ ...appInfo }) + const initialValues = { + appUrl: '', + agreement: false, } const handleSubmit = () => { @@ -153,59 +181,33 @@ const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabl onAppAdded(appInfo) } - const validators = composeValidatorsApps(required, safeAppValidator) - return ( {() => ( <> Add custom app - + + + Token image {}} /> - - - - This app is not a Gnosis product and I agree to use this app
at my own risk. -

- } - name="agreed" - type="checkbox" - validate={required} - /> + + + )}
) } -export default AddAppForm +export default AddApp From 2046e87e082aef1837b9fde837ecf14eb64cacee Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 13:02:20 -0300 Subject: [PATCH 05/17] memoize `getAppInfoFromUrl` to prevent requesting the same information over and over --- src/routes/safe/components/Apps/utils.ts | 75 ++++++++++++------------ 1 file changed, 39 insertions(+), 36 deletions(-) diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index a46607a6e4..ef1054e337 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import memoize from 'lodash.memoize' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' import { getGnosisSafeAppsUrl } from 'src/config/index' @@ -40,49 +41,51 @@ export const getAppInfoFromOrigin = (origin: string): Record | null } } -export const getAppInfoFromUrl = async (appUrl?: string): Promise => { - let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true } +export const getAppInfoFromUrl = memoize( + async (appUrl?: string): Promise => { + let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true } - if (!appUrl?.length) { - return res - } + if (!appUrl?.length) { + return res + } - res.url = appUrl.trim() - const noTrailingSlashUrl = removeLastTrailingSlash(res.url) + res.url = appUrl.trim() + const noTrailingSlashUrl = removeLastTrailingSlash(res.url) - try { - const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`) + try { + const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`) - // verify imported app fulfil safe requirements - if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) { - throw Error('The app does not fulfil the structure required.') - } + // verify imported app fulfil safe requirements + if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) { + throw Error('The app does not fulfil the structure required.') + } - // the DB origin field has a limit of 100 characters - const originFieldSize = 100 - const jsonDataLength = 20 - const remainingSpace = originFieldSize - res.url.length - jsonDataLength + // the DB origin field has a limit of 100 characters + const originFieldSize = 100 + const jsonDataLength = 20 + const remainingSpace = originFieldSize - res.url.length - jsonDataLength - res = { - ...res, - ...appInfo.data, - id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }), - error: false, - } + res = { + ...res, + ...appInfo.data, + id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }), + error: false, + } - if (appInfo.data.iconPath) { - try { - const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 }) - if (/image\/\w/gm.test(iconInfo.headers['content-type'])) { - res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}` + if (appInfo.data.iconPath) { + try { + const iconInfo = await axios.get(`${noTrailingSlashUrl}/${appInfo.data.iconPath}`, { timeout: 1000 * 10 }) + if (/image\/\w/gm.test(iconInfo.headers['content-type'])) { + res.iconUrl = `${noTrailingSlashUrl}/${appInfo.data.iconPath}` + } + } catch (error) { + console.error(`It was not possible to fetch icon from app ${res.url}`) } - } catch (error) { - console.error(`It was not possible to fetch icon from app ${res.url}`) } + return res + } catch (error) { + console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`) + return res } - return res - } catch (error) { - console.error(`It was not possible to fetch app from ${res.url}: ${error.message}`) - return res - } -} + }, +) From 28e5b26ba1d6fc7dfa110ed18c1c8432fe3ca09b Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 13:10:39 -0300 Subject: [PATCH 06/17] prevent requesting data if url is not valid --- src/routes/safe/components/Apps/AddAppForm.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx index 57f753bfd1..48c1f2c4fe 100644 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ b/src/routes/safe/components/Apps/AddAppForm.tsx @@ -93,7 +93,10 @@ const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void } const appInfo = await getAppInfoFromUrl(debouncedValue) onAppInfo({ ...appInfo }) } - updateAppInfo() + + if (isURLValid(debouncedValue)) { + updateAppInfo() + } }, [debouncedValue, onAppInfo]) return null From 6ba3f9d05f15f4c731e92bb279e4f58b332f5394 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 13:12:34 -0300 Subject: [PATCH 07/17] remove logging --- src/routes/safe/components/Apps/AddAppForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx index 48c1f2c4fe..89cd1582f1 100644 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ b/src/routes/safe/components/Apps/AddAppForm.tsx @@ -156,7 +156,6 @@ const SubmitButtonStatus = ({ const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) React.useEffect(() => { - console.log(validating, valid, appInfo.error, appInfo.url, appInfo.name, values) isSubmitDisabled( validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', ) From fafd94f3f9a84ea619af9b9739f48055bcf32d88 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 13:53:02 -0300 Subject: [PATCH 08/17] prevent validating form before visiting the fields --- .../safe/components/Apps/AddAppForm.tsx | 67 ++++++++++--------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx index 89cd1582f1..c46f13857b 100644 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ b/src/routes/safe/components/Apps/AddAppForm.tsx @@ -119,32 +119,39 @@ const appUrlResolver = createDecorator({ const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL') -const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => ( - -) - -const AppAgreement = (): React.ReactElement => ( - - This app is not a Gnosis product and I agree to use this app -
- at my own risk. - - } - name="agreement" - type="checkbox" - validate={required} - /> -) +const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => { + const { visited } = useFormState({ subscription: { visited: true } }) + + // trick to prevent having the field validated by default. Not sure why this happens in this form + const validate = !visited.appUrl ? undefined : composeValidators(required, validateUrl, uniqueApp(appList)) + + return ( + + ) +} + +const AppAgreement = (): React.ReactElement => { + const { visited } = useFormState({ subscription: { visited: true } }) + + // trick to prevent having the field validated by default. Not sure why this happens in this form + const validate = !visited.agreement ? undefined : required + + return ( + + This app is not a Gnosis product and I agree to use this app +
+ at my own risk. + + } + name="agreement" + type="checkbox" + validate={validate} + /> + ) +} const SubmitButtonStatus = ({ appInfo, @@ -184,13 +191,7 @@ const AddApp = ({ } return ( - + {() => ( <> Add custom app From 41c3fdd61d12eeac82585e335e12685f910d22d6 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 14:09:16 -0300 Subject: [PATCH 09/17] refactor AddAppForm reorganize code --- .../safe/components/Apps/AddAppForm.tsx | 216 ------------------ .../Apps/AddAppForm/AppAgreement.tsx | 36 +++ .../components/Apps/AddAppForm/AppUrl.tsx | 62 +++++ .../Apps/AddAppForm/SubmitButtonStatus.tsx | 23 ++ .../safe/components/Apps/AddAppForm/index.tsx | 84 +++++++ src/routes/safe/components/Apps/utils.ts | 27 +++ 6 files changed, 232 insertions(+), 216 deletions(-) delete mode 100644 src/routes/safe/components/Apps/AddAppForm.tsx create mode 100644 src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx create mode 100644 src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx create mode 100644 src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx create mode 100644 src/routes/safe/components/Apps/AddAppForm/index.tsx diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx deleted file mode 100644 index c46f13857b..0000000000 --- a/src/routes/safe/components/Apps/AddAppForm.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Checkbox, Text, TextField } from '@gnosis.pm/safe-react-components' -import createDecorator from 'final-form-calculate' -import memoize from 'lodash.memoize' -import React from 'react' -import { useField, useFormState } from 'react-final-form' -import styled from 'styled-components' -import { useDebounce } from 'use-lodash-debounce' - -import { SafeApp } from './types' -import { getAppInfoFromUrl } from './utils' - -import Field from 'src/components/forms/Field' -import GnoForm from 'src/components/forms/GnoForm' -import { composeValidators, required } from 'src/components/forms/validator' -import Img from 'src/components/layout/Img' -import { getContentFromENS } from 'src/logic/wallets/getWeb3' -import { isValidEnsName } from 'src/logic/wallets/ethAddresses' -import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' -import { isValid as isURLValid } from 'src/utils/url' - -const APP_INFO: SafeApp = { - id: undefined, - url: '', - name: '', - iconUrl: appsIconSvg, - error: false, -} - -const StyledText = styled(Text)` - margin-bottom: 19px; -` - -const StyledTextFileAppName = styled(TextField)` - && { - width: 335px; - } -` - -const AppInfo = styled.div` - margin: 36px 0 24px 0; - - img { - margin-right: 10px; - } -` - -const StyledCheckbox = styled(Checkbox)` - margin: 0; -` - -const getIpfsLinkFromEns = memoize(async (name: string) => { - try { - const content = await getContentFromENS(name) - if (content && content.protocolType === 'ipfs') { - return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/` - } - } catch (error) { - console.error(error) - return undefined - } -}) - -type AddAppFromProps = { - appList: SafeApp[] - closeModal: () => void - formId: string - onAppAdded: (app: SafeApp) => void - setIsSubmitDisabled: (status: boolean) => void -} - -const uniqueApp = (appList: SafeApp[]) => (url: string) => { - const exists = appList.some((a) => { - try { - const currentUrl = new URL(a.url) - const newUrl = new URL(url) - return currentUrl.href === newUrl.href - } catch (error) { - console.error('There was a problem trying to validate the URL existence.', error.message) - return false - } - }) - return exists ? 'This app is already registered.' : undefined -} - -const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void }): React.ReactElement => { - const { - input: { value: appUrl }, - } = useField('appUrl', { subscription: { value: true } }) - const debouncedValue = useDebounce(appUrl, 500) - - React.useEffect(() => { - const updateAppInfo = async () => { - const appInfo = await getAppInfoFromUrl(debouncedValue) - onAppInfo({ ...appInfo }) - } - - if (isURLValid(debouncedValue)) { - updateAppInfo() - } - }, [debouncedValue, onAppInfo]) - - return null -} - -const appUrlResolver = createDecorator({ - field: 'appUrl', - updates: { - appUrl: async (appUrl: string): Promise => { - const ensContent = !isURLValid(appUrl) && isValidEnsName(appUrl) && (await getIpfsLinkFromEns(appUrl)) - - if (ensContent) { - return ensContent - } - - return appUrl - }, - }, -}) - -const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL') - -const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => { - const { visited } = useFormState({ subscription: { visited: true } }) - - // trick to prevent having the field validated by default. Not sure why this happens in this form - const validate = !visited.appUrl ? undefined : composeValidators(required, validateUrl, uniqueApp(appList)) - - return ( - - ) -} - -const AppAgreement = (): React.ReactElement => { - const { visited } = useFormState({ subscription: { visited: true } }) - - // trick to prevent having the field validated by default. Not sure why this happens in this form - const validate = !visited.agreement ? undefined : required - - return ( - - This app is not a Gnosis product and I agree to use this app -
- at my own risk. - - } - name="agreement" - type="checkbox" - validate={validate} - /> - ) -} - -const SubmitButtonStatus = ({ - appInfo, - isSubmitDisabled, -}: { - appInfo: SafeApp - isSubmitDisabled: (disabled: boolean) => void -}): React.ReactElement => { - const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) - - React.useEffect(() => { - isSubmitDisabled( - validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', - ) - }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, isSubmitDisabled]) - - return null -} - -const AddApp = ({ - appList, - closeModal, - formId, - onAppAdded, - setIsSubmitDisabled, -}: AddAppFromProps): React.ReactElement => { - const [appInfo, setAppInfo] = React.useState(APP_INFO) - - const initialValues = { - appUrl: '', - agreement: false, - } - - const handleSubmit = () => { - closeModal() - onAppAdded(appInfo) - } - - return ( - - {() => ( - <> - Add custom app - - - - - - Token image - {}} /> - - - - - - - )} - - ) -} - -export default AddApp diff --git a/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx new file mode 100644 index 0000000000..a08c8e1ab6 --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx @@ -0,0 +1,36 @@ +import { Checkbox, Text } from '@gnosis.pm/safe-react-components' +import React from 'react' +import { useFormState } from 'react-final-form' +import styled from 'styled-components' + +import { required } from 'src/components/forms/validator' +import Field from 'src/components/forms/Field' + +const StyledCheckbox = styled(Checkbox)` + margin: 0; +` + +const AppAgreement = (): React.ReactElement => { + const { visited } = useFormState({ subscription: { visited: true } }) + + // trick to prevent having the field validated by default. Not sure why this happens in this form + const validate = !visited.agreement ? undefined : required + + return ( + + This app is not a Gnosis product and I agree to use this app +
+ at my own risk. + + } + name="agreement" + type="checkbox" + validate={validate} + /> + ) +} + +export default AppAgreement diff --git a/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx new file mode 100644 index 0000000000..0a3fc5f16c --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx @@ -0,0 +1,62 @@ +import { TextField } from '@gnosis.pm/safe-react-components' +import createDecorator from 'final-form-calculate' +import React from 'react' +import { useField, useFormState } from 'react-final-form' +import { useDebounce } from 'use-lodash-debounce' + +import { SafeApp } from 'src/routes/safe/components/Apps/types' +import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils' +import { composeValidators, required } from 'src/components/forms/validator' +import Field from 'src/components/forms/Field' +import { isValid as isURLValid } from 'src/utils/url' +import { isValidEnsName } from 'src/logic/wallets/ethAddresses' + +const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL') + +export const appUrlResolver = createDecorator({ + field: 'appUrl', + updates: { + appUrl: async (appUrl: string): Promise => { + const ensContent = !isURLValid(appUrl) && isValidEnsName(appUrl) && (await getIpfsLinkFromEns(appUrl)) + + if (ensContent) { + return ensContent + } + + return appUrl + }, + }, +}) + +export const AppInfoUpdater = ({ onAppInfo }: { onAppInfo: (appInfo: SafeApp) => void }): React.ReactElement => { + const { + input: { value: appUrl }, + } = useField('appUrl', { subscription: { value: true } }) + const debouncedValue = useDebounce(appUrl, 500) + + React.useEffect(() => { + const updateAppInfo = async () => { + const appInfo = await getAppInfoFromUrl(debouncedValue) + onAppInfo({ ...appInfo }) + } + + if (isURLValid(debouncedValue)) { + updateAppInfo() + } + }, [debouncedValue, onAppInfo]) + + return null +} + +const AppUrl = ({ appList }: { appList: SafeApp[] }): React.ReactElement => { + const { visited } = useFormState({ subscription: { visited: true } }) + + // trick to prevent having the field validated by default. Not sure why this happens in this form + const validate = !visited.appUrl ? undefined : composeValidators(required, validateUrl, uniqueApp(appList)) + + return ( + + ) +} + +export default AppUrl diff --git a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx new file mode 100644 index 0000000000..1537dda196 --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { useFormState } from 'react-final-form' + +import { SafeApp } from 'src/routes/safe/components/Apps/types' + +interface SubmitButtonStatusProps { + appInfo: SafeApp + isSubmitDisabled: (disabled: boolean) => void +} + +const SubmitButtonStatus = ({ appInfo, isSubmitDisabled }: SubmitButtonStatusProps): React.ReactElement => { + const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) + + React.useEffect(() => { + isSubmitDisabled( + validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', + ) + }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, isSubmitDisabled]) + + return null +} + +export default SubmitButtonStatus diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/AddAppForm/index.tsx new file mode 100644 index 0000000000..f7bd2137be --- /dev/null +++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx @@ -0,0 +1,84 @@ +import { Text, TextField } from '@gnosis.pm/safe-react-components' +import React from 'react' +import styled from 'styled-components' + +import AppAgreement from './AppAgreement' +import AppUrl, { AppInfoUpdater, appUrlResolver } from './AppUrl' +import SubmitButtonStatus from './SubmitButtonStatus' + +import { SafeApp } from 'src/routes/safe/components/Apps/types' +import GnoForm from 'src/components/forms/GnoForm' +import Img from 'src/components/layout/Img' +import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' + +const StyledText = styled(Text)` + margin-bottom: 19px; +` + +const StyledTextFileAppName = styled(TextField)` + && { + width: 335px; + } +` + +const AppInfo = styled.div` + margin: 36px 0 24px 0; + + img { + margin-right: 10px; + } +` + +const APP_INFO: SafeApp = { + id: undefined, + url: '', + name: '', + iconUrl: appsIconSvg, + error: false, +} + +interface AddAppProps { + appList: SafeApp[] + closeModal: () => void + formId: string + onAppAdded: (app: SafeApp) => void + setIsSubmitDisabled: (status: boolean) => void +} + +const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => { + const [appInfo, setAppInfo] = React.useState(APP_INFO) + + const initialValues = { + appUrl: '', + agreement: false, + } + + const handleSubmit = () => { + closeModal() + onAppAdded(appInfo) + } + + return ( + + {() => ( + <> + Add custom app + + + + + + Token image + {}} /> + + + + + + + )} + + ) +} + +export default AddApp diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index c76418f25a..0f09f313ba 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -4,6 +4,7 @@ import memoize from 'lodash.memoize' import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' import { getGnosisSafeAppsUrl } from 'src/config/index' import { SafeApp } from './types' +import { getContentFromENS } from '../../../../logic/wallets/getWeb3' const removeLastTrailingSlash = (url) => { if (url.substr(-1) === '/') { @@ -89,3 +90,29 @@ export const getAppInfoFromUrl = memoize( } }, ) + +export const getIpfsLinkFromEns = memoize(async (name: string) => { + try { + const content = await getContentFromENS(name) + if (content && content.protocolType === 'ipfs') { + return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/` + } + } catch (error) { + console.error(error) + return undefined + } +}) + +export const uniqueApp = (appList: SafeApp[]) => (url: string) => { + const exists = appList.some((a) => { + try { + const currentUrl = new URL(a.url) + const newUrl = new URL(url) + return currentUrl.href === newUrl.href + } catch (error) { + console.error('There was a problem trying to validate the URL existence.', error.message) + return false + } + }) + return exists ? 'This app is already registered.' : undefined +} From 7313e78c6dfe78bc1c74e21a72a71ba17299385f Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 17:31:39 -0300 Subject: [PATCH 10/17] fix: change `any` to `unknown` --- src/routes/safe/components/Apps/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index 0f09f313ba..563a1e1dc3 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -33,7 +33,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [ { url: `${gnosisAppsUrl}/tx-builder`, disabled: false }, ] -export const getAppInfoFromOrigin = (origin: string): Record | null => { +export const getAppInfoFromOrigin = (origin: string): Record | null => { try { return JSON.parse(origin) } catch (error) { From 285521ece684fd4968736de88f5a3cac51baa7a2 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Thu, 23 Jul 2020 18:00:10 -0300 Subject: [PATCH 11/17] fix: `uitls.ts` types and imports --- src/routes/safe/components/Apps/utils.ts | 31 +++++++++++++----------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index 563a1e1dc3..21c4ba4ece 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -1,10 +1,11 @@ import axios from 'axios' import memoize from 'lodash.memoize' -import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' -import { getGnosisSafeAppsUrl } from 'src/config/index' import { SafeApp } from './types' -import { getContentFromENS } from '../../../../logic/wallets/getWeb3' + +import { getGnosisSafeAppsUrl } from 'src/config/index' +import { getContentFromENS } from 'src/logic/wallets/getWeb3' +import appsIconSvg from 'src/routes/safe/components/Transactions/TxsTable/TxType/assets/appsIcon.svg' const removeLastTrailingSlash = (url) => { if (url.substr(-1) === '/') { @@ -91,19 +92,21 @@ export const getAppInfoFromUrl = memoize( }, ) -export const getIpfsLinkFromEns = memoize(async (name: string) => { - try { - const content = await getContentFromENS(name) - if (content && content.protocolType === 'ipfs') { - return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/` +export const getIpfsLinkFromEns = memoize( + async (name: string): Promise => { + try { + const content = await getContentFromENS(name) + if (content && content.protocolType === 'ipfs') { + return `${process.env.REACT_APP_IPFS_GATEWAY}/${content.decoded}/` + } + } catch (error) { + console.error(error) + return } - } catch (error) { - console.error(error) - return undefined - } -}) + }, +) -export const uniqueApp = (appList: SafeApp[]) => (url: string) => { +export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => { const exists = appList.some((a) => { try { const currentUrl = new URL(a.url) From bfef9446d8f5425832b5ceebf02ff4bb79582de4 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Fri, 24 Jul 2020 10:13:34 -0300 Subject: [PATCH 12/17] refactor: rename `isSubmitDisabled` to `onSubmitButtonStatusChange` prop --- .../components/Apps/AddAppForm/SubmitButtonStatus.tsx | 8 ++++---- src/routes/safe/components/Apps/AddAppForm/index.tsx | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx index 1537dda196..b43ae7c861 100644 --- a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx @@ -5,17 +5,17 @@ import { SafeApp } from 'src/routes/safe/components/Apps/types' interface SubmitButtonStatusProps { appInfo: SafeApp - isSubmitDisabled: (disabled: boolean) => void + onSubmitButtonStatusChange: (disabled: boolean) => void } -const SubmitButtonStatus = ({ appInfo, isSubmitDisabled }: SubmitButtonStatusProps): React.ReactElement => { +const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): React.ReactElement => { const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) React.useEffect(() => { - isSubmitDisabled( + onSubmitButtonStatusChange( validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', ) - }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, isSubmitDisabled]) + }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, onSubmitButtonStatusChange]) return null } diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/AddAppForm/index.tsx index f7bd2137be..52b4b34b18 100644 --- a/src/routes/safe/components/Apps/AddAppForm/index.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx @@ -42,7 +42,7 @@ interface AddAppProps { closeModal: () => void formId: string onAppAdded: (app: SafeApp) => void - setIsSubmitDisabled: (status: boolean) => void + setIsSubmitDisabled: (disabled: boolean) => void } const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => { @@ -74,7 +74,7 @@ const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled } - + )}
From d679bf9cff7e9f81dab7dbeccc3dfb40790821e6 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Fri, 24 Jul 2020 10:34:10 -0300 Subject: [PATCH 13/17] refactor: rename `agreement` to `agreementAccepted` also, moved `initialValues` to a constant `INITIAL_VALUES` outside the component --- .../components/Apps/AddAppForm/AppAgreement.tsx | 4 ++-- .../safe/components/Apps/AddAppForm/index.tsx | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx index a08c8e1ab6..cc1ba29ae4 100644 --- a/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/AppAgreement.tsx @@ -14,7 +14,7 @@ const AppAgreement = (): React.ReactElement => { const { visited } = useFormState({ subscription: { visited: true } }) // trick to prevent having the field validated by default. Not sure why this happens in this form - const validate = !visited.agreement ? undefined : required + const validate = !visited.agreementAccepted ? undefined : required return ( { at my own risk. } - name="agreement" + name="agreementAccepted" type="checkbox" validate={validate} /> diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/AddAppForm/index.tsx index 52b4b34b18..763231e692 100644 --- a/src/routes/safe/components/Apps/AddAppForm/index.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx @@ -29,6 +29,16 @@ const AppInfo = styled.div` } ` +export interface AddAppFormValues { + appUrl: string + agreementAccepted: boolean +} + +const INITIAL_VALUES: AddAppFormValues = { + appUrl: '', + agreementAccepted: false, +} + const APP_INFO: SafeApp = { id: undefined, url: '', @@ -48,18 +58,13 @@ interface AddAppProps { const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => { const [appInfo, setAppInfo] = React.useState(APP_INFO) - const initialValues = { - appUrl: '', - agreement: false, - } - const handleSubmit = () => { closeModal() onAppAdded(appInfo) } return ( - + {() => ( <> Add custom app From 7c6c9cdab46b2ac0d9f6f4757954115aebb16444 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Fri, 24 Jul 2020 15:35:27 -0300 Subject: [PATCH 14/17] refactor: reimplement `useDebounce` hook in-app --- package.json | 1 - .../components/Apps/AddAppForm/AppUrl.tsx | 2 +- .../safe/container/hooks/useDebounce.tsx | 38 +++++++++++++++++++ yarn.lock | 5 --- 4 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 src/routes/safe/container/hooks/useDebounce.tsx diff --git a/package.json b/package.json index 0adece2448..dbaccc45eb 100644 --- a/package.json +++ b/package.json @@ -218,7 +218,6 @@ "semver": "7.3.2", "styled-components": "^5.0.1", "truffle-contract": "4.0.31", - "use-lodash-debounce": "^1.1.0", "web3": "1.2.9" }, "devDependencies": { diff --git a/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx index 0a3fc5f16c..dfd2a7fd56 100644 --- a/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/AppUrl.tsx @@ -2,7 +2,6 @@ import { TextField } from '@gnosis.pm/safe-react-components' import createDecorator from 'final-form-calculate' import React from 'react' import { useField, useFormState } from 'react-final-form' -import { useDebounce } from 'use-lodash-debounce' import { SafeApp } from 'src/routes/safe/components/Apps/types' import { getAppInfoFromUrl, getIpfsLinkFromEns, uniqueApp } from 'src/routes/safe/components/Apps/utils' @@ -10,6 +9,7 @@ import { composeValidators, required } from 'src/components/forms/validator' import Field from 'src/components/forms/Field' import { isValid as isURLValid } from 'src/utils/url' import { isValidEnsName } from 'src/logic/wallets/ethAddresses' +import { useDebounce } from 'src/routes/safe/container/hooks/useDebounce' const validateUrl = (url: string): string | undefined => (isURLValid(url) ? undefined : 'Invalid URL') diff --git a/src/routes/safe/container/hooks/useDebounce.tsx b/src/routes/safe/container/hooks/useDebounce.tsx new file mode 100644 index 0000000000..e9abbb4329 --- /dev/null +++ b/src/routes/safe/container/hooks/useDebounce.tsx @@ -0,0 +1,38 @@ +import debounce from 'lodash.debounce' +import { useCallback, useEffect, useState, useRef } from 'react' + +/* + This code snippet is copied from https://github.com/gnbaron/use-lodash-debounce + with the sole intention to be able to tweak it if is needed and prevent from having + a new dependency for something relatively trivial +*/ + +interface DebounceOptions { + leading: boolean + maxWait: number + trailing: boolean +} + +export const useDebouncedCallback = unknown>( + callback: T, + delay = 0, + options: DebounceOptions, +): T & { cancel: () => void } => useCallback(debounce(callback, delay, options), [callback, delay, options]) + +export const useDebounce = (value: T, delay = 0, options?: DebounceOptions): T => { + const previousValue = useRef(value) + const [current, setCurrent] = useState(value) + const debouncedCallback = useDebouncedCallback((value: T) => setCurrent(value), delay, options) + + useEffect(() => { + // does trigger the debounce timer initially + if (value !== previousValue.current) { + debouncedCallback(value) + previousValue.current = value + // cancel the debounced callback on clean up + return debouncedCallback.cancel + } + }, [debouncedCallback, value]) + + return current +} diff --git a/yarn.lock b/yarn.lock index 1166f0eea7..668bf72103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16868,11 +16868,6 @@ usb@^1.6.3: nan "2.13.2" prebuild-install "^5.3.3" -use-lodash-debounce@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/use-lodash-debounce/-/use-lodash-debounce-1.1.0.tgz#c31826a7c98788cb1810615e1ad102fc38598fe7" - integrity sha512-VNrIm8hpw4N6JG0cmtxz6w4d4eVGfJM1GkiAEBCqkRBl1GJF05+2ikqOUHqwML+8ppHxin0QnFfJl4jqmK1Fjw== - use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From c80db53d7c5d993d43463ef05d754558f640c4a4 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Fri, 24 Jul 2020 17:10:54 -0300 Subject: [PATCH 15/17] refactor: extract app manifest verification to a helper function also fixed types --- .../Apps/AddAppForm/SubmitButtonStatus.tsx | 14 +++++++++----- .../safe/components/Apps/AddAppForm/index.tsx | 1 + src/routes/safe/components/Apps/index.tsx | 2 +- src/routes/safe/components/Apps/types.d.ts | 1 + src/routes/safe/components/Apps/utils.ts | 16 ++++++++++++++-- .../Transactions/TxsTable/TxType/index.tsx | 12 +++++++++--- 6 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx index b43ae7c861..97a209f872 100644 --- a/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useFormState } from 'react-final-form' import { SafeApp } from 'src/routes/safe/components/Apps/types' +import { isAppManifestValid } from 'src/routes/safe/components/Apps/utils' interface SubmitButtonStatusProps { appInfo: SafeApp @@ -9,13 +10,16 @@ interface SubmitButtonStatusProps { } const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): React.ReactElement => { - const { valid, validating, values } = useFormState({ subscription: { valid: true, validating: true, values: true } }) + const { valid, validating, visited } = useFormState({ + subscription: { valid: true, validating: true, visited: true }, + }) React.useEffect(() => { - onSubmitButtonStatusChange( - validating || !valid || appInfo.error || !appInfo.url || !appInfo.name || appInfo.name === 'unknown', - ) - }, [validating, valid, appInfo.error, appInfo.url, appInfo.name, values, onSubmitButtonStatusChange]) + // if non visited, fields were not evaluated yet. Then, the default value is considered invalid + const fieldsVisited = visited.agreementAccepted && visited.appUrl + + onSubmitButtonStatusChange(validating || !valid || !fieldsVisited || !isAppManifestValid(appInfo)) + }, [validating, valid, visited, onSubmitButtonStatusChange, appInfo]) return null } diff --git a/src/routes/safe/components/Apps/AddAppForm/index.tsx b/src/routes/safe/components/Apps/AddAppForm/index.tsx index 763231e692..cfcbdf6f87 100644 --- a/src/routes/safe/components/Apps/AddAppForm/index.tsx +++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx @@ -45,6 +45,7 @@ const APP_INFO: SafeApp = { name: '', iconUrl: appsIconSvg, error: false, + description: '', } interface AddAppProps { diff --git a/src/routes/safe/components/Apps/index.tsx b/src/routes/safe/components/Apps/index.tsx index 2606f6f999..116a9cde32 100644 --- a/src/routes/safe/components/Apps/index.tsx +++ b/src/routes/safe/components/Apps/index.tsx @@ -322,7 +322,7 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) { try { const currentApp = list[index] - const appInfo: any = await getAppInfoFromUrl(currentApp.url) + const appInfo: SafeApp = await getAppInfoFromUrl(currentApp.url) if (appInfo.error) { throw Error(`There was a problem trying to load app ${currentApp.url}`) } diff --git a/src/routes/safe/components/Apps/types.d.ts b/src/routes/safe/components/Apps/types.d.ts index 8a8ead9b6c..ab285046fd 100644 --- a/src/routes/safe/components/Apps/types.d.ts +++ b/src/routes/safe/components/Apps/types.d.ts @@ -5,6 +5,7 @@ export type SafeApp = { iconUrl: string disabled?: boolean error: boolean + description: string } export type StoredSafeApp = { diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index d7266b630d..4057b5e76d 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -43,9 +43,21 @@ export const getAppInfoFromOrigin = (origin: string): Record | } } +export const isAppManifestValid = (appInfo: SafeApp): boolean => + // `appInfo` exists and `name` exists + !!appInfo?.name && + // if `name` exists is not 'unknown' + appInfo.name !== 'unknown' && + // `description` exists + !!appInfo.description && + // `url` exists + !!appInfo.url && + // no `error` (or `error` undefined) + !appInfo.error + export const getAppInfoFromUrl = memoize( async (appUrl?: string): Promise => { - let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true } + let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' } if (!appUrl?.length) { return res @@ -58,7 +70,7 @@ export const getAppInfoFromUrl = memoize( const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`) // verify imported app fulfil safe requirements - if (!appInfo || !appInfo.data || !appInfo.data.name || !appInfo.data.description) { + if (!appInfo?.data || isAppManifestValid(appInfo.data)) { throw Error('The app does not fulfil the structure required.') } diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx index 487f628a7c..fc060e604d 100644 --- a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx @@ -8,6 +8,7 @@ import SettingsTxIcon from './assets/settings.svg' import CustomIconText from 'src/components/CustomIconText' import { getAppInfoFromOrigin, getAppInfoFromUrl } from 'src/routes/safe/components/Apps/utils' +import { SafeApp } from 'src/routes/safe/components/Apps/types' const typeToIcon = { outgoing: OutgoingTxIcon, @@ -33,9 +34,14 @@ const typeToLabel = { upgrade: 'Contract Upgrade', } -const TxType = ({ origin, txType }: any) => { +interface TxTypeProps { + origin?: string + txType: keyof typeof typeToLabel +} + +const TxType = ({ origin, txType }: TxTypeProps): React.ReactElement => { const [loading, setLoading] = useState(true) - const [appInfo, setAppInfo] = useState() + const [appInfo, setAppInfo] = useState() const [forceCustom, setForceCustom] = useState(false) useEffect(() => { @@ -46,7 +52,7 @@ const TxType = ({ origin, txType }: any) => { setLoading(false) return } - const appInfo = await getAppInfoFromUrl(parsedOrigin.url) + const appInfo: SafeApp = await getAppInfoFromUrl(parsedOrigin.url) setAppInfo(appInfo) setLoading(false) } From 41330c529ff944db725b2ba2337c4d156f22fdf2 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Fri, 24 Jul 2020 17:26:03 -0300 Subject: [PATCH 16/17] fix: prevent accessing `contentWindow` if `iframe` is `null` --- src/routes/safe/components/Apps/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/safe/components/Apps/index.tsx b/src/routes/safe/components/Apps/index.tsx index 9c059156fa..456ea9b58b 100644 --- a/src/routes/safe/components/Apps/index.tsx +++ b/src/routes/safe/components/Apps/index.tsx @@ -226,9 +226,9 @@ function Apps({ closeModal, closeSnackbar, enqueueSnackbar, openModal }) { const sendMessageToIframe = useCallback( (messageId, data) => { const app = getSelectedApp() - iframeEl.contentWindow.postMessage({ messageId, data }, app.url) + iframeEl?.contentWindow.postMessage({ messageId, data }, app.url) }, - [getSelectedApp, iframeEl.contentWindow], + [getSelectedApp, iframeEl], ) // handle messages from iframe From cafafc881c1d566585fdb57f49f5a9d9ae5d04a8 Mon Sep 17 00:00:00 2001 From: fernandomg Date: Wed, 29 Jul 2020 11:09:57 -0300 Subject: [PATCH 17/17] fix: `getAppInfoFromOrigin` return type also, removed the expected type for the `getAppInfoFromOrigin` calls as it is inferred --- src/routes/safe/components/Apps/utils.ts | 2 +- .../safe/components/Transactions/TxsTable/TxType/index.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/routes/safe/components/Apps/utils.ts b/src/routes/safe/components/Apps/utils.ts index 124f4a648e..37233d2120 100644 --- a/src/routes/safe/components/Apps/utils.ts +++ b/src/routes/safe/components/Apps/utils.ts @@ -34,7 +34,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [ { url: `${gnosisAppsUrl}/tx-builder`, disabled: false }, ] -export const getAppInfoFromOrigin = (origin: string): Record | null => { +export const getAppInfoFromOrigin = (origin: string): Record | null => { try { return JSON.parse(origin) } catch (error) { diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx index fc060e604d..1bb36781d0 100644 --- a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx +++ b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx @@ -47,12 +47,15 @@ const TxType = ({ origin, txType }: TxTypeProps): React.ReactElement => { useEffect(() => { const getAppInfo = async () => { const parsedOrigin = getAppInfoFromOrigin(origin) + if (!parsedOrigin) { setForceCustom(true) setLoading(false) return } - const appInfo: SafeApp = await getAppInfoFromUrl(parsedOrigin.url) + + const appInfo = await getAppInfoFromUrl(parsedOrigin.url) + setAppInfo(appInfo) setLoading(false) }