refactor: standardize remaining dialog forms (#1358)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run

* refactor(earn): standardize move-to-jar form

* refactor(earn): standardize bond renewal form

* refactor(send): standardize utxo selection form

* chore(settings): remove stale form migration todo

* refactor(earn): rely on form confirmation default
This commit is contained in:
Parth 2026-07-28 13:27:08 +05:30 committed by GitHub
parent 01a952ff63
commit 0e24250e3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 295 additions and 66 deletions

View file

@ -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)
},
)
})

View file

@ -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<MoveToJarFormValues> =>
yup
.object({
destinationJarIndex: yup.number().oneOf(availableJarIndexes).required(),
})
.required()

View file

@ -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<Step>('select_jar')
const [selectedJarIndex, setSelectedJarIndex] = useState<JarIndex | undefined>()
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
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<MoveToJarFormValues>({
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
<WizardStepFooter
onBack={() => 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
<FidelityBondJarSelector
selectedJarIndex={selectedJarIndex}
onSelect={setSelectedJarIndex}
onSelect={(jarIndex) =>
setValue('destinationJarIndex', jarIndex, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
isJarDisabled={() => false}
/>
</div>

View file

@ -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)
})
})

View file

@ -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<RenewBondFormValues> = yup
.object({
lockdate: yup.string<fb.Lockdate>().required(),
confirmationAccepted: yup.boolean().oneOf([true]).required(),
})
.required()

View file

@ -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<Step>('select_date')
const [selectedLockdate, setSelectedLockdate] = useState<fb.Lockdate | ''>('')
const [confirmationChecked, setConfirmationChecked] = useState(false)
const [txResult, setTxResult] = useState<DirectSendResponse | undefined>()
const {
control,
handleSubmit,
reset,
setValue,
formState: { isSubmitting },
} = useForm<RenewBondFormValues>({
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
<WizardStepFooter
onBack={() => 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
<FidelityBondAmount value={utxo.value} className="text-lg" />
</InfoCard>
<LockdateSelect id="renew-lockdate" value={selectedLockdate} onChange={setSelectedLockdate} />
<LockdateSelect
id="renew-lockdate"
value={selectedLockdate ?? ''}
onChange={(lockdate) =>
setValue('lockdate', lockdate || undefined, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}
@ -193,7 +220,13 @@ export function RenewBondDialog({ open, onOpenChange, walletFileName, utxo }: Re
<ConfirmationToggle
id="renew-confirmation"
checked={confirmationChecked}
onCheckedChange={setConfirmationChecked}
onCheckedChange={(checked) =>
setValue('confirmationAccepted', checked, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
})
}
/>
</div>
)}

View file

@ -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)
})
})

View file

@ -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<UtxoSelectionFormValues> => {
const selectableUtxoIdSet = new Set(selectableUtxoIds)
return yup
.object({
filter: yup.string().defined(),
rowSelection: yup
.mixed<RowSelectionState>()
.defined()
.test('selectable-utxos', (value) =>
Object.entries(value).every(
([utxoId, selected]) => typeof selected === 'boolean' && (!selected || selectableUtxoIdSet.has(utxoId)),
),
),
})
.required()
}

View file

@ -37,40 +37,49 @@ export const UtxoSelectionDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-w-6xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('show_utxos.title')}</DialogTitle>
<DialogDescription>
{t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')}
</DialogDescription>
</DialogHeader>
<form
className="contents"
onSubmit={(event) => {
event.preventDefault()
void onSubmit()
}}
noValidate
>
<DialogHeader>
<DialogTitle>{t('show_utxos.title')}</DialogTitle>
<DialogDescription>
{t('show_utxos.subtitle', { count: selectedCount })} {t('show_utxos.text_subtitle_addon')}
</DialogDescription>
</DialogHeader>
<Input
className="shrink-0"
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('jar_details.utxo_list.placeholder_search')}
disabled={isSubmitting}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<JarUtxosTable
globalFilter={filter}
tableEntries={tableEntries}
pinnedEntries={[]}
initialRowSelection={initialRowSelection}
onRowSelectionChange={onRowSelectionChange}
enableRowSelection={enableRowSelection}
<Input
className="shrink-0"
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('jar_details.utxo_list.placeholder_search')}
disabled={isSubmitting}
/>
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<JarUtxosTable
globalFilter={filter}
tableEntries={tableEntries}
pinnedEntries={[]}
initialRowSelection={initialRowSelection}
onRowSelectionChange={onRowSelectionChange}
enableRowSelection={enableRowSelection}
/>
</div>
<DialogFooter className="shrink-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('modal.confirm_button_reject')}
</Button>
<Button type="button" onClick={() => void onSubmit()} disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : undefined}
{t('modal.confirm_button_accept')}
</Button>
</DialogFooter>
<DialogFooter className="shrink-0">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
{t('modal.confirm_button_reject')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? <Spinner /> : undefined}
{t('modal.confirm_button_accept')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)

View file

@ -33,7 +33,6 @@ type SeedPhraseDialogProps = WithRequiredProperty<
autoCloseTimeout: Milliseconds
}
// TODO: use react-hook-form and yup schema
export const SeedPhraseDialog = ({
open,
onOpenChange,

View file

@ -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<RowSelectionState>({})
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<UtxoSelectionFormValues>({
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 {