feat: Create chain search and override widgets (#4486)
### Description - Widgets: Create `ChainSearchMenu` and `ChainDetailsMenu` components - Widgets: Add required icon and button components - Widgets: Add persisted zustand store and hooks - Widgets: Add clipboard utility functions - Utils: Migrate `fetchWithTimeout` from widgets to utils - Utils: Add `objSlice` function and improve types for `objMerge` - Utils: Add `isUrl` function - SDK: Break out BlockExplorerSchema and export separately - SDK: Migrate RPC + Explorer health tests back to SDK from registry ### Related issues Prerequisite for: - https://github.com/hyperlane-xyz/hyperlane-warp-ui-template/issues/238 - https://github.com/hyperlane-xyz/hyperlane-explorer/issues/108 - https://github.com/hyperlane-xyz/hyperlane-warp-ui-template/issues/215 Helps with: - https://github.com/hyperlane-xyz/issues/issues/1160 - https://github.com/hyperlane-xyz/issues/issues/1234 ### Backward compatibility Yes ### Testing New unit and storybook testspull/4648/head
parent
9c6f80cbe2
commit
2afc484a2b
@ -0,0 +1,6 @@ |
||||
--- |
||||
'@hyperlane-xyz/sdk': minor |
||||
--- |
||||
|
||||
Break out BlockExplorerSchema and export separately |
||||
Migrate RPC + Explorer health tests back to SDK from registry |
@ -0,0 +1,7 @@ |
||||
--- |
||||
'@hyperlane-xyz/utils': minor |
||||
--- |
||||
|
||||
Migrate fetchWithTimeout from widgets to utils |
||||
Add objSlice function and improve types for objMerge |
||||
Add isUrl function |
@ -0,0 +1,8 @@ |
||||
--- |
||||
'@hyperlane-xyz/widgets': minor |
||||
--- |
||||
|
||||
Create ChainSearchMenu and ChainDetailsMenu components |
||||
Add required icon and button components |
||||
Add persisted zustand store and hooks |
||||
Add clipboard utility functions |
@ -0,0 +1,63 @@ |
||||
import { ChainMetadata } from '@hyperlane-xyz/sdk'; |
||||
import { Address, ProtocolType, rootLogger } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { |
||||
getExplorerAddressUrl, |
||||
getExplorerBaseUrl, |
||||
getExplorerTxUrl, |
||||
} from '../metadata/blockExplorer.js'; |
||||
|
||||
const PROTOCOL_TO_ADDRESS: Record<ProtocolType, Address> = { |
||||
[ProtocolType.Ethereum]: '0x0000000000000000000000000000000000000000', |
||||
[ProtocolType.Sealevel]: '11111111111111111111111111111111', |
||||
[ProtocolType.Cosmos]: 'cosmos100000000000000000000000000000000000000', |
||||
}; |
||||
|
||||
const PROTOCOL_TO_TX_HASH: Partial<Record<ProtocolType, Address>> = { |
||||
[ProtocolType.Ethereum]: |
||||
'0x0000000000000000000000000000000000000000000000000000000000000000', |
||||
[ProtocolType.Cosmos]: |
||||
'0000000000000000000000000000000000000000000000000000000000000000', |
||||
}; |
||||
|
||||
export async function isBlockExplorerHealthy( |
||||
chainMetadata: ChainMetadata, |
||||
explorerIndex: number, |
||||
address?: Address, |
||||
txHash?: string, |
||||
): Promise<boolean> { |
||||
const baseUrl = getExplorerBaseUrl(chainMetadata, explorerIndex); |
||||
address ??= PROTOCOL_TO_ADDRESS[chainMetadata.protocol]; |
||||
txHash ??= PROTOCOL_TO_TX_HASH[chainMetadata.protocol]; |
||||
|
||||
if (!baseUrl) return false; |
||||
rootLogger.debug(`Got base url: ${baseUrl}`); |
||||
|
||||
rootLogger.debug(`Checking explorer home for ${chainMetadata.name}`); |
||||
await fetch(baseUrl); |
||||
rootLogger.debug(`Explorer home exists for ${chainMetadata.name}`); |
||||
|
||||
if (address) { |
||||
rootLogger.debug( |
||||
`Checking explorer address page for ${chainMetadata.name}`, |
||||
); |
||||
const addressUrl = getExplorerAddressUrl(chainMetadata, address); |
||||
if (!addressUrl) return false; |
||||
rootLogger.debug(`Got address url: ${addressUrl}`); |
||||
const addressReq = await fetch(addressUrl); |
||||
if (!addressReq.ok && addressReq.status !== 404) return false; |
||||
rootLogger.debug(`Explorer address page okay for ${chainMetadata.name}`); |
||||
} |
||||
|
||||
if (txHash) { |
||||
rootLogger.debug(`Checking explorer tx page for ${chainMetadata.name}`); |
||||
const txUrl = getExplorerTxUrl(chainMetadata, txHash); |
||||
if (!txUrl) return false; |
||||
rootLogger.debug(`Got tx url: ${txUrl}`); |
||||
const txReq = await fetch(txUrl); |
||||
if (!txReq.ok && txReq.status !== 404) return false; |
||||
rootLogger.debug(`Explorer tx page okay for ${chainMetadata.name}`); |
||||
} |
||||
|
||||
return true; |
||||
} |
@ -0,0 +1,85 @@ |
||||
import { Mailbox__factory } from '@hyperlane-xyz/core'; |
||||
import { Address, rootLogger } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainMetadata } from '../metadata/chainMetadataTypes.js'; |
||||
|
||||
import { |
||||
CosmJsProvider, |
||||
CosmJsWasmProvider, |
||||
EthersV5Provider, |
||||
ProviderType, |
||||
SolanaWeb3Provider, |
||||
} from './ProviderType.js'; |
||||
import { protocolToDefaultProviderBuilder } from './providerBuilders.js'; |
||||
|
||||
export async function isRpcHealthy( |
||||
metadata: ChainMetadata, |
||||
rpcIndex: number, |
||||
): Promise<boolean> { |
||||
const rpc = metadata.rpcUrls[rpcIndex]; |
||||
const builder = protocolToDefaultProviderBuilder[metadata.protocol]; |
||||
const provider = builder([rpc], metadata.chainId); |
||||
if (provider.type === ProviderType.EthersV5) |
||||
return isEthersV5ProviderHealthy(provider.provider, metadata); |
||||
else if (provider.type === ProviderType.SolanaWeb3) |
||||
return isSolanaWeb3ProviderHealthy(provider.provider, metadata); |
||||
else if ( |
||||
provider.type === ProviderType.CosmJsWasm || |
||||
provider.type === ProviderType.CosmJs |
||||
) |
||||
return isCosmJsProviderHealthy(provider.provider, metadata); |
||||
else |
||||
throw new Error( |
||||
`Unsupported provider type ${provider.type}, new health check required`, |
||||
); |
||||
} |
||||
|
||||
export async function isEthersV5ProviderHealthy( |
||||
provider: EthersV5Provider['provider'], |
||||
metadata: ChainMetadata, |
||||
mailboxAddress?: Address, |
||||
): Promise<boolean> { |
||||
const chainName = metadata.name; |
||||
const blockNumber = await provider.getBlockNumber(); |
||||
if (!blockNumber || blockNumber < 0) return false; |
||||
rootLogger.debug(`Block number is okay for ${chainName}`); |
||||
|
||||
if (mailboxAddress) { |
||||
const mailbox = Mailbox__factory.createInterface(); |
||||
const topics = mailbox.encodeFilterTopics( |
||||
mailbox.events['DispatchId(bytes32)'], |
||||
[], |
||||
); |
||||
rootLogger.debug(`Checking mailbox logs for ${chainName}`); |
||||
const mailboxLogs = await provider.getLogs({ |
||||
address: mailboxAddress, |
||||
topics, |
||||
fromBlock: blockNumber - 99, |
||||
toBlock: blockNumber, |
||||
}); |
||||
if (!mailboxLogs) return false; |
||||
rootLogger.debug(`Mailbox logs okay for ${chainName}`); |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
export async function isSolanaWeb3ProviderHealthy( |
||||
provider: SolanaWeb3Provider['provider'], |
||||
metadata: ChainMetadata, |
||||
): Promise<boolean> { |
||||
const blockNumber = await provider.getBlockHeight(); |
||||
if (!blockNumber || blockNumber < 0) return false; |
||||
rootLogger.debug(`Block number is okay for ${metadata.name}`); |
||||
return true; |
||||
} |
||||
|
||||
export async function isCosmJsProviderHealthy( |
||||
provider: CosmJsProvider['provider'] | CosmJsWasmProvider['provider'], |
||||
metadata: ChainMetadata, |
||||
): Promise<boolean> { |
||||
const readyProvider = await provider; |
||||
const blockNumber = await readyProvider.getHeight(); |
||||
if (!blockNumber || blockNumber < 0) return false; |
||||
rootLogger.debug(`Block number is okay for ${metadata.name}`); |
||||
return true; |
||||
} |
@ -0,0 +1,18 @@ |
||||
/********* RESULT MONAD *********/ |
||||
export type Result<T> = |
||||
| { |
||||
success: true; |
||||
data: T; |
||||
} |
||||
| { |
||||
success: false; |
||||
error: string; |
||||
}; |
||||
|
||||
export function success<T>(data: T): Result<T> { |
||||
return { success: true, data }; |
||||
} |
||||
|
||||
export function failure<T>(error: string): Result<T> { |
||||
return { success: false, error }; |
||||
} |
@ -0,0 +1,17 @@ |
||||
import { parse as yamlParse } from 'yaml'; |
||||
|
||||
import { rootLogger } from './logging.js'; |
||||
import { Result, failure, success } from './result.js'; |
||||
|
||||
export function tryParseJsonOrYaml<T = any>(input: string): Result<T> { |
||||
try { |
||||
if (input.startsWith('{')) { |
||||
return success(JSON.parse(input)); |
||||
} else { |
||||
return success(yamlParse(input)); |
||||
} |
||||
} catch (error) { |
||||
rootLogger.error('Error parsing JSON or YAML', error); |
||||
return failure('Input is not valid JSON or YAML'); |
||||
} |
||||
} |
@ -0,0 +1,189 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; |
||||
import { |
||||
ChainMap, |
||||
ChainMetadata, |
||||
ChainMetadataSchema, |
||||
MultiProtocolProvider, |
||||
} from '@hyperlane-xyz/sdk'; |
||||
import { |
||||
Result, |
||||
failure, |
||||
success, |
||||
tryParseJsonOrYaml, |
||||
} from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { Button } from '../components/Button.js'; |
||||
import { CopyButton } from '../components/CopyButton.js'; |
||||
import { LinkButton } from '../components/LinkButton.js'; |
||||
import { ChevronIcon } from '../icons/Chevron.js'; |
||||
import { PlusIcon } from '../icons/Plus.js'; |
||||
|
||||
export interface ChainAddMenuProps { |
||||
chainMetadata: ChainMap<ChainMetadata>; |
||||
overrideChainMetadata?: ChainMap<Partial<ChainMetadata> | undefined>; |
||||
onChangeOverrideMetadata: ( |
||||
overrides?: ChainMap<Partial<ChainMetadata> | undefined>, |
||||
) => void; |
||||
onClickBack?: () => void; |
||||
} |
||||
|
||||
export function ChainAddMenu(props: ChainAddMenuProps) { |
||||
return ( |
||||
<div className="htw-space-y-4"> |
||||
<Header {...props} /> |
||||
<Form {...props} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function Header({ onClickBack }: Pick<ChainAddMenuProps, 'onClickBack'>) { |
||||
return ( |
||||
<div> |
||||
{!!onClickBack && ( |
||||
<LinkButton onClick={onClickBack} className="htw-py-1 htw-mb-1.5"> |
||||
<div className="htw-flex htw-items-center htw-gap-1.5"> |
||||
<ChevronIcon |
||||
width={12} |
||||
height={12} |
||||
direction="w" |
||||
className="htw-opacity-70" |
||||
/> |
||||
<span className="htw-text-xs htw-text-gray-600">Back</span> |
||||
</div> |
||||
</LinkButton> |
||||
)} |
||||
<h2 className="htw-text-lg htw-font-medium">Add chain metadata</h2> |
||||
<p className="htw-mt-1 htw-text-sm htw-text-gray-500"> |
||||
Add metadata for chains not yet included in the{' '} |
||||
<a |
||||
href={DEFAULT_GITHUB_REGISTRY} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="htw-underline htw-underline-offset-2" |
||||
> |
||||
Hyperlane Canonical Registry |
||||
</a> |
||||
. Note, this data will only be used locally in your own browser. It does |
||||
not affect the registry. |
||||
</p> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function Form({ |
||||
chainMetadata, |
||||
overrideChainMetadata, |
||||
onChangeOverrideMetadata, |
||||
onClickBack, |
||||
}: ChainAddMenuProps) { |
||||
const [textInput, setTextInput] = useState(''); |
||||
const [error, setError] = useState<any>(null); |
||||
|
||||
const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||
setTextInput(e.target.value); |
||||
setError(null); |
||||
}; |
||||
|
||||
const onClickAdd = () => { |
||||
const result = tryParseMetadataInput(textInput, chainMetadata); |
||||
if (result.success) { |
||||
onChangeOverrideMetadata({ |
||||
...overrideChainMetadata, |
||||
[result.data.name]: result.data, |
||||
}); |
||||
setTextInput(''); |
||||
onClickBack?.(); |
||||
} else { |
||||
setError(`Invalid config: ${result.error}`); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-space-y-1.5"> |
||||
<div className="htw-relative"> |
||||
<textarea |
||||
className={clsx( |
||||
'htw-text-xs htw-resize htw-border htw-border-gray-200 focus:htw-border-gray-400 htw-rounded-sm htw-p-2 htw-w-full htw-min-h-72 htw-outline-none', |
||||
error && 'htw-border-red-500', |
||||
)} |
||||
placeholder={placeholderText} |
||||
value={textInput} |
||||
onChange={onChangeInput} |
||||
></textarea> |
||||
{error && <div className="htw-text-red-600 htw-text-sm">{error}</div>} |
||||
<CopyButton |
||||
copyValue={textInput || placeholderText} |
||||
width={14} |
||||
height={14} |
||||
className="htw-absolute htw-right-6 htw-top-3" |
||||
/> |
||||
</div> |
||||
<Button |
||||
onClick={onClickAdd} |
||||
className="htw-bg-gray-600 htw-px-3 htw-py-1.5 htw-gap-1 htw-text-white htw-text-sm" |
||||
> |
||||
<PlusIcon width={20} height={20} color={ColorPalette.White} /> |
||||
<span>Add chain</span> |
||||
</Button> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function tryParseMetadataInput( |
||||
input: string, |
||||
existingChainMetadata: ChainMap<ChainMetadata>, |
||||
): Result<ChainMetadata> { |
||||
const parsed = tryParseJsonOrYaml(input); |
||||
if (!parsed.success) return parsed; |
||||
|
||||
const result = ChainMetadataSchema.safeParse(parsed.data); |
||||
|
||||
if (!result.success) { |
||||
console.error('Error validating chain config', result.error); |
||||
const firstIssue = result.error.issues[0]; |
||||
return failure(`${firstIssue.path} => ${firstIssue.message}`); |
||||
} |
||||
|
||||
const newMetadata = result.data as ChainMetadata; |
||||
const multiProvider = new MultiProtocolProvider(existingChainMetadata); |
||||
|
||||
if (multiProvider.tryGetChainMetadata(newMetadata.name)) { |
||||
return failure('name is already in use by another chain'); |
||||
} |
||||
|
||||
if (multiProvider.tryGetChainMetadata(newMetadata.chainId)) { |
||||
return failure('chainId is already in use by another chain'); |
||||
} |
||||
|
||||
if ( |
||||
newMetadata.domainId && |
||||
multiProvider.tryGetChainMetadata(newMetadata.domainId) |
||||
) { |
||||
return failure('domainId is already in use by another chain'); |
||||
} |
||||
|
||||
return success(newMetadata); |
||||
} |
||||
|
||||
const placeholderText = `# YAML data
|
||||
--- |
||||
chainId: 11155111 |
||||
name: sepolia |
||||
displayName: Sepolia |
||||
protocol: ethereum |
||||
rpcUrls: |
||||
- http: https://foobar.com
|
||||
blockExplorers: |
||||
- name: Sepolia Etherscan |
||||
family: etherscan |
||||
url: https://sepolia.etherscan.io
|
||||
apiUrl: https://api-sepolia.etherscan.io/api
|
||||
apiKey: '12345' |
||||
blocks: |
||||
confirmations: 1 |
||||
estimateBlockTime: 13 |
||||
`;
|
@ -0,0 +1,505 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; |
||||
import { stringify as yamlStringify } from 'yaml'; |
||||
|
||||
import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; |
||||
import { |
||||
ChainMetadata, |
||||
isValidChainMetadata, |
||||
mergeChainMetadata, |
||||
} from '@hyperlane-xyz/sdk'; |
||||
import { |
||||
Result, |
||||
failure, |
||||
isNullish, |
||||
isUrl, |
||||
objMerge, |
||||
objOmit, |
||||
success, |
||||
tryParseJsonOrYaml, |
||||
} from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { CopyButton } from '../components/CopyButton.js'; |
||||
import { IconButton } from '../components/IconButton.js'; |
||||
import { LinkButton } from '../components/LinkButton.js'; |
||||
import { TextInput } from '../components/TextInput.js'; |
||||
import { Tooltip } from '../components/Tooltip.js'; |
||||
import { BoxArrowIcon } from '../icons/BoxArrow.js'; |
||||
import { CheckmarkIcon } from '../icons/Checkmark.js'; |
||||
import { ChevronIcon } from '../icons/Chevron.js'; |
||||
import { Circle } from '../icons/Circle.js'; |
||||
import { PlusCircleIcon } from '../icons/PlusCircle.js'; |
||||
import { Spinner } from '../icons/Spinner.js'; |
||||
import { XIcon } from '../icons/X.js'; |
||||
import { useConnectionHealthTest } from '../utils/useChainConnectionTest.js'; |
||||
|
||||
import { ChainLogo } from './ChainLogo.js'; |
||||
import { ChainConnectionType } from './types.js'; |
||||
|
||||
export interface ChainDetailsMenuProps { |
||||
chainMetadata: ChainMetadata; |
||||
overrideChainMetadata?: Partial<ChainMetadata>; |
||||
onChangeOverrideMetadata: (overrides?: Partial<ChainMetadata>) => void; |
||||
onClickBack?: () => void; |
||||
onRemoveChain?: () => void; |
||||
} |
||||
|
||||
export function ChainDetailsMenu(props: ChainDetailsMenuProps) { |
||||
const mergedMetadata = useMemo( |
||||
() => |
||||
mergeChainMetadata( |
||||
props.chainMetadata || {}, |
||||
props.overrideChainMetadata || {}, |
||||
), |
||||
[props], |
||||
); |
||||
|
||||
return ( |
||||
<div className="htw-space-y-4"> |
||||
<ChainHeader {...props} chainMetadata={mergedMetadata} /> |
||||
<ButtonRow {...props} chainMetadata={mergedMetadata} /> |
||||
<ChainRpcs {...props} chainMetadata={mergedMetadata} /> |
||||
<ChainExplorers {...props} chainMetadata={mergedMetadata} /> |
||||
<ChainInfo {...props} chainMetadata={mergedMetadata} /> |
||||
<MetadataOverride {...props} chainMetadata={mergedMetadata} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ChainHeader({ |
||||
chainMetadata, |
||||
onClickBack, |
||||
}: Pick<ChainDetailsMenuProps, 'chainMetadata' | 'onClickBack'>) { |
||||
return ( |
||||
<div> |
||||
{!!onClickBack && ( |
||||
<LinkButton onClick={onClickBack} className="htw-py-1 htw-mb-1.5"> |
||||
<div className="htw-flex htw-items-center htw-gap-1.5"> |
||||
<ChevronIcon |
||||
width={12} |
||||
height={12} |
||||
direction="w" |
||||
className="htw-opacity-70" |
||||
/> |
||||
<span className="htw-text-xs htw-text-gray-600">Back</span> |
||||
</div> |
||||
</LinkButton> |
||||
)} |
||||
<div className="htw-flex htw-items-center htw-gap-3"> |
||||
<ChainLogo |
||||
chainName={chainMetadata.name} |
||||
logoUri={chainMetadata.logoURI} |
||||
size={30} |
||||
/> |
||||
<h2 className="htw-text-lg htw-font-medium">{`${chainMetadata.displayName} Metadata`}</h2> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ButtonRow({ chainMetadata, onRemoveChain }: ChainDetailsMenuProps) { |
||||
const { name } = chainMetadata; |
||||
|
||||
const copyValue = useMemo( |
||||
() => yamlStringify(chainMetadata), |
||||
[chainMetadata], |
||||
); |
||||
|
||||
return ( |
||||
<div className="htw-pl-0.5 htw-flex htw-items-center htw-gap-10"> |
||||
<div className="htw-flex htw-items-center htw-gap-1.5"> |
||||
<BoxArrowIcon width={13} height={13} /> |
||||
<a |
||||
// TODO support alternative registries here
|
||||
href={`${DEFAULT_GITHUB_REGISTRY}/tree/main/chains/${name}`} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="htw-text-sm hover:htw-underline htw-underline-offset-2 active:htw-opacity-70 htw-transition-all" |
||||
> |
||||
View in registry |
||||
</a> |
||||
</div> |
||||
<div className="htw-flex htw-items-center htw-gap-1"> |
||||
<CopyButton |
||||
width={12} |
||||
height={12} |
||||
copyValue={copyValue} |
||||
className="htw-text-sm hover:htw-underline htw-underline-offset-2 active:htw-opacity-70" |
||||
> |
||||
Copy Metadata |
||||
</CopyButton> |
||||
</div> |
||||
{onRemoveChain && ( |
||||
<LinkButton |
||||
onClick={onRemoveChain} |
||||
className="htw-text-sm htw-text-red-500 htw-gap-1.5" |
||||
> |
||||
<XIcon width={10} height={10} color={ColorPalette.Red} /> |
||||
<span>Delete Chain</span> |
||||
</LinkButton> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ChainRpcs(props: ChainDetailsMenuProps) { |
||||
return ( |
||||
<ConnectionsSection |
||||
{...props} |
||||
header="Connections" |
||||
type={ChainConnectionType.RPC} |
||||
tooltip="Hyperlane tools require chain metadata<br/>with at least one healthy RPC connection." |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function ChainExplorers(props: ChainDetailsMenuProps) { |
||||
return ( |
||||
<ConnectionsSection |
||||
{...props} |
||||
header="Block Explorers" |
||||
type={ChainConnectionType.Explorer} |
||||
tooltip="Explorers are used to provide transaction links and to query data." |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function ConnectionsSection({ |
||||
chainMetadata, |
||||
overrideChainMetadata, |
||||
onChangeOverrideMetadata, |
||||
header, |
||||
type, |
||||
tooltip, |
||||
}: ChainDetailsMenuProps & { |
||||
header: string; |
||||
type: ChainConnectionType; |
||||
tooltip?: string; |
||||
}) { |
||||
const values = getConnectionValues(chainMetadata, type); |
||||
|
||||
return ( |
||||
<div className="htw-space-y-1.5"> |
||||
<SectionHeader tooltip={tooltip}>{header}</SectionHeader> |
||||
{values.map((_, i) => ( |
||||
<ConnectionRow |
||||
key={i} |
||||
chainMetadata={chainMetadata} |
||||
overrideChainMetadata={overrideChainMetadata} |
||||
onChangeOverrideMetadata={onChangeOverrideMetadata} |
||||
index={i} |
||||
type={type} |
||||
/> |
||||
))} |
||||
<AddConnectionButton |
||||
chainMetadata={chainMetadata} |
||||
overrideChainMetadata={overrideChainMetadata} |
||||
onChangeOverrideMetadata={onChangeOverrideMetadata} |
||||
type={type} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function AddConnectionButton({ |
||||
chainMetadata, |
||||
overrideChainMetadata, |
||||
onChangeOverrideMetadata, |
||||
type, |
||||
}: ChainDetailsMenuProps & { |
||||
type: ChainConnectionType; |
||||
}) { |
||||
const [isAdding, setIsAdding] = useState(false); |
||||
const [isInvalid, setIsInvalid] = useState(false); |
||||
const [url, setUrl] = useState(''); |
||||
|
||||
const onClickDismiss = () => { |
||||
setIsAdding(false); |
||||
setIsInvalid(false); |
||||
setUrl(''); |
||||
}; |
||||
|
||||
const onClickAdd = (e?: React.FormEvent<HTMLFormElement>) => { |
||||
e?.preventDefault(); |
||||
|
||||
const currentValues = getConnectionValues(chainMetadata, type); |
||||
const newValue = url?.trim(); |
||||
if (!newValue || !isUrl(newValue) || currentValues.includes(newValue)) { |
||||
setIsInvalid(true); |
||||
return; |
||||
} |
||||
let newOverrides: Partial<ChainMetadata> = {}; |
||||
if (type === ChainConnectionType.RPC) { |
||||
newOverrides = { |
||||
rpcUrls: [{ http: newValue }], |
||||
}; |
||||
} else if (type === ChainConnectionType.Explorer) { |
||||
const hostName = new URL(newValue).hostname; |
||||
newOverrides = { |
||||
blockExplorers: [{ url: newValue, apiUrl: newValue, name: hostName }], |
||||
}; |
||||
} |
||||
onChangeOverrideMetadata( |
||||
objMerge<Partial<ChainMetadata>>( |
||||
overrideChainMetadata || {}, |
||||
newOverrides, |
||||
10, |
||||
true, |
||||
), |
||||
); |
||||
onClickDismiss(); |
||||
}; |
||||
|
||||
if (!isAdding) { |
||||
return ( |
||||
<LinkButton className="htw-gap-3" onClick={() => setIsAdding(true)}> |
||||
<PlusCircleIcon width={15} height={15} color={ColorPalette.LightGray} /> |
||||
<div className="htw-text-sm">{`Add new ${type}`}</div> |
||||
</LinkButton> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<form |
||||
className="htw-flex htw-items-center htw-gap-2" |
||||
onSubmit={(e) => onClickAdd(e)} |
||||
> |
||||
<PlusCircleIcon width={15} height={15} color={ColorPalette.LightGray} /> |
||||
<div className="htw-flex htw-items-stretch htw-gap-1"> |
||||
<TextInput |
||||
className={`htw-w-64 htw-text-sm htw-px-1 htw-rounded-sm ${ |
||||
isInvalid && 'htw-text-red-500' |
||||
}`}
|
||||
placeholder={`Enter ${type} URL`} |
||||
value={url} |
||||
onChange={setUrl} |
||||
/> |
||||
<IconButton |
||||
onClick={() => onClickAdd()} |
||||
className="htw-bg-gray-600 htw-rounded-sm htw-px-1" |
||||
> |
||||
<CheckmarkIcon width={20} height={20} color={ColorPalette.White} /> |
||||
</IconButton> |
||||
<IconButton |
||||
onClick={onClickDismiss} |
||||
className="htw-bg-gray-600 htw-rounded-sm htw-px-1" |
||||
> |
||||
<XIcon width={9} height={9} color={ColorPalette.White} /> |
||||
</IconButton> |
||||
</div> |
||||
</form> |
||||
); |
||||
} |
||||
|
||||
function ChainInfo({ chainMetadata }: { chainMetadata: ChainMetadata }) { |
||||
const { chainId, domainId, deployer, isTestnet } = chainMetadata; |
||||
|
||||
return ( |
||||
<div className="htw-space-y-1.5"> |
||||
<SectionHeader>Chain Information</SectionHeader> |
||||
<div className="htw-grid htw-grid-cols-2 htw-gap-1.5"> |
||||
<div> |
||||
<SectionHeader className="htw-text-xs">Chain Id</SectionHeader> |
||||
<span className="htw-text-sm">{chainId}</span> |
||||
</div> |
||||
<div> |
||||
<SectionHeader className="htw-text-xs">Domain Id</SectionHeader> |
||||
<span className="htw-text-sm">{domainId}</span> |
||||
</div> |
||||
<div> |
||||
<SectionHeader className="htw-text-xs"> |
||||
Contract Deployer |
||||
</SectionHeader> |
||||
<a |
||||
href={deployer?.url} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
className="htw-text-sm hover:htw-underline htw-underline-offset-2" |
||||
> |
||||
{deployer?.name || 'Unknown'} |
||||
</a> |
||||
</div> |
||||
<div> |
||||
<SectionHeader className="htw-text-xs">Chain Type</SectionHeader> |
||||
<span className="htw-text-sm"> |
||||
{isTestnet ? 'Testnet' : 'Mainnet'} |
||||
</span> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function MetadataOverride({ |
||||
chainMetadata, |
||||
overrideChainMetadata, |
||||
onChangeOverrideMetadata, |
||||
}: ChainDetailsMenuProps) { |
||||
const stringified = overrideChainMetadata |
||||
? yamlStringify(overrideChainMetadata) |
||||
: ''; |
||||
const [overrideInput, setOverrideInput] = useState(stringified); |
||||
const showButton = overrideInput !== stringified; |
||||
const [isInvalid, setIsInvalid] = useState(false); |
||||
|
||||
// Keep input in sync with external changes
|
||||
useEffect(() => { |
||||
setOverrideInput(stringified); |
||||
}, [stringified]); |
||||
|
||||
const onChangeInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||
setOverrideInput(e.target.value); |
||||
setIsInvalid(false); |
||||
}; |
||||
|
||||
const onClickSetOverride = () => { |
||||
const trimmed = overrideInput?.trim(); |
||||
if (!trimmed) { |
||||
onChangeOverrideMetadata(undefined); |
||||
return; |
||||
} |
||||
const result = tryParseInput(trimmed, chainMetadata); |
||||
if (result.success) { |
||||
onChangeOverrideMetadata(result.data); |
||||
setOverrideInput(trimmed); |
||||
setIsInvalid(false); |
||||
} else { |
||||
setIsInvalid(true); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-space-y-1.5"> |
||||
<SectionHeader tooltip="You can set data here to locally override the metadata from the registry."> |
||||
Metadata Overrides |
||||
</SectionHeader> |
||||
<div className="htw-relative"> |
||||
<textarea |
||||
className={clsx( |
||||
'htw-text-xs htw-resize htw-border htw-border-gray-200 focus:htw-border-gray-400 htw-rounded-sm htw-p-1.5 htw-w-full htw-h-12 htw-outline-none', |
||||
isInvalid && 'htw-border-red-500', |
||||
)} |
||||
placeholder={`blocks:\n confirmations: 10`} |
||||
value={overrideInput} |
||||
onChange={onChangeInput} |
||||
></textarea> |
||||
<IconButton |
||||
onClick={onClickSetOverride} |
||||
className={clsx( |
||||
'htw-right-3.5 htw-top-2 htw-bg-gray-600 htw-rounded-sm htw-px-1', |
||||
showButton ? 'htw-absolute' : 'htw-hidden', |
||||
)} |
||||
> |
||||
<CheckmarkIcon width={20} height={20} color={ColorPalette.White} /> |
||||
</IconButton> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function SectionHeader({ |
||||
children, |
||||
className, |
||||
tooltip, |
||||
}: PropsWithChildren<{ className?: string; tooltip?: string }>) { |
||||
return ( |
||||
<div className="htw-flex htw-items-center htw-gap-3"> |
||||
<h3 className={`htw-text-sm htw-text-gray-500 ${className}`}> |
||||
{children} |
||||
</h3> |
||||
{tooltip && <Tooltip id="metadata-help" content={tooltip} />} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ConnectionRow({ |
||||
chainMetadata, |
||||
overrideChainMetadata = {}, |
||||
onChangeOverrideMetadata, |
||||
index, |
||||
type, |
||||
}: ChainDetailsMenuProps & { |
||||
index: number; |
||||
type: ChainConnectionType; |
||||
}) { |
||||
const isHealthy = useConnectionHealthTest(chainMetadata, index, type); |
||||
const value = getConnectionValues(chainMetadata, type)[index]; |
||||
const isRemovable = isOverrideConnection(overrideChainMetadata, type, value); |
||||
|
||||
const onClickRemove = () => { |
||||
let toOmit: Partial<ChainMetadata> = {}; |
||||
if (type === ChainConnectionType.RPC) { |
||||
toOmit = { |
||||
rpcUrls: [ |
||||
overrideChainMetadata.rpcUrls!.find((r) => r.http === value)!, |
||||
], |
||||
}; |
||||
} else if (type === ChainConnectionType.Explorer) { |
||||
toOmit = { |
||||
blockExplorers: [ |
||||
overrideChainMetadata.blockExplorers!.find((r) => r.url === value)!, |
||||
], |
||||
}; |
||||
} |
||||
onChangeOverrideMetadata( |
||||
objOmit<Partial<ChainMetadata>>(overrideChainMetadata, toOmit, 10, true), |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-flex htw-items-center htw-gap-3"> |
||||
{isNullish(isHealthy) && type == ChainConnectionType.RPC && ( |
||||
<Spinner width={14} height={14} /> |
||||
)} |
||||
{isNullish(isHealthy) && type == ChainConnectionType.Explorer && ( |
||||
<Circle size={14} className="htw-bg-gray-400" /> |
||||
)} |
||||
{!isNullish(isHealthy) && ( |
||||
<Circle |
||||
size={14} |
||||
className={isHealthy ? 'htw-bg-green-500' : 'htw-bg-red-500'} |
||||
/> |
||||
)} |
||||
<div className="htw-text-sm htw-truncate">{value}</div> |
||||
{isRemovable && ( |
||||
<IconButton |
||||
className="htw-bg-gray-600 htw-rounded-sm htw-p-1 htw-mt-0.5" |
||||
onClick={onClickRemove} |
||||
> |
||||
<XIcon width={8} height={8} color={ColorPalette.White} /> |
||||
</IconButton> |
||||
)} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function getConnectionValues( |
||||
chainMetadata: Partial<ChainMetadata>, |
||||
type: ChainConnectionType, |
||||
) { |
||||
return ( |
||||
(type === ChainConnectionType.RPC |
||||
? chainMetadata.rpcUrls?.map((r) => r.http) |
||||
: chainMetadata.blockExplorers?.map((b) => b.url)) || [] |
||||
); |
||||
} |
||||
|
||||
function isOverrideConnection( |
||||
overrides: Partial<ChainMetadata> | undefined, |
||||
type: ChainConnectionType, |
||||
value: string, |
||||
) { |
||||
return getConnectionValues(overrides || {}, type).includes(value); |
||||
} |
||||
|
||||
function tryParseInput( |
||||
input: string, |
||||
existingChainMetadata: ChainMetadata, |
||||
): Result<Partial<ChainMetadata>> { |
||||
const parsed = tryParseJsonOrYaml<Partial<ChainMetadata>>(input); |
||||
if (!parsed.success) return parsed; |
||||
const merged = mergeChainMetadata(existingChainMetadata, parsed.data); |
||||
const isValid = isValidChainMetadata(merged); |
||||
return isValid ? success(parsed.data) : failure('Invalid metadata overrides'); |
||||
} |
@ -0,0 +1,322 @@ |
||||
import React, { useCallback, useMemo } from 'react'; |
||||
|
||||
import { |
||||
ChainMap, |
||||
ChainMetadata, |
||||
ChainName, |
||||
mergeChainMetadataMap, |
||||
} from '@hyperlane-xyz/sdk'; |
||||
import { ProtocolType } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { |
||||
SearchMenu, |
||||
SortOrderOption, |
||||
SortState, |
||||
} from '../components/SearchMenu.js'; |
||||
import { SegmentedControl } from '../components/SegmentedControl.js'; |
||||
|
||||
import { ChainAddMenu } from './ChainAddMenu.js'; |
||||
import { ChainDetailsMenu } from './ChainDetailsMenu.js'; |
||||
import { ChainLogo } from './ChainLogo.js'; |
||||
|
||||
enum ChainSortByOption { |
||||
Name = 'name', |
||||
ChainId = 'chain id', |
||||
Protocol = 'protocol', |
||||
} |
||||
|
||||
enum FilterTestnetOption { |
||||
Testnet = 'testnet', |
||||
Mainnet = 'mainnet', |
||||
} |
||||
|
||||
interface ChainFilterState { |
||||
type?: FilterTestnetOption; |
||||
protocol?: ProtocolType; |
||||
} |
||||
|
||||
const defaultFilterState: ChainFilterState = { |
||||
type: undefined, |
||||
protocol: undefined, |
||||
}; |
||||
|
||||
interface CustomListItemField { |
||||
header: string; |
||||
data: ChainMap<{ display: string; sortValue: number }>; |
||||
} |
||||
|
||||
export interface ChainSearchMenuProps { |
||||
chainMetadata: ChainMap<ChainMetadata>; |
||||
overrideChainMetadata?: ChainMap<Partial<ChainMetadata> | undefined>; |
||||
onChangeOverrideMetadata: ( |
||||
overrides?: ChainMap<Partial<ChainMetadata> | undefined>, |
||||
) => void; |
||||
onClickChain: (chain: ChainMetadata) => void; |
||||
// Replace the default 2nd column (deployer) with custom data
|
||||
customListItemField?: CustomListItemField; |
||||
// Auto-navigate to a chain details menu
|
||||
showChainDetails?: ChainName; |
||||
// Auto-navigate to a chain add menu
|
||||
showAddChainMenu?: boolean; |
||||
// Include add button above list
|
||||
showAddChainButton?: boolean; |
||||
} |
||||
|
||||
export function ChainSearchMenu({ |
||||
chainMetadata, |
||||
onChangeOverrideMetadata, |
||||
overrideChainMetadata, |
||||
onClickChain, |
||||
customListItemField, |
||||
showChainDetails, |
||||
showAddChainButton, |
||||
showAddChainMenu, |
||||
}: ChainSearchMenuProps) { |
||||
const [drilldownChain, setDrilldownChain] = React.useState< |
||||
ChainName | undefined |
||||
>(showChainDetails); |
||||
|
||||
const [addChain, setAddChain] = React.useState(showAddChainMenu || false); |
||||
|
||||
const { listData, mergedMetadata } = useMemo(() => { |
||||
const mergedMetadata = mergeChainMetadataMap( |
||||
chainMetadata, |
||||
overrideChainMetadata, |
||||
); |
||||
return { mergedMetadata, listData: Object.values(mergedMetadata) }; |
||||
}, [chainMetadata]); |
||||
|
||||
const { ListComponent, searchFn, sortOptions, defaultSortState } = |
||||
useCustomizedListItems(customListItemField); |
||||
|
||||
if (drilldownChain && mergedMetadata[drilldownChain]) { |
||||
const isLocalOverrideChain = !chainMetadata[drilldownChain]; |
||||
const onRemoveChain = () => { |
||||
const newOverrides = { ...overrideChainMetadata }; |
||||
delete newOverrides[drilldownChain]; |
||||
onChangeOverrideMetadata(newOverrides); |
||||
}; |
||||
|
||||
return ( |
||||
<ChainDetailsMenu |
||||
chainMetadata={chainMetadata[drilldownChain]} |
||||
overrideChainMetadata={overrideChainMetadata?.[drilldownChain]} |
||||
onChangeOverrideMetadata={(o) => |
||||
onChangeOverrideMetadata({ |
||||
...overrideChainMetadata, |
||||
[drilldownChain]: o, |
||||
}) |
||||
} |
||||
onClickBack={() => setDrilldownChain(undefined)} |
||||
onRemoveChain={isLocalOverrideChain ? onRemoveChain : undefined} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
if (addChain) { |
||||
return ( |
||||
<ChainAddMenu |
||||
chainMetadata={chainMetadata} |
||||
overrideChainMetadata={overrideChainMetadata} |
||||
onChangeOverrideMetadata={onChangeOverrideMetadata} |
||||
onClickBack={() => setAddChain(false)} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
return ( |
||||
<SearchMenu< |
||||
ChainMetadata<{ disabled?: boolean }>, |
||||
ChainSortByOption, |
||||
ChainFilterState |
||||
> |
||||
data={listData} |
||||
ListComponent={ListComponent} |
||||
searchFn={searchFn} |
||||
onClickItem={onClickChain} |
||||
onClickEditItem={(chain) => setDrilldownChain(chain.name)} |
||||
sortOptions={sortOptions} |
||||
defaultSortState={defaultSortState} |
||||
FilterComponent={ChainFilters} |
||||
defaultFilterState={defaultFilterState} |
||||
placeholder="Chain Name or ID" |
||||
onClickAddItem={showAddChainButton ? () => setAddChain(true) : undefined} |
||||
/> |
||||
); |
||||
} |
||||
|
||||
function ChainListItem({ |
||||
data: chain, |
||||
customField, |
||||
}: { |
||||
data: ChainMetadata; |
||||
customField?: CustomListItemField; |
||||
}) { |
||||
return ( |
||||
<> |
||||
<div className="htw-flex htw-items-center"> |
||||
<div className="htw-shrink-0"> |
||||
<ChainLogo chainName={chain.name} logoUri={chain.logoURI} size={32} /> |
||||
</div> |
||||
<div className="htw-ml-3 htw-text-left htw-overflow-hidden"> |
||||
<div className="htw-text-sm htw-font-medium truncate"> |
||||
{chain.displayName} |
||||
</div> |
||||
<div className="htw-text-[0.7rem] htw-text-gray-500"> |
||||
{chain.isTestnet ? 'Testnet' : 'Mainnet'} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="htw-text-left htw-overflow-hidden"> |
||||
<div className="htw-text-sm truncate"> |
||||
{customField |
||||
? customField.data[chain.name].display || 'Unknown' |
||||
: chain.deployer?.name || 'Unknown deployer'} |
||||
</div> |
||||
<div className="htw-text-[0.7rem] htw-text-gray-500"> |
||||
{customField ? customField.header : 'Deployer'} |
||||
</div> |
||||
</div> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
function ChainFilters({ |
||||
value, |
||||
onChange, |
||||
}: { |
||||
value: ChainFilterState; |
||||
onChange: (s: ChainFilterState) => void; |
||||
}) { |
||||
return ( |
||||
<div className="htw-py-3 htw-px-2.5 htw-space-y-4"> |
||||
<div className="htw-flex htw-flex-col htw-items-start htw-gap-2"> |
||||
<label className="htw-text-sm htw-text-gray-600 htw-pl-px">Type</label> |
||||
<SegmentedControl |
||||
options={Object.values(FilterTestnetOption)} |
||||
onChange={(selected) => onChange({ ...value, type: selected })} |
||||
allowEmpty |
||||
/> |
||||
</div> |
||||
<div className="htw-flex htw-flex-col htw-items-start htw-gap-2"> |
||||
<label className="htw-text-sm htw-text-gray-600 htw-pl-px"> |
||||
Protocol |
||||
</label> |
||||
<SegmentedControl |
||||
options={Object.values(ProtocolType)} |
||||
onChange={(selected) => onChange({ ...value, protocol: selected })} |
||||
allowEmpty |
||||
/> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function chainSearch({ |
||||
data, |
||||
query, |
||||
sort, |
||||
filter, |
||||
customListItemField, |
||||
}: { |
||||
data: ChainMetadata[]; |
||||
query: string; |
||||
sort: SortState<ChainSortByOption>; |
||||
filter: ChainFilterState; |
||||
customListItemField?: CustomListItemField; |
||||
}) { |
||||
const queryFormatted = query.trim().toLowerCase(); |
||||
return ( |
||||
data |
||||
// Query search
|
||||
.filter( |
||||
(chain) => |
||||
chain.name.includes(queryFormatted) || |
||||
chain.displayName?.toLowerCase().includes(queryFormatted) || |
||||
chain.chainId.toString().includes(queryFormatted) || |
||||
chain.domainId?.toString().includes(queryFormatted), |
||||
) |
||||
// Filter options
|
||||
.filter((chain) => { |
||||
let included = true; |
||||
if (filter.type) { |
||||
included &&= |
||||
!!chain.isTestnet === (filter.type === FilterTestnetOption.Testnet); |
||||
} |
||||
if (filter.protocol) { |
||||
included &&= chain.protocol === filter.protocol; |
||||
} |
||||
return included; |
||||
}) |
||||
// Sort options
|
||||
.sort((c1, c2) => { |
||||
// Special case handling for if the chains are being sorted by the
|
||||
// custom field provided to ChainSearchMenu
|
||||
if (customListItemField && sort.sortBy === customListItemField.header) { |
||||
const result = |
||||
customListItemField.data[c1.name].sortValue - |
||||
customListItemField.data[c2.name].sortValue; |
||||
return sort.sortOrder === SortOrderOption.Asc ? result : -result; |
||||
} |
||||
|
||||
// Otherwise sort by the default options
|
||||
let sortValue1 = c1.name; |
||||
let sortValue2 = c2.name; |
||||
if (sort.sortBy === ChainSortByOption.ChainId) { |
||||
sortValue1 = c1.chainId.toString(); |
||||
sortValue2 = c2.chainId.toString(); |
||||
} else if (sort.sortBy === ChainSortByOption.Protocol) { |
||||
sortValue1 = c1.protocol; |
||||
sortValue2 = c2.protocol; |
||||
} |
||||
return sort.sortOrder === SortOrderOption.Asc |
||||
? sortValue1.localeCompare(sortValue2) |
||||
: sortValue2.localeCompare(sortValue1); |
||||
}) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* This hook creates closures around the provided customListItemField data |
||||
* This is useful because SearchMenu will do handle the list item rendering and |
||||
* management but the custom data is more or a chain-search-specific concern |
||||
*/ |
||||
function useCustomizedListItems(customListItemField) { |
||||
// Create closure of ChainListItem but with customField pre-bound
|
||||
const ListComponent = useCallback( |
||||
({ data }: { data: ChainMetadata<{ disabled?: boolean }> }) => ( |
||||
<ChainListItem data={data} customField={customListItemField} /> |
||||
), |
||||
[ChainListItem, customListItemField], |
||||
); |
||||
|
||||
// Bind the custom field to the search function
|
||||
const searchFn = useCallback( |
||||
(args: Parameters<typeof chainSearch>[0]) => |
||||
chainSearch({ ...args, customListItemField }), |
||||
[customListItemField], |
||||
); |
||||
|
||||
// Merge the custom field into the sort options if a custom field exists
|
||||
const sortOptions = useMemo( |
||||
() => [ |
||||
...(customListItemField ? [customListItemField.header] : []), |
||||
...Object.values(ChainSortByOption), |
||||
], |
||||
[customListItemField], |
||||
) as ChainSortByOption[]; |
||||
|
||||
// Sort by the custom field by default, if one is provided
|
||||
const defaultSortState = useMemo( |
||||
() => |
||||
customListItemField |
||||
? { |
||||
sortBy: customListItemField.header, |
||||
sortOrder: SortOrderOption.Desc, |
||||
} |
||||
: undefined, |
||||
[customListItemField], |
||||
) as SortState<ChainSortByOption> | undefined; |
||||
|
||||
return { ListComponent, searchFn, sortOptions, defaultSortState }; |
||||
} |
@ -0,0 +1,4 @@ |
||||
export enum ChainConnectionType { |
||||
RPC = 'rpc', |
||||
Explorer = 'explorer', |
||||
} |
@ -0,0 +1,21 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react'; |
||||
|
||||
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>; |
||||
|
||||
export function Button(props: Props) { |
||||
const { className, children, ...rest } = props; |
||||
|
||||
const base = |
||||
'htw-flex htw-items-center htw-justify-center htw-rounded-sm htw-transition-all'; |
||||
const onHover = 'hover:htw-opacity-80'; |
||||
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default'; |
||||
const onActive = 'active:htw-scale-95'; |
||||
const allClasses = clsx(base, onHover, onDisabled, onActive, className); |
||||
|
||||
return ( |
||||
<button type="button" className={allClasses} {...rest}> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,51 @@ |
||||
import React, { |
||||
ButtonHTMLAttributes, |
||||
PropsWithChildren, |
||||
useState, |
||||
} from 'react'; |
||||
|
||||
import { CheckmarkIcon } from '../icons/Checkmark.js'; |
||||
import { CopyIcon } from '../icons/Copy.js'; |
||||
import { tryClipboardSet } from '../utils/clipboard.js'; |
||||
|
||||
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & { |
||||
width?: number; |
||||
height?: number; |
||||
copyValue: string; |
||||
}; |
||||
|
||||
export function CopyButton({ |
||||
width, |
||||
height, |
||||
copyValue, |
||||
className, |
||||
children, |
||||
...rest |
||||
}: Props) { |
||||
const [showCheckmark, setShowCheckmark] = useState(false); |
||||
|
||||
const onClick = async () => { |
||||
const result = await tryClipboardSet(copyValue); |
||||
if (result) { |
||||
setShowCheckmark(true); |
||||
setTimeout(() => setShowCheckmark(false), 2000); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<button |
||||
onClick={onClick} |
||||
type="button" |
||||
title="Copy" |
||||
className={`htw-flex htw-items-center htw-justify-center htw-gap-2 htw-transition-all ${className}`} |
||||
{...rest} |
||||
> |
||||
{showCheckmark ? ( |
||||
<CheckmarkIcon width={width} height={height} /> |
||||
) : ( |
||||
<CopyIcon width={width} height={height} /> |
||||
)} |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,24 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react'; |
||||
|
||||
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>> & { |
||||
width?: number; |
||||
height?: number; |
||||
}; |
||||
|
||||
export function IconButton(props: Props) { |
||||
const { className, children, ...rest } = props; |
||||
|
||||
const base = |
||||
'htw-flex htw-items-center htw-justify-center htw-transition-all'; |
||||
const onHover = 'hover:htw-opacity-70 hover:htw-scale-105'; |
||||
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default'; |
||||
const onActive = 'active:htw-opacity-60'; |
||||
const allClasses = clsx(base, onHover, onDisabled, onActive, className); |
||||
|
||||
return ( |
||||
<button type="button" className={allClasses} {...rest}> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,21 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { ButtonHTMLAttributes, PropsWithChildren } from 'react'; |
||||
|
||||
type Props = PropsWithChildren<ButtonHTMLAttributes<HTMLButtonElement>>; |
||||
|
||||
export function LinkButton(props: Props) { |
||||
const { className, children, ...rest } = props; |
||||
|
||||
const base = |
||||
'htw-flex htw-items-center htw-justify-center htw-transition-all'; |
||||
const onHover = 'hover:htw-underline htw-underline-offset-2'; |
||||
const onDisabled = 'disabled:htw-opacity-30 disabled:htw-cursor-default'; |
||||
const onActive = 'active:htw-opacity-70'; |
||||
const allClasses = clsx(base, onHover, onDisabled, onActive, className); |
||||
|
||||
return ( |
||||
<button type="button" className={allClasses} {...rest}> |
||||
{children} |
||||
</button> |
||||
); |
||||
} |
@ -0,0 +1,344 @@ |
||||
import clsx from 'clsx'; |
||||
import React, { ComponentType, useMemo, useState } from 'react'; |
||||
|
||||
import { deepEquals, isObject, toTitleCase } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { ArrowIcon } from '../icons/Arrow.js'; |
||||
import { PencilIcon } from '../icons/Pencil.js'; |
||||
import { PlusIcon } from '../icons/Plus.js'; |
||||
import { SearchIcon } from '../icons/Search.js'; |
||||
import { XIcon } from '../icons/X.js'; |
||||
import { DropdownMenu } from '../layout/DropdownMenu.js'; |
||||
import { Popover } from '../layout/Popover.js'; |
||||
|
||||
import { IconButton } from './IconButton.js'; |
||||
import { InputProps, TextInput } from './TextInput.js'; |
||||
|
||||
export interface SearchMenuProps< |
||||
ListItemData extends { disabled?: boolean }, |
||||
SortBy extends string, |
||||
FilterState, |
||||
> { |
||||
// The list of data items to show
|
||||
data: ListItemData[]; |
||||
// The component with which the list items will be rendered
|
||||
ListComponent: ComponentType<{ data: ListItemData }>; |
||||
// Handler for list item click event
|
||||
onClickItem: (item: ListItemData) => void; |
||||
// Handler for edit list item click event
|
||||
onClickEditItem: (item: ListItemData) => void; |
||||
// Handler for searching through list item data
|
||||
searchFn: (args: { |
||||
data: ListItemData[]; |
||||
query: string; |
||||
sort: SortState<SortBy>; |
||||
filter: FilterState; |
||||
}) => ListItemData[]; |
||||
// List of sort options
|
||||
sortOptions: SortBy[]; |
||||
// Default sort state for list data
|
||||
defaultSortState?: SortState<SortBy>; |
||||
// The component with which the filter state will be rendered
|
||||
FilterComponent: ComponentType<{ |
||||
value: FilterState; |
||||
onChange: (s: FilterState) => void; |
||||
}>; |
||||
// Default filter state for list data
|
||||
defaultFilterState: FilterState; |
||||
// Placeholder text for the search input
|
||||
placeholder?: string; |
||||
// Handler for add button click event
|
||||
onClickAddItem?: () => void; |
||||
} |
||||
|
||||
export function SearchMenu< |
||||
ListItem extends { disabled?: boolean }, |
||||
SortBy extends string, |
||||
FilterState, |
||||
>({ |
||||
data, |
||||
ListComponent, |
||||
searchFn, |
||||
onClickItem, |
||||
onClickEditItem, |
||||
sortOptions, |
||||
defaultSortState, |
||||
FilterComponent, |
||||
defaultFilterState, |
||||
placeholder, |
||||
onClickAddItem, |
||||
}: SearchMenuProps<ListItem, SortBy, FilterState>) { |
||||
const [searchQuery, setSearchQuery] = useState(''); |
||||
const [isEditMode, setIsEditMode] = useState(false); |
||||
const [sortState, setSortState] = useState<SortState<SortBy>>( |
||||
defaultSortState || { |
||||
sortBy: sortOptions[0], |
||||
sortOrder: SortOrderOption.Asc, |
||||
}, |
||||
); |
||||
const [filterState, setFilterState] = |
||||
useState<FilterState>(defaultFilterState); |
||||
|
||||
const results = useMemo( |
||||
() => |
||||
searchFn({ |
||||
data, |
||||
query: searchQuery, |
||||
sort: sortState, |
||||
filter: filterState, |
||||
}), |
||||
[data, searchQuery, sortState, filterState, searchFn], |
||||
); |
||||
|
||||
return ( |
||||
<div className="htw-flex htw-flex-col htw-gap-2"> |
||||
<SearchBar |
||||
value={searchQuery} |
||||
onChange={setSearchQuery} |
||||
placeholder={placeholder} |
||||
/> |
||||
<div className="htw-flex htw-items-center htw-justify-between"> |
||||
<div className="htw-flex htw-items-center htw-gap-5"> |
||||
<SortDropdown |
||||
options={sortOptions} |
||||
value={sortState} |
||||
onChange={setSortState} |
||||
/> |
||||
<FilterDropdown |
||||
value={filterState} |
||||
defaultValue={defaultFilterState} |
||||
onChange={setFilterState} |
||||
FilterComponent={FilterComponent} |
||||
/> |
||||
</div> |
||||
<div className="htw-flex htw-items-center htw-gap-3 htw-mr-0.5"> |
||||
<IconButton |
||||
onClick={() => setIsEditMode(!isEditMode)} |
||||
className="htw-p-1.5 htw-border htw-border-gray-200 htw-rounded-full" |
||||
title="Edit items" |
||||
> |
||||
<PencilIcon |
||||
width={14} |
||||
height={14} |
||||
color={isEditMode ? ColorPalette.Blue : ColorPalette.Black} |
||||
/> |
||||
</IconButton> |
||||
{onClickAddItem && ( |
||||
<IconButton |
||||
onClick={onClickAddItem} |
||||
className="htw-p-0.5 htw-border htw-border-gray-200 htw-rounded-full" |
||||
title="Add item" |
||||
> |
||||
<PlusIcon width={22} height={22} /> |
||||
</IconButton> |
||||
)} |
||||
</div> |
||||
</div> |
||||
<div className="htw-flex htw-flex-col htw-divide-y htw-divide-gray-100"> |
||||
{results.length ? ( |
||||
results.map((data, i) => ( |
||||
<ListItem |
||||
key={i} |
||||
data={data} |
||||
isEditMode={isEditMode} |
||||
onClickItem={onClickItem} |
||||
onClickEditItem={onClickEditItem} |
||||
ListComponent={ListComponent} |
||||
/> |
||||
)) |
||||
) : ( |
||||
<div className="htw-my-8 htw-text-gray-500 htw-text-center"> |
||||
No results found |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function SearchBar(props: InputProps) { |
||||
return ( |
||||
<div className="htw-relative"> |
||||
<SearchIcon |
||||
width={18} |
||||
height={18} |
||||
className="htw-absolute htw-left-4 htw-top-1/2 -htw-translate-y-1/2 htw-opacity-50" |
||||
/> |
||||
<TextInput |
||||
{...props} |
||||
className="htw-w-full htw-rounded-lg htw-px-11 htw-py-3" |
||||
/> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function SortDropdown<SortBy extends string>({ |
||||
options, |
||||
value, |
||||
onChange, |
||||
}: { |
||||
options: SortBy[]; |
||||
value: SortState<SortBy>; |
||||
onChange: (v: SortState<SortBy>) => void; |
||||
}) { |
||||
const onToggleOrder = () => { |
||||
onChange({ |
||||
...value, |
||||
sortOrder: |
||||
value.sortOrder === SortOrderOption.Asc |
||||
? SortOrderOption.Desc |
||||
: SortOrderOption.Asc, |
||||
}); |
||||
}; |
||||
|
||||
const onSetSortBy = (sortBy: SortBy) => { |
||||
onChange({ |
||||
...value, |
||||
sortBy, |
||||
}); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-h-7 htw-flex htw-items-stretch htw-text-sm htw-rounded htw-border htw-border-gray-200"> |
||||
<div className="htw-flex htw-bg-gray-100 htw-px-2"> |
||||
<span className="htw-place-self-center">Sort</span> |
||||
</div> |
||||
<DropdownMenu |
||||
button={ |
||||
<span className="htw-place-self-center htw-px-2"> |
||||
{toTitleCase(value.sortBy)} |
||||
</span> |
||||
} |
||||
buttonClassname="htw-flex htw-items-stretch hover:htw-bg-gray-100 active:htw-scale-95" |
||||
menuClassname="htw-py-1.5 htw-px-2 htw-flex htw-flex-col htw-gap-2 htw-text-sm htw-border htw-border-gray-100" |
||||
menuItems={options.map((o) => ( |
||||
<div |
||||
className="htw-rounded htw-p-1.5 hover:htw-bg-gray-200" |
||||
onClick={() => onSetSortBy(o)} |
||||
> |
||||
{toTitleCase(o)} |
||||
</div> |
||||
))} |
||||
menuProps={{ anchor: 'bottom start' }} |
||||
/> |
||||
<IconButton |
||||
onClick={onToggleOrder} |
||||
className="hover:htw-bg-gray-100 active:htw-scale-95 htw-px-0.5 htw-py-1.5" |
||||
title="Toggle sort" |
||||
> |
||||
<ArrowIcon |
||||
direction={value.sortOrder === SortOrderOption.Asc ? 'n' : 's'} |
||||
width={14} |
||||
height={14} |
||||
/> |
||||
</IconButton> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function FilterDropdown<FilterState>({ |
||||
value, |
||||
defaultValue, |
||||
onChange, |
||||
FilterComponent, |
||||
}: { |
||||
value: FilterState; |
||||
defaultValue: FilterState; |
||||
onChange: (v: FilterState) => void; |
||||
FilterComponent: ComponentType<{ |
||||
value: FilterState; |
||||
onChange: (s: FilterState) => void; |
||||
}>; |
||||
}) { |
||||
const filterValues = useMemo(() => { |
||||
if (!value || !isObject(value)) return []; |
||||
const modifiedKeys = Object.keys(value).filter( |
||||
(k) => !deepEquals(value[k], defaultValue[k]), |
||||
); |
||||
return modifiedKeys.map((k) => value[k]); |
||||
}, [value]); |
||||
const hasFilters = filterValues.length > 0; |
||||
|
||||
const onClear = () => { |
||||
onChange(defaultValue); |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-h-7 htw-flex htw-items-stretch htw-text-sm htw-rounded htw-border htw-border-gray-200"> |
||||
<div className="htw-flex htw-bg-gray-100 htw-px-2"> |
||||
<span className="htw-place-self-center">Filter</span> |
||||
</div> |
||||
<Popover |
||||
button={ |
||||
<span |
||||
className={clsx( |
||||
'htw-place-self-center htw-px-3', |
||||
!hasFilters && 'htw-text-gray-400', |
||||
)} |
||||
> |
||||
{hasFilters ? filterValues.map(toTitleCase).join(', ') : 'None'} |
||||
</span> |
||||
} |
||||
buttonClassname="htw-h-full htw-flex htw-items-stretch hover:htw-bg-gray-100 active:htw-scale-95" |
||||
> |
||||
<FilterComponent value={value} onChange={onChange} /> |
||||
</Popover> |
||||
<IconButton |
||||
disabled={!filterValues.length} |
||||
onClick={onClear} |
||||
className="hover:htw-bg-gray-100 active:htw-scale-95 htw-px-1 htw-py-1.5" |
||||
title="Clear filters" |
||||
> |
||||
<XIcon width={9} height={9} /> |
||||
</IconButton> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
interface ListItemProps<ListItemData extends { disabled?: boolean }> { |
||||
data: ListItemData; |
||||
isEditMode: boolean; |
||||
onClickItem: (item: ListItemData) => void; |
||||
onClickEditItem: (item: ListItemData) => void; |
||||
ListComponent: ComponentType<{ data: ListItemData }>; |
||||
} |
||||
|
||||
function ListItem<ListItemData extends { disabled?: boolean }>({ |
||||
data, |
||||
isEditMode, |
||||
onClickEditItem, |
||||
onClickItem, |
||||
ListComponent, |
||||
}: ListItemProps<ListItemData>) { |
||||
return ( |
||||
<button |
||||
className={clsx( |
||||
'-htw-mx-2 htw-px-2.5 htw-py-2.5 htw-grid htw-grid-cols-[1fr,1fr,auto] htw-items-center htw-relative htw-rounded htw-transition-all htw-duration-250', |
||||
data.disabled |
||||
? 'htw-opacity-50' |
||||
: 'hover:htw-bg-gray-100 active:htw-scale-95', |
||||
)} |
||||
type="button" |
||||
disabled={data.disabled} |
||||
onClick={() => (isEditMode ? onClickEditItem(data) : onClickItem(data))} |
||||
> |
||||
<ListComponent data={data} /> |
||||
{isEditMode && ( |
||||
<div className="htw-justify-self-end"> |
||||
<PencilIcon width={16} height={16} /> |
||||
</div> |
||||
)} |
||||
</button> |
||||
); |
||||
} |
||||
|
||||
export interface SortState<SortBy> { |
||||
sortBy: SortBy; |
||||
sortOrder: SortOrderOption; |
||||
} |
||||
|
||||
export enum SortOrderOption { |
||||
Asc = 'asc', |
||||
Desc = 'desc', |
||||
} |
@ -0,0 +1,51 @@ |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { toTitleCase } from '@hyperlane-xyz/utils'; |
||||
|
||||
interface SegmentedControlProps<O extends string> { |
||||
options: O[]; |
||||
onChange: (selected: O | undefined) => void; |
||||
allowEmpty?: boolean; |
||||
} |
||||
|
||||
export function SegmentedControl<O extends string>({ |
||||
options, |
||||
onChange, |
||||
allowEmpty, |
||||
}: SegmentedControlProps<O>) { |
||||
// State to keep track of the selected option index
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | undefined>( |
||||
allowEmpty ? undefined : 0, |
||||
); |
||||
|
||||
const handleSelect = (index: number) => { |
||||
// Unselect when the same option is re-clicked
|
||||
if (selectedIndex === index && allowEmpty) { |
||||
setSelectedIndex(undefined); |
||||
onChange(undefined); |
||||
} else { |
||||
setSelectedIndex(index); |
||||
onChange(options[index]); |
||||
} |
||||
}; |
||||
|
||||
return ( |
||||
<div className="htw-inline-flex htw-rounded htw-border htw-border-gray-200 htw-divide-x"> |
||||
{options.map((option, index) => ( |
||||
<button |
||||
key={index} |
||||
onClick={() => handleSelect(index)} |
||||
className={`htw-px-2 sm:htw-px-3 htw-py-1 htw-text-sm htw-transition-all htw-duration-200 htw-ease-in-out htw-focus:outline-none first:htw-rounded-l last:htw-rounded-r
|
||||
${ |
||||
selectedIndex === index |
||||
? 'htw-bg-gray-100 htw-font-medium' |
||||
: 'htw-bg-white hover:htw-bg-gray-100' |
||||
}
|
||||
`}
|
||||
> |
||||
{toTitleCase(option!)} |
||||
</button> |
||||
))} |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,25 @@ |
||||
import React, { ChangeEvent, InputHTMLAttributes } from 'react'; |
||||
|
||||
export type InputProps = Omit< |
||||
InputHTMLAttributes<HTMLInputElement>, |
||||
'onChange' |
||||
> & { |
||||
onChange?: (v: string) => void; |
||||
className?: string; |
||||
}; |
||||
|
||||
export function TextInput({ onChange, className, ...props }: InputProps) { |
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => { |
||||
if (onChange) onChange(e?.target?.value || ''); |
||||
}; |
||||
|
||||
return ( |
||||
<input |
||||
type="text" |
||||
autoComplete="off" |
||||
onChange={handleChange} |
||||
className={`htw-bg-gray-100 focus:htw-bg-gray-200 disabled:htw-bg-gray-500 htw-outline-none htw-transition-all htw-duration-300 ${className}`} |
||||
{...props} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,28 @@ |
||||
import React, { AnchorHTMLAttributes } from 'react'; |
||||
import { Tooltip as ReactTooltip } from 'react-tooltip'; |
||||
|
||||
import { Circle } from '../icons/Circle.js'; |
||||
import { QuestionMarkIcon } from '../icons/QuestionMark.js'; |
||||
|
||||
type Props = AnchorHTMLAttributes<HTMLAnchorElement> & { |
||||
id: string; |
||||
content: string; |
||||
size?: number; |
||||
}; |
||||
|
||||
export function Tooltip({ id, content, size = 16, ...rest }: Props) { |
||||
return ( |
||||
<> |
||||
<a data-tooltip-id={id} data-tooltip-html={content} {...rest}> |
||||
<Circle size={size} className="htw-bg-gray-200 htw-border-gray-300"> |
||||
<QuestionMarkIcon |
||||
width={size - 2} |
||||
height={size - 2} |
||||
className="htw-opacity-60" |
||||
/> |
||||
</Circle> |
||||
</a> |
||||
<ReactTooltip id={id} /> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,45 @@ |
||||
import React, { SVGProps, memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
type Props = SVGProps<SVGSVGElement> & { |
||||
direction: 'n' | 'e' | 's' | 'w'; |
||||
}; |
||||
|
||||
function _ArrowIcon({ fill, className, direction, ...rest }: Props) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = 'htw-rotate-180'; |
||||
break; |
||||
case 'e': |
||||
directionClass = '-htw-rotate-90'; |
||||
break; |
||||
case 's': |
||||
directionClass = ''; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'htw-rotate-90'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid direction ${direction}`); |
||||
} |
||||
|
||||
return ( |
||||
<svg |
||||
viewBox="0 0 16 16" |
||||
fill="none" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
className={`${directionClass} ${className}`} |
||||
{...rest} |
||||
> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1" |
||||
fill={fill || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const ArrowIcon = memo(_ArrowIcon); |
@ -0,0 +1,24 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _BoxArrowIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M8.636 3.5a.5.5 0 0 0-.5-.5H1.5A1.5 1.5 0 0 0 0 4.5v10A1.5 1.5 0 0 0 1.5 16h10a1.5 1.5 0 0 0 1.5-1.5V7.864a.5.5 0 0 0-1 0V14.5a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5v-10a.5.5 0 0 1 .5-.5h6.636a.5.5 0 0 0 .5-.5" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M16 .5a.5.5 0 0 0-.5-.5h-5a.5.5 0 0 0 0 1h3.793L6.146 9.146a.5.5 0 1 0 .708.708L15 1.707V5.5a.5.5 0 0 0 1 0z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const BoxArrowIcon = memo(_BoxArrowIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _CheckmarkIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const CheckmarkIcon = memo(_CheckmarkIcon); |
@ -0,0 +1,46 @@ |
||||
import React, { SVGProps, memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
type Props = SVGProps<SVGSVGElement> & { |
||||
direction: 'n' | 'e' | 's' | 'w'; |
||||
}; |
||||
|
||||
function _ChevronIcon({ color, className, direction, ...rest }: Props) { |
||||
let directionClass; |
||||
switch (direction) { |
||||
case 'n': |
||||
directionClass = 'htw-rotate-180'; |
||||
break; |
||||
case 'e': |
||||
directionClass = '-htw-rotate-90'; |
||||
break; |
||||
case 's': |
||||
directionClass = ''; |
||||
break; |
||||
case 'w': |
||||
directionClass = 'htw-rotate-90'; |
||||
break; |
||||
default: |
||||
throw new Error(`Invalid direction ${direction}`); |
||||
} |
||||
|
||||
return ( |
||||
<svg |
||||
width="4" |
||||
height="6" |
||||
viewBox="0 0 16 9" |
||||
fill="none" |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
className={`${directionClass} ${className}`} |
||||
{...rest} |
||||
> |
||||
<path |
||||
d="M15.1 1.4 13.8.1 8 5.9 2.2.2 1 1.6l7.2 7 7-7.2Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const ChevronIcon = memo(_ChevronIcon); |
@ -0,0 +1,34 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _CopyIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 21 21" {...rest}> |
||||
<rect |
||||
x="1" |
||||
y="7" |
||||
width="13" |
||||
height="13" |
||||
rx="1" |
||||
stroke={color || ColorPalette.Black} |
||||
strokeWidth="2.1" |
||||
fill="none" |
||||
/> |
||||
<rect |
||||
x="7" |
||||
y="1" |
||||
width="13" |
||||
height="13" |
||||
rx="1" |
||||
stroke={color || ColorPalette.Black} |
||||
strokeWidth="2.1" |
||||
fill="none" |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const CopyIcon = memo(_CopyIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _FilterIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 22 16" {...rest}> |
||||
<path |
||||
d="M8.55556 16V13.3333H13.4444V16H8.55556ZM3.66667 9.33333V6.66667H18.3333V9.33333H3.66667ZM0 2.66667V0H22V2.66667H0Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const FilterIcon = memo(_FilterIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _FunnelIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const FunnelIcon = memo(_FunnelIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _GearIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 579.2 579.2" {...rest}> |
||||
<path |
||||
d="M570 353.8h9.2V228.4H497a216 216 0 0 0-17.4-42.4l51.4-51.4 6.5-6.5-6.5-6.5-75.7-75.7-6.5-6.5-6.5 6.5-51.6 51.6a217.9 217.9 0 0 0-40-16.5V0H225.3v81c-13 4-25.8 9.1-38 15.5l-50.6-50.6-6.4-6.5-6.5 6.5L48 121.6l-6.5 6.5 6.5 6.5L97.5 184c-7.3 13.1-13.2 27-17.6 41.2H0v125.5h79A216.1 216.1 0 0 0 97.5 395L48 444.6l-6.5 6.5 6.5 6.5 75.8 75.7 6.5 6.5 6.4-6.5 50.6-50.6c13 6.8 26.8 12.3 41 16.4v80.1h125.5v-81.9c12.8-4 25.1-9.3 37-15.7l51.6 51.7 6.5 6.5 6.5-6.5 75.7-75.7 6.5-6.5-6.5-6.5-51.4-51.5a216.6 216.6 0 0 0 16.5-39.3H570zm-152-64.2a130.1 130.1 0 0 1-260 0 130.1 130.1 0 0 1 260.1 0z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const GearIcon = memo(_GearIcon); |
@ -0,0 +1,23 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _PencilIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const PencilIcon = memo(_PencilIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _PlusIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const PlusIcon = memo(_PlusIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _PlusCircleIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15" {...rest}> |
||||
<path |
||||
d="M14.0625 7.03125C14.0625 8.89605 13.3217 10.6845 12.0031 12.0031C10.6845 13.3217 8.89605 14.0625 7.03125 14.0625C5.16645 14.0625 3.37802 13.3217 2.05941 12.0031C0.74079 10.6845 0 8.89605 0 7.03125C0 5.16645 0.74079 3.37802 2.05941 2.05941C3.37802 0.74079 5.16645 0 7.03125 0C8.89605 0 10.6845 0.74079 12.0031 2.05941C13.3217 3.37802 14.0625 5.16645 14.0625 7.03125ZM7.4707 3.95508C7.4707 3.83853 7.4244 3.72675 7.34199 3.64434C7.25958 3.56192 7.1478 3.51562 7.03125 3.51562C6.9147 3.51562 6.80292 3.56192 6.72051 3.64434C6.6381 3.72675 6.5918 3.83853 6.5918 3.95508V6.5918H3.95508C3.83853 6.5918 3.72675 6.6381 3.64434 6.72051C3.56192 6.80292 3.51562 6.9147 3.51562 7.03125C3.51562 7.1478 3.56192 7.25958 3.64434 7.34199C3.72675 7.4244 3.83853 7.4707 3.95508 7.4707H6.5918V10.1074C6.5918 10.224 6.6381 10.3357 6.72051 10.4182C6.80292 10.5006 6.9147 10.5469 7.03125 10.5469C7.1478 10.5469 7.25958 10.5006 7.34199 10.4182C7.4244 10.3357 7.4707 10.224 7.4707 10.1074V7.4707H10.1074C10.224 7.4707 10.3357 7.4244 10.4182 7.34199C10.5006 7.25958 10.5469 7.1478 10.5469 7.03125C10.5469 6.9147 10.5006 6.80292 10.4182 6.72051C10.3357 6.6381 10.224 6.5918 10.1074 6.5918H7.4707V3.95508Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const PlusCircleIcon = memo(_PlusCircleIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _SearchIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const SearchIcon = memo(_SearchIcon); |
@ -0,0 +1,33 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _Spinner({ color, className, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg |
||||
className={`htw-animate-spin htw-text-black ${className}`} |
||||
xmlns="http://www.w3.org/2000/svg" |
||||
fill="none" |
||||
viewBox="0 0 24 24" |
||||
{...rest} |
||||
> |
||||
<circle |
||||
className="htw-opacity-25" |
||||
stroke={color || ColorPalette.Black} |
||||
strokeWidth="4" |
||||
cx="12" |
||||
cy="12" |
||||
r="10" |
||||
></circle> |
||||
<path |
||||
className="htw-opacity-75" |
||||
fill={color || ColorPalette.Black} |
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
||||
></path> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const Spinner = memo(_Spinner); |
@ -0,0 +1,19 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _UpDownArrowsIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" {...rest}> |
||||
<path |
||||
fillRule="evenodd" |
||||
d="M11.5 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L11 2.707V14.5a.5.5 0 0 0 .5.5m-7-14a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L4 13.293V1.5a.5.5 0 0 1 .5-.5" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const UpDownArrowsIcon = memo(_UpDownArrowsIcon); |
@ -0,0 +1,18 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
|
||||
import { DefaultIconProps } from './types.js'; |
||||
|
||||
function _XIcon({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10" {...rest}> |
||||
<path |
||||
d="M10 0.97908L9.02092 0L5 4.02092L0.979081 0L0 0.97908L4.02092 5L0 9.02092L0.979081 10L5 5.97908L9.02092 10L10 9.02092L5.97908 5L10 0.97908Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const XIcon = memo(_XIcon); |
@ -0,0 +1,5 @@ |
||||
import { SVGProps } from 'react'; |
||||
|
||||
export type DefaultIconProps = SVGProps<SVGSVGElement> & { |
||||
color?: string; |
||||
}; |
@ -0,0 +1,45 @@ |
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; |
||||
import clsx from 'clsx'; |
||||
import React, { ComponentProps, ReactNode } from 'react'; |
||||
|
||||
export type DropdownMenuProps = { |
||||
button: ReactNode; |
||||
buttonClassname?: string; |
||||
buttonProps?: ComponentProps<typeof MenuButton>; |
||||
menuClassname?: string; |
||||
menuProps?: ComponentProps<typeof MenuItems>; |
||||
menuItems: Array<ComponentProps<typeof MenuItem>['children']>; |
||||
}; |
||||
|
||||
export function DropdownMenu({ |
||||
button, |
||||
buttonClassname, |
||||
buttonProps, |
||||
menuClassname, |
||||
menuProps, |
||||
menuItems, |
||||
}: DropdownMenuProps) { |
||||
return ( |
||||
<Menu> |
||||
<MenuButton |
||||
className={clsx('htw-focus:outline-none', buttonClassname)} |
||||
{...buttonProps} |
||||
> |
||||
{button} |
||||
</MenuButton> |
||||
<MenuItems |
||||
transition |
||||
anchor="bottom" |
||||
className={clsx( |
||||
'htw-rounded htw-bg-white htw-shadow-md htw-drop-shadow-md htw-backdrop-blur htw-transition htw-duration-200 htw-ease-in-out htw-focus:outline-none [--anchor-gap:var(--spacing-5)] data-[closed]:htw--translate-y-1 data-[closed]:htw-opacity-0 htw-cursor-pointer htw-z-30', |
||||
menuClassname, |
||||
)} |
||||
{...menuProps} |
||||
> |
||||
{menuItems.map((mi, i) => ( |
||||
<MenuItem key={`menu-item-${i}`}>{mi}</MenuItem> |
||||
))} |
||||
</MenuItems> |
||||
</Menu> |
||||
); |
||||
} |
@ -0,0 +1,74 @@ |
||||
import { Dialog, DialogBackdrop, DialogPanel } from '@headlessui/react'; |
||||
import clsx from 'clsx'; |
||||
import React, { ComponentProps, PropsWithChildren, useState } from 'react'; |
||||
|
||||
import { IconButton } from '../components/IconButton.js'; |
||||
import { XIcon } from '../icons/X.js'; |
||||
|
||||
export function useModal() { |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
const open = () => setIsOpen(true); |
||||
const close = () => setIsOpen(false); |
||||
return { isOpen, open, close }; |
||||
} |
||||
|
||||
export type ModalProps = PropsWithChildren<{ |
||||
isOpen: boolean; |
||||
close: () => void; |
||||
dialogClassname?: string; |
||||
dialogProps?: ComponentProps<typeof Dialog>; |
||||
panelClassname?: string; |
||||
panelProps?: ComponentProps<typeof DialogPanel>; |
||||
showCloseButton?: boolean; |
||||
}>; |
||||
|
||||
export function Modal({ |
||||
isOpen, |
||||
close, |
||||
dialogClassname, |
||||
dialogProps, |
||||
panelClassname, |
||||
panelProps, |
||||
showCloseButton, |
||||
children, |
||||
}: ModalProps) { |
||||
return ( |
||||
<Dialog |
||||
open={isOpen} |
||||
as="div" |
||||
className={clsx( |
||||
'htw-relative htw-z-20 htw-focus:outline-none', |
||||
dialogClassname, |
||||
)} |
||||
onClose={close} |
||||
{...dialogProps} |
||||
> |
||||
<DialogBackdrop className="htw-fixed htw-inset-0 htw-bg-black/5 htw-transition-all htw-duration-200" /> |
||||
<div className="htw-fixed htw-inset-0 htw-z-20 htw-w-screen htw-overflow-y-auto"> |
||||
<div className="htw-flex htw-min-h-full htw-items-center htw-justify-center htw-p-4"> |
||||
<DialogPanel |
||||
transition |
||||
className={clsx( |
||||
'htw-w-full htw-max-w-sm htw-max-h-[90vh] htw-rounded-lg htw-shadow htw-overflow-auto no-scrollbar htw-bg-white htw-duration-200 htw-ease-out data-[closed]:htw-transform-[scale(95%)] data-[closed]:htw-opacity-0 data-[closed]:htw--translate-y-1', |
||||
panelClassname, |
||||
)} |
||||
{...panelProps} |
||||
> |
||||
{children} |
||||
{showCloseButton && ( |
||||
<div className="htw-absolute htw-right-3 htw-top-3"> |
||||
<IconButton |
||||
onClick={close} |
||||
title="Close" |
||||
className="hover:htw-rotate-90" |
||||
> |
||||
<XIcon height={10} width={10} /> |
||||
</IconButton> |
||||
</div> |
||||
)} |
||||
</DialogPanel> |
||||
</div> |
||||
</div> |
||||
</Dialog> |
||||
); |
||||
} |
@ -0,0 +1,47 @@ |
||||
import { |
||||
PopoverButton, |
||||
PopoverPanel, |
||||
Popover as _Popover, |
||||
} from '@headlessui/react'; |
||||
import clsx from 'clsx'; |
||||
import React, { ComponentProps, ReactNode } from 'react'; |
||||
|
||||
export type PopoverProps = { |
||||
button: ReactNode; |
||||
buttonClassname?: string; |
||||
buttonProps?: ComponentProps<typeof PopoverButton>; |
||||
panelClassname?: string; |
||||
panelProps?: ComponentProps<typeof PopoverPanel>; |
||||
children: ComponentProps<typeof PopoverPanel>['children']; |
||||
}; |
||||
|
||||
export function Popover({ |
||||
button, |
||||
buttonClassname, |
||||
buttonProps, |
||||
panelClassname, |
||||
panelProps, |
||||
children, |
||||
}: PopoverProps) { |
||||
return ( |
||||
<_Popover> |
||||
<PopoverButton |
||||
className={clsx('htw-focus:outline-none', buttonClassname)} |
||||
{...buttonProps} |
||||
> |
||||
{button} |
||||
</PopoverButton> |
||||
<PopoverPanel |
||||
transition |
||||
anchor="bottom" |
||||
className={clsx( |
||||
'htw-rounded htw-bg-white htw-border htw-border-gray-100 htw-shadow-md htw-drop-shadow-md htw-backdrop-blur htw-transition htw-duration-200 htw-ease-in-out htw-focus:outline-none [--anchor-gap:var(--spacing-5)] data-[closed]:htw--translate-y-1 data-[closed]:htw-opacity-0 htw-z-30', |
||||
panelClassname, |
||||
)} |
||||
{...panelProps} |
||||
> |
||||
{children} |
||||
</PopoverPanel> |
||||
</_Popover> |
||||
); |
||||
} |
@ -0,0 +1,25 @@ |
||||
import React, { memo } from 'react'; |
||||
|
||||
import { ColorPalette } from '../color.js'; |
||||
import { DefaultIconProps } from '../icons/types.js'; |
||||
|
||||
function _HyperlaneLogo({ color, ...rest }: DefaultIconProps) { |
||||
return ( |
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 117 118" {...rest}> |
||||
<path |
||||
d="M64.4787 0H88.4134C91.6788 0 94.6004 1.89614 95.7403 4.7553L116.749 57.4498C116.911 57.8563 116.913 58.3035 116.754 58.7112L116.637 59.014L116.635 59.017L95.7152 112.81C94.5921 115.698 91.6551 117.62 88.3666 117.62H64.4355C63.0897 117.62 62.1465 116.379 62.59 115.192L84.1615 57.4498L62.6428 2.45353C62.1766 1.26188 63.1208 0 64.4787 0Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
<path |
||||
d="M1.99945 0H25.9342C29.1996 0 32.1211 1.89614 33.261 4.7553L54.2696 57.4498C54.4316 57.8563 54.4336 58.3035 54.275 58.7112L54.1573 59.014L54.1561 59.017L33.236 112.81C32.1129 115.698 29.1759 117.62 25.8874 117.62H1.95626C0.610483 117.62 -0.332722 116.379 0.110804 115.192L21.6823 57.4498L0.163626 2.45353C-0.302638 1.26188 0.641544 0 1.99945 0Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
<path |
||||
d="M80.7202 46.2178H46.9324V71.7089H80.7202L86.2411 58.5992L80.7202 46.2178Z" |
||||
fill={color || ColorPalette.Black} |
||||
/> |
||||
</svg> |
||||
); |
||||
} |
||||
|
||||
export const HyperlaneLogo = memo(_HyperlaneLogo); |
@ -0,0 +1,42 @@ |
||||
import { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { chainMetadata } from '@hyperlane-xyz/registry'; |
||||
|
||||
import { ChainDetailsMenu } from '../chains/ChainDetailsMenu.js'; |
||||
|
||||
const meta = { |
||||
title: 'ChainDetailsMenu', |
||||
component: ChainDetailsMenu, |
||||
} satisfies Meta<typeof ChainDetailsMenu>; |
||||
export default meta; |
||||
type Story = StoryObj<typeof meta>; |
||||
|
||||
export const DefaultChainDetails = { |
||||
args: { |
||||
chainMetadata: chainMetadata['ethereum'], |
||||
overrideChainMetadata: undefined, |
||||
onChangeOverrideMetadata: () => {}, |
||||
onClickBack: undefined, |
||||
onRemoveChain: undefined, |
||||
}, |
||||
} satisfies Story; |
||||
|
||||
export const PartialOverrideChainDetails = { |
||||
args: { |
||||
chainMetadata: chainMetadata['ethereum'], |
||||
overrideChainMetadata: { rpcUrls: [{ http: 'https://rpc.fakeasdf.com' }] }, |
||||
onChangeOverrideMetadata: () => {}, |
||||
onClickBack: undefined, |
||||
onRemoveChain: undefined, |
||||
}, |
||||
} satisfies Story; |
||||
|
||||
export const FullOverrideChainDetails = { |
||||
args: { |
||||
chainMetadata: chainMetadata['arbitrum'], |
||||
overrideChainMetadata: chainMetadata['arbitrum'], |
||||
onChangeOverrideMetadata: () => {}, |
||||
onClickBack: () => {}, |
||||
onRemoveChain: () => {}, |
||||
}, |
||||
} satisfies Story; |
@ -0,0 +1,48 @@ |
||||
import { Meta, StoryObj } from '@storybook/react'; |
||||
|
||||
import { chainMetadata } from '@hyperlane-xyz/registry'; |
||||
import { pick } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainSearchMenu } from '../chains/ChainSearchMenu.js'; |
||||
|
||||
const meta = { |
||||
title: 'ChainSearchMenu', |
||||
component: ChainSearchMenu, |
||||
} satisfies Meta<typeof ChainSearchMenu>; |
||||
export default meta; |
||||
type Story = StoryObj<typeof meta>; |
||||
|
||||
export const DefaultChainSearch = { |
||||
args: { |
||||
chainMetadata, |
||||
onChangeOverrideMetadata: () => {}, |
||||
onClickChain: (chain) => console.log('Clicked', chain), |
||||
}, |
||||
} satisfies Story; |
||||
|
||||
export const WithCustomField = { |
||||
args: { |
||||
chainMetadata: pick(chainMetadata, ['alfajores', 'arbitrum', 'ethereum']), |
||||
onChangeOverrideMetadata: () => {}, |
||||
customListItemField: { |
||||
header: 'Warp Routes', |
||||
data: { |
||||
alfajores: { display: '1 token', sortValue: 1 }, |
||||
arbitrum: { display: '2 tokens', sortValue: 2 }, |
||||
ethereum: { display: '1 token', sortValue: 1 }, |
||||
}, |
||||
}, |
||||
showAddChainButton: true, |
||||
}, |
||||
} satisfies Story; |
||||
|
||||
export const WithOverrideChain = { |
||||
args: { |
||||
chainMetadata: pick(chainMetadata, ['alfajores']), |
||||
overrideChainMetadata: { |
||||
arbitrum: { ...chainMetadata['arbitrum'], displayName: 'Fake Arb' }, |
||||
}, |
||||
onChangeOverrideMetadata: () => {}, |
||||
showAddChainButton: true, |
||||
}, |
||||
} satisfies Story; |
@ -0,0 +1,36 @@ |
||||
import { Button } from '@headlessui/react'; |
||||
import { Meta, StoryObj } from '@storybook/react'; |
||||
import React, { useState } from 'react'; |
||||
|
||||
import { Modal } from '../layout/Modal.js'; |
||||
|
||||
function MyModal() { |
||||
const [isOpen, setIsOpen] = useState(false); |
||||
const open = () => setIsOpen(true); |
||||
const close = () => setIsOpen(false); |
||||
|
||||
return ( |
||||
<> |
||||
<Button onClick={open}>Open modal</Button> |
||||
<Modal |
||||
isOpen={isOpen} |
||||
close={close} |
||||
showCloseButton |
||||
panelClassname="htw-bg-gray-100" |
||||
> |
||||
<div>Hello Modal</div> |
||||
</Modal> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const meta = { |
||||
title: 'Modal', |
||||
component: MyModal, |
||||
} satisfies Meta<typeof Modal>; |
||||
export default meta; |
||||
type Story = StoryObj<typeof meta>; |
||||
|
||||
export const BasicModal = { |
||||
args: {}, |
||||
} satisfies Story; |
@ -1,3 +1,16 @@ |
||||
@tailwind base; |
||||
@tailwind components; |
||||
@tailwind utilities; |
||||
|
||||
/* Tailwind extension to hide scrollbar */ |
||||
@layer utilities { |
||||
/* Chrome, Safari and Opera */ |
||||
.no-scrollbar::-webkit-scrollbar { |
||||
display: none; |
||||
} |
||||
|
||||
.no-scrollbar { |
||||
-ms-overflow-style: none; /* IE and Edge */ |
||||
scrollbar-width: none; /* Firefox */ |
||||
} |
||||
} |
||||
|
@ -0,0 +1,24 @@ |
||||
export function isClipboardReadSupported() { |
||||
return !!navigator?.clipboard?.readText; |
||||
} |
||||
|
||||
export async function tryClipboardSet(value: string) { |
||||
try { |
||||
await navigator.clipboard.writeText(value); |
||||
return true; |
||||
} catch (error) { |
||||
console.error('Failed to set clipboard', error); |
||||
return false; |
||||
} |
||||
} |
||||
|
||||
export async function tryClipboardGet() { |
||||
try { |
||||
// Note: doesn't work in firefox, which only allows extensions to read clipboard
|
||||
const value = await navigator.clipboard.readText(); |
||||
return value; |
||||
} catch (error) { |
||||
console.error('Failed to read from clipboard', error); |
||||
return null; |
||||
} |
||||
} |
@ -1,14 +0,0 @@ |
||||
export async function fetchWithTimeout( |
||||
resource: RequestInfo, |
||||
options?: RequestInit, |
||||
timeout = 10000, |
||||
) { |
||||
const controller = new AbortController(); |
||||
const id = setTimeout(() => controller.abort(), timeout); |
||||
const response = await fetch(resource, { |
||||
...options, |
||||
signal: controller.signal, |
||||
}); |
||||
clearTimeout(id); |
||||
return response; |
||||
} |
@ -0,0 +1,32 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
|
||||
import { |
||||
ChainMetadata, |
||||
isBlockExplorerHealthy, |
||||
isRpcHealthy, |
||||
} from '@hyperlane-xyz/sdk'; |
||||
import { timeout } from '@hyperlane-xyz/utils'; |
||||
|
||||
import { ChainConnectionType } from '../chains/types.js'; |
||||
|
||||
const HEALTH_TEST_TIMEOUT = 5000; // 5s
|
||||
|
||||
export function useConnectionHealthTest( |
||||
chainMetadata: ChainMetadata, |
||||
index: number, |
||||
type: ChainConnectionType, |
||||
) { |
||||
const [isHealthy, setIsHealthy] = useState<boolean | undefined>(undefined); |
||||
const tester = |
||||
type === ChainConnectionType.RPC ? isRpcHealthy : isBlockExplorerHealthy; |
||||
|
||||
useEffect(() => { |
||||
// TODO run explorer test through CORS proxy, otherwise it's blocked by browser
|
||||
if (type === ChainConnectionType.Explorer) return; |
||||
timeout(tester(chainMetadata, index), HEALTH_TEST_TIMEOUT) |
||||
.then((result) => setIsHealthy(result)) |
||||
.catch(() => setIsHealthy(false)); |
||||
}, [chainMetadata, index, tester]); |
||||
|
||||
return isHealthy; |
||||
} |
Loading…
Reference in new issue