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:
Felipe Mendes 2023-04-04 10:12:33 -03:00 committed by GitHub
parent d3f00fd291
commit 2a9378fa52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 163 additions and 55 deletions

View File

@ -3,3 +3,4 @@ export * from './flex.styles';
export * from './stepper';
export * from './nav-bar';
export * from './page';
export * from './skeleton.styles';

View File

@ -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',
});

View File

@ -1 +1,2 @@
export * from './nfa-card';
export * from './nfa-card-skeleton';

View File

@ -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>
);
};

View File

@ -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',
}),
},
};

View File

@ -1 +1,3 @@
export * from './use-transaction-cost';
export * from './use-window-scroll-end';
export * from './use-debounce';

View File

@ -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]);
};

View File

@ -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 = {

View File

@ -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>
);