Mobx state mgmt for swap controls (#314)

impl mobx managed state for swap cards
This commit is contained in:
Artem Ash
2024-12-25 17:11:30 -08:00
committed by GitHub
parent 03a290c061
commit 53132af89c
20 changed files with 415 additions and 199 deletions
+1
View File
@@ -8,6 +8,7 @@
],
"exclude": [ "/node_modules" ],
"compilerOptions": {
"noEmit": true,
"baseUrl": "src",
"paths": {
"@/*": ["*"],
+21 -24
View File
@@ -1,30 +1,23 @@
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import type { Asset } from '@luxfi/core'
import { cn } from '@hanzo/ui/util'
import CurrencyInput, { type CurrencyInputOnChangeValues } from './currency-input'
import { useSwapState } from '@/contexts/swap-state'
import AssetCombobox from './asset-combobox'
const AssetCard: React.FC<{
assets: Asset[]
asset: Asset | null
setAsset: (asset: Asset | null) => void
amountChanged: (a: number) => void
usdValue?: number
assetsAvailable: number | null
availableOfAsset?: number
className?: string
}> = ({
assets,
asset,
setAsset,
amountChanged,
}> = observer(({
usdValue,
assetsAvailable,
availableOfAsset,
className=''
}) => {
const [_amount, _setAmount] = useState<number>(0)
const swapState = useSwapState()
// component ensures a valid number string
const onAmountChange = (
@@ -33,8 +26,7 @@ const AssetCard: React.FC<{
values?: CurrencyInputOnChangeValues | undefined
) => {
const a = value ? Number(value) : 0
_setAmount(a)
amountChanged(a)
swapState.setAmount(a)
}
return (
@@ -50,15 +42,15 @@ const AssetCard: React.FC<{
}
/>
<AssetCombobox
assets={assets}
asset={asset}
setAsset={setAsset}
assets={swapState.assetsAvailable}
asset={swapState.asset}
setAsset={swapState.setAsset}
buttonClx='shrink-0 border-none'
popoverAlign='end'
/>
</div>
<div className='flex justify-between items-center text-muted text-sm gap-1.5'>
{ (_amount > 0 && usdValue) ? (
{ (swapState.amount > 0 && usdValue) ? (
<CurrencyInput
readOnly
prefix='$'
@@ -69,13 +61,18 @@ const AssetCard: React.FC<{
) : (
<div/>
)}
<span className={cn('block shrink-0', (assetsAvailable === null || asset === null) ? 'invisible w-[1px] h-[1px] overflow-x-hidden' : '')}>
{`${assetsAvailable} ${asset?.name} avail`}
<span className={cn(
'block shrink-0',
(availableOfAsset === undefined || !swapState.asset) ?
'invisible w-[1px] h-[1px] overflow-x-hidden'
:
''
)}>
{`${availableOfAsset} ${swapState.asset?.name} avail`}
</span>
</div>
</div>
)
}
})
export default AssetCard
@@ -1,6 +1,6 @@
import { cn } from '@hanzo/ui/util'
import { Bridge } from '@/domain/types'
import { type Bridge } from '@/domain/types'
const BridgeLabel: React.FC<{
bridge: Bridge
@@ -1,17 +1,14 @@
import React from 'react'
import { observer } from 'mobx-react-lite'
import { cn } from '@hanzo/ui/util'
import type { Network, Asset } from '@luxfi/core'
import type { Bridge, } from '@/domain/types'
import { useSwapState } from '@/contexts/swap-state'
import BridgeLabel from './bridge-label'
const ReceiveCard: React.FC<{
from: Network
to: Network
asset: Asset
usdValue: number
usdFee: number
assetGas: number
@@ -20,10 +17,7 @@ const ReceiveCard: React.FC<{
bridge?: Bridge
onSelect?: (bridge: Bridge) => void
className?: string
}> = ({
from,
to,
asset,
}> = observer(({
usdValue,
usdFee,
assetGas,
@@ -32,7 +26,10 @@ const ReceiveCard: React.FC<{
onSelect,
className=''
}) => {
return (
const swapState = useSwapState()
return swapState.amount > 0 ? (
<div
className={cn(
'border border-muted-4 py-2 px-2',
@@ -42,15 +39,19 @@ const ReceiveCard: React.FC<{
onClick={(!!bridge && !!onSelect) ? () => {onSelect(bridge)} : undefined}
>
<div className='flex flex-col justify-between items-center text-sm'>
<span className='block'>Receive on {to.display_name}</span>
{bridge ? (
<BridgeLabel bridge={bridge} />
) : (
null
)}
{swapState.to ? (
<span className='block'>Receive on {swapState.to.display_name }</span>
) : (
<span className='block'/>
)}
{bridge ? (
<BridgeLabel bridge={bridge} />
) : (
<div />
)}
</div>
</div>
)
}
) : null
})
export default ReceiveCard
@@ -1,7 +1,8 @@
import type { Network } from '@luxfi/core'
import { observer } from 'mobx-react-lite'
import NetworkCombobox from '../network-combobox'
import ReverseButton from './reverse-button'
import { useSwapState } from '@/contexts/swap-state'
const ABS_CENTERED = {
position: 'absolute',
@@ -11,42 +12,39 @@ const ABS_CENTERED = {
} satisfies React.CSSProperties
const FromToCard: React.FC<{
from: Network | null
to: Network | null
fromNetworks: Network[]
toNetworks: Network[]
setFrom: (v: Network | null) => void
setTo: (v: Network | null) => void
swapFromAndTo: () => void
className?: string
}> = ({
from,
to,
fromNetworks,
toNetworks,
setFrom,
setTo,
swapFromAndTo,
}> = observer(({
className=''
}) => {
const swapState = useSwapState()
const swapFromAndTo = () => {
const tmp = swapState.from
swapState.setFrom(swapState.to)
swapState.setTo(tmp)
const tmpNetworks = swapState.fromNetworks
swapState.setFromNetworks(swapState.toNetworks)
swapState.setToNetworks(tmpNetworks)
}
// 'flex w-full gap-2 relative'
return (
<div className={className}>
<NetworkCombobox
networks={fromNetworks}
setNetwork={setFrom}
network={from}
networks={swapState.fromNetworks}
setNetwork={swapState.setFrom}
network={swapState.from}
buttonClx='grow pr-4'
popoverClx='w-[350px]'
popoverAlign='start'
label='from'
/>
<NetworkCombobox
networks={toNetworks}
setNetwork={setTo}
network={to}
networks={swapState.toNetworks}
setNetwork={swapState.setTo}
network={swapState.to}
buttonClx='grow pl-4'
popoverClx='w-[350px]'
popoverAlign='end'
@@ -60,6 +58,6 @@ const FromToCard: React.FC<{
/>
</div>
)
}
})
export default FromToCard
+10 -76
View File
@@ -2,7 +2,6 @@ import React, { useRef, useState } from 'react'
import { cn } from '@hanzo/ui/util'
import type { Network, Asset } from '@luxfi/core'
import type { Bridge } from '@/domain/types'
import AssetCard from '../asset-card'
@@ -24,94 +23,29 @@ const FIXTURE = {
const SwapCard: React.FC<{
className?: string
fromNetworks: Network[]
fromInitial?: Network | null
toNetworks: Network[]
toInitial?: Network | null
}> = ({
className='',
fromNetworks,
fromInitial,
toNetworks,
toInitial,
}) => {
const _from = useRef<Network | null>(fromInitial ?? null)
const _to = useRef<Network | null>(toInitial ?? null)
const _fromNetworks = useRef<Network[]>(fromNetworks)
const _toNetworks = useRef<Network[]>(toNetworks)
const _asset = useRef<Asset | null>(fromInitial?.currencies![0] ?? null)
const _amount = useRef<number>(0)
const [triggerUpdate, setTriggerUpdate] = useState<boolean>(false)
const update = () => {setTriggerUpdate((c) => (!c))}
const setFrom = (v: Network | null) => {
_from.current = v
update()
}
const setTo = (v: Network | null) => {
_to.current = v
update()
}
const setAmount = (n: number) => {
_amount.current = n
}
const setAsset = (t: Asset | null) => {
_asset.current = t
update()
}
const swapFromAndTo = () => {
const tmp = _from.current
_from.current = _to.current
_to.current = tmp
const tmpNetworks = _fromNetworks.current
_fromNetworks.current = _toNetworks.current
_toNetworks.current = tmpNetworks
setTriggerUpdate((c) => (!c))
}
return (
<div className={cn(
'flex flex-col justify-start items-center p-6 rounded border border-muted-4',
className
)}>
<FromToCard
swapFromAndTo={swapFromAndTo}
from={_from.current}
to={_to.current}
fromNetworks={_fromNetworks.current}
toNetworks={_toNetworks.current}
setFrom={setFrom}
setTo={setTo}
className='flex w-full gap-2 relative'
/>
<FromToCard className='flex w-full gap-2 relative' />
<AssetCard
assets={_from.current?.currencies!}
asset={_asset.current}
setAsset={setAsset}
usdValue={FIXTURE.usdValue}
amountChanged={setAmount}
assetsAvailable={FIXTURE.assetsAvailable}
availableOfAsset={FIXTURE.assetsAvailable}
className='w-full rounded-lg mt-2'
/>
{_amount.current > 0 && (
<ReceiveCard
from={_from.current!}
to={_to.current!}
asset={_asset.current!}
usdValue={FIXTURE.usdValue}
usdFee={FIXTURE.usdFee}
assetGas={FIXTURE.assetGas}
bridge={FIXTURE.bridge}
txnTime={FIXTURE.txnTime}
className=''
/>
)}
<ReceiveCard
usdValue={FIXTURE.usdValue}
usdFee={FIXTURE.usdFee}
assetGas={FIXTURE.assetGas}
bridge={FIXTURE.bridge}
txnTime={FIXTURE.txnTime}
className=''
/>
</div>
)
}
+6 -7
View File
@@ -1,18 +1,17 @@
import type { PropsWithChildren } from 'react'
import { SettingsProvider } from '@/contexts/settings'
import type BridgeSettings from '@/domain/types/app-settings'
import SettingsProvider from '@/contexts/settings'
import SwapStateProvider from '@/contexts/swap-state'
const Contexts: React.FC<{
settings?: BridgeSettings
} & PropsWithChildren> = ({
settings,
const Contexts: React.FC<PropsWithChildren> = ({
children
}) => {
return (
<SettingsProvider settings={settings}>
<SettingsProvider >
<SwapStateProvider>
{children}
</SwapStateProvider>
</SettingsProvider>
)
}
+4 -18
View File
@@ -6,9 +6,7 @@ interface AppSettingsRef {
settings: AppSettings | undefined
}
const SettingsContext = createContext<AppSettingsRef | null>({
settings: undefined
} satisfies AppSettingsRef)
const SettingsContext = createContext<AppSettingsRef | null>(null)
const SettingsProvider: React.FC<{
settings?: AppSettings
@@ -23,17 +21,6 @@ const SettingsProvider: React.FC<{
)
}
const useSettingsRef = (): AppSettingsRef => {
const ref = useContext(SettingsContext)
if (!ref ) {
throw new Error('useSettingsRef must be used withing a SettingsProvider!')
}
return ref
}
const useSettings = (): AppSettings => {
const ref = useContext(SettingsContext)
@@ -42,11 +29,10 @@ const useSettings = (): AppSettings => {
}
return ref.settings
};
}
export {
SettingsProvider,
useSettingsRef,
SettingsProvider as default,
SettingsContext,
useSettings,
type AppSettingsRef
}
+67
View File
@@ -0,0 +1,67 @@
import { createContext, useContext, type PropsWithChildren } from 'react'
import type { Network } from '@luxfi/core'
import type { SwapState } from '@/domain/types'
import getInstance from '@/domain/swap-store/get-instance'
interface SwapStateRef {
swapState: SwapState | null
}
const SwapStateContext = createContext<SwapStateRef | null>(null)
const SwapStateProvider: React.FC<PropsWithChildren> = ({
children,
}) => (
<SwapStateContext.Provider value={{ swapState: null }}>
{children}
</SwapStateContext.Provider>
)
const useInitializeSwapState = (
networks: Network[],
initialTo?: Network,
initialFrom?: Network
): void => {
const ref = useContext(SwapStateContext)
if (!ref ) {
throw new Error('useInitializeSwapState() must be used within the scope of a SwapStateProvider!')
}
if (ref.swapState) {
ref.swapState.setNetworks(
networks,
initialTo,
initialFrom
)
}
else {
ref.swapState = getInstance(
networks,
initialTo,
initialFrom
)
}
}
const useSwapState = (): SwapState => {
const ref = useContext(SwapStateContext)
if (!ref || !ref.swapState) {
throw new Error('SwapStateProvider or SwapState not ititialized!')
}
return ref.swapState
};
export {
SwapStateProvider as default,
SwapStateContext,
useInitializeSwapState,
useSwapState,
}
@@ -0,0 +1,29 @@
import { useContext } from 'react'
import type AppSettings from '@/domain/types/app-settings'
import { SettingsContext } from './settings'
import { useInitializeSwapState } from './swap-state'
import type { Network } from '@luxfi/core'
const useSetSettings = (
settings: AppSettings,
fromInitial?: Network,
toInitial?: Network
): void => {
useInitializeSwapState(
settings.networks,
fromInitial,
toInitial
)
const settingsRef = useContext(SettingsContext)
if (!settingsRef) {
throw new Error('useSetSettings() must be within SettingsProvider and SwapStateProvider!')
}
settingsRef.settings = settings
}
export {
useSetSettings as default
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { BackendService } from '../types'
import { type BackendService } from '../types'
import { default as getSettings } from './get-settings'
export default {
@@ -0,0 +1,40 @@
import { enableStaticRendering } from 'mobx-react-lite'
import type { Network } from '@luxfi/core'
import type { SwapState } from '@/domain/types'
import SwapStore from './index'
// https://dev.to/ivandotv/mobx-server-side-rendering-with-next-js-4m18
enableStaticRendering(typeof window === "undefined")
let instance: SwapState | undefined = undefined
const getInstance = (
networks: Network[],
initialFrom?: Network,
initialTo?: Network
): SwapState => {
// Server side: create a new copy of the store each time
if (typeof window === "undefined") {
return new SwapStore(
networks,
initialTo,
initialFrom
)
}
// Client side: create the store only once
if (!instance) {
instance = new SwapStore(
networks,
initialTo,
initialFrom
)
}
return instance
}
export default getInstance
+149
View File
@@ -0,0 +1,149 @@
import {
computed,
makeObservable,
observable,
action,
} from 'mobx'
import type { Asset, Network } from '@luxfi/core'
import type SwapState from '../types/swap-state'
class SwapStore implements SwapState {
private _from: Network | null
private _to: Network | null
private _fromNetworks: Network[]
private _toNetworks: Network[]
private _assetsAvailable: Asset[]
private _asset: Asset | null
private _amount: number
private _networks: Network[]
constructor(
networks: Network[],
initialTo?: Network,
initialFrom?: Network
) {
this._networks = networks
this._fromNetworks = networks
this._toNetworks = networks
this._from = initialFrom ?? networks[0]
this._to = initialTo ?? networks[1]
// TODO
this._assetsAvailable = this._from.currencies
this._asset = this._from.currencies[0]
this._amount = 0
makeObservable<
SwapStore,
'_from' |
'_to' |
'_fromNetworks' |
'_toNetworks' |
'_asset' |
'_assetsAvailable' |
'_amount'
>(this, {
_from: observable.shallow,
_to : observable.shallow,
_fromNetworks: observable.shallow,
_toNetworks : observable.shallow,
_asset: observable.shallow,
_assetsAvailable: observable.shallow,
_amount: observable
})
makeObservable(this, {
from: computed,
to: computed,
fromNetworks: computed,
toNetworks: computed,
assetsAvailable: computed,
asset: computed,
amount: computed,
setFromNetworks: action.bound,
setToNetworks: action.bound,
setFrom: action.bound,
setTo: action.bound,
setAsset: action.bound,
setNetworks: action.bound,
setAmount: action.bound
})
}
get from() : Network | null {
return this._from
}
get to() : Network| null {
return this._to
}
get fromNetworks() : Network[] {
return this._fromNetworks
}
get toNetworks() : Network[] {
return this._toNetworks
}
get assetsAvailable() : Asset[] {
return this._assetsAvailable
}
get asset() : Asset | null {
return this._asset
}
get amount() : number {
return this._amount
}
setAmount = (a: number): void => {
this._amount = a
}
setFrom = (n: Network | null) => {
this._from = n
}
setTo = (n: Network | null) => {
this._to = n
}
setFromNetworks = (n: Network[]) => {
this._fromNetworks = n
}
setToNetworks = (n: Network[]) => {
this._toNetworks = n
}
setAsset = (a: Asset | null) => {
this._asset = a
}
setNetworks = (
networks: Network[],
initialTo?: Network,
initialFrom?: Network
) => {
this._networks = networks
this._fromNetworks = networks
this._toNetworks = networks
this._from = initialFrom ?? networks[0]
this._to = initialTo ?? networks[1]
}
}
export default SwapStore
@@ -1,4 +1,4 @@
import AppSettings from './app-settings'
import type AppSettings from './app-settings'
interface BackendService {
getSettings: () => Promise<AppSettings | undefined>
+1
View File
@@ -1,3 +1,4 @@
export type { default as Bridge } from './bridge'
export type { default as AppSettings } from './app-settings'
export type { default as BackendService } from './backend-service'
export type { default as SwapState } from './swap-state'
@@ -0,0 +1,29 @@
import type { Network, Asset } from '@luxfi/core'
interface SwapState {
get fromNetworks(): Network[]
get toNetworks(): Network[]
get to() : Network | null
get from() : Network | null
get assetsAvailable() : Asset[]
get asset() : Asset | null
setFrom(n: Network | null) : void
setTo(n: Network | null): void
setFromNetworks(n: Network[]): void
setToNetworks(n: Network[]): void
setAsset(a: Asset | null): void
get amount() : number
setAmount(a: number): void
setNetworks(
networks: Network[],
initialTo?: Network,
initialFrom?: Network
): void
}
export {
type SwapState as default
}
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { PropsWithChildren } from 'react'
import React, { type PropsWithChildren } from 'react'
import {
Links,
+7 -12
View File
@@ -5,7 +5,7 @@ import type { Network } from '@luxfi/core'
import SwapCard from '@/components/swap-card'
import backend from '@/domain/backend'
import { useSettingsRef } from '@/contexts/settings'
import useSetSettings from '@/contexts/use-set-settings'
export const loader = async () => {
@@ -36,17 +36,12 @@ export const shouldRevalidate = () => false;
const Index: React.FC = () => {
const { settings, fromInitial, toInitial } = useLoaderData<typeof loader>()
const settingsRef = useSettingsRef()
settingsRef.settings = settings
useSetSettings(
settings,
fromInitial,
toInitial
)
return (
<SwapCard
className='w-[500px] h-[530px]'
fromNetworks={settingsRef.settings!.networks!}
fromInitial={fromInitial}
toNetworks={settingsRef.settings!.networks!}
toInitial={toInitial}
/>
)
return ( <SwapCard className='w-[500px] h-[530px]' /> )
}
export default Index
+6 -14
View File
@@ -1,28 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"baseUrl": ".",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
// All settings are as per remix rec's
// https://remix.run/docs/hi/main/guides/typescript
"target": "ES2022",
"jsx": "react-jsx",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./app/*"
]
"@/*": [ "./app/*" ]
},
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022",
"types": [
"@vercel/remix",
"vite/client",
+1 -3
View File
@@ -15,7 +15,5 @@
"verbatimModuleSyntax": true,
"jsx": "preserve",
"declaration": false,
"noEmit": true
},
"plugins": [ { "name": "next" } ]
}
}