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) { |
||||
return (config: unknown): config is z.infer<S> => |
||||
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