chore: Migrate various utils from the Warp UI to the Widgets lib (#4856)
### Description More ground-work for the upcoming Warp Deploy app No new code, just migrating things from the Warp UI ### Backward compatibility Yes ### Testing Tested in Warp UIpull/4877/head
parent
6f2d50fbd6
commit
0e285a4433
@ -0,0 +1,5 @@ |
|||||||
|
--- |
||||||
|
'@hyperlane-xyz/utils': minor |
||||||
|
--- |
||||||
|
|
||||||
|
Add an isRelativeUrl function |
@ -0,0 +1,5 @@ |
|||||||
|
--- |
||||||
|
'@hyperlane-xyz/sdk': minor |
||||||
|
--- |
||||||
|
|
||||||
|
Add a validateZodResult util function |
@ -0,0 +1,5 @@ |
|||||||
|
--- |
||||||
|
'@hyperlane-xyz/widgets': minor |
||||||
|
--- |
||||||
|
|
||||||
|
Add various utility hooks: useIsSsr, useTimeout, useDebounce, useInterval |
@ -1,6 +1,20 @@ |
|||||||
import { z } from 'zod'; |
import { SafeParseReturnType, z } from 'zod'; |
||||||
|
|
||||||
|
import { rootLogger } from '@hyperlane-xyz/utils'; |
||||||
|
|
||||||
export function isCompliant<S extends Zod.Schema>(schema: S) { |
export function isCompliant<S extends Zod.Schema>(schema: S) { |
||||||
return (config: unknown): config is z.infer<S> => |
return (config: unknown): config is z.infer<S> => |
||||||
schema.safeParse(config).success; |
schema.safeParse(config).success; |
||||||
} |
} |
||||||
|
|
||||||
|
export function validateZodResult<T>( |
||||||
|
result: SafeParseReturnType<T, T>, |
||||||
|
desc: string = 'config', |
||||||
|
): T { |
||||||
|
if (!result.success) { |
||||||
|
rootLogger.warn(`Invalid ${desc}`, result.error); |
||||||
|
throw new Error(`Invalid desc: ${result.error.toString()}`); |
||||||
|
} else { |
||||||
|
return result.data; |
||||||
|
} |
||||||
|
} |
||||||
|
@ -0,0 +1,32 @@ |
|||||||
|
import { expect } from 'chai'; |
||||||
|
|
||||||
|
import { isHttpsUrl, isRelativeUrl, isUrl } from './url.js'; |
||||||
|
|
||||||
|
describe('URL Utilities', () => { |
||||||
|
it('isUrl', () => { |
||||||
|
expect(isUrl(undefined)).to.be.false; |
||||||
|
expect(isUrl(null)).to.be.false; |
||||||
|
expect(isUrl('')).to.be.false; |
||||||
|
expect(isUrl('foobar')).to.be.false; |
||||||
|
expect(isUrl('https://hyperlane.xyz')).to.be.true; |
||||||
|
}); |
||||||
|
|
||||||
|
it('isHttpsUrl', () => { |
||||||
|
expect(isHttpsUrl(undefined)).to.be.false; |
||||||
|
expect(isHttpsUrl(null)).to.be.false; |
||||||
|
expect(isHttpsUrl('')).to.be.false; |
||||||
|
expect(isHttpsUrl('foobar')).to.be.false; |
||||||
|
expect(isHttpsUrl('http://hyperlane.xyz')).to.be.false; |
||||||
|
expect(isHttpsUrl('https://hyperlane.xyz')).to.be.true; |
||||||
|
}); |
||||||
|
|
||||||
|
it('isRelativeUrl', () => { |
||||||
|
expect(isRelativeUrl(undefined)).to.be.false; |
||||||
|
expect(isRelativeUrl(null)).to.be.false; |
||||||
|
expect(isRelativeUrl('')).to.be.false; |
||||||
|
expect(isRelativeUrl('foobar')).to.be.false; |
||||||
|
expect(isRelativeUrl('https://hyperlane.xyz')).to.be.false; |
||||||
|
expect(isRelativeUrl('/foobar')).to.be.true; |
||||||
|
expect(isRelativeUrl('/foo/bar', 'https://hyperlane.xyz')).to.be.true; |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,47 @@ |
|||||||
|
import React, { Component, PropsWithChildren, ReactNode } from 'react'; |
||||||
|
|
||||||
|
import { errorToString } from '@hyperlane-xyz/utils'; |
||||||
|
|
||||||
|
import { ErrorIcon } from '../icons/Error.js'; |
||||||
|
|
||||||
|
type Props = PropsWithChildren<{ |
||||||
|
supportLink?: ReactNode; |
||||||
|
}>; |
||||||
|
|
||||||
|
interface State { |
||||||
|
error: any; |
||||||
|
errorInfo: any; |
||||||
|
} |
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> { |
||||||
|
constructor(props: Props) { |
||||||
|
super(props); |
||||||
|
this.state = { error: null, errorInfo: null }; |
||||||
|
} |
||||||
|
|
||||||
|
componentDidCatch(error: any, errorInfo: any) { |
||||||
|
this.setState({ |
||||||
|
error, |
||||||
|
errorInfo, |
||||||
|
}); |
||||||
|
console.error('Error caught by error boundary', error, errorInfo); |
||||||
|
} |
||||||
|
|
||||||
|
render() { |
||||||
|
const errorInfo = this.state.error || this.state.errorInfo; |
||||||
|
if (errorInfo) { |
||||||
|
const details = errorToString(errorInfo, 1000); |
||||||
|
return ( |
||||||
|
<div className="htw-flex htw-h-screen htw-w-screen htw-items-center htw-justify-center htw-bg-gray-50"> |
||||||
|
<div className="htw-flex htw-flex-col htw-items-center htw-space-y-5"> |
||||||
|
<ErrorIcon width={80} height={80} /> |
||||||
|
<h1 className="htw-text-lg">Fatal Error Occurred</h1> |
||||||
|
<div className="htw-max-w-2xl htw-text-sm">{details}</div> |
||||||
|
{this.props.supportLink} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
return this.props.children; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
import React, { memo } from 'react'; |
||||||
|
|
||||||
|
import { ColorPalette } from '../color.js'; |
||||||
|
|
||||||
|
import { DefaultIconProps } from './types.js'; |
||||||
|
|
||||||
|
function _Error({ color, ...rest }: DefaultIconProps) { |
||||||
|
return ( |
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" {...rest}> |
||||||
|
<path |
||||||
|
fill={color || ColorPalette.Black} |
||||||
|
d="M24 34q.7 0 1.18-.47.47-.48.47-1.18t-.47-1.18q-.48-.47-1.18-.47t-1.18.47q-.47.48-.47 1.18t.47 1.18Q23.3 34 24 34Zm.15-7.65q.65 0 1.07-.42.43-.43.43-1.08V15.2q0-.65-.42-1.07-.43-.43-1.08-.43-.65 0-1.07.42-.43.43-.43 1.08v9.65q0 .65.42 1.07.43.43 1.08.43ZM24 44q-4.1 0-7.75-1.57-3.65-1.58-6.38-4.3-2.72-2.73-4.3-6.38Q4 28.1 4 23.95q0-4.1 1.57-7.75 1.58-3.65 4.3-6.35 2.73-2.7 6.38-4.28Q19.9 4 24.05 4q4.1 0 7.75 1.57 3.65 1.58 6.35 4.28 2.7 2.7 4.28 6.35Q44 19.85 44 24q0 4.1-1.57 7.75-1.58 3.65-4.28 6.38t-6.35 4.3Q28.15 44 24 44Zm.05-3q7.05 0 12-4.97T41 23.95q0-7.05-4.95-12T24 7q-7.05 0-12.03 4.95Q7 16.9 7 24q0 7.05 4.97 12.03Q16.95 41 24.05 41ZM24 24Z" |
||||||
|
/> |
||||||
|
</svg> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export const ErrorIcon = memo(_Error); |
@ -0,0 +1,32 @@ |
|||||||
|
import { Meta, StoryObj } from '@storybook/react'; |
||||||
|
import React from 'react'; |
||||||
|
|
||||||
|
import { ErrorBoundary } from '../components/ErrorBoundary'; |
||||||
|
|
||||||
|
function ErrorTest() { |
||||||
|
return ( |
||||||
|
<ErrorBoundary supportLink={<SupportLink />}> |
||||||
|
<ComponentThatThrows /> |
||||||
|
</ErrorBoundary> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function ComponentThatThrows() { |
||||||
|
if (React) throw new Error('Something went wrong'); |
||||||
|
return <div>Hello</div>; |
||||||
|
} |
||||||
|
|
||||||
|
function SupportLink() { |
||||||
|
return <a>MyLink</a>; |
||||||
|
} |
||||||
|
|
||||||
|
const meta = { |
||||||
|
title: 'ErrorBoundary', |
||||||
|
component: ErrorTest, |
||||||
|
} satisfies Meta<typeof ErrorTest>; |
||||||
|
export default meta; |
||||||
|
type Story = StoryObj<typeof meta>; |
||||||
|
|
||||||
|
export const DefaultErrorBoundary = { |
||||||
|
args: {}, |
||||||
|
} satisfies Story; |
@ -0,0 +1,18 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
|
||||||
|
// Based on https://usehooks.com/useDebounce
|
||||||
|
export function useDebounce<T>(value: T, delayMs = 500): T { |
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handler = setTimeout(() => { |
||||||
|
setDebouncedValue(value); |
||||||
|
}, delayMs); |
||||||
|
|
||||||
|
return () => { |
||||||
|
clearTimeout(handler); |
||||||
|
}; |
||||||
|
}, [value, delayMs]); |
||||||
|
|
||||||
|
return debouncedValue; |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
|
||||||
|
export function useIsSsr() { |
||||||
|
const [isSsr, setIsSsr] = useState(true); |
||||||
|
// Effects are only run on the client side
|
||||||
|
useEffect(() => { |
||||||
|
setIsSsr(false); |
||||||
|
}, []); |
||||||
|
return isSsr; |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; |
||||||
|
|
||||||
|
const useIsomorphicLayoutEffect = |
||||||
|
typeof window !== 'undefined' ? useLayoutEffect : useEffect; |
||||||
|
|
||||||
|
// https://usehooks-typescript.com/react-hook/use-interval
|
||||||
|
export function useInterval(callback: () => void, delay: number | null) { |
||||||
|
const savedCallback = useRef(callback); |
||||||
|
|
||||||
|
// Remember the latest callback if it changes.
|
||||||
|
useIsomorphicLayoutEffect(() => { |
||||||
|
savedCallback.current = callback; |
||||||
|
}, [callback]); |
||||||
|
|
||||||
|
// Set up the interval.
|
||||||
|
useEffect(() => { |
||||||
|
// Don't schedule if no delay is specified.
|
||||||
|
// Note: 0 is a valid value for delay.
|
||||||
|
if (!delay && delay !== 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const id = setInterval(() => savedCallback.current(), delay); |
||||||
|
|
||||||
|
return () => clearInterval(id); |
||||||
|
}, [delay]); |
||||||
|
} |
||||||
|
|
||||||
|
// https://medium.com/javascript-in-plain-english/usetimeout-react-hook-3cc58b94af1f
|
||||||
|
export const useTimeout = ( |
||||||
|
callback: () => void, |
||||||
|
delay = 0, // in ms (default: immediately put into JS Event Queue)
|
||||||
|
): (() => void) => { |
||||||
|
const timeoutIdRef = useRef<NodeJS.Timeout>(); |
||||||
|
|
||||||
|
const cancel = useCallback(() => { |
||||||
|
const timeoutId = timeoutIdRef.current; |
||||||
|
if (timeoutId) { |
||||||
|
timeoutIdRef.current = undefined; |
||||||
|
clearTimeout(timeoutId); |
||||||
|
} |
||||||
|
}, [timeoutIdRef]); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (delay >= 0) { |
||||||
|
timeoutIdRef.current = setTimeout(callback, delay); |
||||||
|
} |
||||||
|
return cancel; |
||||||
|
}, [callback, delay, cancel]); |
||||||
|
|
||||||
|
return cancel; |
||||||
|
}; |
@ -1,27 +0,0 @@ |
|||||||
import { useEffect, useLayoutEffect, useRef } from 'react'; |
|
||||||
|
|
||||||
const useIsomorphicLayoutEffect = |
|
||||||
typeof window !== 'undefined' ? useLayoutEffect : useEffect; |
|
||||||
|
|
||||||
// https://usehooks-typescript.com/react-hook/use-interval
|
|
||||||
export function useInterval(callback: () => void, delay: number | null) { |
|
||||||
const savedCallback = useRef(callback); |
|
||||||
|
|
||||||
// Remember the latest callback if it changes.
|
|
||||||
useIsomorphicLayoutEffect(() => { |
|
||||||
savedCallback.current = callback; |
|
||||||
}, [callback]); |
|
||||||
|
|
||||||
// Set up the interval.
|
|
||||||
useEffect(() => { |
|
||||||
// Don't schedule if no delay is specified.
|
|
||||||
// Note: 0 is a valid value for delay.
|
|
||||||
if (!delay && delay !== 0) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const id = setInterval(() => savedCallback.current(), delay); |
|
||||||
|
|
||||||
return () => clearInterval(id); |
|
||||||
}, [delay]); |
|
||||||
} |
|
Loading…
Reference in new issue