diff --git a/package.json b/package.json index d90d5d6..7ee8f04 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^3.3.4", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", "@tanstack/react-query": "5.24.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6772096..3eb28f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.2.60)(react@18.2.0) + '@radix-ui/react-switch': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.60)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-query': specifier: 5.24.1 version: 5.24.1(react@18.2.0) @@ -727,6 +730,12 @@ packages: requiresBuild: true optional: true + /@radix-ui/primitive@1.0.1: + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + dependencies: + '@babel/runtime': 7.23.9 + dev: false + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.60)(react@18.2.0): resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} peerDependencies: @@ -741,6 +750,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-context@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + /@radix-ui/react-label@2.0.2(@types/react-dom@18.2.19)(@types/react@18.2.60)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} peerDependencies: @@ -798,6 +821,105 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.60)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.60)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@types/react': 18.2.60 + '@types/react-dom': 18.2.19 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + + /@radix-ui/react-use-size@1.0.1(@types/react@18.2.60)(react@18.2.0): + resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.23.9 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.60)(react@18.2.0) + '@types/react': 18.2.60 + react: 18.2.0 + dev: false + /@rollup/rollup-android-arm-eabi@4.12.0: resolution: {integrity: sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==} cpu: [arm] diff --git a/src/App.tsx b/src/App.tsx index d76fc03..84944bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,14 +9,16 @@ import { Textarea } from '@/components/ui/textarea'; import { toast } from 'sonner'; import dictionary from '@/dictionary/en.json'; import { TokenDetails } from '@/types/tokenDetails-response'; -import { HoldersForm } from '@/components/HoldersForm'; +import { FormData, HoldersForm } from '@/components/HoldersForm'; +import { Switch } from '@/components/ui/switch'; const App = () => { const [tokenDetails, setTokenDetails] = useState(); - const [tokenId, setTokenId] = useState(''); - const [minAmount, setMinAmount] = useState(null); + const [formData, setFormData] = useState([]); const [data, setData] = useState([]); - const [shouldFetch, setShouldFetch] = useState(false); + const [responses, setResponses] = useState([]); + const [shouldFetch, setShouldFetch] = useState(false); + const [isAllConditionsRequired, setIsAllConditionsRequired] = useState(true); const copyToClipboard = async (textToCopy: string) => { if (navigator.clipboard && window.isSecureContext) { @@ -43,7 +45,7 @@ const App = () => { } }; - const createFetchUrl = () => { + const createFetchUrl = (tokenId: string, minAmount: string) => { if (Boolean(tokenDetails?.type === 'FUNGIBLE_COMMON')) { // Move digits to the right to match the token's decimals const amount = Number(minAmount) * Math.pow(10, Number(tokenDetails?.decimals)); @@ -53,22 +55,48 @@ const App = () => { return `${nodeUrl}/api/v1/tokens/${tokenId}/balances?account.balance=gte:${minAmount}&limit=100`; }; - const fetchData = async (url: string) => { - try { - const response = await fetch(url); + const filterData = (responses: Balance[][], isAllConditionsRequired: boolean): Balance[] => { + let data = responses.flatMap((response) => response); - if (!response.ok) { - throw new Error(`${dictionary.httpError} ${response.status}`); - } + if (isAllConditionsRequired) { + return data.filter( + (balance, index, self) => + self.findIndex((b) => b.account === balance.account) === index && + responses.every((response) => response.some((b) => b.account === balance.account)), + ); + } else { + return data.filter((balance, index, self) => self.findIndex((b) => b.account === balance.account) === index); + } + }; + + const fetchData = async (url: string): Promise => { + const response = await fetch(url); - const data = await response.json(); + if (!response.ok) { + throw new Error(`${dictionary.httpError} ${response.status}`); + } - setData((prevData: Balance[]) => [...prevData, ...data?.balances]); + const data = await response.json(); - if (data.links.next) { - await fetchData(`${nodeUrl}${data.links.next}`); - } + let nextData: Balance[] = []; + if (data.links.next) { + nextData = await fetchData(`${nodeUrl}${data.links.next}`); + } + + return [...data.balances, ...nextData]; + }; + const fetchAllData = async () => { + try { + const responses = await Promise.all( + formData.map(async (item) => { + const { tokenId, minAmount } = item; + return fetchData(createFetchUrl(tokenId, minAmount)); + }), + ); + + setResponses(responses); + setData(filterData(responses, isAllConditionsRequired)); return data; } catch (error) { throw error; @@ -80,7 +108,7 @@ const App = () => { retry: 0, throwOnError: false, queryKey: ['balancesList'], - queryFn: () => fetchData(createFetchUrl()), + queryFn: () => fetchAllData(), }); useEffect(() => { @@ -97,21 +125,22 @@ const App = () => { if (!isFetching && isFetched) setShouldFetch(false); }, [isFetched, isFetching]); + useEffect(() => { + setData(filterData(responses, isAllConditionsRequired)); + }, [isAllConditionsRequired]); + return (

{dictionary.title}

{dictionary.description}

+
+ + +
+
- +
{isFetched || isFetching ? ( diff --git a/src/components/HoldersForm.tsx b/src/components/HoldersForm.tsx index 7184098..343e633 100644 --- a/src/components/HoldersForm.tsx +++ b/src/components/HoldersForm.tsx @@ -1,25 +1,19 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import dictionary from '@/dictionary/en.json'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { Loader2 } from 'lucide-react'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { formSchema } from '@/utils/formSchema'; -import { useForm } from 'react-hook-form'; +import { useFieldArray, useForm, UseFormProps } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useQuery } from '@tanstack/react-query'; +import { z } from 'zod'; import { nodeUrl } from '@/utils/const'; import { toast } from 'sonner'; import { TokenDetails } from '@/types/tokenDetails-response'; -type FormValues = { - tokenId: string; - minAmount: string; -}; - type HoldersFormProps = { - setTokenId: (tokenId: string) => void; - setMinAmount: (minAmount: number) => void; + setFormData: (formData: FormData['formData']) => void; setData: (data: any) => void; setShouldFetch: (shouldFetch: boolean) => void; setTokenDetails: (tokenDetails: TokenDetails) => void; @@ -27,27 +21,37 @@ type HoldersFormProps = { isBalancesFetching: boolean; }; -export const HoldersForm = ({ - setTokenId, - setMinAmount, - setData, - setShouldFetch, - setTokenDetails, - tokenDetails, - isBalancesFetching, -}: HoldersFormProps) => { - const [tokenIdValue, setTokenIdValue] = useState(''); - const [shouldFetchAccountDetails, setShouldFetchAccountDetails] = useState(false); - const [accountName, setAccountName] = useState(); - const form = useForm({ - resolver: zodResolver(formSchema(Boolean(tokenDetails?.type === 'FUNGIBLE_COMMON'), Number(tokenDetails?.decimals) || 0)), - defaultValues: { - tokenId: '', - minAmount: '', +export type FormData = { + formData: { tokenId: string; minAmount: string; tokenName: string }[]; +}; + +export const HoldersForm = ({ setFormData, setData, setShouldFetch, isBalancesFetching, setTokenDetails, tokenDetails }: HoldersFormProps) => { + const useZodForm = ( + props: Omit, 'resolver'> & { + schema: TSchema; }, + ) => { + return useForm({ + ...props, + resolver: zodResolver(props.schema, undefined, { + raw: true, + }), + }); + }; + + const methods = useZodForm({ + schema: formSchema(Boolean(tokenDetails?.type === 'FUNGIBLE_COMMON'), Number(tokenDetails?.decimals) || 0), + defaultValues: { formData: [{ tokenId: '', minAmount: '0', tokenName: '' }] }, + }); + + const { control, handleSubmit } = methods; + + const { fields, append, remove, update } = useFieldArray({ + name: 'formData', + control, }); - const fetchTokenData = async (url: string) => { + const fetchTokenData = async (url: string, index: number, formData: { tokenId: string; minAmount: string; tokenName: string }) => { try { const response = await fetch(url); @@ -56,109 +60,121 @@ export const HoldersForm = ({ } const data: TokenDetails = await response.json(); - setTokenDetails(data); - setAccountName(data.name); - setShouldFetchAccountDetails(false); + update(index, { tokenId: formData.tokenId, minAmount: formData.minAmount, tokenName: data.name }); return data; } catch (error) { - throw error; + toast.error((error as Error).toString()); + update(index, { tokenId: formData.tokenId, minAmount: formData.minAmount, tokenName: dictionary.wrongTokenId }); } }; - const { error } = useQuery({ - enabled: shouldFetchAccountDetails, - retry: 0, - throwOnError: false, - queryKey: ['accountDetails'], - queryFn: () => fetchTokenData(`${nodeUrl}/api/v1/tokens/${tokenIdValue}`), - }); + const onSubmit = (data: FormData) => { + setFormData(data.formData); + setData([]); + setShouldFetch(true); + }; const isValidTokenId = (tokenId: string): boolean => { const regex = /^0\.0\.\d*$/; return regex.test(tokenId); }; - const handleTokenIdChange = (tokenId: string) => { - setTokenIdValue(tokenId); - if (!tokenId || !isValidTokenId(tokenId)) { - setAccountName(undefined); - } - }; - - const handleTokenIdBlur = (tokenId: string) => { + const handleTokenIdBlur = async (tokenId: string, index: number) => { if (tokenId && isValidTokenId(tokenId)) { - setShouldFetchAccountDetails(true); + const url = `${nodeUrl}/api/v1/tokens/${tokenId}`; + const data = methods.getValues(); + try { + await fetchTokenData(url, index, data.formData[index]); + } catch (error) { + toast.error((error as Error).toString()); + } } }; - const onSubmit = ({ tokenId, minAmount }: FormValues) => { - setTokenId(tokenId); - setMinAmount(Number(minAmount)); - setData([]); - setShouldFetch(true); - }; - - useEffect(() => { - if (error) { - toast.error(error.toString()); - setAccountName(dictionary.wrongTokenId); - setShouldFetchAccountDetails(false); + const handleTokenIdChange = (tokenId: string, index: number) => { + if (!tokenId && !isValidTokenId(tokenId)) { + const data = methods.getValues(); + const formData = data.formData[index]; + update(index, { + tokenId: formData.tokenId, + minAmount: formData.minAmount, + tokenName: '', + }); } - }, [error]); + }; return ( -
- -
-
- ( - - {dictionary.tokenId} - - <> - { - handleTokenIdChange(e.target.value); - field.onChange(e); - }} - value={tokenIdValue} - onBlur={() => { - handleTokenIdBlur(tokenIdValue); - field.onBlur(); - }} - /> - {accountName &&

{accountName}

} - -
- -
- )} - /> -
- -
- ( - - {dictionary.minAmount} - - - - - - )} - /> + + + {fields.map((field, index) => ( +
+
+ ( + + {dictionary.tokenId} + + <> + { + field.onChange(event); + handleTokenIdChange(event.target.value, index); + }} + onBlur={(event) => { + field.onBlur(); + handleTokenIdBlur(event.target.value, index); + }} + /> + {fields[index].tokenName &&

{fields[index].tokenName}

} + +
+ +
+ )} + /> +
+ +
+ ( + + {dictionary.minAmount} + + + + + + )} + /> +
+
+ ))} +
+
+