diff --git a/package.json b/package.json index 56d9711e1c..b56d5089b7 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@openzeppelin/contracts": "4.4.2", "@sentry/react": "^6.10.0", "@sentry/tracing": "^6.10.0", + "@truffle/hdwallet-provider": "^2.0.8", "@unstoppabledomains/resolution": "^1.17.0", "abi-decoder": "^2.4.0", "axios": "0.21.4", @@ -139,8 +140,7 @@ "web3": "1.7.0", "web3-core": "^1.7.0", "web3-eth-contract": "^1.7.0", - "web3-utils": "^1.7.0", - "@truffle/hdwallet-provider": "^2.0.8" + "web3-utils": "^1.7.0" }, "devDependencies": { "@gnosis.pm/safe-core-sdk-types": "1.0.0", diff --git a/src/components/InfiniteScroll/index.tsx b/src/components/InfiniteScroll/index.tsx index 75be255e7b..44ebd1ffb5 100644 --- a/src/components/InfiniteScroll/index.tsx +++ b/src/components/InfiniteScroll/index.tsx @@ -29,12 +29,11 @@ InfiniteScrollProvider.displayName = 'InfiniteScrollProvider' type InfiniteScrollProps = { children: ReactNode - hasMore: boolean next: () => Promise config?: InViewHookResponse } -export const InfiniteScroll = ({ children, hasMore, next, config }: InfiniteScrollProps): ReactElement => { +export const InfiniteScroll = ({ children, next, config }: InfiniteScrollProps): ReactElement => { const { ref, inView } = useInView({ threshold: 0, root: document.querySelector(`#${INFINITE_SCROLL_CONTAINER}`), @@ -47,14 +46,14 @@ export const InfiniteScroll = ({ children, hasMore, next, config }: InfiniteScro // Avoid memory leak - queue/history have separate InfiniteScroll wrappers let isMounted = true - if (isMounted && inView && hasMore) { + if (isMounted && inView) { next() } return () => { isMounted = false } - }, [inView, hasMore, next]) + }, [inView, next]) return {children} } diff --git a/src/logic/currencyValues/store/actions/updateAvailableCurrencies.ts b/src/logic/currencyValues/store/actions/updateAvailableCurrencies.ts index 87a731b2df..2260d7f354 100644 --- a/src/logic/currencyValues/store/actions/updateAvailableCurrencies.ts +++ b/src/logic/currencyValues/store/actions/updateAvailableCurrencies.ts @@ -3,8 +3,8 @@ import { ThunkDispatch } from 'redux-thunk' import { AppReduxState } from 'src/store' import { AvailableCurrenciesPayload } from 'src/logic/currencyValues/store/reducer/currencyValues' import { setAvailableCurrencies } from 'src/logic/currencyValues/store/actions/setAvailableCurrencies' -import { Errors, logError } from 'src/logic/exceptions/CodedException' import { getFiatCurrencies } from '@gnosis.pm/safe-react-gateway-sdk' +import { Errors, logError } from 'src/logic/exceptions/CodedException' export const updateAvailableCurrencies = () => diff --git a/src/logic/safe/store/actions/transactions/fetchTransactions/index.ts b/src/logic/safe/store/actions/transactions/fetchTransactions/index.ts index 67f58f9040..1d53caee7e 100644 --- a/src/logic/safe/store/actions/transactions/fetchTransactions/index.ts +++ b/src/logic/safe/store/actions/transactions/fetchTransactions/index.ts @@ -7,23 +7,30 @@ import { } from 'src/logic/safe/store/actions/transactions/gatewayTransactions' import { loadHistoryTransactions, loadQueuedTransactions } from './loadGatewayTransactions' import { AppReduxState } from 'src/store' +import { history } from 'src/routes/routes' +import { isTxFilter } from 'src/routes/safe/components/Transactions/TxList/Filter/utils' export default (chainId: string, safeAddress: string) => async (dispatch: ThunkDispatch): Promise => { - const loadTxs = async ( - loadFn: typeof loadHistoryTransactions | typeof loadQueuedTransactions, - actionFn: typeof addHistoryTransactions | typeof addQueuedTransactions, - ) => { + const loadHistory = async () => { try { - const values = (await loadFn(safeAddress)) as any[] - dispatch(actionFn({ chainId, safeAddress, values })) + const query = Object.fromEntries(new URLSearchParams(history.location.search)) + const filter = isTxFilter(query) ? query : undefined + const values = await loadHistoryTransactions(safeAddress, filter) + dispatch(addHistoryTransactions({ chainId, safeAddress, values })) } catch (e) { e.log() } } - await Promise.all([ - loadTxs(loadHistoryTransactions, addHistoryTransactions), - loadTxs(loadQueuedTransactions, addQueuedTransactions), - ]) + const loadQueue = async () => { + try { + const values = await loadQueuedTransactions(safeAddress) + dispatch(addQueuedTransactions({ chainId, safeAddress, values })) + } catch (e) { + e.log() + } + } + + await Promise.all([loadHistory(), loadQueue()]) } diff --git a/src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions.ts b/src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions.ts index 4872360361..cb58790b22 100644 --- a/src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions.ts +++ b/src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions.ts @@ -1,13 +1,99 @@ -import { getTransactionHistory, getTransactionQueue } from '@gnosis.pm/safe-react-gateway-sdk' +import { + getTransactionHistory, + getTransactionQueue, + TransactionListPage, + getIncomingTransfers, + getMultisigTransactions, + getModuleTransactions, +} from '@gnosis.pm/safe-react-gateway-sdk' import { _getChainId } from 'src/config' import { HistoryGatewayResponse, QueuedGatewayResponse } from 'src/logic/safe/store/models/types/gateway.d' import { checksumAddress } from 'src/utils/checksumAddress' import { Errors, CodedException } from 'src/logic/exceptions/CodedException' +import { FilterForm, FilterType, FILTER_TYPE_FIELD_NAME } from 'src/routes/safe/components/Transactions/TxList/Filter' +import { + getIncomingFilter, + getMultisigFilter, + getModuleFilter, +} from 'src/routes/safe/components/Transactions/TxList/Filter/utils' +import { ChainId } from 'src/config/chain.d' +import { operations } from '@gnosis.pm/safe-react-gateway-sdk/dist/types/api' /*************/ /* HISTORY */ /*************/ -const historyPointers: { [chainId: string]: { [safeAddress: string]: { next?: string; previous?: string } } } = {} +const historyPointers: { + [chainId: string]: { + [safeAddress: string]: { + next?: string + previous?: string + } + } +} = {} + +const getHistoryTxListPage = async ( + chainId: ChainId, + safeAddress: string, + filter?: FilterForm | Partial, +): Promise => { + let txListPage: TransactionListPage = { + next: undefined, + previous: undefined, + results: [], + } + + const { next } = historyPointers[chainId]?.[safeAddress] || {} + + let query: + | operations['incoming_transfers' | 'incoming_transfers' | 'module_transactions']['parameters']['query'] + | undefined + + switch (filter?.[FILTER_TYPE_FIELD_NAME]) { + case FilterType.INCOMING: { + query = filter ? getIncomingFilter(filter) : undefined + txListPage = await getIncomingTransfers(chainId, safeAddress, query, next) + break + } + case FilterType.MULTISIG: { + query = filter ? getMultisigFilter(filter, true) : undefined + txListPage = await getMultisigTransactions(chainId, safeAddress, query, next) + break + } + case FilterType.MODULE: { + query = filter ? getModuleFilter(filter) : undefined + txListPage = await getModuleTransactions(chainId, safeAddress, query, next) + break + } + default: { + txListPage = await getTransactionHistory(chainId, safeAddress, next) + } + } + + const getPageUrl = (pageUrl?: string): string | undefined => { + if (!pageUrl || !query) { + return pageUrl + } + + let url: URL + + try { + url = new URL(pageUrl) + } catch { + return pageUrl + } + + Object.entries(query).forEach(([key, value]) => { + url.searchParams.set(key, String(value)) + }) + + return url.toString() + } + + historyPointers[chainId][safeAddress].next = getPageUrl(txListPage?.next) + historyPointers[chainId][safeAddress].previous = getPageUrl(txListPage?.previous) + + return txListPage +} /** * Fetch next page if there is a next pointer for the safeAddress. @@ -18,39 +104,39 @@ export const loadPagedHistoryTransactions = async ( safeAddress: string, ): Promise<{ values: HistoryGatewayResponse['results']; next?: string } | undefined> => { const chainId = _getChainId() - // if `historyPointers[safeAddress] is `undefined` it means `loadHistoryTransactions` wasn't called - // if `historyPointers[safeAddress].next is `null`, it means it reached the last page in gateway-client - if (!historyPointers[chainId][safeAddress]?.next) { + + if (!historyPointers[chainId]?.[safeAddress]?.next) { throw new CodedException(Errors._608) } try { - const { results, next, previous } = await getTransactionHistory( - chainId, - checksumAddress(safeAddress), - historyPointers[chainId][safeAddress].next, - ) - - historyPointers[chainId][safeAddress] = { next, previous } + const { results, next } = await getHistoryTxListPage(chainId, safeAddress) - return { values: results, next: historyPointers[chainId][safeAddress].next } + return { values: results, next } } catch (e) { throw new CodedException(Errors._602, e.message) } } -export const loadHistoryTransactions = async (safeAddress: string): Promise => { +export const loadHistoryTransactions = async ( + safeAddress: string, + filter?: FilterForm | Partial, +): Promise => { const chainId = _getChainId() - try { - const { results, next, previous } = await getTransactionHistory(chainId, checksumAddress(safeAddress)) - if (!historyPointers[chainId]) { - historyPointers[chainId] = {} - } + if (!historyPointers[chainId]) { + historyPointers[chainId] = {} + } - if (!historyPointers[chainId][safeAddress]) { - historyPointers[chainId][safeAddress] = { next, previous } + if (!historyPointers[chainId][safeAddress] || filter) { + historyPointers[chainId][safeAddress] = { + next: undefined, + previous: undefined, } + } + + try { + const { results } = await getHistoryTxListPage(chainId, safeAddress, filter) return results } catch (e) { diff --git a/src/logic/safe/store/actions/transactions/gatewayTransactions.ts b/src/logic/safe/store/actions/transactions/gatewayTransactions.ts index fd0109e534..9fd0b95669 100644 --- a/src/logic/safe/store/actions/transactions/gatewayTransactions.ts +++ b/src/logic/safe/store/actions/transactions/gatewayTransactions.ts @@ -1,9 +1,12 @@ import { createAction } from 'redux-actions' -import { HistoryPayload, QueuedPayload } from 'src/logic/safe/store/reducer/gatewayTransactions' +import { HistoryPayload, QueuedPayload, RemoveHistoryPayload } from 'src/logic/safe/store/reducer/gatewayTransactions' export const ADD_HISTORY_TRANSACTIONS = 'ADD_HISTORY_TRANSACTIONS' export const addHistoryTransactions = createAction(ADD_HISTORY_TRANSACTIONS) +export const REMOVE_HISTORY_TRANSACTIONS = 'REMOVE_HISTORY_TRANSACTIONS' +export const removeHistoryTransactions = createAction(REMOVE_HISTORY_TRANSACTIONS) + export const ADD_QUEUED_TRANSACTIONS = 'ADD_QUEUED_TRANSACTIONS' export const addQueuedTransactions = createAction(ADD_QUEUED_TRANSACTIONS) diff --git a/src/logic/safe/store/reducer/gatewayTransactions.ts b/src/logic/safe/store/reducer/gatewayTransactions.ts index 9c146ad918..8814deb7df 100644 --- a/src/logic/safe/store/reducer/gatewayTransactions.ts +++ b/src/logic/safe/store/reducer/gatewayTransactions.ts @@ -6,6 +6,7 @@ import { LabelValue } from '@gnosis.pm/safe-react-gateway-sdk' import { ADD_HISTORY_TRANSACTIONS, ADD_QUEUED_TRANSACTIONS, + REMOVE_HISTORY_TRANSACTIONS, } from 'src/logic/safe/store/actions/transactions/gatewayTransactions' import { HistoryGatewayResponse, @@ -31,6 +32,8 @@ type BasePayload = { chainId: string; safeAddress: string; isTail?: boolean } export type HistoryPayload = BasePayload & { values: HistoryGatewayResponse['results'] } +export type RemoveHistoryPayload = Omit + export type QueuedPayload = BasePayload & { values: QueuedGatewayResponse['results'] } export type TransactionDetailsPayload = { @@ -40,7 +43,7 @@ export type TransactionDetailsPayload = { value: Transaction['txDetails'] } -type Payload = HistoryPayload | QueuedPayload | TransactionDetailsPayload +type Payload = HistoryPayload | RemoveHistoryPayload | QueuedPayload | TransactionDetailsPayload /** * Create a hash map of transactions by nonce. @@ -106,6 +109,19 @@ export const gatewayTransactionsReducer = handleActions) => { + const { chainId, safeAddress } = action.payload + return { + ...state, + [chainId]: { + [safeAddress]: { + ...state[chainId]?.[safeAddress], + history: {}, + }, + }, + } + }, + // Queue is overwritten completely on every update // CGW sends a list of items where some items are LABELS (next and queued), // some are CONFLICT_HEADERS (ignored), diff --git a/src/routes/safe/components/CurrencyDropdown/CurrencyDropdown.test.tsx b/src/routes/safe/components/CurrencyDropdown/CurrencyDropdown.test.tsx index ca84ae1509..5fcecb2c4c 100644 --- a/src/routes/safe/components/CurrencyDropdown/CurrencyDropdown.test.tsx +++ b/src/routes/safe/components/CurrencyDropdown/CurrencyDropdown.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, render, screen, getByText, waitFor, queryByText } from 'src/ import { CurrencyDropdown } from '.' import { history, ROOT_ROUTE } from 'src/routes/routes' import { mockedEndpoints } from 'src/setupTests' -import {} from 'src/utils/constants' const mockedAvailableCurrencies = ['USD', 'EUR', 'AED', 'AFN', 'ALL', 'ARS'] const rinkebyNetworkId = '4' diff --git a/src/routes/safe/components/Transactions/TxList/Filter/RHFAddressSearchField.tsx b/src/routes/safe/components/Transactions/TxList/Filter/RHFAddressSearchField.tsx index 0fcf29db40..d823c59d71 100644 --- a/src/routes/safe/components/Transactions/TxList/Filter/RHFAddressSearchField.tsx +++ b/src/routes/safe/components/Transactions/TxList/Filter/RHFAddressSearchField.tsx @@ -5,6 +5,7 @@ import Autocomplete from '@material-ui/lab/Autocomplete/Autocomplete' import TextField from '@material-ui/core/TextField/TextField' import InputAdornment from '@material-ui/core/InputAdornment/InputAdornment' import CircularProgress from '@material-ui/core/CircularProgress/CircularProgress' +import styled from 'styled-components' import { currentNetworkAddressBook } from 'src/logic/addressBook/store/selectors' import { isValidEnsName, isValidCryptoDomainName } from 'src/logic/wallets/ethAddresses' @@ -16,21 +17,32 @@ import { checksumAddress } from 'src/utils/checksumAddress' type Props = { name: Path - hiddenName: Path methods: UseFormReturn label: string } +const StyledAutocomplete = styled(Autocomplete)` + .MuiAutocomplete-input:not([value='']) { + text-overflow: ellipsis; + overflow: hidden !important; + padding-right: 34px !important; + + + .MuiAutocomplete-clearIndicator { + visibility: visible; + } + } +` + const RHFAddressSearchField = ({ name, - hiddenName, - methods: { control, register }, + methods: { control }, label, ...props }: Props): ReactElement => { const addressBookOnChain = useSelector(currentNetworkAddressBook) const [isResolving, setIsResolving] = useState(false) + const [inputValue, setInputValue] = useState('') // Field that holds the address value const { field, fieldState } = useController({ @@ -45,15 +57,9 @@ const RHFAddressSearchField = ({ }, }) - // Field that holds the input value - const { field: hiddenField } = useController({ - name: hiddenName, - control, - }) - // On autocomplete selection/text input find address from address book/resolve ENS domain const onInputChange = async (newValue: string) => { - hiddenField.onChange(newValue) + setInputValue(newValue) const addressBookEntry = addressBookOnChain.find(({ name }) => name === newValue) if (addressBookEntry) { @@ -87,42 +93,39 @@ const RHFAddressSearchField = ({ } return ( - <> - - name} - onInputChange={(_, value) => onInputChange(value)} - renderInput={({ inputProps, InputProps, ...params }) => ( - - - - ) : ( - InputProps.endAdornment - ), - }} - /> - )} - /> - + name} + onInputChange={(_, value) => onInputChange(value)} + renderInput={({ inputProps, InputProps, ...params }) => ( + + + + ) : ( + InputProps.endAdornment + ), + }} + /> + )} + /> ) } diff --git a/src/routes/safe/components/Transactions/TxList/Filter/RHFModuleSearchField.tsx b/src/routes/safe/components/Transactions/TxList/Filter/RHFModuleSearchField.tsx deleted file mode 100644 index af3d136684..0000000000 --- a/src/routes/safe/components/Transactions/TxList/Filter/RHFModuleSearchField.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { ReactElement } from 'react' -import { Control, FieldValues, Path, useController, UseControllerProps } from 'react-hook-form' -import Autocomplete from '@material-ui/lab/Autocomplete/Autocomplete' -import TextField from '@material-ui/core/TextField/TextField' -import { SettingsInfo, SettingsInfoType } from '@gnosis.pm/safe-react-gateway-sdk' - -type Props = { - name: Path - control: Control - rules?: UseControllerProps['rules'] - label: string -} - -// TODO: Create enum in the types for these types -const MODULES: { label: string; type: SettingsInfo['type'] }[] = [ - { - type: SettingsInfoType.SET_FALLBACK_HANDLER, - label: 'Set fallback handler', - }, - { - type: SettingsInfoType.ADD_OWNER, - label: 'Add owner', - }, - { - type: SettingsInfoType.REMOVE_OWNER, - label: 'Remove owner', - }, - { - type: SettingsInfoType.SWAP_OWNER, - label: 'Swap owner', - }, - { - type: SettingsInfoType.CHANGE_THRESHOLD, - label: 'Change required confirmations', - }, - { - type: SettingsInfoType.CHANGE_IMPLEMENTATION, - label: 'Change implementation', - }, - { - type: SettingsInfoType.ENABLE_MODULE, - label: 'Enable module', - }, - { - type: SettingsInfoType.DISABLE_MODULE, - label: 'Disable module', - }, - { - type: SettingsInfoType.SET_GUARD, - label: 'Set guard', - }, - { - type: SettingsInfoType.DELETE_GUARD, - label: 'Delete guard', - }, -] - -const isValidModule = (module: SettingsInfo['type']): string | undefined => { - if (module && MODULES.every(({ type }) => type !== module)) { - return 'Invalid module' - } -} - -const RHFModuleSearchField = ({ name, control, ...props }: Props): ReactElement => { - const { field, fieldState } = useController({ - name, - control, - rules: { - validate: isValidModule, - }, - }) - - return ( - label} - onChange={(_, module) => field.onChange(module?.type)} - noOptionsText="No module found" - renderInput={(params) => ( - - )} - /> - ) -} - -export default RHFModuleSearchField diff --git a/src/routes/safe/components/Transactions/TxList/Filter/RHFTextField.tsx b/src/routes/safe/components/Transactions/TxList/Filter/RHFTextField.tsx index a0ede4569c..5af9c7ddf3 100644 --- a/src/routes/safe/components/Transactions/TxList/Filter/RHFTextField.tsx +++ b/src/routes/safe/components/Transactions/TxList/Filter/RHFTextField.tsx @@ -10,9 +10,19 @@ type Props = { rules?: UseControllerProps['rules'] label: string type?: HTMLInputTypeAttribute + // To disable date fields + disabled?: boolean + endAdornment?: ReactElement } -const RHFTextField = ({ name, rules, control, type, ...props }: Props): ReactElement => { +const RHFTextField = ({ + name, + rules, + control, + type, + endAdornment = undefined, + ...props +}: Props): ReactElement => { const { field: { ref, value, ...field }, fieldState: { error }, @@ -33,6 +43,7 @@ const RHFTextField = ({ name, rules, control, type, ...pr error={!!error} helperText={getFilterHelperText(value, error)} InputLabelProps={{ shrink: type === 'date' || !!value }} + InputProps={{ endAdornment }} /> ) } diff --git a/src/routes/safe/components/Transactions/TxList/Filter/__tests__/utils.test.ts b/src/routes/safe/components/Transactions/TxList/Filter/__tests__/utils.test.ts index 04dce8071a..7dc6d9441f 100644 --- a/src/routes/safe/components/Transactions/TxList/Filter/__tests__/utils.test.ts +++ b/src/routes/safe/components/Transactions/TxList/Filter/__tests__/utils.test.ts @@ -1,4 +1,5 @@ import * as appearanceSelectors from 'src/logic/appearance/selectors' +import { FilterType } from '..' import * as utils from '../utils' const VALID_ADDRESS = '0x1234567890123456789012345678901234567890' @@ -79,4 +80,105 @@ describe('utils', () => { expect(utils.getFilterHelperText('testValue')).toBe(undefined) }) }) + describe('isTxFilter', () => { + const filterKeys = [ + 'type', + 'execution_date__gte', + 'execution_date__lte', + 'to', + 'value', + 'token_address', + 'module', + 'nonce', + ] + it('should return true if the object has only valid filter keys', () => { + filterKeys.forEach((key) => { + expect(utils.isTxFilter({ [key]: 'test' })).toBe(true) + }) + }) + + it('should return true if the object has a valid filter key', () => { + filterKeys.forEach((key, i) => { + const str = i.toString() + expect(utils.isTxFilter({ str, [key]: 'test' })).toBe(true) + }) + }) + + it('should return true if the object has only invalid filter keys', () => { + expect(utils.isTxFilter({ test: 'test' })).toBe(false) + }) + }) + + describe('getIncomingFilter', () => { + it('should extract the incoming filter values from the filter, correctly formatted', () => { + const filter = { + execution_date__gte: '1970-01-01', + execution_date__lte: '2000-01-01', + type: FilterType.INCOMING, + value: '123', + } + + expect(utils.getIncomingFilter(filter)).toEqual({ + execution_date__gte: '1970-01-01T00:00:00.000Z', + execution_date__lte: '2000-01-01T00:00:00.000Z', + value: '123000000000000000000', + }) + }) + }) + describe('getMultisigFilter', () => { + it('should extract the incoming filter values from the filter, correctly formatted', () => { + const filter = { + __to: 'fakeaddress.eth', + to: '0x1234567890123456789012345678901234567890', + execution_date__gte: '1970-01-01', + execution_date__lte: '2000-01-01', + type: FilterType.MULTISIG, + value: '123', + nonce: '123', + } + + expect(utils.getMultisigFilter(filter)).toEqual({ + to: '0x1234567890123456789012345678901234567890', + execution_date__gte: '1970-01-01T00:00:00.000Z', + execution_date__lte: '2000-01-01T00:00:00.000Z', + value: '123000000000000000000', + nonce: '123', + }) + }) + it('should add the executed param if defined', () => { + const filter = { + __to: 'fakeaddress.eth', + to: '0x1234567890123456789012345678901234567890', + execution_date__gte: '1970-01-01', + execution_date__lte: '2000-01-01', + type: FilterType.MULTISIG, + value: '123', + nonce: '123', + } + + expect(utils.getMultisigFilter(filter, true)).toEqual({ + to: '0x1234567890123456789012345678901234567890', + execution_date__gte: '1970-01-01T00:00:00.000Z', + execution_date__lte: '2000-01-01T00:00:00.000Z', + value: '123000000000000000000', + nonce: '123', + executed: 'true', + }) + }) + }) + describe('getModuleFilter', () => { + it('should extract the incoming filter values from the filter, correctly formatted', () => { + const filter = { + __module: 'fakeaddress.eth', + to: '0x1234567890123456789012345678901234567890', + module: '0x1234567890123456789012345678901234567890', + type: FilterType.MODULE, + } + + expect(utils.getModuleFilter(filter)).toEqual({ + to: '0x1234567890123456789012345678901234567890', + module: '0x1234567890123456789012345678901234567890', + }) + }) + }) }) diff --git a/src/routes/safe/components/Transactions/TxList/Filter/__tests__/validation.test.ts b/src/routes/safe/components/Transactions/TxList/Filter/__tests__/validation.test.ts new file mode 100644 index 0000000000..18f39669d8 --- /dev/null +++ b/src/routes/safe/components/Transactions/TxList/Filter/__tests__/validation.test.ts @@ -0,0 +1,30 @@ +import { isValidAmount, isValidNonce } from '../validation' + +describe('validation', () => { + describe('isValidAmount', () => { + it('should return undefined if value is valid', () => { + expect(isValidAmount('1')).toBeUndefined() + }) + it('should return "Invalid number" if value is not a number', () => { + expect(isValidAmount('a')).toBe('Invalid number') + }) + }) + + describe('isValidNonce', () => { + it('should return undefined if value is valid', () => { + expect(isValidNonce('1')).toBeUndefined() + }) + + it('should return undefined when no nonce is provided', () => { + expect(isValidNonce('')).toBeUndefined() + }) + + it('should return "Invalid number" if value is not a number', () => { + expect(isValidNonce('a')).toBe('Invalid number') + }) + + it('should return "Nonce cannot be negative" if value is negative', () => { + expect(isValidNonce('-1')).toBe('Nonce cannot be negative') + }) + }) +}) diff --git a/src/routes/safe/components/Transactions/TxList/Filter/index.tsx b/src/routes/safe/components/Transactions/TxList/Filter/index.tsx index 1705da57dd..6d54093888 100644 --- a/src/routes/safe/components/Transactions/TxList/Filter/index.tsx +++ b/src/routes/safe/components/Transactions/TxList/Filter/index.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useRef, useState } from 'react' +import { ReactElement, useCallback, useEffect, useState } from 'react' import { Controller, DefaultValues, useForm } from 'react-hook-form' import styled from 'styled-components' import ExpandMoreIcon from '@material-ui/icons/ExpandMore' @@ -10,152 +10,212 @@ import Paper from '@material-ui/core/Paper/Paper' import FormControl from '@material-ui/core/FormControl/FormControl' import FormLabel from '@material-ui/core/FormLabel/FormLabel' import FormControlLabel from '@material-ui/core/FormControlLabel/FormControlLabel' -import type { SettingsInfo } from '@gnosis.pm/safe-react-gateway-sdk' +import { parse } from 'query-string' +import { useHistory, useLocation } from 'react-router-dom' +import { useDispatch, useSelector } from 'react-redux' import Button from 'src/components/layout/Button' import RHFTextField from 'src/routes/safe/components/Transactions/TxList/Filter/RHFTextField' import RHFAddressSearchField from 'src/routes/safe/components/Transactions/TxList/Filter/RHFAddressSearchField' -import RHFModuleSearchField from 'src/routes/safe/components/Transactions/TxList/Filter/RHFModuleSearchField' import BackdropLayout from 'src/components/layout/Backdrop' import filterIcon from 'src/routes/safe/components/Transactions/TxList/assets/filter-icon.svg' - -import { lg, md, primary300, grey400, largeFontSize, primary200, sm } from 'src/theme/variables' +import { lg, md, primary300, grey400, largeFontSize, primary200, sm, black300, fontColor } from 'src/theme/variables' import { trackEvent } from 'src/utils/googleTagManager' import { TX_LIST_EVENTS } from 'src/utils/events/txList' - -// Types cannot take computed property names -const TYPE_FIELD_NAME = 'type' -const DATE_FROM_FIELD_NAME = 'execution_date__gte' -const DATE_TO_FIELD_NAME = 'execution_date__lte' -const RECIPIENT_FIELD_NAME = 'to' -const HIDDEN_RECIPIENT_FIELD_NAME = '__to' -const AMOUNT_FIELD_NAME = 'value' -const TOKEN_ADDRESS_FIELD_NAME = 'token_address' -const HIDDEN_TOKEN_ADDRESS_FIELD_NAME = '__token_address' -const MODULE_FIELD_NAME = 'module' -const NONCE_FIELD_NAME = 'nonce' - -enum FilterType { +import { isValidAmount, isValidNonce } from 'src/routes/safe/components/Transactions/TxList/Filter/validation' +import { currentChainId } from 'src/logic/config/store/selectors' +import useSafeAddress from 'src/logic/currentSession/hooks/useSafeAddress' +import { + addHistoryTransactions, + removeHistoryTransactions, +} from 'src/logic/safe/store/actions/transactions/gatewayTransactions' +import { loadHistoryTransactions } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions' +import { checksumAddress } from 'src/utils/checksumAddress' +import { ChainId } from 'src/config/chain' +import { Dispatch } from 'src/logic/safe/store/actions/types' +import HourglassEmptyIcon from '@material-ui/icons/HourglassEmpty' +import { IconButton, InputAdornment } from '@material-ui/core' +import { Tooltip } from 'src/components/layout/Tooltip' +import { isEqual } from 'lodash' + +export const FILTER_TYPE_FIELD_NAME = 'type' +export const DATE_FROM_FIELD_NAME = 'execution_date__gte' +export const DATE_TO_FIELD_NAME = 'execution_date__lte' +export const RECIPIENT_FIELD_NAME = 'to' +export const AMOUNT_FIELD_NAME = 'value' +export const TOKEN_ADDRESS_FIELD_NAME = 'token_address' +export const MODULE_FIELD_NAME = 'module' +export const NONCE_FIELD_NAME = 'nonce' + +export enum FilterType { INCOMING = 'Incoming', MULTISIG = 'Outgoing', MODULE = 'Module-based', } -type FilterForm = { - [TYPE_FIELD_NAME]: FilterType +// Types cannot take computed property names +export type FilterForm = { + [FILTER_TYPE_FIELD_NAME]: FilterType [DATE_FROM_FIELD_NAME]: string [DATE_TO_FIELD_NAME]: string [RECIPIENT_FIELD_NAME]: string - [HIDDEN_RECIPIENT_FIELD_NAME]: string [AMOUNT_FIELD_NAME]: string [TOKEN_ADDRESS_FIELD_NAME]: string - [HIDDEN_TOKEN_ADDRESS_FIELD_NAME]: string - [MODULE_FIELD_NAME]: SettingsInfo['type'] + [MODULE_FIELD_NAME]: string [NONCE_FIELD_NAME]: string } -const isValidAmount = (value: FilterForm['value']): string | undefined => { - if (value && isNaN(Number(value))) { - return 'Invalid number' - } -} - -const isValidNonce = (value: FilterForm['nonce']): string | undefined => { - if (value.length === 0) { - return - } - - const number = Number(value) - if (isNaN(number)) { - return 'Invalid number' - } - if (number < 0) { - return 'Nonce cannot be negative' - } -} - -const getTransactionFilter = ({ execution_date__gte, execution_date__lte, to, value }: FilterForm) => { - return { - execution_date__gte: execution_date__gte ? new Date(execution_date__gte).toISOString() : undefined, - execution_date__lte: execution_date__lte ? new Date(execution_date__lte).toISOString() : undefined, - to: to || undefined, - value: value ? Number(value) : undefined, - } +const defaultValues: DefaultValues = { + [FILTER_TYPE_FIELD_NAME]: FilterType.INCOMING, + [DATE_FROM_FIELD_NAME]: '', + [DATE_TO_FIELD_NAME]: '', + [RECIPIENT_FIELD_NAME]: '', + [AMOUNT_FIELD_NAME]: '', + [TOKEN_ADDRESS_FIELD_NAME]: '', + [MODULE_FIELD_NAME]: '', + [NONCE_FIELD_NAME]: '', } -const getIncomingFilter = (filter: FilterForm) => { +const getInitialValues = (search: string): DefaultValues => { return { - ...getTransactionFilter(filter), - token_address: filter.token_address || undefined, + ...defaultValues, + ...parse(search), } } -const getOutgoingFilter = (filter: FilterForm) => { - return { - ...getTransactionFilter(filter), - nonce: filter.nonce ? Number(filter.nonce) : undefined, +const loadTransactions = ({ + chainId, + safeAddress, + filter, +}: { + chainId: ChainId + safeAddress: string + filter?: FilterForm +}) => { + return async (dispatch: Dispatch) => { + dispatch(removeHistoryTransactions({ chainId, safeAddress })) + + try { + const values = await loadHistoryTransactions(safeAddress, filter) + dispatch(addHistoryTransactions({ chainId, safeAddress, values })) + } catch (e) { + e.log() + } } } -const getModuleFilter = ({ module }: FilterForm) => { - return { - module, +const StyledRHFTextField = styled(RHFTextField)` + &:hover { + .MuiOutlinedInput-notchedOutline { + border-color: ${black300}; + } } -} +` const Filter = (): ReactElement => { + const dispatch = useDispatch() + const chainId = useSelector(currentChainId) + const { safeAddress } = useSafeAddress() + const { pathname, search } = useLocation() + const history = useHistory() + const [showFilter, setShowFilter] = useState(false) const hideFilter = () => setShowFilter(false) - const toggleFilter = () => setShowFilter((prev) => !prev) - - // We cannot rely on the default values in `useForm` because they are updated on unmount - // meaning that each `reset` does not retain the 'original' default values - const defaultValues = useRef>({ - [TYPE_FIELD_NAME]: FilterType.INCOMING, - [DATE_FROM_FIELD_NAME]: '', - [DATE_TO_FIELD_NAME]: '', - [RECIPIENT_FIELD_NAME]: '', - [HIDDEN_RECIPIENT_FIELD_NAME]: '', - [AMOUNT_FIELD_NAME]: '', - [TOKEN_ADDRESS_FIELD_NAME]: '', - [HIDDEN_TOKEN_ADDRESS_FIELD_NAME]: '', - [MODULE_FIELD_NAME]: undefined, - [NONCE_FIELD_NAME]: '', - }) + + const initialValues = getInitialValues(search) const methods = useForm({ - defaultValues: defaultValues.current, + defaultValues: initialValues, + shouldUnregister: true, }) - const { handleSubmit, formState, reset, watch, control } = methods + const { handleSubmit, reset, watch, control } = methods - const type = watch(TYPE_FIELD_NAME) + const toggleFilter = () => { + if (showFilter) { + setShowFilter(false) + return + } + setShowFilter(true) - const isClearable = Object.entries(formState.dirtyFields).some(([name, value]) => value && name !== TYPE_FIELD_NAME) - const clearParameters = () => { - reset({ ...defaultValues.current, type }) + // We use `shouldUnregister` to avoid saving every value to search + // We must therefore reset the form to the values from it + Object.entries(initialValues).forEach(([key, value]) => { + methods.setValue(key as keyof FilterForm, value) + }) } + const clearFilter = useCallback( + ({ clearSearch = true } = {}) => { + if (search && clearSearch) { + history.replace(pathname) + dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress) })) + reset(defaultValues) + } + + hideFilter() + }, + [search, history, pathname, chainId, dispatch, reset, safeAddress], + ) + + useEffect(() => { + return () => { + // If search is programatically cleared on unmount, the router routes back to here + // Search is inherently cleared when unmounting either way + clearFilter({ clearSearch: false }) + } + }, [clearFilter]) + + const filterType = watch(FILTER_TYPE_FIELD_NAME) + const onSubmit = (filter: FilterForm) => { - const params = - type === FilterType.INCOMING - ? getIncomingFilter(filter) - : FilterType.MULTISIG - ? getOutgoingFilter(filter) - : getModuleFilter(filter) + // Don't apply the same filter twice + if (isEqual(filter, initialValues)) { + hideFilter() + } + + const query = Object.fromEntries(Object.entries(filter).filter(([, value]) => !!value)) + + history.replace({ pathname, search: `?${new URLSearchParams(query).toString()}` }) - console.log(params) + dispatch(loadTransactions({ chainId, safeAddress: checksumAddress(safeAddress), filter })) + + const trackedFields = [ + FILTER_TYPE_FIELD_NAME, + // DATE_FROM_FIELD_NAME, + // DATE_TO_FIELD_NAME, + RECIPIENT_FIELD_NAME, + AMOUNT_FIELD_NAME, + TOKEN_ADDRESS_FIELD_NAME, + MODULE_FIELD_NAME, + NONCE_FIELD_NAME, + ] + + trackedFields.forEach((field) => { + if (query[field]) { + trackEvent({ ...TX_LIST_EVENTS.FILTER, label: query[field] }) + } + }) - trackEvent(TX_LIST_EVENTS.FILTER) hideFilter() } + const comingSoonAdornment = ( + + + + + + + + ) + return ( <> - - Filters{' '} + + {search ? initialValues[FILTER_TYPE_FIELD_NAME] : 'Filter'} {showFilter ? : } {showFilter && ( @@ -165,7 +225,7 @@ const Filter = (): ReactElement => { Transaction type - name={TYPE_FIELD_NAME} + name={FILTER_TYPE_FIELD_NAME} control={control} render={({ field }) => ( @@ -179,25 +239,25 @@ const Filter = (): ReactElement => { Parameters - {type !== FilterType.MODULE && ( + {filterType !== FilterType.MODULE && ( <> - + {/* @ts-expect-error - styled-components don't have strict types */} + name={DATE_FROM_FIELD_NAME} label="From" - type="date" + // type="date" control={control} + disabled + endAdornment={comingSoonAdornment} /> - + {/* @ts-expect-error - styled-components don't have strict types */} + name={DATE_TO_FIELD_NAME} label="To" - type="date" + // type="date" control={control} - /> - - name={RECIPIENT_FIELD_NAME} - hiddenName={HIDDEN_RECIPIENT_FIELD_NAME} - label="Recipient" - methods={methods} + disabled + endAdornment={comingSoonAdornment} /> name={AMOUNT_FIELD_NAME} @@ -209,33 +269,39 @@ const Filter = (): ReactElement => { /> )} - {type === FilterType.INCOMING && ( + {filterType === FilterType.INCOMING && ( name={TOKEN_ADDRESS_FIELD_NAME} - hiddenName={HIDDEN_TOKEN_ADDRESS_FIELD_NAME} label="Token address" methods={methods} /> )} - {type === FilterType.MULTISIG && ( - - name={NONCE_FIELD_NAME} - label="Nonce" - control={control} - rules={{ - validate: isValidNonce, - }} - /> + {filterType === FilterType.MULTISIG && ( + <> + + name={RECIPIENT_FIELD_NAME} + label="Recipient" + methods={methods} + /> + + name={NONCE_FIELD_NAME} + label="Nonce" + control={control} + rules={{ + validate: isValidNonce, + }} + /> + )} - {type === FilterType.MODULE && ( - name={MODULE_FIELD_NAME} label="Module" control={control} /> + {filterType === FilterType.MODULE && ( + name={MODULE_FIELD_NAME} label="Module" methods={methods} /> )} - - @@ -252,19 +318,16 @@ const Filter = (): ReactElement => { export default Filter -const StyledFilterButton = styled(Button)` +const StyledFilterButton = styled(Button)<{ $isFiltered: boolean }>` &.MuiButton-root { align-items: center; - background-color: ${primary200}; - border: 2px solid ${primary300}; + background-color: ${({ $isFiltered }) => ($isFiltered ? primary200 : 'transparent')}; + border: ${({ $isFiltered }) => `2px solid ${$isFiltered ? primary300 : fontColor}`}; color: #162d45; align-self: flex-end; margin-right: ${md}; margin-top: -51px; margin-bottom: ${md}; - &:hover { - background-color: ${primary200}; - } } ` @@ -288,6 +351,7 @@ const StyledPaper = styled(Paper)` margin-left: 10px; top: 0; left: 0; + box-shadow: 1px 2px 10px 0 rgba(40, 54, 61, 0.18); ` const FilterWrapper = styled.div` diff --git a/src/routes/safe/components/Transactions/TxList/Filter/utils.ts b/src/routes/safe/components/Transactions/TxList/Filter/utils.ts index e53b004368..f9ce7e9f51 100644 --- a/src/routes/safe/components/Transactions/TxList/Filter/utils.ts +++ b/src/routes/safe/components/Transactions/TxList/Filter/utils.ts @@ -1,4 +1,6 @@ +import { operations } from '@gnosis.pm/safe-react-gateway-sdk/dist/types/api' import { TextFieldProps } from '@material-ui/core/TextField/TextField' +import { ParsedUrlQuery } from 'querystring' import { FieldError } from 'react-hook-form' import { showShortNameSelector } from 'src/logic/appearance/selectors' @@ -6,6 +8,21 @@ import { store } from 'src/store' import { isValidAddress, isValidPrefixedAddress } from 'src/utils/isValidAddress' import { parsePrefixedAddress } from 'src/utils/prefixedAddress' import { textShortener } from 'src/utils/strings' +import { toWei } from 'web3-utils' +import { + AMOUNT_FIELD_NAME, + DATE_FROM_FIELD_NAME, + DATE_TO_FIELD_NAME, + FilterForm, + FilterType, + FILTER_TYPE_FIELD_NAME, + MODULE_FIELD_NAME, + NONCE_FIELD_NAME, + RECIPIENT_FIELD_NAME, + TOKEN_ADDRESS_FIELD_NAME, +} from '.' + +// Value formatters export const getFormattedAddress = (value: string, shorten = false): string => { const { prefix, address } = parsePrefixedAddress(value) @@ -23,6 +40,8 @@ export const formatInputValue = (value: string): string => { return value } +// Helper text formatter + export const getFilterHelperText = (value: string, error?: FieldError): TextFieldProps['helperText'] => { if (error?.message) { return error.message @@ -34,3 +53,65 @@ export const getFilterHelperText = (value: string, error?: FieldError): TextFiel return undefined } + +// Filter helper + +export const isTxFilter = (object: ParsedUrlQuery): object is Partial => { + const FILTER_FIELD_NAMES = [ + FILTER_TYPE_FIELD_NAME, + DATE_FROM_FIELD_NAME, + DATE_TO_FIELD_NAME, + RECIPIENT_FIELD_NAME, + AMOUNT_FIELD_NAME, + TOKEN_ADDRESS_FIELD_NAME, + MODULE_FIELD_NAME, + NONCE_FIELD_NAME, + ] + return Object.keys(object).some((key) => FILTER_FIELD_NAMES.includes(key)) +} + +// Filter formatters + +type IncomingFilter = operations['incoming_transfers']['parameters']['query'] +type OutgoingFilter = operations['multisig_transactions']['parameters']['query'] +type Filter = (FilterForm | Partial) & { type?: FilterType } + +const getTransactionFilter = ({ + execution_date__gte, + execution_date__lte, + value, +}: Filter): Partial => { + const getISOString = (date: string): string => new Date(date).toISOString() + return { + ...(execution_date__gte && { execution_date__gte: getISOString(execution_date__gte) }), + ...(execution_date__lte && { execution_date__lte: getISOString(execution_date__lte) }), + ...(value && { value: toWei(value) }), + } +} + +export const getIncomingFilter = (filter: Filter): IncomingFilter => { + const { token_address } = filter + return { + ...getTransactionFilter(filter), + ...(token_address && { token_address }), + } +} + +export const getMultisigFilter = (filter: Filter, executed = false): OutgoingFilter => { + const { to, nonce } = filter + return { + ...getTransactionFilter(filter), + ...(to && { to }), + ...(nonce && { nonce }), + ...(executed && { executed: `${executed}` }), + } +} + +type ModuleFilter = operations['module_transactions']['parameters']['query'] + +export const getModuleFilter = ({ to, module }: Filter): ModuleFilter => { + return { + ...(to && { to }), + ...(module && { module }), + } +} diff --git a/src/routes/safe/components/Transactions/TxList/Filter/validation.ts b/src/routes/safe/components/Transactions/TxList/Filter/validation.ts new file mode 100644 index 0000000000..84d88bb70d --- /dev/null +++ b/src/routes/safe/components/Transactions/TxList/Filter/validation.ts @@ -0,0 +1,21 @@ +import { FilterForm } from '.' + +export const isValidAmount = (value: FilterForm['value']): string | undefined => { + if (value && isNaN(Number(value))) { + return 'Invalid number' + } +} + +export const isValidNonce = (value: FilterForm['nonce']): string | undefined => { + if (value.length === 0) { + return + } + + const number = Number(value) + if (isNaN(number)) { + return 'Invalid number' + } + if (number < 0) { + return 'Nonce cannot be negative' + } +} diff --git a/src/routes/safe/components/Transactions/TxList/HistoryTransactions.tsx b/src/routes/safe/components/Transactions/TxList/HistoryTransactions.tsx index 84d70b6056..ede837b5a4 100644 --- a/src/routes/safe/components/Transactions/TxList/HistoryTransactions.tsx +++ b/src/routes/safe/components/Transactions/TxList/HistoryTransactions.tsx @@ -7,10 +7,13 @@ import { HistoryTxList } from './HistoryTxList' import { TxsInfiniteScroll } from './TxsInfiniteScroll' import Img from 'src/components/layout/Img' import NoTransactionsImage from './assets/no-transactions.svg' -import Filter from './Filter' +import Filter, { FILTER_TYPE_FIELD_NAME } from './Filter' +import { useLocation } from 'react-router-dom' export const HistoryTransactions = (): ReactElement => { - const { count, hasMore, next, transactions, isLoading } = usePagedHistoryTransactions() + const { count, next, transactions, isLoading } = usePagedHistoryTransactions() + const { search } = useLocation() + const isFiltered = search.includes(`${FILTER_TYPE_FIELD_NAME}=`) if (count === 0 && isLoading) { return ( @@ -20,21 +23,19 @@ export const HistoryTransactions = (): ReactElement => { ) } - if (count === 0 || !transactions.length) { - return ( - - No Transactions yet - History transactions will appear here - - ) - } - return ( <> - - - + {count === 0 || !transactions.length ? ( + + No Transactions yet + {isFiltered ? 'No results found' : 'History transactions will appear here'} + + ) : ( + + + + )} ) } diff --git a/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx b/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx index 0500469614..fd178c92dd 100644 --- a/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx +++ b/src/routes/safe/components/Transactions/TxList/QueueTransactions.tsx @@ -14,7 +14,7 @@ import { TX_LIST_EVENTS } from 'src/utils/events/txList' import { BatchExecuteHoverProvider } from 'src/routes/safe/components/Transactions/TxList/BatchExecuteHoverProvider' export const QueueTransactions = (): ReactElement => { - const { count, isLoading, hasMore, next, transactions } = usePagedQueuedTransactions() + const { count, isLoading, next, transactions } = usePagedQueuedTransactions() const queuedTxCount = useMemo( () => (transactions ? transactions.next.count + transactions.queue.count : 0), @@ -51,7 +51,7 @@ export const QueueTransactions = (): ReactElement => { return ( - + {/* Next list */} {transactions.next.count !== 0 && } diff --git a/src/routes/safe/components/Transactions/TxList/TxsInfiniteScroll.tsx b/src/routes/safe/components/Transactions/TxList/TxsInfiniteScroll.tsx index 20e8b19d11..d994e18dd0 100644 --- a/src/routes/safe/components/Transactions/TxList/TxsInfiniteScroll.tsx +++ b/src/routes/safe/components/Transactions/TxList/TxsInfiniteScroll.tsx @@ -7,13 +7,12 @@ import { HorizontallyCentered, ScrollableTransactionsContainer } from './styled' type TxsInfiniteScrollProps = { children: ReactNode next: () => Promise - hasMore: boolean isLoading: boolean } -export const TxsInfiniteScroll = ({ children, next, hasMore, isLoading }: TxsInfiniteScrollProps): ReactElement => { +export const TxsInfiniteScroll = ({ children, next, isLoading }: TxsInfiniteScrollProps): ReactElement => { return ( - + {children} diff --git a/src/routes/safe/components/Transactions/TxList/hooks/usePagedHistoryTransactions.ts b/src/routes/safe/components/Transactions/TxList/hooks/usePagedHistoryTransactions.ts index 5fc80437d4..c590925e06 100644 --- a/src/routes/safe/components/Transactions/TxList/hooks/usePagedHistoryTransactions.ts +++ b/src/routes/safe/components/Transactions/TxList/hooks/usePagedHistoryTransactions.ts @@ -12,7 +12,6 @@ import useSafeAddress from 'src/logic/currentSession/hooks/useSafeAddress' type PagedTransactions = { count: number transactions: TransactionDetails['transactions'] - hasMore: boolean next: () => Promise isLoading: boolean } @@ -23,7 +22,6 @@ export const usePagedHistoryTransactions = (): PagedTransactions => { const dispatch = useDispatch() const { safeAddress } = useSafeAddress() - const [hasMore, setHasMore] = useState(true) const [isLoading, setIsLoading] = useState(false) const next = useCallback(async () => { @@ -40,24 +38,17 @@ export const usePagedHistoryTransactions = (): PagedTransactions => { } if (!results) { - setHasMore(false) setIsLoading(false) return } - const { values, next } = results - - if (next === null) { - setHasMore(false) - } + const { values } = results if (values) { dispatch(addHistoryTransactions({ chainId, safeAddress, values })) - } else { - setHasMore(false) } setIsLoading(false) }, [chainId, dispatch, safeAddress]) - return { count, transactions, hasMore, next, isLoading } + return { count, transactions, next, isLoading } } diff --git a/src/routes/safe/components/Transactions/TxList/hooks/usePagedQueuedTransactions.ts b/src/routes/safe/components/Transactions/TxList/hooks/usePagedQueuedTransactions.ts index fb47d8cd28..97f4a9fcaa 100644 --- a/src/routes/safe/components/Transactions/TxList/hooks/usePagedQueuedTransactions.ts +++ b/src/routes/safe/components/Transactions/TxList/hooks/usePagedQueuedTransactions.ts @@ -1,4 +1,3 @@ -import { useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { loadPagedQueuedTransactions } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions' import { addQueuedTransactions } from 'src/logic/safe/store/actions/transactions/gatewayTransactions' @@ -12,7 +11,6 @@ type PagedQueuedTransactions = { count: number isLoading: boolean transactions?: QueueTransactionsInfo - hasMore: boolean next: () => Promise } @@ -22,7 +20,6 @@ export const usePagedQueuedTransactions = (): PagedQueuedTransactions => { const dispatch = useDispatch() const { safeAddress } = useSafeAddress() - const [hasMore, setHasMore] = useState(true) const nextPage = async () => { let results: Await> @@ -35,21 +32,8 @@ export const usePagedQueuedTransactions = (): PagedQueuedTransactions => { } } - if (!results) { - setHasMore(false) - return - } - - const { values, next } = results - - if (next === null) { - setHasMore(false) - } - - if (values) { - dispatch(addQueuedTransactions({ chainId, safeAddress, values })) - } else { - setHasMore(false) + if (results) { + dispatch(addQueuedTransactions({ chainId, safeAddress, values: results.values })) } } @@ -60,5 +44,5 @@ export const usePagedQueuedTransactions = (): PagedQueuedTransactions => { const isLoading = typeof transactions === 'undefined' || typeof count === 'undefined' - return { count, isLoading, transactions, hasMore, next: nextPage } + return { count, isLoading, transactions, next: nextPage } } diff --git a/src/setupTests.js b/src/setupTests.js index 98f547285a..010a1bd41b 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -54,6 +54,9 @@ jest.mock('@gnosis.pm/safe-react-gateway-sdk', () => { getTransactionQueue: jest.fn(), postTransaction: jest.fn(), getChainsConfig: jest.fn(), + getIncomingTransfers: jest.fn(), + getMultisigTransactions: jest.fn(), + getModuleTransactions: jest.fn(), } }) diff --git a/src/utils/__tests__/isValidAddress.test.ts b/src/utils/__tests__/isValidAddress.test.ts index 6632a16f06..5638f3ca90 100644 --- a/src/utils/__tests__/isValidAddress.test.ts +++ b/src/utils/__tests__/isValidAddress.test.ts @@ -4,6 +4,11 @@ describe('isValidAddress', () => { it('Returns false for an empty string', () => { expect(isValidAddress('')).toBeFalsy() }) + it('Returns false for non-string values', () => { + ;[123, true, false, null, undefined].forEach((value) => { + expect(isValidAddress(value as unknown as string)).toBeFalsy() + }) + }) it('Returns false when address is `undefined`', () => { expect(isValidAddress(undefined)).toBeFalsy() }) @@ -22,6 +27,11 @@ describe('isValidPrefixedAddress', () => { it('Returns false for an empty string', () => { expect(isValidPrefixedAddress('')).toBeFalsy() }) + it('Returns false for non-string values', () => { + ;[123, true, false, null, undefined].forEach((value) => { + expect(isValidPrefixedAddress(value as unknown as string)).toBeFalsy() + }) + }) it('Returns false when address is `undefined`', () => { expect(isValidPrefixedAddress(undefined)).toBeFalsy() }) diff --git a/src/utils/isValidAddress.ts b/src/utils/isValidAddress.ts index f75dd97534..c864805707 100644 --- a/src/utils/isValidAddress.ts +++ b/src/utils/isValidAddress.ts @@ -13,7 +13,7 @@ export const isValidAddress = (address?: string): boolean => { } export const isValidPrefixedAddress = (value?: string): boolean => { - if (!value) { + if (!value || typeof value !== 'string') { return false } diff --git a/yarn.lock b/yarn.lock index b80e895cb2..f1712445dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6753,16 +6753,11 @@ data-urls@^2.0.0: whatwg-mimetype "^2.3.0" whatwg-url "^8.0.0" -date-fns@^2.16.1: +date-fns@^2.16.1, date-fns@^2.20.2: version "2.25.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680" integrity sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w== -date-fns@^2.20.2: - version "2.28.0" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" - integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== - dayjs@^1.10.4: version "1.11.0" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.0.tgz#009bf7ef2e2ea2d5db2e6583d2d39a4b5061e805" @@ -14830,9 +14825,9 @@ react-gtm-module@^2.0.11: integrity sha512-8gyj4TTxeP7eEyc2QKawEuQoAZdjKvMY4pgWfycGmqGByhs17fR+zEBs0JUDq4US/l+vbTl+6zvUIx27iDo/Vw== react-hook-form@^7.29.0: - version "7.29.0" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.29.0.tgz#5e7e41a483b70731720966ed8be52163ea1fecf1" - integrity sha512-NcJqWRF6el5HMW30fqZRt27s+lorvlCCDbTpAyHoodQeYWXgQCvZJJQLC1kRMKdrJknVH0NIg3At6TUzlZJFOQ== + version "7.31.1" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.31.1.tgz#16c357dd366bc226172e6acbb5a1672873bbfb28" + integrity sha512-QjtjZ8r8KtEBWWpcXLyQordCraTFxILtyQpaz5KLLxN2YzcC+FZ9LLtOnNGuOnzZo9gCoB+viK3ZHV9Mb2htmQ== react-intersection-observer@^8.32.0: version "8.32.1"