diff --git a/package.json b/package.json
index 76da2899..40f5fa81 100644
--- a/package.json
+++ b/package.json
@@ -191,6 +191,7 @@
"immortal-db": "^1.0.3",
"immutable": "^4.0.0-rc.12",
"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",
diff --git a/src/routes/safe/components/Apps/AddAppForm.tsx b/src/routes/safe/components/Apps/AddAppForm.tsx
deleted file mode 100644
index a422faa2..00000000
--- a/src/routes/safe/components/Apps/AddAppForm.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-import { Checkbox, Text, TextField } from '@gnosis.pm/safe-react-components'
-import memoize from 'lodash.memoize'
-import React, { useState } from 'react'
-import { FormSpy } from 'react-final-form'
-import styled from 'styled-components'
-
-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 Img from 'src/components/layout/Img'
-import { getContentFromENS } from 'src/logic/wallets/getWeb3'
-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: '',
- 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 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) => {
- 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
- }
-})
-
-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 = memoize((appList) => async (value: string) => {
- const url = await getUrlFromFormValue(value)
-
- if (!url) {
- return 'Provide a valid url or ENS name.'
- }
-
- 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 composeValidatorsApps = (...validators) => (value, values, meta) => {
- if (!meta.modified) {
- return
- }
- return validators.reduce((error, validator) => error || validator(value), undefined)
-}
-
-type Props = {
- formId: string
- appList: Array
- closeModal: () => void
- onAppAdded: (app: SafeApp) => void
- setIsSubmitDisabled: (status: boolean) => void
-}
-
-const AddAppForm = ({ appList, formId, closeModal, onAppAdded, setIsSubmitDisabled }: Props) => {
- const [appInfo, setAppInfo] = useState(APP_INFO)
- const safeAppValidator = curriedSafeAppValidator(appList)
-
- const onFormStatusChange = async ({ pristine, valid, validating, values, errors }) => {
- if (!pristine) {
- setIsSubmitDisabled(validating || !valid)
- }
-
- if (validating) {
- return
- }
-
- if (!values.appUrl || !values.appUrl.length || errors.appUrl) {
- setAppInfo(APP_INFO)
- return
- }
-
- const url = await getUrlFromFormValue(values.appUrl)
- const appInfo = await getAppInfoFromUrl(url)
- setAppInfo({ ...appInfo })
- }
-
- const handleSubmit = () => {
- closeModal()
- onAppAdded(appInfo)
- }
-
- const onTextFieldChange = () => {}
-
- return (
-
- {() => (
- <>
- Add custom app
-
-
-
-
-
-
-
-
-
-
- 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
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 00000000..cc1ba29a
--- /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.agreementAccepted ? undefined : required
+
+ return (
+
+ This app is not a Gnosis product and I agree to use this app
+
+ at my own risk.
+
+ }
+ name="agreementAccepted"
+ 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 00000000..dfd2a7fd
--- /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 { 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'
+import { useDebounce } from 'src/routes/safe/container/hooks/useDebounce'
+
+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 00000000..97a209f8
--- /dev/null
+++ b/src/routes/safe/components/Apps/AddAppForm/SubmitButtonStatus.tsx
@@ -0,0 +1,27 @@
+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
+ onSubmitButtonStatusChange: (disabled: boolean) => void
+}
+
+const SubmitButtonStatus = ({ appInfo, onSubmitButtonStatusChange }: SubmitButtonStatusProps): React.ReactElement => {
+ const { valid, validating, visited } = useFormState({
+ subscription: { valid: true, validating: true, visited: true },
+ })
+
+ React.useEffect(() => {
+ // 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
+}
+
+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 00000000..cfcbdf6f
--- /dev/null
+++ b/src/routes/safe/components/Apps/AddAppForm/index.tsx
@@ -0,0 +1,90 @@
+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;
+ }
+`
+
+export interface AddAppFormValues {
+ appUrl: string
+ agreementAccepted: boolean
+}
+
+const INITIAL_VALUES: AddAppFormValues = {
+ appUrl: '',
+ agreementAccepted: false,
+}
+
+const APP_INFO: SafeApp = {
+ id: undefined,
+ url: '',
+ name: '',
+ iconUrl: appsIconSvg,
+ error: false,
+ description: '',
+}
+
+interface AddAppProps {
+ appList: SafeApp[]
+ closeModal: () => void
+ formId: string
+ onAppAdded: (app: SafeApp) => void
+ setIsSubmitDisabled: (disabled: boolean) => void
+}
+
+const AddApp = ({ appList, closeModal, formId, onAppAdded, setIsSubmitDisabled }: AddAppProps): React.ReactElement => {
+ const [appInfo, setAppInfo] = React.useState(APP_INFO)
+
+ const handleSubmit = () => {
+ closeModal()
+ onAppAdded(appInfo)
+ }
+
+ return (
+
+ {() => (
+ <>
+ Add custom app
+
+
+
+
+
+
+ {}} />
+
+
+
+
+
+ >
+ )}
+
+ )
+}
+
+export default AddApp
diff --git a/src/routes/safe/components/Apps/ManageApps.tsx b/src/routes/safe/components/Apps/ManageApps.tsx
index b8928516..404d2e12 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,30 +32,30 @@ 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 && (
- }
+ formBody={Form}
isSubmitFormDisabled={isSubmitDisabled}
itemList={getItemList()}
onClose={closeModal}
diff --git a/src/routes/safe/components/Apps/index.tsx b/src/routes/safe/components/Apps/index.tsx
index 456ea9b5..afea1955 100644
--- a/src/routes/safe/components/Apps/index.tsx
+++ b/src/routes/safe/components/Apps/index.tsx
@@ -338,7 +338,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 8a8ead9b..ab285046 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 ccf77cdb..37233d21 100644
--- a/src/routes/safe/components/Apps/utils.ts
+++ b/src/routes/safe/components/Apps/utils.ts
@@ -1,9 +1,12 @@
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 { 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) === '/') {
return url.substr(0, url.length - 1)
@@ -31,7 +34,7 @@ export const staticAppsList: Array<{ url: string; disabled: boolean }> = [
{ url: `${gnosisAppsUrl}/tx-builder`, disabled: false },
]
-export const getAppInfoFromOrigin = (origin) => {
+export const getAppInfoFromOrigin = (origin: string): Record | null => {
try {
return JSON.parse(origin)
} catch (error) {
@@ -40,49 +43,91 @@ export const getAppInfoFromOrigin = (origin) => {
}
}
-export const getAppInfoFromUrl = async (appUrl?: string): Promise => {
- let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true }
+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
- if (!appUrl?.length) {
- return res
- }
+export const getAppInfoFromUrl = memoize(
+ async (appUrl?: string): Promise => {
+ let res = { id: undefined, url: appUrl, name: 'unknown', iconUrl: appsIconSvg, error: true, description: '' }
- res.url = appUrl.trim()
- const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
-
- try {
- const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
-
- // 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.')
+ if (!appUrl?.length) {
+ return res
}
- // the DB origin field has a limit of 100 characters
- const originFieldSize = 100
- const jsonDataLength = 20
- const remainingSpace = originFieldSize - res.url.length - jsonDataLength
+ res.url = appUrl.trim()
+ const noTrailingSlashUrl = removeLastTrailingSlash(res.url)
- res = {
- ...res,
- ...appInfo.data,
- id: JSON.stringify({ url: res.url, name: appInfo.data.name.substring(0, remainingSpace) }),
- error: false,
- }
+ try {
+ const appInfo = await axios.get(`${noTrailingSlashUrl}/manifest.json`, { timeout: 5_000 })
- 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}`)
+ // verify imported app fulfil safe requirements
+ if (!appInfo?.data || isAppManifestValid(appInfo.data)) {
+ 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
+
+ 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}`
+ }
+ } 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
- }
+ },
+)
+
+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
+ }
+ },
+)
+
+export const uniqueApp = (appList: SafeApp[]) => (url: string): string | undefined => {
+ 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
}
diff --git a/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx b/src/routes/safe/components/Transactions/TxsTable/TxType/index.tsx
index 487f628a..1bb36781 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,20 +34,28 @@ 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(() => {
const getAppInfo = async () => {
const parsedOrigin = getAppInfoFromOrigin(origin)
+
if (!parsedOrigin) {
setForceCustom(true)
setLoading(false)
return
}
+
const appInfo = await getAppInfoFromUrl(parsedOrigin.url)
+
setAppInfo(appInfo)
setLoading(false)
}
diff --git a/src/routes/safe/container/hooks/useDebounce.tsx b/src/routes/safe/container/hooks/useDebounce.tsx
new file mode 100644
index 00000000..e9abbb43
--- /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 cc015c98..5228fdda 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10933,6 +10933,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"