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 './nav-bar';
|
||||
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-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 { 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',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
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 {
|
||||
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<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({
|
||||
link: new GraphApolloLink(GraphClient),
|
||||
cache: new InMemoryCache(),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
tokens: {
|
||||
keyArgs: ['where'],
|
||||
merge: mergeByKey('id'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
type ApolloProviderProps = {
|
||||
|
|
|
|||
|
|
@ -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 = () => (
|
||||
<>
|
||||
<NFACardSkeleton />
|
||||
<NFACardSkeleton />
|
||||
<NFACardSkeleton />
|
||||
<NFACardSkeleton />
|
||||
</>
|
||||
);
|
||||
|
||||
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 <div>Loading...</div>; //TODO handle loading
|
||||
if (errorMintedTokens || errorTotalTokens) return <div>Error</div>; //TODO handle error
|
||||
|
||||
const handlePreviousPage = (): void => {
|
||||
if (pageNumber > 1) {
|
||||
setPageNumber((prevState) => prevState - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = (): void => {
|
||||
if (pageNumber + 1 <= totalPages)
|
||||
useWindowScrollEnd(() => {
|
||||
if (isLoading || endReached) return;
|
||||
setPageNumber((prevState) => prevState + 1);
|
||||
};
|
||||
});
|
||||
|
||||
if (queryError) return <div>Error</div>; //TODO handle error
|
||||
|
||||
return (
|
||||
<Flex css={{ flexDirection: 'column', gap: '$2' }}>
|
||||
<Flex css={{ gap: '$2' }}>
|
||||
{/* TODO this will be remove when we have pagination component */}
|
||||
<span>items per page: {pageSize}</span>
|
||||
<span>
|
||||
page: {pageNumber}/{totalPages}
|
||||
</span>
|
||||
|
||||
<Button onClick={handlePreviousPage} disabled={pageNumber === 1}>
|
||||
Previous page
|
||||
</Button>
|
||||
<Button onClick={handleNextPage} disabled={pageNumber === totalPages}>
|
||||
Next page
|
||||
</Button>
|
||||
</Flex>
|
||||
<Flex
|
||||
css={{
|
||||
flexDirection: 'column',
|
||||
gap: '$2',
|
||||
my: '$6',
|
||||
minHeight: '50vh',
|
||||
marginBottom: '30vh', // TODO: remove this if we add page footer
|
||||
}}
|
||||
>
|
||||
<Flex css={{ gap: '$6', flexWrap: 'wrap' }}>
|
||||
{dataMintedTokens && dataMintedTokens.tokens.length > 0 ? (
|
||||
dataMintedTokens.tokens.map((mint) => (
|
||||
<NFACard data={mint} key={mint.id} />
|
||||
))
|
||||
) : (
|
||||
<NoResults />
|
||||
)}
|
||||
{tokens.map((token) => (
|
||||
<NFACard data={token} key={token.id} />
|
||||
))}
|
||||
{isLoading && <LoadingSkeletons />}
|
||||
{!isLoading && tokens.length === 0 && <NoResults />}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue