diff --git a/apps/main/src/lend/components/PageLoanCreate/index.tsx b/apps/main/src/lend/components/PageLoanCreate/index.tsx index 520c539ba4..19b0c9a6b1 100644 --- a/apps/main/src/lend/components/PageLoanCreate/index.tsx +++ b/apps/main/src/lend/components/PageLoanCreate/index.tsx @@ -11,9 +11,11 @@ import type { OnBorrowFormUpdate } from '@/llamalend/features/borrow/types' import Stack from '@mui/material/Stack' import { AppFormContentWrapper } from '@ui/AppForm' import { useNavigate } from '@ui-kit/hooks/router' +import { useDebounced } from '@ui-kit/hooks/useDebounce' import { useCreateLoanMuiForm } from '@ui-kit/hooks/useFeatureFlags' import { t } from '@ui-kit/lib/i18n' -import { TabsSwitcher, type TabOption } from '@ui-kit/shared/ui/TabsSwitcher' +import { type TabOption, TabsSwitcher } from '@ui-kit/shared/ui/TabsSwitcher' +import { Duration } from '@ui-kit/themes/design/0_primitives' import { SizesAndSpaces } from '@ui-kit/themes/design/1_sizes_spaces' const { MaxWidth } = SizesAndSpaces @@ -21,21 +23,29 @@ const { MaxWidth } = SizesAndSpaces /** * Callback that synchronizes the `ChartOhlc` component with the `RangeSlider` component in the new `BorrowTabContents`. */ -const useOnFormUpdate = ({ api, market }: PageContentProps): OnBorrowFormUpdate => - useCallback( +const useOnFormUpdate = ({ api, market }: PageContentProps): OnBorrowFormUpdate => { + const [setFormValues] = useDebounced( + useStore((store) => store.loanCreate.setFormValues), + Duration.FormDebounce, + ) + const [setStateByKeys] = useDebounced( + useStore((store) => store.loanCreate.setStateByKeys), + Duration.FormDebounce, + ) + return useCallback( async ({ debt, userCollateral, range, slippage, leverageEnabled }) => { - const { setFormValues, setStateByKeys } = useStore.getState().loanCreate const formValues: FormValues = { ...DEFAULT_FORM_VALUES, n: range, debt: `${debt ?? ''}`, userCollateral: `${userCollateral ?? ''}`, } - await setFormValues(api, market, formValues, `${slippage}`, leverageEnabled) + setFormValues(api, market, formValues, `${slippage}`, leverageEnabled) setStateByKeys({ isEditLiqRange: true }) }, - [api, market], + [api, market, setFormValues, setStateByKeys], ) +} const LoanCreate = (pageProps: PageContentProps & { params: MarketUrlParams }) => { const { rChainId, rOwmId, rFormType, market, params, api } = pageProps diff --git a/apps/main/src/llamalend/features/borrow/components/CreateLoanForm.tsx b/apps/main/src/llamalend/features/borrow/components/CreateLoanForm.tsx index 4be08fc370..c5719ecfbb 100644 --- a/apps/main/src/llamalend/features/borrow/components/CreateLoanForm.tsx +++ b/apps/main/src/llamalend/features/borrow/components/CreateLoanForm.tsx @@ -68,10 +68,16 @@ export const CreateLoanForm = ({ creationError, txHash, formErrors, - tooMuchDebt, isApproved, } = useCreateLoanForm({ market, network, preset, onCreated }) - const setRange = useCallback((range: number) => form.setValue('range', range, setValueOptions), [form]) + const setRange = useCallback( + (range: number) => { + // maxDebt is reset when query restarts, clear now to disable queries until recalculated + form.setValue('maxDebt', undefined, setValueOptions) + form.setValue('range', range, setValueOptions) + }, + [form], + ) useFormSync(values, onUpdate) return ( @@ -84,7 +90,6 @@ export const CreateLoanForm = ({ values={values} collateralToken={collateralToken} borrowToken={borrowToken} - tooMuchDebt={tooMuchDebt} networks={networks} onSlippageChange={(value) => form.setValue('slippage', value, setValueOptions)} /> @@ -117,7 +122,11 @@ export const CreateLoanForm = ({ symbol={borrowToken?.symbol} balance={values.maxDebt} loading={maxTokenValues.debt.isLoading} - onClick={() => form.setValue('debt', values.maxDebt, setValueOptions)} + onClick={() => { + form.setValue('debt', values.maxDebt, setValueOptions) + void form.trigger('maxDebt') // re-validate maxDebt when debt changes + }} + buttonTestId="borrow-set-debt-to-max" /> } /> diff --git a/apps/main/src/llamalend/features/borrow/components/CreateLoanInfoAccordion.tsx b/apps/main/src/llamalend/features/borrow/components/CreateLoanInfoAccordion.tsx index 923d4c3934..67786f40f8 100644 --- a/apps/main/src/llamalend/features/borrow/components/CreateLoanInfoAccordion.tsx +++ b/apps/main/src/llamalend/features/borrow/components/CreateLoanInfoAccordion.tsx @@ -1,7 +1,9 @@ import type { NetworkDict } from '@/llamalend/llamalend.types' import { useMarketRates } from '@/llamalend/queries/market-rates' import type { IChainId } from '@curvefi/llamalend-api/lib/interfaces' +import type { UseQueryResult } from '@tanstack/react-query' import { useSwitch } from '@ui-kit/hooks/useSwitch' +import type { Query } from '@ui-kit/types/util' import { Decimal } from '@ui-kit/utils' import { useCreateLoanEstimateGas } from '../../../queries/create-loan/create-loan-approve-estimate-gas.query' import { useCreateLoanBands } from '../../../queries/create-loan/create-loan-bands.query' @@ -15,6 +17,17 @@ import { LoanInfoAccordion } from '../../../widgets/manage-loan/LoanInfoAccordio import { useLoanToValue } from '../hooks/useLoanToValue' import { type BorrowForm, type BorrowFormQueryParams, type Token } from '../types' +/** + * Helper to extract only the relevant fields from a UseQueryResult into the Query type. + * This is necessary because passing UseQueryResult to any react component will crash the rendering due to + * react trying to serialize the react-query proxy object. + */ +const q = ({ data, isLoading, error }: UseQueryResult): Query => ({ + data, + isLoading, + error, +}) + /** * Accordion with action infos about the loan (like health, band range, price range, N, borrow APR, LTV, estimated gas, slippage) * By default, only the health info is visible. The rest is visible when the accordion is expanded. @@ -25,7 +38,6 @@ export const CreateLoanInfoAccordion = ({ values: { range, slippage, leverageEnabled }, collateralToken, borrowToken, - tooMuchDebt, networks, onSlippageChange, }: { @@ -33,22 +45,20 @@ export const CreateLoanInfoAccordion = ({ values: BorrowForm collateralToken: Token | undefined borrowToken: Token | undefined - tooMuchDebt: boolean networks: NetworkDict onSlippageChange: (newSlippage: Decimal) => void }) => { const [isOpen, , , toggle] = useSwitch(false) - return ( ({ }, isOpen, )} - gas={useCreateLoanEstimateGas(networks, params, isOpen && !tooMuchDebt)} + gas={useCreateLoanEstimateGas(networks, params, isOpen)} leverage={{ enabled: leverageEnabled, - expectedCollateral: useCreateLoanExpectedCollateral(params, isOpen && leverageEnabled), - maxReceive: useCreateLoanMaxReceive(params, isOpen && leverageEnabled && !tooMuchDebt), - priceImpact: useCreateLoanPriceImpact(params, isOpen && leverageEnabled), + expectedCollateral: useCreateLoanExpectedCollateral(params, isOpen), + maxReceive: useCreateLoanMaxReceive(params, isOpen), + priceImpact: useCreateLoanPriceImpact(params, isOpen), slippage, onSlippageChange, collateralSymbol: collateralToken?.symbol, diff --git a/apps/main/src/llamalend/features/borrow/components/LoanPresetSelector.tsx b/apps/main/src/llamalend/features/borrow/components/LoanPresetSelector.tsx index b5d231d278..38f66cdb53 100644 --- a/apps/main/src/llamalend/features/borrow/components/LoanPresetSelector.tsx +++ b/apps/main/src/llamalend/features/borrow/components/LoanPresetSelector.tsx @@ -56,7 +56,7 @@ export const LoanPresetSelector = ({ sx={{ width: '100%', paddingBottom: Spacing.sm }} > {Object.values(BorrowPreset).map((p) => ( - + {PRESETS[p].title} ))} diff --git a/apps/main/src/llamalend/features/borrow/hooks/useCreateLoanForm.tsx b/apps/main/src/llamalend/features/borrow/hooks/useCreateLoanForm.tsx index 9a7792c955..e6d808cedf 100644 --- a/apps/main/src/llamalend/features/borrow/hooks/useCreateLoanForm.tsx +++ b/apps/main/src/llamalend/features/borrow/hooks/useCreateLoanForm.tsx @@ -13,7 +13,7 @@ import { SLIPPAGE_PRESETS } from '@ui-kit/widgets/SlippageSettings/slippage.util import { BORROW_PRESET_RANGES, BorrowPreset } from '../../../constants' import { type CreateLoanOptions, useCreateLoanMutation } from '../../../mutations/create-loan.mutation' import { useBorrowCreateLoanIsApproved } from '../../../queries/create-loan/borrow-create-loan-approved.query' -import { borrowFormValidationSuite } from '../../../queries/validation/borrow.validation' +import { borrowQueryValidationSuite } from '../../../queries/validation/borrow.validation' import { useFormErrors } from '../react-form.utils' import { type BorrowForm } from '../types' import { useMaxTokenValues } from './useMaxTokenValues' @@ -21,6 +21,9 @@ import { useMaxTokenValues } from './useMaxTokenValues' const useCallbackAfterFormUpdate = (form: UseFormReturn, callback: () => void) => useEffect(() => form.subscribe({ formState: { values: true }, callback }), [form, callback]) +// to crete a loan we need the debt/maxDebt, but we skip the market validation as that's given separately to the mutation +const resolver = vestResolver(borrowQueryValidationSuite({ debtRequired: false, skipMarketValidation: true })) + export function useCreateLoanForm({ market, network, @@ -36,8 +39,7 @@ export function useCreateLoanForm({ const { address: userAddress } = useConnection() const form = useForm({ ...formDefaultOptions, - // todo: also validate maxLeverage and maxCollateral - resolver: vestResolver(borrowFormValidationSuite), + resolver, defaultValues: { userCollateral: undefined, userBorrowed: `0` satisfies Decimal, @@ -75,7 +77,7 @@ export function useCreateLoanForm({ values, params, isPending: form.formState.isSubmitting || isCreating, - onSubmit: form.handleSubmit(onSubmit), // todo: handle form errors + onSubmit: form.handleSubmit(onSubmit), maxTokenValues: useMaxTokenValues(collateralToken, params, form), borrowToken, collateralToken, @@ -83,7 +85,6 @@ export function useCreateLoanForm({ creationError, txHash: data?.hash, isApproved: useBorrowCreateLoanIsApproved(params), - tooMuchDebt: !!form.formState.errors['maxDebt'], formErrors: useFormErrors(form.formState), } } diff --git a/apps/main/src/llamalend/features/borrow/types.ts b/apps/main/src/llamalend/features/borrow/types.ts index 6fe6eb12b7..03030d74d4 100644 --- a/apps/main/src/llamalend/features/borrow/types.ts +++ b/apps/main/src/llamalend/features/borrow/types.ts @@ -31,5 +31,10 @@ export type BorrowFormQuery = MarketQuery & CompleteBorrowForm /** Fields of the borrow form query before validation */ export type BorrowFormQueryParams = FieldsOf> +/** Borrow form query including max debt field */ +export type BorrowDebtQuery = BorrowFormQuery & Pick +/** Fields of the borrow debt query before validation */ +export type BorrowDebtParams = FieldsOf> + /** A simple token representation */ export type Token = { symbol: string; address: Address } diff --git a/apps/main/src/llamalend/features/market-list/LlamaMarketSort.tsx b/apps/main/src/llamalend/features/market-list/LlamaMarketSort.tsx deleted file mode 100644 index 6cb1be7231..0000000000 --- a/apps/main/src/llamalend/features/market-list/LlamaMarketSort.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useCallback } from 'react' -import { OnChangeFn, SortingState } from '@tanstack/react-table' -import { type Option, SelectFilter } from '@ui-kit/shared/ui/DataTable/SelectFilter' -import { LlamaMarketColumnId } from './columns.enum' -import { useLlamaMarketSortOptions } from './hooks/useLlamaMarketSortOptions' - -export const LlamaMarketSort = ({ - sortField, - onSortingChange, -}: { - onSortingChange: OnChangeFn - sortField: LlamaMarketColumnId -}) => ( - ) => onSortingChange([{ id, desc: true }]), - [onSortingChange], - )} - value={sortField} - /> -) diff --git a/apps/main/src/llamalend/mutations/add-collateral.mutation.ts b/apps/main/src/llamalend/mutations/add-collateral.mutation.ts index 64c4ad5534..33b823c55b 100644 --- a/apps/main/src/llamalend/mutations/add-collateral.mutation.ts +++ b/apps/main/src/llamalend/mutations/add-collateral.mutation.ts @@ -43,7 +43,8 @@ export const useAddCollateralMutation = ({ mutationKey: [...rootKeys.userMarket({ chainId, marketId, userAddress }), 'add-collateral'] as const, mutationFn: async (mutation, { market }) => { await waitForApproval({ - isApproved: () => fetchAddCollateralIsApproved({ chainId, marketId, userAddress, ...mutation }), + isApproved: () => + fetchAddCollateralIsApproved({ chainId, marketId, userAddress, ...mutation }, { staleTime: 0 }), onApprove: () => approve(market, mutation), message: t`Approved collateral addition`, config, diff --git a/apps/main/src/llamalend/mutations/create-loan.mutation.ts b/apps/main/src/llamalend/mutations/create-loan.mutation.ts index 58b7e89d3e..5dd1d08b0e 100644 --- a/apps/main/src/llamalend/mutations/create-loan.mutation.ts +++ b/apps/main/src/llamalend/mutations/create-loan.mutation.ts @@ -4,7 +4,7 @@ import { formatTokenAmounts } from '@/llamalend/llama.utils' import type { LlamaMarketTemplate } from '@/llamalend/llamalend.types' import { type LlammaMutationOptions, useLlammaMutation } from '@/llamalend/mutations/useLlammaMutation' import { fetchBorrowCreateLoanIsApproved } from '@/llamalend/queries/create-loan/borrow-create-loan-approved.query' -import { borrowFormValidationSuite } from '@/llamalend/queries/validation/borrow.validation' +import { borrowQueryValidationSuite } from '@/llamalend/queries/validation/borrow.validation' import type { IChainId as LlamaChainId, IChainId, @@ -72,14 +72,14 @@ export const useCreateLoanMutation = ({ mutationFn: async (mutation, { market }) => { const params = { ...mutation, chainId, marketId } await waitForApproval({ - isApproved: () => fetchBorrowCreateLoanIsApproved(params), + isApproved: async () => await fetchBorrowCreateLoanIsApproved(params, { staleTime: 0 }), onApprove: () => approve(market, mutation), message: t`Approved loan creation`, config, }) return { hash: await create(market, mutation) } }, - validationSuite: borrowFormValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true }), pendingMessage: (mutation, { market }) => t`Creating loan... ${formatTokenAmounts(market, mutation)}`, successMessage: (mutation, { market }) => t`Loan created! ${formatTokenAmounts(market, mutation)}`, onSuccess: onCreated, diff --git a/apps/main/src/llamalend/mutations/repay.mutation.ts b/apps/main/src/llamalend/mutations/repay.mutation.ts index 44fc7c3156..0e35a06576 100644 --- a/apps/main/src/llamalend/mutations/repay.mutation.ts +++ b/apps/main/src/llamalend/mutations/repay.mutation.ts @@ -78,15 +78,18 @@ export const useRepayMutation = ({ await waitForApproval({ isApproved: () => - fetchRepayIsApproved({ - chainId, - marketId, - userAddress, - stateCollateral, - userCollateral, - userBorrowed, - isFull, - }), + fetchRepayIsApproved( + { + chainId, + marketId, + userAddress, + stateCollateral, + userCollateral, + userBorrowed, + isFull, + }, + { staleTime: 0 }, + ), onApprove: () => approveRepay(market, mutation), message: t`Approved repayment`, config, diff --git a/apps/main/src/llamalend/mutations/useLlammaMutation.ts b/apps/main/src/llamalend/mutations/useLlammaMutation.ts index ba62bda04b..3c6bf19c54 100644 --- a/apps/main/src/llamalend/mutations/useLlammaMutation.ts +++ b/apps/main/src/llamalend/mutations/useLlammaMutation.ts @@ -133,6 +133,7 @@ export function useLlammaMutation { + console.error(`Error in mutation ${mutationKey}:`, error) logError(mutationKey, { error, variables, marketId: context?.market.id }) notify(error.message, 'error') }, diff --git a/apps/main/src/llamalend/queries/create-loan/borrow-create-loan-approved.query.ts b/apps/main/src/llamalend/queries/create-loan/borrow-create-loan-approved.query.ts index ec6b33a524..b25f862e34 100644 --- a/apps/main/src/llamalend/queries/create-loan/borrow-create-loan-approved.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/borrow-create-loan-approved.query.ts @@ -1,7 +1,7 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' import { MintMarketTemplate } from '@curvefi/llamalend-api/lib/mintMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtQuery, BorrowFormQueryParams } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' export const { useQuery: useBorrowCreateLoanIsApproved, fetchQuery: fetchBorrowCreateLoanIsApproved } = queryFactory({ @@ -18,7 +18,7 @@ export const { useQuery: useBorrowCreateLoanIsApproved, fetchQuery: fetchBorrowC userBorrowed = '0', userCollateral = '0', leverageEnabled, - }: BorrowFormQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) return leverageEnabled ? market instanceof MintMarketTemplate && market.leverageV2.hasLeverage() @@ -27,5 +27,5 @@ export const { useQuery: useBorrowCreateLoanIsApproved, fetchQuery: fetchBorrowC : await market.createLoanIsApproved(userCollateral) }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: false }), }) diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-approve-estimate-gas.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-approve-estimate-gas.query.ts index 273bfea7f3..546a35c5e5 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-approve-estimate-gas.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-approve-estimate-gas.query.ts @@ -1,3 +1,4 @@ +import type { Suite } from 'vest' import { useEstimateGas } from '@/llamalend/hooks/useEstimateGas' import { getLlamaMarket } from '@/llamalend/llama.utils' import { type NetworkDict } from '@/llamalend/llamalend.types' @@ -36,7 +37,10 @@ const { useQuery: useCreateLoanApproveEstimateGas } = queryFactory({ ? await market.leverageV2.estimateGas.createLoanApprove(userCollateral, userBorrowed) : await market.leverage.estimateGas.createLoanApprove(userCollateral) }, - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: false }) as Suite< + keyof CreateLoanApproveEstimateGasQuery, + string + >, dependencies: (params) => [createLoanMaxReceiveKey(params)], }) diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-bands.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-bands.query.ts index 13f8679a91..23f7437671 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-bands.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-bands.query.ts @@ -1,7 +1,7 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtParams, BorrowDebtQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' import { createLoanExpectedCollateralQueryKey } from './create-loan-expected-collateral.query' import { createLoanMaxReceiveKey } from './create-loan-max-receive.query' @@ -17,7 +17,8 @@ export const { useQuery: useCreateLoanBands } = queryFactory({ debt = '0', leverageEnabled, range, - }: BorrowFormQueryParams) => + maxDebt, + }: BorrowDebtParams) => [ ...rootKeys.market({ chainId, marketId }), 'createLoanBands', @@ -26,6 +27,7 @@ export const { useQuery: useCreateLoanBands } = queryFactory({ { debt }, { leverageEnabled }, { range }, + { maxDebt }, ] as const, queryFn: ({ marketId, @@ -34,7 +36,7 @@ export const { useQuery: useCreateLoanBands } = queryFactory({ debt = '0', leverageEnabled, range, - }: BorrowFormQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) return leverageEnabled ? market instanceof LendMarketTemplate @@ -45,7 +47,7 @@ export const { useQuery: useCreateLoanBands } = queryFactory({ : market.createLoanBands(userCollateral, debt, range) }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true }), dependencies: (params) => [ createLoanMaxReceiveKey(params), ...(params.leverageEnabled ? [createLoanExpectedCollateralQueryKey(params)] : []), diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-expected-collateral.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-expected-collateral.query.ts index be8698d837..4ed3df0055 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-expected-collateral.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-expected-collateral.query.ts @@ -2,7 +2,7 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' import { assert, decimal, Decimal } from '@ui-kit/utils' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtParams, BorrowDebtQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' type BorrowExpectedCollateralResult = { @@ -46,7 +46,9 @@ export const { useQuery: useCreateLoanExpectedCollateral, queryKey: createLoanEx userCollateral = '0', debt, slippage, - }: BorrowFormQueryParams) => + leverageEnabled, + maxDebt, + }: BorrowDebtParams) => [ ...rootKeys.market({ chainId, marketId }), 'createLoanExpectedCollateral', @@ -54,6 +56,8 @@ export const { useQuery: useCreateLoanExpectedCollateral, queryKey: createLoanEx { userBorrowed }, { debt }, { slippage }, + { leverageEnabled }, + { maxDebt }, ] as const, queryFn: async ({ marketId, @@ -61,7 +65,7 @@ export const { useQuery: useCreateLoanExpectedCollateral, queryKey: createLoanEx userCollateral = '0', debt, slippage, - }: BorrowFormQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) if (market instanceof LendMarketTemplate) { return convertNumbers( @@ -79,5 +83,5 @@ export const { useQuery: useCreateLoanExpectedCollateral, queryKey: createLoanEx return convertNumbers({ userCollateral, leverage, totalCollateral: collateral }) }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true, isLeverageRequired: true }), }) diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-health.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-health.query.ts index 24ec015617..a2549a66db 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-health.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-health.query.ts @@ -3,7 +3,7 @@ import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' import type { Decimal } from '@ui-kit/utils' import { decimal } from '@ui-kit/utils' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtParams, BorrowDebtQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' import { createLoanExpectedCollateralQueryKey } from './create-loan-expected-collateral.query' import { createLoanMaxReceiveKey } from './create-loan-max-receive.query' @@ -17,7 +17,8 @@ export const { useQuery: useCreateLoanHealth } = queryFactory({ debt, leverageEnabled, range, - }: BorrowFormQueryParams) => + maxDebt, + }: BorrowDebtParams) => [ ...rootKeys.market({ chainId, marketId }), 'createLoanHealth', @@ -26,6 +27,7 @@ export const { useQuery: useCreateLoanHealth } = queryFactory({ { debt }, { leverageEnabled }, { range }, + { maxDebt }, ] as const, queryFn: async ({ marketId, @@ -34,7 +36,7 @@ export const { useQuery: useCreateLoanHealth } = queryFactory({ debt = '0', leverageEnabled, range, - }: BorrowFormQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) return decimal( leverageEnabled @@ -47,7 +49,7 @@ export const { useQuery: useCreateLoanHealth } = queryFactory({ )! }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true }), dependencies: (params) => [ createLoanMaxReceiveKey(params), ...(params.leverageEnabled ? [createLoanExpectedCollateralQueryKey(params)] : []), diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-max-receive.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-max-receive.query.ts index 97ab0f4242..8aab0d7b7c 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-max-receive.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-max-receive.query.ts @@ -39,11 +39,19 @@ const convertNumbers = ({ }) export const maxReceiveValidation = createValidationSuite( - ({ chainId, marketId, userBorrowed, userCollateral, range, slippage }: CreateLoanMaxReceiveParams) => { + ({ + chainId, + marketId, + userBorrowed, + userCollateral, + range, + slippage, + leverageEnabled, + }: CreateLoanMaxReceiveParams) => { marketIdValidationSuite({ chainId, marketId }) borrowFormValidationGroup( - { userBorrowed, userCollateral, debt: undefined, range, slippage }, - { debtRequired: false }, + { userBorrowed, userCollateral, debt: undefined, range, slippage, leverageEnabled }, + { debtRequired: false, isMaxDebtRequired: false, isLeverageRequired: false }, ) }, ) diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-price-impact.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-price-impact.query.ts index e74cb4b24e..43ec77d744 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-price-impact.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-price-impact.query.ts @@ -1,27 +1,37 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtParams, BorrowDebtQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' import { createLoanExpectedCollateralQueryKey } from './create-loan-expected-collateral.query' type BorrowPriceImpactResult = number // percentage export const { useQuery: useCreateLoanPriceImpact } = queryFactory({ - queryKey: ({ chainId, marketId, userBorrowed = '0', userCollateral = '0', debt = '0' }: BorrowFormQueryParams) => + queryKey: ({ + chainId, + marketId, + userBorrowed = '0', + userCollateral = '0', + debt = '0', + leverageEnabled, + maxDebt, + }: BorrowDebtParams) => [ ...rootKeys.market({ chainId, marketId }), 'createLoanPriceImpact', { userCollateral }, { userBorrowed }, { debt }, + { leverageEnabled }, + { maxDebt }, ] as const, queryFn: async ({ marketId, userBorrowed = '0', userCollateral = '0', debt = '0', - }: BorrowFormQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) return market instanceof LendMarketTemplate ? +(await market.leverage.createLoanPriceImpact(userBorrowed, debt)) @@ -30,6 +40,6 @@ export const { useQuery: useCreateLoanPriceImpact } = queryFactory({ : +(await market.leverage.priceImpact(userCollateral, debt)) }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true, isLeverageRequired: true }), dependencies: (params) => [createLoanExpectedCollateralQueryKey(params)], }) diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-prices.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-prices.query.ts index 9b5a62a70c..a69e7a762c 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-prices.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-prices.query.ts @@ -3,12 +3,12 @@ import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { type FieldsOf } from '@ui-kit/lib' import { queryFactory, rootKeys } from '@ui-kit/lib/model' import { Decimal } from '@ui-kit/utils' -import type { BorrowFormQuery } from '../../features/borrow/types' +import type { BorrowDebtQuery, BorrowForm, BorrowFormQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' import { createLoanExpectedCollateralQueryKey } from './create-loan-expected-collateral.query' import { createLoanMaxReceiveKey } from './create-loan-max-receive.query' -type BorrowPricesReceiveQuery = BorrowFormQuery +type BorrowPricesReceiveQuery = BorrowFormQuery & Pick type BorrowPricesReceiveParams = FieldsOf type BorrowPricesResult = [Decimal, Decimal] @@ -23,6 +23,7 @@ export const { useQuery: useCreateLoanPrices } = queryFactory({ debt = '0', leverageEnabled, range, + maxDebt, }: BorrowPricesReceiveParams) => [ ...rootKeys.market({ chainId, marketId }), @@ -32,6 +33,7 @@ export const { useQuery: useCreateLoanPrices } = queryFactory({ { debt }, { leverageEnabled }, { range }, + { maxDebt }, ] as const, queryFn: async ({ marketId, @@ -40,7 +42,7 @@ export const { useQuery: useCreateLoanPrices } = queryFactory({ debt = '0', leverageEnabled, range, - }: BorrowPricesReceiveQuery): Promise => { + }: BorrowDebtQuery): Promise => { const market = getLlamaMarket(marketId) return !leverageEnabled ? convertNumbers(await market.createLoanPrices(userCollateral, debt, range)) @@ -51,7 +53,7 @@ export const { useQuery: useCreateLoanPrices } = queryFactory({ : convertNumbers(await market.leverage.createLoanPrices(userCollateral, debt, range)) }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true }), dependencies: (params) => [ createLoanMaxReceiveKey(params), ...(params.leverageEnabled ? [createLoanExpectedCollateralQueryKey(params)] : []), diff --git a/apps/main/src/llamalend/queries/create-loan/create-loan-route-image.query.ts b/apps/main/src/llamalend/queries/create-loan/create-loan-route-image.query.ts index eccb7e2155..41922bade4 100644 --- a/apps/main/src/llamalend/queries/create-loan/create-loan-route-image.query.ts +++ b/apps/main/src/llamalend/queries/create-loan/create-loan-route-image.query.ts @@ -1,13 +1,20 @@ import { getLlamaMarket } from '@/llamalend/llama.utils' +import { createLoanExpectedCollateralQueryKey } from '@/llamalend/queries/create-loan/create-loan-expected-collateral.query' import { LendMarketTemplate } from '@curvefi/llamalend-api/lib/lendMarkets' import { queryFactory, rootKeys } from '@ui-kit/lib/model' -import type { BorrowFormQuery, BorrowFormQueryParams } from '../../features/borrow/types' +import type { BorrowDebtParams, BorrowDebtQuery } from '../../features/borrow/types' import { borrowQueryValidationSuite } from '../validation/borrow.validation' export const { useQuery: useCreateLoanRouteImage } = queryFactory({ - queryKey: ({ chainId, marketId, userBorrowed = '0', debt = '0' }: BorrowFormQueryParams) => - [...rootKeys.market({ chainId, marketId }), 'createLoanRouteImage', { userBorrowed }, { debt }] as const, - queryFn: async ({ marketId, userBorrowed = '0', debt = '0' }: BorrowFormQuery) => { + queryKey: ({ chainId, marketId, userBorrowed = '0', debt = '0', maxDebt }: BorrowDebtParams) => + [ + ...rootKeys.market({ chainId, marketId }), + 'createLoanRouteImage', + { userBorrowed }, + { debt }, + { maxDebt }, + ] as const, + queryFn: async ({ marketId, userBorrowed = '0', debt = '0' }: BorrowDebtQuery) => { const market = getLlamaMarket(marketId) return market instanceof LendMarketTemplate ? await market.leverage.createLoanRouteImage(userBorrowed, debt) @@ -16,5 +23,6 @@ export const { useQuery: useCreateLoanRouteImage } = queryFactory({ : null }, staleTime: '1m', - validationSuite: borrowQueryValidationSuite, + validationSuite: borrowQueryValidationSuite({ debtRequired: true }), + dependencies: (params) => [createLoanExpectedCollateralQueryKey(params)], }) diff --git a/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts b/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts index e81f90261e..55348eba37 100644 --- a/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts +++ b/apps/main/src/llamalend/queries/validation/borrow-fields.validation.ts @@ -1,4 +1,4 @@ -import { enforce, test } from 'vest' +import { enforce, test, skipWhen } from 'vest' import { BORROW_PRESET_RANGES } from '@/llamalend/constants' import { Decimal } from '@ui-kit/utils' @@ -15,10 +15,10 @@ export const validateUserCollateral = (userCollateral: Decimal | undefined | nul } export const validateDebt = (debt: Decimal | undefined | null, required: boolean = true) => { - test('debt', `Debt must be a positive number${required ? '' : ' or null'}`, () => { - if (required || debt != null) { + skipWhen(!required && !debt, () => { + test('debt', `Debt must be a positive number${required ? '' : ' or null'}`, () => { enforce(debt).isNumeric().gt(0) - } + }) }) } @@ -34,11 +34,29 @@ export const validateRange = (range: number | null | undefined, { MaxLtv, Safe } }) } -export const validateMaxDebt = (debt: Decimal | undefined | null, maxDebt: Decimal | undefined | null) => { - test('debt', 'Debt must be less than or equal to maximum borrowable amount', () => { - if (debt != null && maxDebt != null) { +export const validateMaxDebt = ( + debt: Decimal | undefined | null, + maxDebt: Decimal | undefined | null, + isMaxDebtRequired: boolean, +) => { + skipWhen(!isMaxDebtRequired, () => { + test('maxDebt', 'Maximum debt must be calculated before debt can be validated', () => { + enforce(maxDebt).isNotNullish() + }) + }) + skipWhen(maxDebt == null || debt == null, () => { + test('maxDebt', 'Debt is too high', () => { + enforce(maxDebt).isNotNullish() enforce(debt).lte(maxDebt) - } + }) + }) +} + +export const validateLeverageEnabled = (leverageEnabled: boolean | undefined | null, isLeverageRequired: boolean) => { + skipWhen(!isLeverageRequired, () => { + test('leverageEnabled', 'Leverage must be enabled', () => { + enforce(leverageEnabled).equals(true) + }) }) } @@ -46,10 +64,10 @@ export const validateMaxCollateral = ( userCollateral: Decimal | undefined | null, maxCollateral: Decimal | undefined | null, ) => { - test('userCollateral', 'Collateral must be less than or equal to your wallet balance', () => { - if (userCollateral != null && maxCollateral != null) { + skipWhen(userCollateral == null || maxCollateral == null, () => { + test('userCollateral', 'Collateral must be less than or equal to your wallet balance', () => { enforce(userCollateral).lte(maxCollateral) - } + }) }) } diff --git a/apps/main/src/llamalend/queries/validation/borrow.validation.ts b/apps/main/src/llamalend/queries/validation/borrow.validation.ts index d05df09d03..2fd75710ae 100644 --- a/apps/main/src/llamalend/queries/validation/borrow.validation.ts +++ b/apps/main/src/llamalend/queries/validation/borrow.validation.ts @@ -1,6 +1,7 @@ -import { group } from 'vest' +import { group, skipWhen } from 'vest' import { validateDebt, + validateLeverageEnabled, validateMaxCollateral, validateMaxDebt, validateRange, @@ -10,11 +11,24 @@ import { } from '@/llamalend/queries/validation/borrow-fields.validation' import { createValidationSuite, type FieldsOf } from '@ui-kit/lib' import { marketIdValidationSuite } from '@ui-kit/lib/model/query/market-id-validation' -import { type BorrowForm, type BorrowFormQueryParams } from '../../features/borrow/types' +import { type BorrowDebtParams, type BorrowForm } from '../../features/borrow/types' export const borrowFormValidationGroup = ( - { userBorrowed, userCollateral, debt, range, slippage, maxDebt, maxCollateral }: FieldsOf, - { debtRequired = true }: { debtRequired?: boolean } = {}, + { + userBorrowed, + userCollateral, + debt, + range, + slippage, + maxDebt, + maxCollateral, + leverageEnabled, + }: FieldsOf, + { + debtRequired, + isMaxDebtRequired, + isLeverageRequired, + }: { debtRequired: boolean; isMaxDebtRequired: boolean; isLeverageRequired: boolean }, ) => group('borrowFormValidationGroup', () => { validateUserBorrowed(userBorrowed) @@ -22,27 +36,29 @@ export const borrowFormValidationGroup = ( validateDebt(debt, debtRequired) validateSlippage(slippage) validateRange(range) - validateMaxDebt(debt, maxDebt) + validateMaxDebt(debt, maxDebt, isMaxDebtRequired) validateMaxCollateral(userCollateral, maxCollateral) + validateLeverageEnabled(leverageEnabled, isLeverageRequired) }) -export const borrowFormValidationSuite = createValidationSuite(borrowFormValidationGroup) - -export const borrowQueryValidationSuite = createValidationSuite( - ({ - chainId, - leverageEnabled, - marketId, - userBorrowed, - userCollateral, - debt, - range, - slippage, - }: BorrowFormQueryParams) => { - marketIdValidationSuite({ chainId, marketId }) +export const borrowQueryValidationSuite = ({ + debtRequired, + isMaxDebtRequired = debtRequired, + isLeverageRequired = false, + skipMarketValidation = false, +}: { + debtRequired: boolean + isMaxDebtRequired?: boolean + isLeverageRequired?: boolean + skipMarketValidation?: boolean +}) => + createValidationSuite((params: BorrowDebtParams) => { + const { chainId, leverageEnabled, marketId, userBorrowed, userCollateral, debt, range, slippage, maxDebt } = params + skipWhen(skipMarketValidation, () => { + marketIdValidationSuite({ chainId, marketId }) + }) borrowFormValidationGroup( - { userBorrowed, userCollateral, debt, range, slippage, leverageEnabled }, - { debtRequired: true }, + { userBorrowed, userCollateral, debt, range, slippage, leverageEnabled, maxDebt }, + { debtRequired, isMaxDebtRequired, isLeverageRequired }, ) - }, -) + }) diff --git a/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx b/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx index c553f7a154..1fb4bb46bc 100644 --- a/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx +++ b/apps/main/src/llamalend/widgets/manage-loan/LoanFormTokenInput.tsx @@ -7,7 +7,7 @@ import type { LlamaNetwork } from '@/llamalend/llamalend.types' import type { INetworkName } from '@curvefi/llamalend-api/lib/interfaces' import type { PartialRecord } from '@curvefi/prices-api/objects.util' import { useTokenBalance } from '@ui-kit/hooks/useTokenBalance' -import { LargeTokenInput } from '@ui-kit/shared/ui/LargeTokenInput' +import { HelperMessage, LargeTokenInput } from '@ui-kit/shared/ui/LargeTokenInput' import { TokenLabel } from '@ui-kit/shared/ui/TokenLabel' import type { Query } from '@ui-kit/types/util' import { Decimal } from '@ui-kit/utils' @@ -52,8 +52,9 @@ export const LoanFormTokenInput = < } = useTokenBalance({ chainId: network?.chainId, userAddress }, token) const errors = form.formState.errors as PartialRecord, Error> - const relatedMaxFieldError = max?.fieldName && errors[max.fieldName] + const relatedMaxFieldError = max?.data && max?.fieldName && errors[max.fieldName] const error = errors[name] || max?.error || balanceError || relatedMaxFieldError + return ( form.setValue(name, v as FieldPathValue, setValueOptions), - [form, name], + (v?: Decimal) => { + form.setValue(name, v as FieldPathValue, setValueOptions) + if (max?.fieldName) void form.trigger(max.fieldName) // validate max field when balance changes + }, + [form, max?.fieldName, name], )} isError={!!error} - message={error?.message ?? message} + message={error?.message} walletBalance={useMemo( // todo: support separate isLoading for balance and for maxBalance in LargeTokenInput () => ({ balance, symbol: token?.symbol, loading: isBalanceLoading }), [balance, isBalanceLoading, token?.symbol], )} maxBalance={useMemo(() => max && { balance: max.data, chips: 'max' }, [max])} - /> + > + {message && } + ) } diff --git a/packages/curve-ui-kit/src/features/connect-wallet/lib/CurveProvider.tsx b/packages/curve-ui-kit/src/features/connect-wallet/lib/CurveProvider.tsx index afdc508c5e..d35cf103aa 100644 --- a/packages/curve-ui-kit/src/features/connect-wallet/lib/CurveProvider.tsx +++ b/packages/curve-ui-kit/src/features/connect-wallet/lib/CurveProvider.tsx @@ -38,7 +38,7 @@ export const CurveProvider = ({ }) => { const [connectState, setConnectState] = useState(LOADING) const walletChainId = useChainId() - const { switchChainAsync } = useSwitchChain() + const { mutateAsync: switchChainAsync } = useSwitchChain() const { wallet, provider, isReconnecting } = useWagmiWallet() const isFocused = useIsDocumentFocused() const libKey = AppLibs[app] diff --git a/packages/curve-ui-kit/src/hooks/useDebounce.ts b/packages/curve-ui-kit/src/hooks/useDebounce.ts index 1e7fa509d6..ba43ddfdd5 100644 --- a/packages/curve-ui-kit/src/hooks/useDebounce.ts +++ b/packages/curve-ui-kit/src/hooks/useDebounce.ts @@ -1,6 +1,40 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Duration } from '@ui-kit/themes/design/0_primitives' +/** + * A hook that debounces a function call and calls a callback when the debouncing period has elapsed. + * + * @param debounceMs - The debouncing period in milliseconds + * @param callback - Callback function that is called after the debounce period + * @param onChange - Optional callback function that is called immediately when the value changes + * @returns A tuple containing the debounced function and a cancel function + */ +export function useDebounced( + callback: (...value: T) => void, + debounceMs: number, + onChange?: (...value: T) => void, +) { + const timerRef = useRef(null) + const cancel = useCallback(() => void (timerRef.current && clearTimeout(timerRef.current)), []) + useEffect(() => cancel, [cancel]) + return [ + useCallback( + (...newValue: T) => { + cancel() + onChange?.(...newValue) + + // Initiate a new timer + timerRef.current = window.setTimeout(() => { + callback(...newValue) + timerRef.current = null + }, debounceMs) + }, + [callback, cancel, debounceMs, onChange], + ), + cancel, + ] as const +} + /** * A hook that debounces a value and calls a callback when the debounce period has elapsed. * @@ -32,42 +66,8 @@ import { Duration } from '@ui-kit/themes/design/0_primitives' */ export function useDebounce(initialValue: T, debounceMs: number, callback: (value: T) => void) { const [value, setValue] = useState(initialValue) - const timerRef = useRef(null) - - // Update value when initialValue changes for controlled components - useEffect(() => { - setValue(initialValue) - }, [initialValue]) - - // Clear timer on unmount - useEffect( - () => () => { - if (timerRef.current !== null) { - clearTimeout(timerRef.current) - } - }, - [], - ) - - // Clear any existing timer - const cancel = useCallback(() => timerRef.current && clearTimeout(timerRef.current), []) - - // Sets the internal value, but calls the callback after a delay unless retriggered again. - const setDebouncedValue = useCallback( - (newValue: T) => { - setValue(newValue) - cancel() - - // Initiate a new timer - timerRef.current = window.setTimeout(() => { - callback(newValue) - timerRef.current = null - }, debounceMs) - }, - [callback, cancel, debounceMs], - ) - - return [value, setDebouncedValue, cancel] as const + useEffect(() => setValue(initialValue), [initialValue]) + return [value, ...useDebounced(callback, debounceMs, setValue)] as const } /** diff --git a/packages/curve-ui-kit/src/shared/ui/LargeTokenInput.tsx b/packages/curve-ui-kit/src/shared/ui/LargeTokenInput.tsx index c516047e26..8a80835103 100644 --- a/packages/curve-ui-kit/src/shared/ui/LargeTokenInput.tsx +++ b/packages/curve-ui-kit/src/shared/ui/LargeTokenInput.tsx @@ -27,10 +27,10 @@ const { Spacing, FontSize, FontWeight, Sizing } = SizesAndSpaces type HelperMessageProps = { message: string | ReactNode - isError: boolean + isError?: boolean } -const HelperMessage = ({ message, isError }: HelperMessageProps) => ( +export const HelperMessage = ({ message, isError }: HelperMessageProps) => ( ( color: (t) => isError ? t.design.Text.TextColors.FilledFeedback.Warning.Primary : t.design.Text.TextColors.Tertiary, }} + data-testid={`helper-message-${isError ? 'error' : 'info'}`} > {message} @@ -77,7 +78,6 @@ const CHIPS_PRESETS: Record = { type BalanceTextFieldProps = { balance: Decimal | undefined - maxBalance?: Decimal isError: boolean disabled?: boolean /** Callback fired when the numeric value changes, can be a temporary non decimal value like "5." or "-" */ @@ -195,6 +195,9 @@ export type LargeTokenInputProps = { /** Optional props forwarded to the slider */ sliderProps?: SliderInputProps['sliderProps'] + + /** Optional children to be rendered below the input */ + children?: ReactNode } /** @@ -252,6 +255,7 @@ export const LargeTokenInput = ({ inputBalanceUsd, testId, sliderProps, + children, }: LargeTokenInputProps) => { const [percentage, setPercentage] = useState(undefined) const [balance, setBalance, cancelSetBalance] = useUniqueDebounce({ @@ -303,13 +307,6 @@ export const LargeTokenInput = ({ [maxBalance?.balance, setBalance, cancelSetBalance, onBalance], ) - const handleChip = useCallback( - (chip: InputChip) => { - handleBalanceChange(chip.newBalance(maxBalance?.balance)) - }, - [handleBalanceChange, maxBalance?.balance], - ) - const updatePercentageOnNewMaxBalance = useEffectEvent((newMaxBalance?: Decimal) => { setPercentage(newMaxBalance && balance ? calculateNewPercentage(balance, newMaxBalance) : undefined) }) @@ -384,7 +381,7 @@ export const LargeTokenInput = ({ color="default" clickable disabled={disabled} - onClick={() => handleChip(chip)} + onClick={() => handleBalanceChange(chip.newBalance(maxBalance?.balance))} > ))} @@ -398,7 +395,6 @@ export const LargeTokenInput = ({ disabled={disabled} balance={balance} name={name} - maxBalance={maxBalance?.balance} isError={isError} onChange={handleBalanceChange} /> @@ -420,25 +416,15 @@ export const LargeTokenInput = ({ {/** Fourth row showing optional slider for max balance. */} {showSlider && ( - + handlePercentageChange(value as Decimal)} - sliderProps={{ - 'data-rail-background': 'danger', - ...sliderProps, - }} + sliderProps={{ 'data-rail-background': 'danger', ...sliderProps }} min={MIN_PERCENTAGE} max={MAX_PERCENTAGE} - inputProps={{ - variant: 'standard', - adornment: 'percentage', - }} + inputProps={{ variant: 'standard', adornment: 'percentage' }} /> )} @@ -446,6 +432,7 @@ export const LargeTokenInput = ({ {/** Fourth row containing optional helper (or error) message */} {message && } + {children} ) } diff --git a/packages/curve-ui-kit/src/themes/design/0_primitives.ts b/packages/curve-ui-kit/src/themes/design/0_primitives.ts index 1db05295b8..f227de53b5 100644 --- a/packages/curve-ui-kit/src/themes/design/0_primitives.ts +++ b/packages/curve-ui-kit/src/themes/design/0_primitives.ts @@ -134,13 +134,13 @@ export const Sizing = { } as const export const Duration = { + Delay: 100, + Flicker: 1000, + Focus: 50, + FormDebounce: 250, Snackbar: 6000, Tooltip: { Enter: 500, Exit: 500 }, - Flicker: 1000, - FormDebounce: 500, Transition: 256, - Focus: 50, - Delay: 100, } export const TransitionFunction = `ease-out ${Duration.Transition}ms` diff --git a/packages/curve-ui-kit/src/types/util.ts b/packages/curve-ui-kit/src/types/util.ts index f2743315ba..17eb674967 100644 --- a/packages/curve-ui-kit/src/types/util.ts +++ b/packages/curve-ui-kit/src/types/util.ts @@ -1,5 +1,3 @@ -// Various Typescript utility types, useful everywhere! - /** * Creates a deep partial type that makes all properties optional recursively, * while preserving function types as-is diff --git a/tests/cypress/component/llamalend/borrow-tab-contents.rpc.cy.tsx b/tests/cypress/component/llamalend/create-loan-form.rpc.cy.tsx similarity index 85% rename from tests/cypress/component/llamalend/borrow-tab-contents.rpc.cy.tsx rename to tests/cypress/component/llamalend/create-loan-form.rpc.cy.tsx index 0fb35e04e5..018fe09a4b 100644 --- a/tests/cypress/component/llamalend/borrow-tab-contents.rpc.cy.tsx +++ b/tests/cypress/component/llamalend/create-loan-form.rpc.cy.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { prefetchMarkets } from '@/lend/entities/chain/chain-query' +import { BorrowPreset } from '@/llamalend/constants' import { CreateLoanForm } from '@/llamalend/features/borrow/components/CreateLoanForm' import type { OnBorrowFormUpdate } from '@/llamalend/features/borrow/types' import type { CreateLoanOptions } from '@/llamalend/mutations/create-loan.mutation' @@ -57,7 +58,7 @@ function BorrowTabTest({ type, onCreated }: BorrowTabTestProps) { ) } -describe('BorrowTabContents Component Tests', () => { +describe('CreateLoanForm Component Tests', () => { const privateKey = generatePrivateKey() const { address } = privateKeyToAccount(privateKey) const getVirtualNetwork = createVirtualTestnet((uuid) => ({ @@ -99,26 +100,10 @@ describe('BorrowTabContents Component Tests', () => { const getActionValue = (name: string) => cy.get(`[data-testid="${name}-value"]`, LOAD_TIMEOUT) - it(`calculates max debt and health for ${marketType} market ${leverageEnabled ? 'with' : 'without'} leverage`, () => { - const onCreated = cy.stub() - cy.mount() - cy.get('[data-testid="borrow-debt-input"] [data-testid="balance-value"]', LOAD_TIMEOUT).should('exist') - cy.get('[data-testid="borrow-collateral-input"] input[type="text"]').first().type(collateral) - cy.get('[data-testid="borrow-debt-input"] [data-testid="balance-value"]').should('not.contain.text', '?') - getActionValue('borrow-health').should('have.text', '∞') - cy.get('[data-testid="borrow-debt-input"] input[type="text"]').first().type(borrow) - getActionValue('borrow-health').should('not.contain.text', '∞') - - if (leverageEnabled) { - cy.get('[data-testid="leverage-checkbox"]').click() - } - - // open borrow advanced settings and check all fields - cy.contains('button', 'Health').click() - + function assertLoanDetailsLoaded() { getActionValue('borrow-band-range') .invoke(LOAD_TIMEOUT, 'text') - .should('match', /(\d(\.\d+)?) to (\d(\.\d+)?)/) + .should('match', /(\d(\.\d+)?) to (-?\d(\.\d+)?)/) getActionValue('borrow-price-range') .invoke(LOAD_TIMEOUT, 'text') .should('match', /(\d(\.\d+)?) - (\d(\.\d+)?)/) @@ -136,6 +121,37 @@ describe('BorrowTabContents Component Tests', () => { } cy.get('[data-testid="loan-form-errors"]').should('not.exist') + } + + it(`calculates max debt and health for ${marketType} market ${leverageEnabled ? 'with' : 'without'} leverage`, () => { + const onCreated = cy.stub() + cy.mount() + cy.get('[data-testid="borrow-debt-input"] [data-testid="balance-value"]', LOAD_TIMEOUT).should('exist') + cy.get('[data-testid="borrow-collateral-input"] input[type="text"]').first().type(collateral) + cy.get('[data-testid="borrow-debt-input"] [data-testid="balance-value"]').should('not.contain.text', '?') + getActionValue('borrow-health').should('have.text', '∞') + cy.get('[data-testid="borrow-debt-input"] input[type="text"]').first().type(borrow) + getActionValue('borrow-health').should('not.contain.text', '∞') + + if (leverageEnabled) { + cy.get('[data-testid="leverage-checkbox"]').click() + } + + // open borrow advanced settings and check all fields + cy.contains('button', 'Health').click() + assertLoanDetailsLoaded() + + // click max ltv and max borrow, then back to safe, expect error. Clear it by setting max again + cy.get(`[data-testid="loan-preset-${BorrowPreset.MaxLtv}"]`).click() + cy.get('[data-testid="borrow-set-debt-to-max"]').should('not.exist') // should only render after loaded + cy.get('[data-testid="borrow-set-debt-to-max"]', LOAD_TIMEOUT).click() + cy.get(`[data-testid="loan-preset-${BorrowPreset.Safe}"]`).click() + cy.get('[data-testid="helper-message-error"]', LOAD_TIMEOUT).should('contain.text', 'Debt is too high') + cy.get('[data-testid="borrow-set-debt-to-max"]').click() // set max again to fix error + cy.get('[data-testid="helper-message-error"]').should('not.exist') + assertLoanDetailsLoaded() + + // create the loan, expect the onCreated to be called cy.get('[data-testid="create-loan-submit-button"]').click() cy.get('[data-testid="create-loan-submit-button"]').should('be.disabled') cy.get('[data-testid="create-loan-submit-button"]', LOAD_TIMEOUT).should('be.enabled') diff --git a/tests/cypress/component/useDebounce.cy.tsx b/tests/cypress/component/useDebounce.cy.tsx new file mode 100644 index 0000000000..a1c5bc15a6 --- /dev/null +++ b/tests/cypress/component/useDebounce.cy.tsx @@ -0,0 +1,511 @@ +import { useState } from 'react' +import { useDebounced, useDebounce, useDebouncedValue, useUniqueDebounce } from '@ui-kit/hooks/useDebounce' + +// Test component for useDebounced +function UseDebouncedTest({ + debounceMs, + callback, + onChange, +}: { + debounceMs: number + callback: (...args: any[]) => void + onChange?: (...args: any[]) => void +}) { + const [debouncedFn, cancel] = useDebounced(callback, debounceMs, onChange) + + return ( +
+ + + +
+ ) +} + +// Test component for useDebounce +function UseDebounceTest({ + initialValue, + debounceMs, + callback, +}: { + initialValue: string + debounceMs: number + callback: (value: string) => void +}) { + const [value, setValue, cancel] = useDebounce(initialValue, debounceMs, callback) + + return ( +
+ setValue(e.target.value)} /> +
{value}
+ +
+ ) +} + +// Test component for useDebouncedValue +function UseDebouncedValueTest({ + givenValue, + debounceMs, + defaultValue, +}: { + givenValue: string + debounceMs?: number + defaultValue?: string +}) { + const debouncedValue = useDebouncedValue(givenValue, { debounceMs, defaultValue }) + + return ( +
+
{debouncedValue}
+
{givenValue}
+
+ ) +} + +// Test component for useUniqueDebounce +function UseUniqueDebounceTest({ + defaultValue, + callback, + debounceMs, + equals, +}: { + defaultValue: string + callback: (value: string) => void + debounceMs?: number + equals?: (a: string, b: string) => boolean +}) { + const [value, setValue, cancel] = useUniqueDebounce({ + defaultValue, + callback, + debounceMs, + equals, + }) + + return ( +
+ setValue(e.target.value)} /> +
{value}
+ +
+ ) +} + +// Test component for useUniqueDebounce with objects +function UseUniqueDebounceObjectTest({ + defaultValue, + callback, + equals, +}: { + defaultValue: { id: number; name: string } + callback: (value: { id: number; name: string }) => void + equals?: (a: { id: number; name: string }, b: { id: number; name: string }) => boolean +}) { + const [value, setValue] = useUniqueDebounce({ + defaultValue, + callback, + debounceMs: 200, + equals, + }) + + return ( +
+ + +
{JSON.stringify(value)}
+
+ ) +} + +describe('useDebounced', () => { + beforeEach(() => { + cy.clock() + }) + + it('calls callback after debounce period', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="trigger"]').click() + cy.get('@callback').should('not.have.been.called') + + cy.tick(300) + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'test-value') + }) + + it('cancels previous timeout when called multiple times', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="trigger"]').click() + cy.tick(100) + cy.get('[data-testid="trigger"]').click() + cy.tick(100) + cy.get('[data-testid="trigger"]').click() + cy.tick(300) + + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'test-value') + }) + + it('calls onChange immediately when provided', () => { + const callback = cy.stub().as('callback') + const onChange = cy.stub().as('onChange') + cy.mount() + + cy.get('[data-testid="trigger"]').click() + + cy.get('@onChange').should('have.been.calledOnce') + cy.get('@onChange').should('have.been.calledWith', 'test-value') + cy.get('@callback').should('not.have.been.called') + + cy.tick(300) + cy.get('@callback').should('have.been.calledOnce') + }) + + it('supports multiple arguments', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="trigger-with-args"]').click() + cy.tick(200) + + cy.get('@callback').should('have.been.calledWith', 'arg1', 42, true) + }) + + it('cancel function prevents callback execution', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="trigger"]').click() + cy.tick(100) + cy.get('[data-testid="cancel"]').click() + cy.tick(300) + + cy.get('@callback').should('not.have.been.called') + }) +}) + +describe('useDebounce', () => { + beforeEach(() => { + cy.clock() + }) + + it('returns initial value', () => { + const callback = cy.stub() + cy.mount() + + cy.get('[data-testid="current-value"]').should('have.text', 'initial') + }) + + it('updates value immediately but calls callback after debounce', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').type('new value') + + cy.get('[data-testid="current-value"]').should('have.text', 'new value') + cy.get('@callback').should('not.have.been.called') + + cy.tick(300) + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'new value') + }) + + it('debounces multiple rapid changes', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').type('a') + cy.tick(50) + cy.get('[data-testid="input"]').type('b') + cy.tick(50) + cy.get('[data-testid="input"]').type('c') + cy.tick(200) + + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'abc') + }) + + it('cancel function stops pending callback', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').type('test') + cy.tick(100) + cy.get('[data-testid="cancel"]').click() + cy.tick(300) + + cy.get('@callback').should('not.have.been.called') + }) +}) + +describe('useDebouncedValue', () => { + beforeEach(() => { + cy.clock() + }) + + it('returns default value initially when provided', () => { + cy.mount() + + cy.get('[data-testid="debounced-value"]').should('have.text', 'default') + cy.get('[data-testid="given-value"]').should('have.text', 'actual') + }) + + it('returns given value as default when defaultValue not provided', () => { + cy.mount() + + cy.get('[data-testid="debounced-value"]').should('have.text', 'test') + }) + + it('updates debounced value after delay', () => { + function DebouncedValueWrapper() { + const [value, setValue] = useState('initial') + return ( +
+ + +
+ ) + } + + cy.mount() + + cy.get('[data-testid="debounced-value"]').should('have.text', 'initial') + + cy.get('[data-testid="update"]').click() + cy.get('[data-testid="given-value"]').should('have.text', 'updated') + cy.get('[data-testid="debounced-value"]').should('have.text', 'initial') + + cy.tick(300) + cy.get('[data-testid="debounced-value"]').should('have.text', 'updated') + }) + + it('cancels previous timeout on value change', () => { + function DebouncedValueWrapper() { + const [value, setValue] = useState('first') + return ( +
+ + + +
+ ) + } + + cy.mount() + + cy.get('[data-testid="set-second"]').click() + cy.tick(100) + cy.get('[data-testid="set-third"]').click() + cy.tick(100) + cy.get('[data-testid="debounced-value"]').should('have.text', 'first') + + cy.tick(300) + cy.get('[data-testid="debounced-value"]').should('have.text', 'third') + }) +}) + +describe('useUniqueDebounce', () => { + beforeEach(() => { + cy.clock() + }) + + it('only calls callback when value changes', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').clear().type('new value') + cy.tick(200) + + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'new value') + + // Set to same value + cy.get('[data-testid="input"]').clear().type('new value') + cy.tick(200) + + // Should not call callback again + cy.get('@callback').should('have.been.calledOnce') + }) + + it('uses custom equality function when provided', () => { + const callback = cy.stub().as('callback') + const equals = (a: string, b: string) => a.toLowerCase() === b.toLowerCase() + + cy.mount() + + cy.get('[data-testid="input"]').clear().type('TEST') + cy.tick(200) + + // Should not call callback because values are equal (case-insensitive) + cy.get('@callback').should('not.have.been.called') + + cy.get('[data-testid="input"]').clear().type('different') + cy.tick(200) + + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'different') + }) + + it('uses default debounce time of 166ms when not provided', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').type('test') + cy.tick(166) + + cy.get('@callback').should('have.been.calledOnce') + }) + + it('updates lastValue when defaultValue changes (async initialization)', () => { + const callback = cy.stub().as('callback') + + // This test simulates the scenario described in the hook's useEffect comment: + // 1. Component mounts with empty defaultValue (before async load) + // 2. Async data loads and defaultValue updates to a saved value + // 3. User clears the input back to empty + // 4. Callback should fire because we compare against the updated defaultValue, not the original + function AsyncInitWrapper() { + const [defaultValue, setDefaultValue] = useState('') + const [value, setValue] = useUniqueDebounce({ + defaultValue, + callback, + debounceMs: 200, + }) + + return ( +
+ + +
{value}
+
{defaultValue}
+
+ ) + } + + cy.mount() + + // Initial state: both values are empty, lastValue.current = '' + cy.get('[data-testid="current-value"]').should('have.text', '') + cy.get('[data-testid="default-value"]').should('have.text', '') + + // Simulate async load from localStorage + // This updates defaultValue to 'saved search' and lastValue.current to 'saved search' via useEffect + cy.get('[data-testid="load-async"]').click() + cy.get('[data-testid="default-value"]').should('have.text', 'saved search') + + // User clears the search + // Without the useEffect update, this would compare '' to '' (initial lastValue) + // With the useEffect update, this compares '' to 'saved search' (updated lastValue) + cy.get('[data-testid="clear"]').click() + cy.tick(200) + + // Callback should fire because '' !== 'saved search' + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', '') + }) + + it('handles object values with custom equality', () => { + const callback = cy.stub().as('callback') + const equals = (a: { id: number; name: string }, b: { id: number; name: string }) => a.id === b.id + + cy.mount( + , + ) + + cy.get('[data-testid="set-same-id"]').click() + cy.tick(200) + + // Should not call callback because id is the same + cy.get('@callback').should('not.have.been.called') + + cy.get('[data-testid="set-different-id"]').click() + cy.tick(200) + + // Should call callback because id changed + cy.get('@callback').should('have.been.calledOnce') + }) + + it('debounces rapid changes and only fires once for unique values', () => { + const callback = cy.stub().as('callback') + cy.mount() + + cy.get('[data-testid="input"]').type('a') + cy.tick(50) + cy.get('[data-testid="input"]').type('b') + cy.tick(50) + cy.get('[data-testid="input"]').type('c') + cy.tick(200) + + cy.get('@callback').should('have.been.calledOnce') + cy.get('@callback').should('have.been.calledWith', 'abc') + }) + + it('handles primitive number values correctly', () => { + const callback = cy.stub().as('callback') + + function NumberTest() { + const [value, setValue] = useUniqueDebounce({ + defaultValue: 0, + callback, + debounceMs: 200, + }) + + return ( +
+ + + +
{value}
+
+ ) + } + + cy.mount() + + cy.get('[data-testid="set-5"]').click() + cy.tick(200) + cy.get('@callback').should('have.been.calledWith', 5) + + cy.get('[data-testid="set-5-again"]').click() + cy.tick(200) + cy.get('@callback').should('have.been.calledOnce') // No additional call + + cy.get('[data-testid="set-10"]').click() + cy.tick(200) + cy.get('@callback').should('have.been.calledTwice') + cy.get('@callback').should('have.been.calledWith', 10) + }) +}) diff --git a/tests/cypress/e2e/main/basic.cy.ts b/tests/cypress/e2e/main/basic.cy.ts index 1ddc7c613b..01e233c90c 100644 --- a/tests/cypress/e2e/main/basic.cy.ts +++ b/tests/cypress/e2e/main/basic.cy.ts @@ -1,5 +1,5 @@ import { oneOf } from '@cy/support/generators' -import { LOAD_TIMEOUT } from '@cy/support/ui' +import { API_LOAD_TIMEOUT, LOAD_TIMEOUT } from '@cy/support/ui' describe('Basic Access Test', () => { const path = oneOf('/', '/dex') @@ -40,7 +40,7 @@ describe('Basic Access Test', () => { cy.visitWithoutTestConnector('dex/corn/pools') cy.title(LOAD_TIMEOUT).should('equal', 'Pools - Curve') cy.url().should('include', '/dex/corn/pools') - cy.contains(/LBTC\/wBTCN/i, LOAD_TIMEOUT).should('be.visible') + cy.contains(/LBTC\/wBTCN/i, API_LOAD_TIMEOUT).should('be.visible') }) it('shows 404 on /dex/:network/pools/404', () => {