diff --git a/src/components/earn/MoveToJarDialog.schema.test.ts b/src/components/earn/MoveToJarDialog.schema.test.ts new file mode 100644 index 00000000..efa65a6a --- /dev/null +++ b/src/components/earn/MoveToJarDialog.schema.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { createMoveToJarFormSchema } from './MoveToJarDialog.schema' + +describe('createMoveToJarFormSchema', () => { + const schema = createMoveToJarFormSchema([0, 2]) + + it('accepts an available destination jar', async () => { + await expect(schema.validate({ destinationJarIndex: 2 })).resolves.toEqual({ destinationJarIndex: 2 }) + }) + + it.each([{ destinationJarIndex: 1 }, { destinationJarIndex: undefined }])( + 'rejects an unavailable destination jar', + async (values) => { + await expect(schema.isValid(values)).resolves.toBe(false) + }, + ) +}) diff --git a/src/components/earn/MoveToJarDialog.schema.ts b/src/components/earn/MoveToJarDialog.schema.ts new file mode 100644 index 00000000..a14aa72a --- /dev/null +++ b/src/components/earn/MoveToJarDialog.schema.ts @@ -0,0 +1,13 @@ +import * as yup from 'yup' +import type { JarIndex } from '@/types/global' + +export type MoveToJarFormValues = { + destinationJarIndex: JarIndex +} + +export const createMoveToJarFormSchema = (availableJarIndexes: JarIndex[]): yup.ObjectSchema => + yup + .object({ + destinationJarIndex: yup.number().oneOf(availableJarIndexes).required(), + }) + .required() diff --git a/src/components/earn/MoveToJarDialog.tsx b/src/components/earn/MoveToJarDialog.tsx index 7b7234c0..2175f706 100644 --- a/src/components/earn/MoveToJarDialog.tsx +++ b/src/components/earn/MoveToJarDialog.tsx @@ -1,8 +1,10 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { getaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { useQuery } from '@tanstack/react-query' import { AlertTriangleIcon, UnlockIcon } from 'lucide-react' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertTitle } from '@/components/ui/alert' @@ -11,7 +13,7 @@ import { useJamWalletInfoContext } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import type { WalletFileName } from '@/lib/utils' -import type { JarIndex } from '@/types/global' +import { createMoveToJarFormSchema, type MoveToJarFormValues } from './MoveToJarDialog.schema' import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout' import { AddressPreview, @@ -45,9 +47,25 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo const walletInfo = useJamWalletInfoContext() const [step, setStep] = useState('select_jar') - const [selectedJarIndex, setSelectedJarIndex] = useState() const [txResult, setTxResult] = useState() + const availableJarIndexes = useMemo(() => walletInfo.jars.map((jar) => jar.jarIndex), [walletInfo.jars]) + const formSchema = useMemo(() => createMoveToJarFormSchema(availableJarIndexes), [availableJarIndexes]) + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: { + destinationJarIndex: undefined, + }, + resolver: yupResolver(formSchema), + }) + const selectedJarIndex = useWatch({ control, name: 'destinationJarIndex' }) + const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({ walletFileName, utxo, @@ -77,7 +95,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo const handleReset = () => { setStep('select_jar') - setSelectedJarIndex(undefined) + reset() setTxResult(undefined) setError(undefined) } @@ -89,8 +107,8 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo onOpenChange(newOpen) } - const handleSubmit = async () => { - if (selectedJarIndex === undefined || !destinationAddress) return + const submitMove = handleSubmit(async () => { + if (!destinationAddress) return setStep('sending') const swept = await sweep(destinationAddress, (result) => { @@ -99,7 +117,7 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo toast.success(t('earn.fidelity_bond.move.success_text')) }) if (!swept) setStep('confirm') - } + }) const renderFooter = () => { switch (step) { @@ -117,9 +135,9 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo setStep('select_jar')} onCancel={() => handleOpenChange(false)} - onPrimary={() => void handleSubmit()} + onPrimary={() => void submitMove()} primaryDisabled={!destinationAddress} - isLoading={isLoading} + isLoading={isLoading || isSubmitting} primaryLabel={t('earn.fidelity_bond.move.text_button_submit')} /> ) @@ -150,7 +168,13 @@ export function MoveToJarDialog({ open, onOpenChange, walletFileName, utxo }: Mo + setValue('destinationJarIndex', jarIndex, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } isJarDisabled={() => false} /> diff --git a/src/components/earn/RenewBondDialog.schema.test.ts b/src/components/earn/RenewBondDialog.schema.test.ts new file mode 100644 index 00000000..2053bb0a --- /dev/null +++ b/src/components/earn/RenewBondDialog.schema.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest' +import { renewBondFormSchema } from './RenewBondDialog.schema' + +describe('renewBondFormSchema', () => { + it('accepts a lockdate after confirmation', async () => { + const values = { lockdate: '2030-01' as const, confirmationAccepted: true } + await expect(renewBondFormSchema.validate(values)).resolves.toEqual(values) + }) + + it.each([ + { lockdate: undefined, confirmationAccepted: true }, + { lockdate: '2030-01' as const, confirmationAccepted: false }, + ])('rejects incomplete renewal values', async (values) => { + await expect(renewBondFormSchema.isValid(values)).resolves.toBe(false) + }) +}) diff --git a/src/components/earn/RenewBondDialog.schema.ts b/src/components/earn/RenewBondDialog.schema.ts new file mode 100644 index 00000000..a36649f9 --- /dev/null +++ b/src/components/earn/RenewBondDialog.schema.ts @@ -0,0 +1,19 @@ +import * as yup from 'yup' +import * as fb from '@/lib/fidelityBondUtils' + +export type RenewBondFormValues = { + lockdate?: fb.Lockdate + confirmationAccepted: boolean +} + +export const RENEW_BOND_FORM_DEFAULT_VALUES: RenewBondFormValues = { + lockdate: undefined, + confirmationAccepted: false, +} + +export const renewBondFormSchema: yup.ObjectSchema = yup + .object({ + lockdate: yup.string().required(), + confirmationAccepted: yup.boolean().oneOf([true]).required(), + }) + .required() diff --git a/src/components/earn/RenewBondDialog.tsx b/src/components/earn/RenewBondDialog.tsx index db02845b..68819346 100644 --- a/src/components/earn/RenewBondDialog.tsx +++ b/src/components/earn/RenewBondDialog.tsx @@ -1,8 +1,10 @@ import { useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { gettimelockaddressOptions } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import type { DirectSendResponse } from '@joinmarket-webui/joinmarket-ng-api-ts/jm' import { useQuery } from '@tanstack/react-query' import { AlertTriangleIcon, CalendarIcon, RefreshCwIcon } from 'lucide-react' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -11,6 +13,7 @@ import { useApiClient } from '@/hooks/useApiClient' import type { FidelityBondUtxo } from '@/hooks/useQueryUtxos' import * as fb from '@/lib/fidelityBondUtils' import type { WalletFileName } from '@/lib/utils' +import { RENEW_BOND_FORM_DEFAULT_VALUES, renewBondFormSchema, type RenewBondFormValues } from './RenewBondDialog.schema' import { FidelityBondDialogLayout } from './fidelity-bond/FidelityBondDialogLayout' import { AddressPreview, @@ -44,10 +47,25 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re const client = useApiClient() const [step, setStep] = useState('select_date') - const [selectedLockdate, setSelectedLockdate] = useState('') - const [confirmationChecked, setConfirmationChecked] = useState(false) const [txResult, setTxResult] = useState() + const { + control, + handleSubmit, + reset, + setValue, + formState: { isSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: RENEW_BOND_FORM_DEFAULT_VALUES, + resolver: yupResolver(renewBondFormSchema), + }) + const selectedLockdate = useWatch({ control, name: 'lockdate' }) + const confirmationChecked = useWatch({ + control, + name: 'confirmationAccepted', + }) + const { sweep, isLoading, error, setError, sourceJar } = useFidelityBondSweep({ walletFileName, utxo, @@ -76,8 +94,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re const handleReset = () => { setStep('select_date') - setSelectedLockdate('') - setConfirmationChecked(false) + reset(RENEW_BOND_FORM_DEFAULT_VALUES) setTxResult(undefined) setError(undefined) } @@ -89,7 +106,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re onOpenChange(newOpen) } - const handleSubmit = async () => { + const submitRenewal = handleSubmit(async () => { if (!destinationAddress) return setStep('sending') @@ -99,7 +116,7 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re toast.success(t('earn.fidelity_bond.renew.success_text')) }) if (!swept) setStep('confirm') - } + }) const renderFooter = () => { switch (step) { @@ -117,9 +134,9 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re setStep('select_date')} onCancel={() => handleOpenChange(false)} - onPrimary={() => void handleSubmit()} + onPrimary={() => void submitRenewal()} primaryDisabled={!confirmationChecked || !destinationAddress} - isLoading={isLoading} + isLoading={isLoading || isSubmitting} primaryLabel={t('earn.fidelity_bond.renew.text_button_submit')} /> ) @@ -148,7 +165,17 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re - + + setValue('lockdate', lockdate || undefined, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + /> )} @@ -193,7 +220,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re + setValue('confirmationAccepted', checked, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } /> )} diff --git a/src/components/send/UtxoSelectionDialog.schema.test.ts b/src/components/send/UtxoSelectionDialog.schema.test.ts new file mode 100644 index 00000000..e8b680ce --- /dev/null +++ b/src/components/send/UtxoSelectionDialog.schema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { createUtxoSelectionFormSchema } from './UtxoSelectionDialog.schema' + +describe('createUtxoSelectionFormSchema', () => { + const schema = createUtxoSelectionFormSchema(['tx-a:0', 'tx-b:1']) + + it('accepts selectable UTXO rows and a filter', async () => { + const values = { + filter: 'confirmed', + rowSelection: { 'tx-a:0': true, 'tx-b:1': false }, + } + await expect(schema.validate(values)).resolves.toEqual(values) + }) + + it('allows clearing the selection', async () => { + await expect(schema.isValid({ filter: '', rowSelection: {} })).resolves.toBe(true) + }) + + it('rejects selected rows that are not available', async () => { + await expect(schema.isValid({ filter: '', rowSelection: { 'unknown:0': true } })).resolves.toBe(false) + }) +}) diff --git a/src/components/send/UtxoSelectionDialog.schema.ts b/src/components/send/UtxoSelectionDialog.schema.ts new file mode 100644 index 00000000..6ed25f30 --- /dev/null +++ b/src/components/send/UtxoSelectionDialog.schema.ts @@ -0,0 +1,32 @@ +import type { RowSelectionState } from '@tanstack/react-table' +import * as yup from 'yup' + +export type UtxoSelectionFormValues = { + filter: string + rowSelection: RowSelectionState +} + +export const UTXO_SELECTION_FORM_DEFAULT_VALUES: UtxoSelectionFormValues = { + filter: '', + rowSelection: {}, +} + +export const createUtxoSelectionFormSchema = ( + selectableUtxoIds: string[], +): yup.ObjectSchema => { + const selectableUtxoIdSet = new Set(selectableUtxoIds) + + return yup + .object({ + filter: yup.string().defined(), + rowSelection: yup + .mixed() + .defined() + .test('selectable-utxos', (value) => + Object.entries(value).every( + ([utxoId, selected]) => typeof selected === 'boolean' && (!selected || selectableUtxoIdSet.has(utxoId)), + ), + ), + }) + .required() +} diff --git a/src/components/send/UtxoSelectionDialog.tsx b/src/components/send/UtxoSelectionDialog.tsx index 4b3e2684..b28f1027 100644 --- a/src/components/send/UtxoSelectionDialog.tsx +++ b/src/components/send/UtxoSelectionDialog.tsx @@ -37,40 +37,49 @@ export const UtxoSelectionDialog = ({ return ( - - {t('show_utxos.title')} - - {t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')} - - +
{ + event.preventDefault() + void onSubmit() + }} + noValidate + > + + {t('show_utxos.title')} + + {t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')} + + - onFilterChange(event.target.value)} - placeholder={t('jar_details.utxo_list.placeholder_search')} - disabled={isSubmitting} - /> -
- onFilterChange(event.target.value)} + placeholder={t('jar_details.utxo_list.placeholder_search')} + disabled={isSubmitting} /> -
+
+ +
- - - - + + + + +
) diff --git a/src/components/settings/SeedPhraseDialog.tsx b/src/components/settings/SeedPhraseDialog.tsx index d4e91e92..146ade70 100644 --- a/src/components/settings/SeedPhraseDialog.tsx +++ b/src/components/settings/SeedPhraseDialog.tsx @@ -33,7 +33,6 @@ type SeedPhraseDialogProps = WithRequiredProperty< autoCloseTimeout: Milliseconds } -// TODO: use react-hook-form and yup schema export const SeedPhraseDialog = ({ open, onOpenChange, diff --git a/src/hooks/useUtxoSelectionDialog.ts b/src/hooks/useUtxoSelectionDialog.ts index 369837c3..1682b289 100644 --- a/src/hooks/useUtxoSelectionDialog.ts +++ b/src/hooks/useUtxoSelectionDialog.ts @@ -1,10 +1,17 @@ import { useMemo, useState } from 'react' +import { yupResolver } from '@hookform/resolvers/yup' import { freezeMutation } from '@joinmarket-webui/joinmarket-ng-api-ts/@tanstack/react-query' import { useMutation } from '@tanstack/react-query' import type { RowSelectionState } from '@tanstack/react-table' +import { useForm, useWatch } from 'react-hook-form' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import type { UtxoSelectionDialogProps } from '@/components/send/UtxoSelectionDialog' +import { + createUtxoSelectionFormSchema, + UTXO_SELECTION_FORM_DEFAULT_VALUES, + type UtxoSelectionFormValues, +} from '@/components/send/UtxoSelectionDialog.schema' import { useJamWalletInfoContext, type AddressSummary, type Jar } from '@/context/JamWalletInfoContext' import { useApiClient } from '@/hooks/useApiClient' import type { Utxo } from '@/hooks/useQueryUtxos' @@ -26,8 +33,6 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma const { refetch: walletInfoRefetch } = useJamWalletInfoContext() const [open, setOpen] = useState(false) - const [filter, setFilter] = useState('') - const [rowSelection, setRowSelection] = useState({}) const tableEntries = useMemo(() => { return (sourceJar?.utxos || []).map((utxo) => ({ @@ -45,6 +50,26 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma }, {} as RowSelectionState) }, [sourceJar?.utxos]) + const selectableUtxoIds = useMemo( + () => (sourceJar?.utxos || []).filter((utxo) => !fb.utxo.isFidelityBond(utxo)).map((utxo) => utxo.utxo), + [sourceJar?.utxos], + ) + const formSchema = useMemo(() => createUtxoSelectionFormSchema(selectableUtxoIds), [selectableUtxoIds]) + const { + control, + getValues, + handleSubmit, + reset, + setValue, + formState: { isSubmitting: isFormSubmitting }, + } = useForm({ + mode: 'onChange', + defaultValues: UTXO_SELECTION_FORM_DEFAULT_VALUES, + resolver: yupResolver(formSchema), + }) + const filter = useWatch({ control, name: 'filter', defaultValue: '' }) + const rowSelection = useWatch({ control, name: 'rowSelection', defaultValue: {} }) + const selectedUtxos = useMemo(() => { return (sourceJar?.utxos || []).filter((utxo) => rowSelection[utxo.utxo] === true) }, [sourceJar?.utxos, rowSelection]) @@ -56,7 +81,7 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma retry: false, }) - const { mutateAsync: applyUtxoSelectionMutateAsync, isPending: isSubmitting } = useMutation({ + const { mutateAsync: applyUtxoSelectionMutateAsync, isPending: isMutationPending } = useMutation({ mutationFn: async ({ utxosToFreeze, utxosToUnfreeze }: { utxosToFreeze: Utxo[]; utxosToUnfreeze: Utxo[] }) => { const [freezeResult, unfreezeResult] = await Promise.all([ Promise.allSettled( @@ -94,25 +119,28 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma const onOpenUtxoSelector = () => { if (!sourceJar) return toast.dismiss(SEND_AUTO_SELECTION_TOAST_ID) - setFilter('') - setRowSelection(initialRowSelection) + reset({ + filter: '', + rowSelection: initialRowSelection, + }) setOpen(true) } - const onSubmit = async () => { + const onSubmit = handleSubmit(async ({ rowSelection: submittedRowSelection }) => { if (!sourceJar) return - const selectedUtxoIds = new Set(selectedUtxos.map((it) => it.utxo)) - const selectedAddresses = new Set(selectedUtxos.map((it) => it.address)) + const submittedUtxos = sourceJar.utxos.filter((utxo) => submittedRowSelection[utxo.utxo] === true) + const selectedUtxoIds = new Set(submittedUtxos.map((it) => it.utxo)) + const selectedAddresses = new Set(submittedUtxos.map((it) => it.address)) const mutableUtxos = sourceJar.utxos.filter((it) => !fb.utxo.isFidelityBond(it)) const groupedSelectedUtxos = mutableUtxos.filter((it) => selectedAddresses.has(it.address)) const groupedDeselectedUtxos = mutableUtxos.filter((it) => !selectedAddresses.has(it.address)) const userDeselectedUtxos = mutableUtxos.filter((it) => !selectedUtxoIds.has(it.utxo)) - if (groupedSelectedUtxos.length > selectedUtxos.length) { + if (groupedSelectedUtxos.length > submittedUtxos.length) { toast.warning(t('jar_details.utxo_list.toast_auto_selection_title'), { description: t('jar_details.utxo_list.toast_auto_selected_matching', { - count: groupedSelectedUtxos.length - selectedUtxos.length, + count: groupedSelectedUtxos.length - submittedUtxos.length, }), id: SEND_AUTO_SELECTION_TOAST_ID, }) @@ -178,7 +206,9 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma toast.error(t('jar_details.utxo_list.toast_unfreeze_error', { count: utxosToUnfreeze.length })) } } - } + }) + + const isSubmitting = isFormSubmitting || isMutationPending const dialogProps: UtxoSelectionDialogProps = { open, @@ -190,11 +220,26 @@ export const useUtxoSelectionDialog = ({ walletFileName, sourceJar, addressSumma enableRowSelection: isSubmitting ? false : undefined, onOpenChange: (nextOpen: boolean) => { if (isSubmitting) return + if (!nextOpen) { + reset(UTXO_SELECTION_FORM_DEFAULT_VALUES) + } setOpen(nextOpen) }, - onFilterChange: setFilter, - onRowSelectionChange: setRowSelection, - onSubmit, + onFilterChange: (nextFilter) => + setValue('filter', nextFilter, { + shouldDirty: true, + shouldValidate: true, + }), + onRowSelectionChange: (updater) => { + const currentSelection = getValues('rowSelection') + const nextSelection = typeof updater === 'function' ? updater(currentSelection) : updater + setValue('rowSelection', nextSelection, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + }, + onSubmit: async () => onSubmit(), } return {