Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: map props to hooks #2564

Merged
merged 13 commits into from
Oct 16, 2023
18 changes: 14 additions & 4 deletions src/components/address-book/AddressBookHeader/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { ADDRESS_BOOK_EVENTS } from '@/services/analytics/events/addressBook'
import PageHeader from '@/components/common/PageHeader'
import { ModalType } from '../AddressBookTable'
import { useAppSelector } from '@/store'
import { selectAllAddressBooks } from '@/store/addressBookSlice'
import { type AddressBookState, selectAllAddressBooks } from '@/store/addressBookSlice'
import ImportIcon from '@/public/images/common/import.svg'
import ExportIcon from '@/public/images/common/export.svg'
import AddCircleIcon from '@/public/images/common/add-outlined.svg'
import mapProps from '@/utils/mad-props'

const HeaderButton = ({
icon,
Expand All @@ -35,13 +36,18 @@ const HeaderButton = ({
}

type Props = {
allAddressBooks: AddressBookState
handleOpenModal: (type: ModalType) => () => void
searchQuery: string
onSearchQueryChange: (searchQuery: string) => void
}

const AddressBookHeader = ({ handleOpenModal, searchQuery, onSearchQueryChange }: Props): ReactElement => {
const allAddressBooks = useAppSelector(selectAllAddressBooks)
function AddressBookHeader({
allAddressBooks,
handleOpenModal,
searchQuery,
onSearchQueryChange,
}: Props): ReactElement {
const canExport = Object.values(allAddressBooks).some((addressBook) => Object.keys(addressBook || {}).length > 0)

return (
Expand Down Expand Up @@ -96,4 +102,8 @@ const AddressBookHeader = ({ handleOpenModal, searchQuery, onSearchQueryChange }
)
}

export default AddressBookHeader
const useAllAddressBooks = () => useAppSelector(selectAllAddressBooks)

export default mapProps(AddressBookHeader, {
allAddressBooks: useAllAddressBooks,
})
20 changes: 15 additions & 5 deletions src/components/address-book/AddressBookTable/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useContext, useMemo, useState } from 'react'
import { Box } from '@mui/material'
import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk'

import EnhancedTable from '@/components/common/EnhancedTable'
import type { AddressEntry } from '@/components/address-book/EntryDialog'
import EntryDialog from '@/components/address-book/EntryDialog'
Expand All @@ -21,9 +23,10 @@ import PagePlaceholder from '@/components/common/PagePlaceholder'
import NoEntriesIcon from '@/public/images/address-book/no-entries.svg'
import { useCurrentChain } from '@/hooks/useChains'
import tableCss from '@/components/common/EnhancedTable/styles.module.css'
import { TxModalContext } from '@/components/tx-flow'
import { TxModalContext, type TxModalContextType } from '@/components/tx-flow'
import TokenTransferFlow from '@/components/tx-flow/flows/TokenTransfer'
import CheckWallet from '@/components/common/CheckWallet'
import madProps from '@/utils/mad-props'

const headCells = [
{ id: 'name', label: 'Name' },
Expand All @@ -45,10 +48,12 @@ const defaultOpen = {
[ModalType.REMOVE]: false,
}

const AddressBookTable = () => {
const chain = useCurrentChain()
const { setTxFlow } = useContext(TxModalContext)
type AddressBookTableProps = {
chain?: ChainInfo
setTxFlow: TxModalContextType['setTxFlow']
}

function AddressBookTable({ chain, setTxFlow }: AddressBookTableProps) {
const [open, setOpen] = useState<typeof defaultOpen>(defaultOpen)
const [searchQuery, setSearchQuery] = useState('')
const [defaultValues, setDefaultValues] = useState<AddressEntry | undefined>(undefined)
Expand Down Expand Up @@ -170,4 +175,9 @@ const AddressBookTable = () => {
)
}

export default AddressBookTable
const useSetTxFlow = () => useContext(TxModalContext).setTxFlow

export default madProps(AddressBookTable, {
chain: useCurrentChain,
setTxFlow: useSetTxFlow,
})
12 changes: 8 additions & 4 deletions src/components/address-book/EntryDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,30 @@ import NameInput from '@/components/common/NameInput'
import useChainId from '@/hooks/useChainId'
import { useAppDispatch } from '@/store'
import { upsertAddressBookEntry } from '@/store/addressBookSlice'
import madProps from '@/utils/mad-props'

export type AddressEntry = {
name: string
address: string
}

const EntryDialog = ({
function EntryDialog({
handleClose,
defaultValues = {
name: '',
address: '',
},
disableAddressInput = false,
chainId,
currentChainId,
}: {
handleClose: () => void
defaultValues?: AddressEntry
disableAddressInput?: boolean
chainId?: string
}): ReactElement => {
currentChainId: string
}): ReactElement {
const dispatch = useAppDispatch()
const currentChainId = useChainId()

const methods = useForm<AddressEntry>({
defaultValues,
Expand Down Expand Up @@ -81,4 +83,6 @@ const EntryDialog = ({
)
}

export default EntryDialog
export default madProps(EntryDialog, {
currentChainId: useChainId,
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { _getCsvData } from '.'
import ExportDialog from '.'
import { render, screen } from '@/tests/test-utils'

describe('ExportDialog', () => {
describe('getCsvData', () => {
Expand All @@ -22,4 +24,10 @@ describe('ExportDialog', () => {
])
})
})

it('should render the export dialog', () => {
const onClose = jest.fn()
render(<ExportDialog handleClose={onClose} />)
expect(screen.getByText('Export address book')).toBeInTheDocument()
})
})
20 changes: 15 additions & 5 deletions src/components/address-book/ExportDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useAppSelector } from '@/store'
import { trackEvent, ADDRESS_BOOK_EVENTS } from '@/services/analytics'
import ExternalLink from '@/components/common/ExternalLink'
import { HelpCenterArticle } from '@/config/constants'
import madProps from '@/utils/mad-props'

const COL_1 = 'address'
const COL_2 = 'name'
Expand All @@ -35,14 +36,19 @@ export const _getCsvData = (addressBooks: AddressBookState): CsvData => {
return csvData
}

const ExportDialog = ({ handleClose }: { handleClose: () => void }): ReactElement => {
const addressBooks: AddressBookState = useAppSelector(selectAllAddressBooks)
const length = Object.values(addressBooks).reduce<number>((acc, entries) => acc + Object.keys(entries).length, 0)
function ExportDialog({
allAddressBooks,
handleClose,
}: {
allAddressBooks: AddressBookState
handleClose: () => void
}): ReactElement {
const length = Object.values(allAddressBooks).reduce<number>((acc, entries) => acc + Object.keys(entries).length, 0)
const { CSVDownloader } = useCSVDownloader()
// safe-address-book-1970-01-01
const filename = `safe-address-book-${new Date().toISOString().slice(0, 10)}`

const csvData = useMemo(() => _getCsvData(addressBooks), [addressBooks])
const csvData = useMemo(() => _getCsvData(allAddressBooks), [allAddressBooks])

const onSubmit = (e: SyntheticEvent) => {
e.preventDefault()
Expand Down Expand Up @@ -87,4 +93,8 @@ const ExportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen
)
}

export default ExportDialog
const useAllAddressBooks = () => useAppSelector(selectAllAddressBooks)

export default madProps(ExportDialog, {
allAddressBooks: useAllAddressBooks,
})
2 changes: 1 addition & 1 deletion src/components/tx-flow/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import useSafeInfo from '@/hooks/useSafeInfo'

const noop = () => {}

type TxModalContextType = {
export type TxModalContextType = {
txFlow: JSX.Element | undefined
setTxFlow: (txFlow: TxModalContextType['txFlow'], onClose?: () => void, shouldWarn?: boolean) => void
setFullWidth: (fullWidth: boolean) => void
Expand Down
34 changes: 34 additions & 0 deletions src/utils/__tests__/mad-props.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { render, waitFor, screen } from '@/tests/test-utils'
import madProps from '../mad-props'

describe('madProps', () => {
it('should map a set of props to hooks', async () => {
const Component = ({ foo, bar }: { foo: string; bar: string }) => <>{foo + bar}</>

const MappedComponent = madProps(Component, {
foo: () => 'foo',
bar: () => 'bar',
})

render(<MappedComponent />)

await waitFor(() => {
expect(screen.getByText('foobar')).toBeInTheDocument()
})
})

it('should accept additional props', async () => {
const Component = ({ foo, bar, baz }: { foo: string; bar: string; baz: string }) => <>{foo + bar + baz}</>

const MappedComponent = madProps(Component, {
foo: () => 'foo',
bar: () => 'bar',
})

render(<MappedComponent baz="baz" />)

await waitFor(() => {
expect(screen.getByText('foobarbaz')).toBeInTheDocument()
})
})
})
40 changes: 40 additions & 0 deletions src/utils/mad-props.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ComponentType, FC } from 'react'
import React, { memo } from 'react'

type HookMap<P> = {
[K in keyof P]?: () => P[K]
}

/**
* Injects props into a component using hooks.
* This allows to keep the original component pure and testable.
*
* @param Component The component to wrap
* @param hookMap A map of hooks to use, keys are the props to inject, values are the hooks
* @returns A new component with the props injected
*/
const madProps = <P extends Record<string, unknown>, H extends keyof P>(
usame-algan marked this conversation as resolved.
Show resolved Hide resolved
Component: ComponentType<P>,
hookMap: HookMap<Pick<P, H>>,
): FC<Omit<P, H>> => {
const MadComponent = (externalProps: Omit<P, H>) => {
let newProps: P = { ...externalProps } as P

for (const key in hookMap) {
const hook = hookMap[key]
if (hook !== undefined) {
newProps[key as H] = hook()
}
}

return <Component {...newProps} />
}

MadComponent.displayName = Component.displayName || Component.name

// Wrapping MadComponent with React.memo and casting to FC<Omit<P, H>>
// The casting is only needed because of memo, the component itself satisfies the type
return memo(MadComponent) as unknown as FC<Omit<P, H>>
}

export default madProps