feat: explore pagination view and loading skeletons (#202)
* feat: add skeleton styles * feat: add nfa card skeleton * refactor: fetch nfas with single list * refactor: fetching nfa list backwards until no more data * feat: add end of scroll fetch * fix: nfa card skeleton sizing * feat: add threshold to scroll end hook
This commit is contained in:
parent
d3f00fd291
commit
2a9378fa52
|
|
@ -3,3 +3,4 @@ export * from './flex.styles';
|
||||||
export * from './stepper';
|
export * from './stepper';
|
||||||
export * from './nav-bar';
|
export * from './nav-bar';
|
||||||
export * from './page';
|
export * from './page';
|
||||||
|
export * from './skeleton.styles';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './nfa-card';
|
export * from './nfa-card';
|
||||||
|
export * from './nfa-card-skeleton';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { NFACardStyles as S } from './nfa-card.styles';
|
||||||
|
|
||||||
|
export const NFACardSkeleton: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<S.Container as="div">
|
||||||
|
<S.Skeleton.Preview />
|
||||||
|
<S.Body>
|
||||||
|
<S.Skeleton.Title />
|
||||||
|
<S.Skeleton.Content />
|
||||||
|
</S.Body>
|
||||||
|
</S.Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,8 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { dripStitches } from '@/theme';
|
import { dripStitches } from '@/theme';
|
||||||
|
|
||||||
|
import { Skeleton } from '../layout';
|
||||||
|
|
||||||
const { styled } = dripStitches;
|
const { styled } = dripStitches;
|
||||||
|
|
||||||
export const NFACardStyles = {
|
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',
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1 +1,3 @@
|
||||||
export * from './use-transaction-cost';
|
export * from './use-transaction-cost';
|
||||||
|
export * from './use-window-scroll-end';
|
||||||
|
export * from './use-debounce';
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
ApolloClient,
|
ApolloClient,
|
||||||
ApolloProvider as Provider,
|
ApolloProvider as Provider,
|
||||||
|
FieldMergeFunction,
|
||||||
InMemoryCache,
|
InMemoryCache,
|
||||||
} from '@apollo/client';
|
} from '@apollo/client';
|
||||||
import { GraphApolloLink } from '@graphprotocol/client-apollo';
|
import { GraphApolloLink } from '@graphprotocol/client-apollo';
|
||||||
|
|
@ -8,9 +9,47 @@ import React from 'react';
|
||||||
|
|
||||||
import * as GraphClient from '@/graphclient';
|
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<number, number> = Object.create(null);
|
||||||
|
if (existing) {
|
||||||
|
existing.forEach((token, index) => {
|
||||||
|
const id = readField<number>(key, token) as number;
|
||||||
|
keyToIndex[id] = index;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
incoming.forEach((token) => {
|
||||||
|
const id = readField<number>(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({
|
const client = new ApolloClient({
|
||||||
link: new GraphApolloLink(GraphClient),
|
link: new GraphApolloLink(GraphClient),
|
||||||
cache: new InMemoryCache(),
|
cache: new InMemoryCache({
|
||||||
|
typePolicies: {
|
||||||
|
Query: {
|
||||||
|
fields: {
|
||||||
|
tokens: {
|
||||||
|
keyArgs: ['where'],
|
||||||
|
merge: mergeByKey('id'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ApolloProviderProps = {
|
type ApolloProviderProps = {
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1,69 @@
|
||||||
/* eslint-disable react/react-in-jsx-scope */
|
|
||||||
import { useQuery } from '@apollo/client';
|
import { useQuery } from '@apollo/client';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Button, Flex, NFACard, NoResults } from '@/components';
|
import { Flex, NFACard, NFACardSkeleton, NoResults } from '@/components';
|
||||||
import { lastNFAsPaginatedDocument, totalTokensDocument } from '@/graphclient';
|
import { lastNFAsPaginatedDocument } from '@/graphclient';
|
||||||
|
import { useWindowScrollEnd } from '@/hooks';
|
||||||
|
|
||||||
const pageSize = 10; //Set this size to test pagination
|
const pageSize = 10; //Set this size to test pagination
|
||||||
|
|
||||||
|
const LoadingSkeletons: React.FC = () => (
|
||||||
|
<>
|
||||||
|
<NFACardSkeleton />
|
||||||
|
<NFACardSkeleton />
|
||||||
|
<NFACardSkeleton />
|
||||||
|
<NFACardSkeleton />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
export const NFAList: React.FC = () => {
|
export const NFAList: React.FC = () => {
|
||||||
const [pageNumber, setPageNumber] = useState(1);
|
const [pageNumber, setPageNumber] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [endReached, setEndReached] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: totalTokens,
|
data: { tokens } = { tokens: [] },
|
||||||
loading: loadingTotalTokens,
|
loading: isLoading,
|
||||||
error: errorTotalTokens,
|
error: queryError,
|
||||||
} = useQuery(totalTokensDocument);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: dataMintedTokens,
|
|
||||||
loading: loadingMintedTokens,
|
|
||||||
error: errorMintedTokens,
|
|
||||||
} = useQuery(lastNFAsPaginatedDocument, {
|
} = useQuery(lastNFAsPaginatedDocument, {
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
variables: {
|
variables: {
|
||||||
//first page is 0
|
|
||||||
pageSize,
|
pageSize,
|
||||||
skip: pageNumber > 0 ? (pageNumber - 1) * pageSize : pageNumber,
|
skip: pageNumber * pageSize,
|
||||||
|
},
|
||||||
|
onCompleted: (data) => {
|
||||||
|
if (data.tokens.length - tokens.length < pageSize) setEndReached(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalTokens && totalTokens.tokens.length > 0) {
|
// Update page number when there are cached tokens
|
||||||
setTotalPages(Math.ceil(totalTokens.tokens.length / pageSize));
|
setPageNumber(Math.ceil(tokens.length / pageSize));
|
||||||
}
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [totalTokens]);
|
}, []);
|
||||||
|
|
||||||
if (loadingMintedTokens || loadingTotalTokens) return <div>Loading...</div>; //TODO handle loading
|
useWindowScrollEnd(() => {
|
||||||
if (errorMintedTokens || errorTotalTokens) return <div>Error</div>; //TODO handle error
|
if (isLoading || endReached) return;
|
||||||
|
setPageNumber((prevState) => prevState + 1);
|
||||||
|
});
|
||||||
|
|
||||||
const handlePreviousPage = (): void => {
|
if (queryError) return <div>Error</div>; //TODO handle error
|
||||||
if (pageNumber > 1) {
|
|
||||||
setPageNumber((prevState) => prevState - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = (): void => {
|
|
||||||
if (pageNumber + 1 <= totalPages)
|
|
||||||
setPageNumber((prevState) => prevState + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex css={{ flexDirection: 'column', gap: '$2' }}>
|
<Flex
|
||||||
<Flex css={{ gap: '$2' }}>
|
css={{
|
||||||
{/* TODO this will be remove when we have pagination component */}
|
flexDirection: 'column',
|
||||||
<span>items per page: {pageSize}</span>
|
gap: '$2',
|
||||||
<span>
|
my: '$6',
|
||||||
page: {pageNumber}/{totalPages}
|
minHeight: '50vh',
|
||||||
</span>
|
marginBottom: '30vh', // TODO: remove this if we add page footer
|
||||||
|
}}
|
||||||
<Button onClick={handlePreviousPage} disabled={pageNumber === 1}>
|
>
|
||||||
Previous page
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleNextPage} disabled={pageNumber === totalPages}>
|
|
||||||
Next page
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ gap: '$6', flexWrap: 'wrap' }}>
|
<Flex css={{ gap: '$6', flexWrap: 'wrap' }}>
|
||||||
{dataMintedTokens && dataMintedTokens.tokens.length > 0 ? (
|
{tokens.map((token) => (
|
||||||
dataMintedTokens.tokens.map((mint) => (
|
<NFACard data={token} key={token.id} />
|
||||||
<NFACard data={mint} key={mint.id} />
|
))}
|
||||||
))
|
{isLoading && <LoadingSkeletons />}
|
||||||
) : (
|
{!isLoading && tokens.length === 0 && <NoResults />}
|
||||||
<NoResults />
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue