diff --git a/ui/src/components/layout/index.ts b/ui/src/components/layout/index.ts index 8e3e9fa..a0c4cdf 100644 --- a/ui/src/components/layout/index.ts +++ b/ui/src/components/layout/index.ts @@ -3,3 +3,4 @@ export * from './flex.styles'; export * from './stepper'; export * from './nav-bar'; export * from './page'; +export * from './skeleton.styles'; diff --git a/ui/src/components/layout/skeleton.styles.ts b/ui/src/components/layout/skeleton.styles.ts new file mode 100644 index 0000000..b54f820 --- /dev/null +++ b/ui/src/components/layout/skeleton.styles.ts @@ -0,0 +1,20 @@ +import { dripStitches } from '@/theme'; + +const { styled, keyframes } = dripStitches; + +const SkeletonKeyframes = keyframes({ + '0%': { + opacity: '1', + }, + '50%': { + opacity: '0.5', + }, + '100%': { + opacity: '1', + }, +}); + +export const Skeleton = styled('div', { + animation: `${SkeletonKeyframes} 1s ease-in-out infinite`, + backgroundColor: '$slate6', +}); diff --git a/ui/src/components/nfa-card/index.ts b/ui/src/components/nfa-card/index.ts index f8fa163..a2f2569 100644 --- a/ui/src/components/nfa-card/index.ts +++ b/ui/src/components/nfa-card/index.ts @@ -1 +1,2 @@ export * from './nfa-card'; +export * from './nfa-card-skeleton'; diff --git a/ui/src/components/nfa-card/nfa-card-skeleton.tsx b/ui/src/components/nfa-card/nfa-card-skeleton.tsx new file mode 100644 index 0000000..86d509a --- /dev/null +++ b/ui/src/components/nfa-card/nfa-card-skeleton.tsx @@ -0,0 +1,13 @@ +import { NFACardStyles as S } from './nfa-card.styles'; + +export const NFACardSkeleton: React.FC = () => { + return ( + + + + + + + + ); +}; diff --git a/ui/src/components/nfa-card/nfa-card.styles.ts b/ui/src/components/nfa-card/nfa-card.styles.ts index 23e1f57..9e3ca44 100644 --- a/ui/src/components/nfa-card/nfa-card.styles.ts +++ b/ui/src/components/nfa-card/nfa-card.styles.ts @@ -2,6 +2,8 @@ import { Link } from 'react-router-dom'; import { dripStitches } from '@/theme'; +import { Skeleton } from '../layout'; + const { styled } = dripStitches; export const NFACardStyles = { @@ -75,4 +77,23 @@ export const NFACardStyles = { }, }, }), + + Skeleton: { + Preview: styled(Skeleton, { + width: '100%', + height: 'calc(14.6875rem - 2px)', + }), + + Title: styled(Skeleton, { + width: '100%', + height: 'calc(1.4 * $fontSizes$xl)', + borderRadius: '$lg', + }), + + Content: styled(Skeleton, { + width: '100%', + height: 'calc(1.43 * $fontSizes$sm)', + borderRadius: '$lg', + }), + }, }; diff --git a/ui/src/hooks/index.ts b/ui/src/hooks/index.ts index 4d095a7..c32fad0 100644 --- a/ui/src/hooks/index.ts +++ b/ui/src/hooks/index.ts @@ -1 +1,3 @@ export * from './use-transaction-cost'; +export * from './use-window-scroll-end'; +export * from './use-debounce'; diff --git a/ui/src/hooks/use-window-scroll-end.ts b/ui/src/hooks/use-window-scroll-end.ts new file mode 100644 index 0000000..3b43647 --- /dev/null +++ b/ui/src/hooks/use-window-scroll-end.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { useDebounce } from './use-debounce'; + +export const useWindowScrollEnd = ( + callback: () => void, + threshold = 0.3 // threshold used to recognize scroll end (30% of the remaining scroll) +): void => { + const debounced = useDebounce(() => { + const { scrollHeight, scrollTop, offsetHeight } = document.documentElement; + if (scrollHeight * (1 - threshold) > scrollTop + offsetHeight) return; + callback(); + }, 100); + + useEffect(() => { + debounced(); + window.addEventListener('scroll', debounced); + return () => window.removeEventListener('scroll', debounced); + }, [debounced]); +}; diff --git a/ui/src/providers/apollo-provider.tsx b/ui/src/providers/apollo-provider.tsx index bdf799c..1ab36f7 100644 --- a/ui/src/providers/apollo-provider.tsx +++ b/ui/src/providers/apollo-provider.tsx @@ -1,6 +1,7 @@ import { ApolloClient, ApolloProvider as Provider, + FieldMergeFunction, InMemoryCache, } from '@apollo/client'; import { GraphApolloLink } from '@graphprotocol/client-apollo'; @@ -8,9 +9,47 @@ import React from 'react'; import * as GraphClient from '@/graphclient'; +// https://www.apollographql.com/docs/react/caching/cache-field-behavior/#merging-arrays-of-non-normalized-objects +/* eslint-disable @typescript-eslint/no-explicit-any */ +const mergeByKey = + (key: string): FieldMergeFunction => + (existing: any[] = [], incoming: any[], { mergeObjects, readField }) => { + const merged: any[] = existing ? existing.slice(0) : []; + const keyToIndex: Record = Object.create(null); + if (existing) { + existing.forEach((token, index) => { + const id = readField(key, token) as number; + keyToIndex[id] = index; + }); + } + incoming.forEach((token) => { + const id = readField(key, token) as number; + const index = keyToIndex[id]; + if (typeof index === 'number') { + merged[index] = mergeObjects(merged[index], token); + } else { + keyToIndex[id] = merged.length; + merged.push(token); + } + }); + return merged; + }; +/** eslint-enable @typescript-eslint/no-explicit-any */ + const client = new ApolloClient({ link: new GraphApolloLink(GraphClient), - cache: new InMemoryCache(), + cache: new InMemoryCache({ + typePolicies: { + Query: { + fields: { + tokens: { + keyArgs: ['where'], + merge: mergeByKey('id'), + }, + }, + }, + }, + }), }); type ApolloProviderProps = { diff --git a/ui/src/views/explore/list-nfas/nfa-list/nfa-list.tsx b/ui/src/views/explore/list-nfas/nfa-list/nfa-list.tsx index 5aa4beb..6dbc468 100644 --- a/ui/src/views/explore/list-nfas/nfa-list/nfa-list.tsx +++ b/ui/src/views/explore/list-nfas/nfa-list/nfa-list.tsx @@ -1,78 +1,69 @@ -/* eslint-disable react/react-in-jsx-scope */ import { useQuery } from '@apollo/client'; import { useEffect, useState } from 'react'; -import { Button, Flex, NFACard, NoResults } from '@/components'; -import { lastNFAsPaginatedDocument, totalTokensDocument } from '@/graphclient'; +import { Flex, NFACard, NFACardSkeleton, NoResults } from '@/components'; +import { lastNFAsPaginatedDocument } from '@/graphclient'; +import { useWindowScrollEnd } from '@/hooks'; const pageSize = 10; //Set this size to test pagination +const LoadingSkeletons: React.FC = () => ( + <> + + + + + +); + export const NFAList: React.FC = () => { - const [pageNumber, setPageNumber] = useState(1); - const [totalPages, setTotalPages] = useState(0); + const [pageNumber, setPageNumber] = useState(0); + const [endReached, setEndReached] = useState(false); const { - data: totalTokens, - loading: loadingTotalTokens, - error: errorTotalTokens, - } = useQuery(totalTokensDocument); - - const { - data: dataMintedTokens, - loading: loadingMintedTokens, - error: errorMintedTokens, + data: { tokens } = { tokens: [] }, + loading: isLoading, + error: queryError, } = useQuery(lastNFAsPaginatedDocument, { + fetchPolicy: 'cache-and-network', variables: { - //first page is 0 pageSize, - skip: pageNumber > 0 ? (pageNumber - 1) * pageSize : pageNumber, + skip: pageNumber * pageSize, + }, + onCompleted: (data) => { + if (data.tokens.length - tokens.length < pageSize) setEndReached(true); }, }); useEffect(() => { - if (totalTokens && totalTokens.tokens.length > 0) { - setTotalPages(Math.ceil(totalTokens.tokens.length / pageSize)); - } - }, [totalTokens]); + // Update page number when there are cached tokens + setPageNumber(Math.ceil(tokens.length / pageSize)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - if (loadingMintedTokens || loadingTotalTokens) return
Loading...
; //TODO handle loading - if (errorMintedTokens || errorTotalTokens) return
Error
; //TODO handle error + useWindowScrollEnd(() => { + if (isLoading || endReached) return; + setPageNumber((prevState) => prevState + 1); + }); - const handlePreviousPage = (): void => { - if (pageNumber > 1) { - setPageNumber((prevState) => prevState - 1); - } - }; - - const handleNextPage = (): void => { - if (pageNumber + 1 <= totalPages) - setPageNumber((prevState) => prevState + 1); - }; + if (queryError) return
Error
; //TODO handle error return ( - - - {/* TODO this will be remove when we have pagination component */} - items per page: {pageSize} - - page: {pageNumber}/{totalPages} - - - - - + - {dataMintedTokens && dataMintedTokens.tokens.length > 0 ? ( - dataMintedTokens.tokens.map((mint) => ( - - )) - ) : ( - - )} + {tokens.map((token) => ( + + ))} + {isLoading && } + {!isLoading && tokens.length === 0 && } );