diff --git a/package-lock.json b/package-lock.json index b1d531962..5c90370c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 8ff8f6845..3df8ca7e0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/settings/RescanChain.tsx b/src/components/settings/RescanChain.tsx index 8122c49f9..649891cf5 100644 --- a/src/components/settings/RescanChain.tsx +++ b/src/components/settings/RescanChain.tsx @@ -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 + disabled?: boolean +} + +function RescanChainForm({ rescanInfo, onSubmit, disabled }: RescanChainFormProps) { + const { t } = useTranslation() + const { + register, + handleSubmit, + formState: { errors, isSubmitting, isValid }, + } = useForm({ + mode: 'all', + defaultValues: { + blockHeight: SEGWIT_ACTIVATION_BLOCK, + }, + resolver: yupResolver(schema), + }) + + return ( + /* "handleSubmit" will validate your inputs before invoking "onSubmit" */ +
+ {/* include validation with required or other standard HTML validation rules */} +
+ +

{t('rescan_chain.description_blockheight')}

+
+
+ +
+ + +
+ {errors.blockHeight && ( +
+ {t('rescan_chain.feedback_invalid_blockheight', { min: INPUT_BLOCK_HEIGHT_MIN })} +
+ )} +
+ +
+ ) +} + 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(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 = async (data) => { + return handleRescan(data.blockHeight) + } + return (
@@ -88,55 +171,12 @@ export const RescanChain = ({ walletFileName }: RescanChainProps) => {

{t('rescan_chain.subtitle')}

- -
-
- -

{t('rescan_chain.description_blockheight')}

-
-
- -
- setRescanHeight(parseInt(e.target.value))} - className="bg-background pl-10" - placeholder="Enter block height" - disabled={rescanInfo.rescanning} - /> -
-
- - - - {rescanInfo.rescanning && ( -
-
- - - {rescanInfo?.progress === undefined - ? t('app.alert_rescan_in_progress') - : t('app.alert_rescan_in_progress_with_progress', { - progress: rescanInfo.progress, - })} - -
-
- )} -
+ +
diff --git a/src/hooks/useRescanStatus.ts b/src/hooks/useRescanStatus.ts index ae9fcb56b..cd40fba67 100644 --- a/src/hooks/useRescanStatus.ts +++ b/src/hooks/useRescanStatus.ts @@ -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