chore: add react-hook-form (#1000)

* build(deps): add react-hook-form

* chore(rescan): use react-hook-form on rescan page

* build(deps): update react-hook-form to v7.70.0

* chore(rescan): use yup for form schema validation
This commit is contained in:
Thebora Kompanioni 2026-01-05 11:55:34 +01:00 committed by GitHub
parent 49fcba4dea
commit fe4b1087f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 182 additions and 59 deletions

80
package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@hookform/resolvers": "5.2.2",
"@joinmarket-webui/joinmarket-api-ts": "0.3.0",
"@noble/hashes": "2.0.1",
"@radix-ui/react-accordion": "1.2.12",
@ -30,12 +31,14 @@
"qrcode": "1.5.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "7.70.0",
"react-i18next": "16.3.5",
"react-router-dom": "7.9.6",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"yup": "1.7.1",
"zustand": "5.0.9"
},
"devDependencies": {
@ -1041,6 +1044,18 @@
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@hookform/resolvers": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.55.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2601,6 +2616,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@storybook/addon-a11y": {
"version": "10.1.11",
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-10.1.11.tgz",
@ -7307,6 +7328,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -7389,6 +7416,23 @@
"react": "^19.2.0"
}
},
"node_modules/react-hook-form": {
"version": "7.70.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
@ -8171,6 +8215,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@ -8271,6 +8321,12 @@
"node": ">=8.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
@ -8347,6 +8403,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@ -9222,6 +9290,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",

View file

@ -44,6 +44,7 @@
"*.{json,css,md}": "prettier --write"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
"@joinmarket-webui/joinmarket-api-ts": "0.3.0",
"@noble/hashes": "2.0.1",
"@radix-ui/react-accordion": "1.2.12",
@ -65,12 +66,14 @@
"qrcode": "1.5.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-hook-form": "7.70.0",
"react-i18next": "16.3.5",
"react-router-dom": "7.9.6",
"sonner": "2.0.7",
"tailwind-merge": "3.4.0",
"tailwindcss": "4.1.18",
"tw-animate-css": "1.4.0",
"yup": "1.7.1",
"zustand": "5.0.9"
},
"devDependencies": {
@ -88,6 +91,8 @@
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "5.1.1",
"@vitest/browser": "4.0.9",
"@vitest/browser-playwright": "4.0.9",
"@vitest/coverage-v8": "4.0.9",
"conventional-changelog": "7.1.1",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "7.0.1",
@ -102,9 +107,7 @@
"typescript": "~5.9.3",
"typescript-eslint": "8.51.0",
"vite": "7.2.4",
"vitest": "4.0.9",
"@vitest/browser-playwright": "4.0.9",
"@vitest/coverage-v8": "4.0.9"
"vitest": "4.0.9"
},
"overrides": {
"storybook": "$storybook",

View file

@ -1,10 +1,13 @@
import { useState } from 'react'
import { yupResolver } from '@hookform/resolvers/yup'
import { rescanblockchain } from '@joinmarket-webui/joinmarket-api-ts/jm'
import { useMutation } from '@tanstack/react-query'
import { ArrowLeft, RefreshCw } from 'lucide-react'
import { useForm } from 'react-hook-form'
import type { SubmitHandler } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import * as yup from 'yup'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@ -12,9 +15,87 @@ import { Label } from '@/components/ui/label'
import { routes } from '@/constants/routes'
import { useApiClient } from '@/hooks/useApiClient'
import { useRescanStatus } from '@/hooks/useRescanStatus'
import type { RescanInfo } from '@/hooks/useRescanStatus'
import { SEGWIT_ACTIVATION_BLOCK } from '@/lib/utils'
import type { WalletFileName } from '@/lib/utils'
type Inputs = {
blockHeight: number
}
const INPUT_BLOCK_HEIGHT_MIN = 0
const schema = yup
.object({
blockHeight: yup.number().integer().default(SEGWIT_ACTIVATION_BLOCK).min(INPUT_BLOCK_HEIGHT_MIN).required(),
})
.required()
interface RescanChainFormProps {
rescanInfo: RescanInfo
onSubmit: SubmitHandler<Inputs>
disabled?: boolean
}
function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProps) {
const { t } = useTranslation()
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
} = useForm<Inputs>({
mode: 'all',
defaultValues: {
blockHeight: SEGWIT_ACTIVATION_BLOCK,
},
resolver: yupResolver(schema),
})
return (
/* "handleSubmit" will validate your inputs before invoking "onSubmit" */
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* include validation with required or other standard HTML validation rules */}
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('rescan_chain.label_blockheight')}
</Label>
<p className="text-muted-foreground text-xs">{t('rescan_chain.description_blockheight')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">
<RefreshCw className="text-muted-foreground h-4 w-4" />
</div>
<Input
{...register('blockHeight', {
disabled: rescanInfo.rescanning,
})}
type="number"
step={1}
className="bg-background pl-10"
/* TODO: i18n */
placeholder="Enter block height"
/>
</div>
{errors.blockHeight && (
<div className="text-muted-foreground light:text-red-700 text-xs text-red-500">
<span>{t('rescan_chain.feedback_invalid_blockheight', { min: INPUT_BLOCK_HEIGHT_MIN })}</span>
</div>
)}
</div>
<Button
type="submit"
disabled={disabled || !isValid || isSubmitting || rescanInfo.rescanning}
className="w-full"
size="lg"
>
{isSubmitting || rescanInfo.rescanning
? t('rescan_chain.text_button_submitting')
: t('rescan_chain.text_button_submit')}
</Button>
</form>
)
}
interface RescanChainProps {
walletFileName: WalletFileName
}
@ -24,7 +105,6 @@ export const RescanChain = ({ walletFileName }: RescanChainProps) => {
const navigate = useNavigate()
const client = useApiClient()
const { rescanInfo, setRescanInfo } = useRescanStatus({ walletFileName })
const [rescanHeight, setRescanHeight] = useState<number>(SEGWIT_ACTIVATION_BLOCK)
const rescanMutation = useMutation({
mutationFn: async (blockHeight: number) => {
@ -61,16 +141,19 @@ export const RescanChain = ({ walletFileName }: RescanChainProps) => {
},
})
const handleRescan = async () => {
const blockHeight = rescanHeight
if (isNaN(blockHeight) || blockHeight < 0) {
toast.error(t('rescan_chain.feedback_invalid_blockheight', { min: 0 }))
const handleRescan = async (blockHeight: number) => {
if (isNaN(blockHeight) || blockHeight < INPUT_BLOCK_HEIGHT_MIN) {
toast.error(t('rescan_chain.feedback_invalid_blockheight', { min: INPUT_BLOCK_HEIGHT_MIN }))
return
}
await rescanMutation.mutateAsync(blockHeight)
}
const onSubmit: SubmitHandler<Inputs> = async (data) => {
return handleRescan(data.blockHeight)
}
return (
<div className="mx-auto max-w-2xl space-y-3 p-4">
<div className="flex items-center gap-3">
@ -88,55 +171,12 @@ export const RescanChain = ({ walletFileName }: RescanChainProps) => {
<p className="text-muted-foreground ml-11 text-sm">{t('rescan_chain.subtitle')}</p>
<Card className="ml-11 border-0 shadow-sm">
<CardContent className="p-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="rescanHeight" className="text-sm font-medium">
{t('rescan_chain.label_blockheight')}
</Label>
<p className="text-muted-foreground text-xs">{t('rescan_chain.description_blockheight')}</p>
<div className="relative">
<div className="absolute top-1/2 left-3 -translate-y-1/2">
<RefreshCw className="text-muted-foreground h-4 w-4" />
</div>
<Input
id="rescanHeight"
type="number"
value={rescanHeight}
onChange={(e) => setRescanHeight(parseInt(e.target.value))}
className="bg-background pl-10"
placeholder="Enter block height"
disabled={rescanInfo.rescanning}
/>
</div>
</div>
<Button
onClick={handleRescan}
disabled={!rescanHeight || rescanInfo.rescanning || rescanMutation.isPending}
className="w-full"
size="lg"
>
{rescanMutation.isPending || rescanInfo.rescanning
? t('rescan_chain.text_button_submitting')
: t('rescan_chain.text_button_submit')}
</Button>
{rescanInfo.rescanning && (
<div className="bg-muted/50 mt-4 rounded-lg p-3">
<div className="flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin motion-reduce:hidden" />
<span className="text-sm">
{rescanInfo?.progress === undefined
? t('app.alert_rescan_in_progress')
: t('app.alert_rescan_in_progress_with_progress', {
progress: rescanInfo.progress,
})}
</span>
</div>
</div>
)}
</div>
<CardContent className="px-6">
<RescanChainForm
rescanInfo={rescanInfo}
onSubmit={onSubmit}
disabled={rescanInfo.rescanning || rescanMutation.isPending}
/>
</CardContent>
</Card>
</div>

View file

@ -7,7 +7,7 @@ import { withQueryDelay } from '@/lib/queryClient'
import { jmSessionStore } from '@/store/jmSessionStore'
import { useApiClient } from './useApiClient'
interface RescanInfo {
export interface RescanInfo {
updatedAt: number
rescanning: boolean
progress?: number