From 0db38ce08a6adafe680cb88eb23b9b6079789a6c Mon Sep 17 00:00:00 2001 From: Nazarii-4chain Date: Tue, 27 Aug 2024 16:24:02 +0300 Subject: [PATCH] feat(SPV-971): Added validation for xpriv and xpub fields --- package.json | 2 +- .../AddXpubDialog/AddXpubDialog.tsx | 195 +++++++++++++----- yarn.lock | 8 +- 3 files changed, 144 insertions(+), 61 deletions(-) diff --git a/package.json b/package.json index e466b4e5..404904c3 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "sonner": "^1.4.41", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", - "use-debounce": "^10.0.1", + "use-debounce": "^10.0.3", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/components/AddXpubDialog/AddXpubDialog.tsx b/src/components/AddXpubDialog/AddXpubDialog.tsx index 781e0c5c..f1ec097e 100644 --- a/src/components/AddXpubDialog/AddXpubDialog.tsx +++ b/src/components/AddXpubDialog/AddXpubDialog.tsx @@ -8,28 +8,55 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog.tsx'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input.tsx'; -import { Label } from '@/components/ui/label.tsx'; import { useSpvWalletClient } from '@/contexts'; import { errorWrapper } from '@/utils'; import { HD } from '@bsv/sdk'; +import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CirclePlus } from 'lucide-react'; +import { CirclePlus, CircleX } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; +import { useDebouncedCallback } from 'use-debounce'; +import { z } from 'zod'; interface AddXpubDialogProps { className?: string; } +const formSchema = z.object({ + xPriv: z.union([ + z + .string() + .refine((val) => val.startsWith('xprv'), 'xPriv should start with xprv') + .refine((val) => val.length === 111, 'Invalid xPriv length.'), + z.literal(''), + ]), + xPub: z + .string() + .refine((val) => val.startsWith('xpub'), 'xPub should starts with xpub.') + .refine((val) => val.length === 111, 'Invalid xPub length.'), +}); + export const AddXpubDialog = ({ className }: AddXpubDialogProps) => { const [isOpen, setIsOpen] = useState(false); - const [xPriv, setXPriv] = useState(''); - const [xPub, setXPub] = useState(''); const queryClient = useQueryClient(); + const xPrivRef = useRef(null); + const xPubRef = useRef(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + xPriv: undefined, + xPub: undefined, + }, + }); + const { spvWalletClient } = useSpvWalletClient(); const mutation = useMutation({ @@ -40,53 +67,81 @@ export const AddXpubDialog = ({ className }: AddXpubDialogProps) => { onSuccess: () => queryClient.invalidateQueries(), }); - const handleXPrivChange = (event: React.ChangeEvent) => { - setXPriv(event.target.value); - }; + useEffect(() => { + form.reset(); + }, [isOpen]); - const handleXPubChange = (event: React.ChangeEvent) => { - setXPub(event.target.value); + const onSubmit = async (values: z.infer) => { + const { xPub } = values; + try { + HD.fromString(values.xPub); + await mutation.mutateAsync(xPub); + toast.success('xPub successfully added'); + form.reset(); + } catch (error) { + toast.error('Unable to add xPub'); + errorWrapper(error); + } }; - useEffect(() => { - if (!xPriv) { + const debouncedXPriv = useDebouncedCallback(() => { + const parsedXPriv = formSchema.safeParse({ xPriv: form.getValues('xPriv') }); + + if (form.getValues('xPriv').length === 0) { + form.clearErrors(); + return; + } + form.trigger('xPriv'); + + if (!parsedXPriv.success) { return; } try { - const xPrivHD = HD.fromString(xPriv); + const xPrivHD = HD.fromString(parsedXPriv.data.xPriv); const xPubString = xPrivHD.toPublic().toString(); - setXPub(xPubString); + form.setValue('xPub', xPubString); toast.success('Converted xPriv to xPub'); } catch (error) { toast.error('Unable to convert xPriv to xPub'); - setXPub(''); } - }, [xPriv]); + }, 700); - useEffect(() => { - setXPriv(''); - }, [xPub]); + const debouncedXPub = useDebouncedCallback(() => { + if (form.getValues('xPub')?.length === 0) { + form.clearErrors(); + return; + } + form.trigger('xPub'); + }, 700); const handeDialogToggle = () => { setIsOpen((prev) => !prev); - setXPriv(''); - setXPub(''); + form.reset(); }; - const onSubmit = async () => { - if (!xPub) { - return; + const onXPrivChange = (e: React.ChangeEvent) => { + form.setValue('xPriv', e.target.value); + debouncedXPriv(); + }; + + const onXPubChange = (e: React.ChangeEvent) => { + form.setValue('xPub', e.target.value); + form.resetField('xPriv'); + debouncedXPub(); + }; + + const onXPubClear = () => { + form.resetField('xPub'); + if (xPubRef.current) { + xPubRef.current?.focus(); } - try { - HD.fromString(xPub); - await mutation.mutateAsync(xPub); - toast.success('Added xPub'); - setXPriv(''); - setXPub(''); - } catch (error) { - toast.error('Unable to add xPub'); - errorWrapper(error); + }; + + const onXPrivClear = () => { + form.resetField('xPriv'); + if (xPrivRef.current) { + xPrivRef.current?.focus(); } }; @@ -99,29 +154,57 @@ export const AddXpubDialog = ({ className }: AddXpubDialogProps) => { - - Add xPub - Get xpub from xpriv - -
-
- - -
-
Or put xpub directly
- -
- - -
-
- - - +
+ + + Add xPub + Get xpub from xpriv + + ( + + xPriv + + + + + + + )} + name="xPriv" + /> + ( + + xPub + + onXPubChange(e)} + /> + + + + + )} + name="xPub" + /> + + + + +
); diff --git a/yarn.lock b/yarn.lock index fd26d810..bf012677 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4948,10 +4948,10 @@ use-callback-ref@^1.3.0: dependencies: tslib "^2.0.0" -use-debounce@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.1.tgz#adc42e7f7b08a237f5bbf0ea6cadee5813863cdc" - integrity sha512-0uUXjOfm44e6z4LZ/woZvkM8FwV1wiuoB6xnrrOmeAEjRDDzTLQNRFtYHvqUsJdrz1X37j0rVGIVp144GLHGKg== +use-debounce@^10.0.3: + version "10.0.3" + resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.0.3.tgz#636094a37f7aa2bcc77b26b961481a0b571bf7ea" + integrity sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg== use-sidecar@^1.1.2: version "1.1.2"