Skip to content

Commit

Permalink
chore: ux improvements for new route config (#584)
Browse files Browse the repository at this point in the history
* show route data more nicely

* reconnect account on new route

* make chain switch work

* only show mod select when an avatar is configured

* make tests more realistic

* optimistic value for module select

* harmonize AvatarInput api with other components

* use optimistic values when connecting the wallet

* disable save button when data is being processed

* update disabled styles of inputs

* do not refetch modules all the time

* use enum instead of hard coded string

* make tests more realisitc

* do not try to connect wallet on the server

* use different provider config on server

* inline environment check

* update page title

* disable debug route
  • Loading branch information
frontendphil authored Jan 20, 2025
1 parent 7ff1b91 commit 3e679aa
Show file tree
Hide file tree
Showing 29 changed files with 379 additions and 208 deletions.
26 changes: 14 additions & 12 deletions deployables/app/app/components/AvatarInput.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { validateAddress } from '@/utils'
import { ZERO_ADDRESS } from '@zodiac/chains'
import type { HexAddress, StartingWaypoint } from '@zodiac/schema'
import { getChainId, ZERO_ADDRESS } from '@zodiac/chains'
import { getPilotAddress } from '@zodiac/modules'
import type { HexAddress, Waypoints } from '@zodiac/schema'
import { Blockie, Select, selectStyles, TextInput } from '@zodiac/ui'
import { getAddress } from 'ethers'
import { useEffect, useState } from 'react'
import { useFetcher } from 'react-router'
import { splitPrefixedAddress, type PrefixedAddress } from 'ser-kit'
import { parsePrefixedAddress, type PrefixedAddress } from 'ser-kit'

type Props = {
value: PrefixedAddress
startingWaypoint?: StartingWaypoint
waypoints?: Waypoints
onChange(value: HexAddress | null): void
}

Expand All @@ -18,8 +19,9 @@ type Option = {
label: string
}

export const AvatarInput = ({ value, startingWaypoint, onChange }: Props) => {
const [chainId, address] = splitPrefixedAddress(value)
export const AvatarInput = ({ value, waypoints, onChange }: Props) => {
const address = parsePrefixedAddress(value)
const chainId = getChainId(value)
const [pendingValue, setPendingValue] = useState<string>(
address === ZERO_ADDRESS ? '' : address,
)
Expand All @@ -28,21 +30,21 @@ export const AvatarInput = ({ value, startingWaypoint, onChange }: Props) => {
setPendingValue(address === ZERO_ADDRESS ? '' : address)
}, [address])

const { load, state, data } = useFetcher<HexAddress[]>({
key: 'available-safes',
})
const { load, state, data } = useFetcher<HexAddress[]>()

const pilotAddress = waypoints == null ? null : getPilotAddress(waypoints)

useEffect(() => {
if (startingWaypoint == null) {
if (pilotAddress == ZERO_ADDRESS) {
return
}

if (chainId == null) {
return
}

load(`/${startingWaypoint.account.address}/${chainId}/available-safes`)
}, [chainId, load, startingWaypoint])
load(`/${pilotAddress}/${chainId}/available-safes`)
}, [chainId, load, pilotAddress])

const checksumAvatarAddress = validateAddress(pendingValue)

Expand Down
2 changes: 1 addition & 1 deletion deployables/app/app/components/ChainSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ChainSelect = ({ value, onChange }: Props) => (
label="Chain"
isMulti={false}
options={options}
defaultValue={options.find((op) => op.value === value)}
value={options.find((op) => op.value === value)}
onChange={(option) => {
invariant(option != null, 'Empty value selected as chain')

Expand Down
16 changes: 0 additions & 16 deletions deployables/app/app/components/DebugRouteData.tsx

This file was deleted.

6 changes: 4 additions & 2 deletions deployables/app/app/components/ModSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { ZERO_ADDRESS } from '@zodiac/chains'
import type { HexAddress } from '@zodiac/schema'
import { Blockie, Select, type SelectProps } from '@zodiac/ui'
import { getAddress } from 'ethers'
import type { PropsWithChildren } from 'react'

export const NO_MODULE_OPTION = { value: '', label: '' }
export const NO_MODULE_OPTION = { value: ZERO_ADDRESS, label: '' }

export interface Option {
value: string
value: HexAddress
label: string
}

Expand Down
111 changes: 69 additions & 42 deletions deployables/app/app/components/ZodiacMod.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getChainId } from '@zodiac/chains'
import { getChainId, ZERO_ADDRESS, type ChainId } from '@zodiac/chains'
import {
decodeRoleKey,
encodeRoleKey,
Expand All @@ -7,7 +7,7 @@ import {
ZODIAC_MODULE_NAMES,
type ZodiacModule,
} from '@zodiac/modules'
import type { Waypoints } from '@zodiac/schema'
import type { HexAddress, Waypoints } from '@zodiac/schema'
import { TextInput } from '@zodiac/ui'
import { useEffect, useState } from 'react'
import { useFetcher } from 'react-router'
Expand All @@ -33,44 +33,26 @@ export const ZodiacMod = ({
waypoints,
onSelect,
}: ZodiacModProps) => {
const {
load: loadSafes,
state: safesState,
data: safes = [],
} = useFetcher<string[]>({
key: 'available-safes',
})
const {
load: loadDelegates,
state: delegatesState,
data: delegates = [],
} = useFetcher<string[]>({
key: 'delegates',
})
const {
load: loadModules,
state: modulesState,
data: modules = [],
} = useFetcher<ZodiacModule[]>({
key: 'modules',
})
const chainId = getChainId(avatar)
const pilotAddress = waypoints == null ? null : getPilotAddress(waypoints)

useEffect(() => {
if (pilotAddress == null) {
return
}
const hasAvatar = parsePrefixedAddress(avatar) !== ZERO_ADDRESS

loadSafes(`/${pilotAddress}/${chainId}/available-safes`)
loadDelegates(`/${pilotAddress}/${chainId}/delegates`)
}, [chainId, loadDelegates, loadSafes, pilotAddress])
const [isLoadingSafes, safes] = useSafes(chainId, pilotAddress)
const [isLoadingDelegates, delegates] = useDelegates(chainId, pilotAddress)
const [isLoadingModules, modules] = useModules(chainId, avatar)

const [optimisticModuleAddress, setOptimisticModuleAddress] = useState(
getModuleAddress(waypoints),
)

useEffect(() => {
const address = parsePrefixedAddress(avatar)
setOptimisticModuleAddress(getModuleAddress(waypoints))
}, [waypoints])

loadModules(`/${address}/${chainId}/modules`)
}, [avatar, chainId, loadModules])
if (!hasAvatar) {
return null
}

const pilotIsOwner = safes.some(
(safe) => safe.toLowerCase() === avatar.toLowerCase(),
Expand All @@ -83,16 +65,11 @@ export const ZodiacMod = ({
const defaultModOption =
pilotIsOwner || pilotIsDelegate ? NO_MODULE_OPTION : undefined

const moduleAddress = getModuleAddress(waypoints)

const selectedModule = modules.find(
(module) => module.moduleAddress === moduleAddress,
(module) => module.moduleAddress === optimisticModuleAddress,
)

const isLoading =
safesState === 'loading' ||
delegatesState === 'loading' ||
modulesState === 'loading'
const isLoading = isLoadingSafes || isLoadingDelegates || isLoadingModules

return (
<>
Expand All @@ -108,6 +85,7 @@ export const ZodiacMod = ({
]}
onChange={async (selected) => {
if (selected == null) {
setOptimisticModuleAddress(null)
onSelect(null)

return
Expand All @@ -118,11 +96,13 @@ export const ZodiacMod = ({
)

if (module == null) {
setOptimisticModuleAddress(null)
onSelect(null)

return
}

setOptimisticModuleAddress(module.moduleAddress)
onSelect(module)
}}
value={
Expand All @@ -144,23 +124,69 @@ export const ZodiacMod = ({
<TextInput
label="Role ID"
name="roleId"
disabled={isLoadingModules}
defaultValue={getRoleId(waypoints) ?? ''}
placeholder="0"
/>
)}

{selectedModule?.type === SupportedZodiacModuleType.ROLES_V2 && (
<RoleKey waypoints={waypoints} />
<RoleKey waypoints={waypoints} disabled={isLoadingModules} />
)}
</>
)
}

const useSafes = (chainId: ChainId, pilotAddress: HexAddress | null) => {
const { load, state, data = [] } = useFetcher<string[]>()

useEffect(() => {
if (pilotAddress == null || pilotAddress === ZERO_ADDRESS) {
return
}

load(`/${pilotAddress}/${chainId}/available-safes`)
}, [chainId, load, pilotAddress])

return [state === 'loading', data] as const
}

const useDelegates = (chainId: ChainId, pilotAddress: HexAddress | null) => {
const { load, state, data = [] } = useFetcher<string[]>()

useEffect(() => {
if (pilotAddress == null || pilotAddress === ZERO_ADDRESS) {
return
}

load(`/${pilotAddress}/${chainId}/delegates`)
}, [chainId, load, pilotAddress])

return [state === 'loading', data] as const
}

const useModules = (chainId: ChainId, avatar: PrefixedAddress) => {
const { load, state, data = [] } = useFetcher<ZodiacModule[]>()

useEffect(() => {
const address = parsePrefixedAddress(avatar)

if (address === ZERO_ADDRESS) {
return
}

load(`/${address}/${chainId}/modules`)
}, [avatar, chainId, load])

return [state === 'loading', data] as const
}

type RoleKeyProps = {
disabled?: boolean
waypoints?: Waypoints
}

const RoleKey = ({ waypoints }: RoleKeyProps) => {
const RoleKey = ({ waypoints, disabled }: RoleKeyProps) => {
const [value, setValue] = useState(getRoleKey(waypoints) ?? '')

return (
Expand All @@ -170,6 +196,7 @@ const RoleKey = ({ waypoints }: RoleKeyProps) => {
<TextInput
label="Role Key"
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
placeholder="Enter key as bytes32 hex string or in human-readable decoding"
/>
Expand Down
1 change: 0 additions & 1 deletion deployables/app/app/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { AvatarInput } from './AvatarInput'
export { ChainSelect } from './ChainSelect'
export { DebugRouteData } from './DebugRouteData'
export * from './wallet'
export { ZodiacMod } from './ZodiacMod'
51 changes: 45 additions & 6 deletions deployables/app/app/components/wallet/ConnectWallet.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { invariant } from '@epic-web/invariant'
import { ZERO_ADDRESS } from '@zodiac/chains'
import { verifyChainId, ZERO_ADDRESS } from '@zodiac/chains'
import { type HexAddress, ProviderType } from '@zodiac/schema'
import { useEffect, useState } from 'react'
import { type ChainId } from 'ser-kit'
import { useDisconnect } from 'wagmi'
import { useAccountEffect, useDisconnect } from 'wagmi'
import { Connect } from './Connect'
import { Wallet } from './Wallet'

Expand All @@ -13,7 +14,7 @@ interface Props {
onConnect(args: {
providerType: ProviderType
chainId: ChainId
account: string
account: HexAddress
}): void
onDisconnect(): void
}
Expand All @@ -27,7 +28,45 @@ export const ConnectWallet = ({
}: Props) => {
const { disconnect } = useDisconnect()

if (pilotAddress == null || pilotAddress === ZERO_ADDRESS) {
const [isConnecting, setIsConnecting] = useState(false)

const accountNotConnected =
pilotAddress == null || pilotAddress === ZERO_ADDRESS

useEffect(() => {
if (!isConnecting) {
return
}

if (accountNotConnected) {
return
}

setIsConnecting(false)
}, [accountNotConnected, isConnecting])

useAccountEffect({
onConnect({ isReconnected, address, chainId, connector }) {
if (isConnecting) {
return
}

if (accountNotConnected && isReconnected) {
setIsConnecting(true)

onConnect({
account: address,
chainId: verifyChainId(chainId),
providerType:
connector.type === 'injected'
? ProviderType.InjectedWallet
: ProviderType.WalletConnect,
})
}
},
})

if (accountNotConnected) {
return <Connect onConnect={onConnect} />
}

Expand All @@ -42,9 +81,9 @@ export const ConnectWallet = ({
providerType={providerType}
pilotAddress={pilotAddress}
onDisconnect={() => {
disconnect()

onDisconnect()

disconnect()
}}
/>
)
Expand Down
4 changes: 3 additions & 1 deletion deployables/app/app/components/wallet/Wallet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export const Wallet = ({
return (
<SwitchChain
chainId={chainId}
onSwitch={() => switchChain({ chainId })}
onSwitch={() => {
switchChain({ chainId })
}}
onDisconnect={onDisconnect}
>
<Account type={providerType}>{pilotAddress}</Account>
Expand Down
Loading

0 comments on commit 3e679aa

Please sign in to comment.