Skip to content

Commit

Permalink
Package it up
Browse files Browse the repository at this point in the history
  • Loading branch information
blittle committed May 12, 2023
1 parent 025385b commit f190bb9
Show file tree
Hide file tree
Showing 9 changed files with 295 additions and 44 deletions.
5 changes: 5 additions & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export {graphiqlLoader} from './routing/graphiql';
export {Seo} from './seo/seo';
export {type SeoConfig} from './seo/generate-seo-tags';
export type {SeoHandleFunction} from './seo/seo';
export {
Pagination,
getPaginationVariables,
// PAGINATION_PAGE_INFO_FRAGMENT,
} from './pagination/Pagination';

export {
AnalyticsEventName,
Expand Down
Empty file.
Empty file.
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ interface PaginationInfo {
export function Pagination<Resource extends Connection>({
connection,
children = () => null,
autoLoadOnScroll = true,
autoLoadOnScroll = false,
}: Props<Resource> & {
children: ({
endCursor,
Expand All @@ -51,8 +51,8 @@ export function Pagination<Resource extends Connection>({
startCursor,
}: PaginationInfo) => JSX.Element | null;
}) {
const {state} = useNavigation();
const isLoading = state === 'loading';
const transition = useNavigation();
const isLoading = transition.state === 'loading';
const autoScrollEnabled = Boolean(autoLoadOnScroll);
const autoScrollConfig = (
autoScrollEnabled
Expand Down Expand Up @@ -97,6 +97,8 @@ export function Pagination<Resource extends Connection>({
});
}

let hydrating = true;

/**
* Get cumulative pagination logic for a given connection
*/
Expand All @@ -108,40 +110,46 @@ export function usePagination(
state: PaginationState;
search: string;
};
const [hydrated, setHydrated] = useState(() => !hydrating);

useEffect(function hydrate() {
hydrating = false;
setHydrated(true);
}, []);

const params = new URLSearchParams(search);
const direction = params.get('direction');
const isPrevious = direction === 'previous';

const {hasNextPage, hasPreviousPage, startCursor, endCursor} =
connection.pageInfo;

// `connection` represents the data that came from the server
// `state` represents the data that came from the client
const currentPageInfo = useMemo(() => {
let pageStartCursor =
state?.pageInfo?.startCursor === undefined
? startCursor
? connection.pageInfo.startCursor
: state.pageInfo.startCursor;

let pageEndCursor =
state?.pageInfo?.endCursor === undefined
? endCursor
? connection.pageInfo.endCursor
: state.pageInfo.endCursor;

if (state?.nodes) {
if (isPrevious) {
pageStartCursor = startCursor;
pageStartCursor = connection.pageInfo.startCursor;
} else {
pageEndCursor = endCursor;
pageEndCursor = connection.pageInfo.endCursor;
}
}

const previousPageExists =
state?.pageInfo?.hasPreviousPage === undefined
? hasPreviousPage
? connection.pageInfo.hasPreviousPage
: state.pageInfo.hasPreviousPage;

const nextPageExists =
state?.pageInfo?.hasNextPage === undefined
? hasNextPage
? connection.pageInfo.hasNextPage
: state.pageInfo.hasNextPage;

return {
Expand All @@ -150,7 +158,14 @@ export function usePagination(
hasPreviousPage: previousPageExists,
hasNextPage: nextPageExists,
};
}, [isPrevious, state, hasNextPage, hasPreviousPage, startCursor, endCursor]);
}, [
isPrevious,
state,
connection.pageInfo.hasNextPage,
connection.pageInfo.hasPreviousPage,
connection.pageInfo.startCursor,
connection.pageInfo.endCursor,
]);

const prevPageUrl = useMemo(() => {
const params = new URLSearchParams(search);
Expand Down Expand Up @@ -222,6 +237,7 @@ function useLoadMoreWhenInView<Resource extends Connection>({
location.pathname + `?index&cursor=${endCursor}&direction=next`;

navigate(nextPageUrl, {
preventScrollReset: true,
state: {
pageInfo: {
endCursor,
Expand All @@ -247,13 +263,13 @@ function useLoadMoreWhenInView<Resource extends Connection>({

/**
* Get variables for route loader to support pagination
* @param autoLoadOnScroll enable auto loading
* @param inView trigger element is in viewport
* @param isIdle page transition is idle
* @param connection Storefront API connection
* @returns cumulativePageInfo {startCursor, endCursor, hasPreviousPage, hasNextPage}
*/
export function getPaginationVariables(request: Request, pageBy: number) {
export function getPaginationVariables(
request: Request,
options: {pageBy: number} = {pageBy: 20},
) {
const {pageBy} = options;
const searchParams = new URLSearchParams(new URL(request.url).search);

const cursor = searchParams.get('cursor') ?? undefined;
Expand Down
243 changes: 243 additions & 0 deletions rfc/pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
# Pagination

Pagination is a complex component, that becomes even more complex for online storefronts. The goal of our Pagination component should be to take on the undifferentiated difficult parts of paginating a Storefront APO collection in Hydrogen projects. This includes:

- Caching already loaded items
- Dealing with cursors
- Drop-in bi-directional infinite loading
- Traditional load more or prev/next UI
- Consistent scroll position after navigations
- Support permalinks to a given slice of items
- Easy turn-key DX

## Usage

## Create route

Add a `/products` route is you don't already have one.

```bash
touch routes/products.tsx
```

## Fetch a Storefront connection in the loader

Add a loader and query for the products in the shop. This is what a typical loader might look like without pagination applied.

```tsx
export async function loader({context, request}: LoaderArgs) {
const {products} = await context.storefront.query<{
products: ProductConnection;
}>(PRODUCTS_QUERY, {
variables: {
country: context.storefront.i18n?.country,
language: context.storefront.i18n?.language,
},
});

if (!products) {
throw new Response(null, {status: 404});
}

return json({products});
}
```

And a sample query:

```tsx
const PRODUCTS_QUERY = `#graphql
query (
$country: CountryCode
$language: LanguageCode
) @inContext(country: $country, language: $language) {
products() {
nodes {
id
title
publishedAt
handle
variants(first: 1) {
nodes {
id
image {
url
altText
width
height
}
}
}
}
}
}
`;
```

### Add the pagination variables to the query

First import and use a helper `getPaginationVariables(request: Request)` to build the pagination variables from the request object. We spread those values into the query, and also need to add those variables to the query along with the associated fragment.

```diff
+ import {getPaginationVariables, PAGINATION_PAGE_INFO_FRAGMENT} from '~/components';

export async function loader({context, request}: LoaderArgs) {
const variables = getPaginationVariables(request, 4);
const {products} = await context.storefront.query<{
products: ProductConnection;
}>(PRODUCTS_QUERY, {
variables: {
+ ...variables,
country: context.storefront.i18n?.country,
language: context.storefront.i18n?.language,
},
});

if (!products) {
throw new Response(null, {status: 404});
}

return json({products});
}
```

And a add the fragment and variables to the query:

```diff
const PRODUCTS_QUERY = `#graphql
+ ${PAGINATION_PAGE_INFO_FRAGMENT}
query (
$country: CountryCode
$language: LanguageCode
+ $first: Int
+ $last: Int
+ $startCursor: String
+ $endCursor: String
) @inContext(country: $country, language: $language) {
products(
+ first: $first,
+ last: $last,
+ before: $startCursor,
+ after: $endCursor
) {
nodes {
id
title
publishedAt
handle
variants(first: 1) {
nodes {
id
image {
url
altText
width
height
}
}
}
}
+ pageInfo {
+ ...PaginationPageInfoFragment
+ }
}
}
`;
```

### Render the `<Pagination />` component

In the default export, we can start to build our UI. This starts with rendering the `<Pagination >` component and passing the `products` loader data to the `connection` prop. The other prop this component takes is a boolean called `autoLoadOnScroll` that toggles infinite scrolling.

```tsx
export default function Products() {
const {products} = useLoaderData<typeof loader>();

return (
<>
<Pagination connection={products} autoLoadOnScroll />
</>
);
}
```

Next we can expand the render prop to build our grid and navigation elements. We receive a number of helpful bits of information in the render prop that we can use to build the interface we want.

To enable the state-based cache, we pass the variables along to the Link component's state. This may be something we want to abstract away, but wanted to leave these guts-out for now.

```tsx
export default function Products() {
const {products} = useLoaderData<typeof loader>();

return (
<>
<Pagination connection={products} autoLoadOnScroll>
{({
endCursor,
hasNextPage,
hasPreviousPage,
nextPageUrl,
nodes,
prevPageUrl,
startCursor,
nextLinkRef,
isLoading,
}) => {
const itemsMarkup = nodes.map((product, i) => (
<Link to={`/products/${product.handle}`} key={product.id}>
{product.title}
</Link>
));

return (
<>
{hasPreviousPage && (
<Link
preventScrollReset={true}
to={prevPageUrl}
prefetch="intent"
state={{
pageInfo: {
endCursor,
hasNextPage,
startCursor,
hasPreviousPage: undefined,
},
nodes,
}}
>
{isLoading ? 'Loading...' : 'Previous'}
</Link>
)}
{itemsMarkup}
{hasNextPage && (
<Link
preventScrollReset={true}
ref={nextLinkRef}
to={nextPageUrl}
prefetch="intent"
state={{
pageInfo: {
endCursor,
hasPreviousPage,
hasNextPage: undefined,
startCursor,
},
nodes,
}}
>
{isLoading ? 'Loading...' : 'Next'}
</Link>
)}
</>
);
}}
</Pagination>
</>
);
}
```

## Conclusion

And that's it! You should now have a working pagination with all goals we outlined above.
1 change: 0 additions & 1 deletion templates/demo-store/app/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export {Hero} from './Hero';
export {SortFilter} from './SortFilter';
export {Grid} from './Grid';
export {FeaturedProducts} from './FeaturedProducts';
export {Pagination, getPaginationVariables, usePagination} from './Pagination';
export {AddToCartButton} from './AddToCartButton';
// Sue me
export * from './Icon';
Loading

0 comments on commit f190bb9

Please sign in to comment.