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 188be055..145c5080 100644 --- a/src/components/AddXpubDialog/AddXpubDialog.tsx +++ b/src/components/AddXpubDialog/AddXpubDialog.tsx @@ -1,13 +1,3 @@ -import { HD } from '@bsv/sdk'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { CirclePlus } from 'lucide-react'; - -import React, { useEffect, useState } from 'react'; - -import { toast } from 'sonner'; - -import { useDebounce } from 'use-debounce'; - import { Button } from '@/components/ui'; import { Dialog, @@ -18,21 +8,57 @@ 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, CircleX } from 'lucide-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.'), +}); + +const xPrivSchema = formSchema.pick({ xPriv: true }); + export const AddXpubDialog = ({ className }: AddXpubDialogProps) => { - const [xPriv, setXPriv] = useState(''); - const [xPub, setXPub] = useState(''); - const [debouncedXPriv] = useDebounce(xPriv, 500); + const [isOpen, setIsOpen] = useState(false); const queryClient = useQueryClient(); + const xPrivRef = useRef(null); + const xPubRef = useRef(null); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + xPriv: '', + xPub: '', + }, + }); + const { spvWalletClient } = useSpvWalletClient(); const mutation = useMutation({ @@ -43,45 +69,86 @@ export const AddXpubDialog = ({ className }: AddXpubDialogProps) => { onSuccess: () => queryClient.invalidateQueries(), }); - const handleXPrivChange = (event: React.ChangeEvent) => { - setXPriv(event.target.value); + useEffect(() => { + form.reset(); + }, [isOpen]); + + 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(() => { - setXPub(''); - if (!xPriv) { + const debouncedXPriv = useDebouncedCallback(() => { + const parsedXPriv = xPrivSchema.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(debouncedXPriv); + 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(''); } - }, [debouncedXPriv]); + }, 700); - const onSubmit = async () => { - if (!xPub) { + const debouncedXPub = useDebouncedCallback(() => { + if (form.getValues('xPub')?.length === 0) { + form.clearErrors(); return; } + form.trigger('xPub'); + }, 700); - 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 handeDialogToggle = () => { + setIsOpen((prev) => !prev); + form.reset(); + }; + + 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(); } }; + + const onXPrivClear = () => { + form.resetField('xPriv'); + if (xPrivRef.current) { + xPrivRef.current?.focus(); + } + }; + return ( - + - - Add xPub - Register a new xPub here. - -
-
- - -
-
- - -
-
- - - +
+ + + Add xPub + Get xpub from xpriv + + ( + + xPriv + + + + + + + )} + name="xPriv" + /> + ( + + xPub + + onXPubChange(e)} + /> + + + + + )} + name="xPub" + /> + + + + +
); diff --git a/src/routes/admin/_admin.xpub.tsx b/src/routes/admin/_admin.xpub.tsx index 57146aff..a3707fc6 100644 --- a/src/routes/admin/_admin.xpub.tsx +++ b/src/routes/admin/_admin.xpub.tsx @@ -1,3 +1,11 @@ +import { useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, useSearch } from '@tanstack/react-router'; +import { useState } from 'react'; + +import { useDebounce } from 'use-debounce'; + +import { z } from 'zod'; + import { AddXpubDialog, CustomErrorComponent, @@ -12,14 +20,7 @@ import { } from '@/components'; import { useSpvWalletClient } from '@/contexts'; -import { addStatusField, getDeletedElements, xPubQueryOptions } from '@/utils'; -import { useSuspenseQuery } from '@tanstack/react-query'; -import { createFileRoute, useSearch } from '@tanstack/react-router'; -import { useState } from 'react'; - -import { useDebounce } from 'use-debounce'; - -import { z } from 'zod'; +import { addStatusField, xPubQueryOptions } from '@/utils'; export const Route = createFileRoute('/admin/_admin/xpub')({ validateSearch: z.object({ @@ -45,8 +46,6 @@ export function Xpub() { const mappedXpubs = addStatusField(xpubs); - const deletedXpubs = getDeletedElements(mappedXpubs); - // TODO: Add server pagination for xpubs when search and count will be merged return ( @@ -55,7 +54,6 @@ export function Xpub() {
All - Deleted
@@ -65,9 +63,6 @@ export function 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"