Skip to content

Commit

Permalink
feat: show avatar in companion app (#506)
Browse files Browse the repository at this point in the history
* render avatar address

* make new route config look a little better

* repair lockfile
  • Loading branch information
frontendphil authored Jan 10, 2025
1 parent bd26c43 commit 1e548a3
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 11 deletions.
109 changes: 109 additions & 0 deletions deployables/app/app/components/AvatarInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Blockie, Select, selectStyles, TextInput } from '@zodiac/ui'
import { getAddress } from 'ethers'
import { useEffect, useState } from 'react'

type Props = {
value: string
availableSafes?: string[]
onChange(value: string): void
}

type Option = {
value: string
label: string
}

export const AvatarInput = ({
value,
onChange,
availableSafes = [],
}: Props) => {
const [pendingValue, setPendingValue] = useState(value)

useEffect(() => {
setPendingValue(value)
}, [value])

const checksumAvatarAddress = validateAddress(pendingValue)

if (availableSafes.length > 0 || checksumAvatarAddress) {
return (
<Select
allowCreate
blurInputOnSelect
isClearable
label="Piloted Safe"
clearLabel="Clear piloted Safe"
dropdownLabel="View all available Safes"
formatOptionLabel={SafeOptionLabel}
placeholder="Paste an address or select from the list"
classNames={selectStyles<{ value: string; label: string }>()}
value={
checksumAvatarAddress !== ''
? {
value: checksumAvatarAddress,
label: checksumAvatarAddress,
}
: undefined
}
options={availableSafes.map((address) => ({
value: address,
label: address,
}))}
onChange={(option) => {
if (option) {
const sanitized = (option as Option).value
.trim()
.replace(/^[a-z]{3}:/g, '')

if (validateAddress(sanitized)) {
onChange(sanitized.toLowerCase())
}
} else {
onChange('')
}
}}
isValidNewOption={(option) => {
return !!validateAddress(option)
}}
/>
)
}

return (
<TextInput
label="Piloted Safe"
value={pendingValue}
placeholder="Paste in Safe address"
onChange={(ev) => {
const sanitized = ev.target.value.trim().replace(/^[a-z]{3}:/g, '')
setPendingValue(sanitized)
if (validateAddress(sanitized)) {
onChange(sanitized.toLowerCase())
}
}}
/>
)
}

const SafeOptionLabel = (option: Option) => {
const checksumAddress = getAddress(option.value).toLowerCase()

return (
<div className="flex items-center gap-4 py-2">
<Blockie address={option.value} className="size-5 shrink-0" />

<code className="overflow-hidden text-ellipsis whitespace-nowrap font-mono">
{checksumAddress}
</code>
</div>
)
}

const validateAddress = (address: string) => {
try {
return getAddress(address).toLowerCase()
} catch {
return ''
}
}
1 change: 1 addition & 0 deletions deployables/app/app/components/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { AvatarInput } from './AvatarInput'
export { ChainSelect } from './ChainSelect'
19 changes: 19 additions & 0 deletions deployables/app/app/routes/edit-route.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { screen } from '@testing-library/react'
import { CHAIN_NAME } from '@zodiac/chains'
import {
createMockExecutionRoute,
randomAddress,
randomPrefixedAddress,
} from '@zodiac/test-utils'
import type { ChainId } from 'ser-kit'
Expand Down Expand Up @@ -46,4 +47,22 @@ describe('Edit route', () => {
},
)
})

describe('Avatar', () => {
it('shows the avatar of a route', async () => {
const avatar = randomAddress()

const route = createMockExecutionRoute({
avatar: randomPrefixedAddress({ address: avatar }),
})

await render<typeof import('./edit-route')>(
'/edit-route',
{ path: '/edit-route', Component: EditRoute, loader },
{ searchParams: { route: btoa(JSON.stringify(route)) } },
)

expect(screen.getByText(avatar)).toBeInTheDocument()
})
})
})
13 changes: 8 additions & 5 deletions deployables/app/app/routes/edit-route.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChainSelect } from '@/components'
import { AvatarInput, ChainSelect } from '@/components'
import { invariantResponse } from '@epic-web/invariant'
import { executionRouteSchema } from '@zodiac/schema'
import { TextInput } from '@zodiac/ui'
Expand All @@ -20,20 +20,23 @@ export const loader = ({ request }: Route.LoaderArgs) => {
const rawJson = JSON.parse(decodedData.toString())
const route = executionRouteSchema.parse(rawJson)

const [chainId] = splitPrefixedAddress(route.avatar)
const [chainId, avatar] = splitPrefixedAddress(route.avatar)

return { label: route.label, chainId }
return { label: route.label, chainId, avatar }
} catch {
throw new Response(null, { status: 400 })
}
}

const EditRoute = ({ loaderData }: Route.ComponentProps) => {
return (
<>
<main className="mx-auto flex max-w-3xl flex-col gap-4">
<h1 className="my-8 text-3xl font-semibold">Route configuration</h1>

<TextInput label="Label" defaultValue={loaderData.label} />
<ChainSelect value={loaderData.chainId} onChange={() => {}} />
</>
<AvatarInput value={loaderData.avatar} onChange={() => {}} />
</main>
)
}

Expand Down
1 change: 1 addition & 0 deletions deployables/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@zodiac/chains": "workspace:*",
"@zodiac/schema": "workspace:*",
"@zodiac/ui": "workspace:*",
"ethers": "6.13.5",
"isbot": "^5.1.17",
"react": "^19.0.0",
"react-dom": "^19.0.0",
Expand Down
27 changes: 23 additions & 4 deletions packages/ui/src/inputs/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import BaseSelect, {
type GroupBase,
type Props,
} from 'react-select'
import Creatable, { type CreatableProps } from 'react-select/creatable'
import { GhostButton } from '../buttons'
import { Input, useClearLabel, useDropdownLabel } from './Input'

Expand All @@ -28,14 +29,32 @@ export const selectStyles = <
noOptionsMessage: () => 'p-4 italic opacity-75',
})

export function Select<Option = unknown, Multi extends boolean = boolean>({
type SelectProps<Creatable extends boolean> = {
label: string
clearLabel?: string
dropdownLabel?: string
allowCreate?: Creatable
}

export function Select<
Creatable extends boolean = false,
Option = unknown,
Multi extends boolean = boolean,
>({
label,
clearLabel,
dropdownLabel,
allowCreate,
...props
}: Props<Option, Multi> & { label: string }) {
}: Creatable extends true
? CreatableProps<Option, Multi, GroupBase<Option>> & SelectProps<Creatable>
: Props<Option, Multi> & SelectProps<Creatable>) {
const Component = allowCreate ? Creatable : BaseSelect

return (
<Input label={label}>
<Input label={label} clearLabel={clearLabel} dropdownLabel={dropdownLabel}>
{({ inputId }) => (
<BaseSelect
<Component
{...props}
unstyled
inputId={inputId}
Expand Down
7 changes: 5 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 1e548a3

Please sign in to comment.