Skip to content

Commit

Permalink
Bidirectional cursor pagination (#846)
Browse files Browse the repository at this point in the history
* remove direction from refetch object

* more store definitions

* fix build errors

* add missing methods to query store

* add e2e test

* fix initial args in bidirectional pagination test

* add previous button

* fix connection implementation on server

* tidy up docs

* treat updates as a list of strings to match against

* start on query modification

* only generate pagination queries that match the api

* fix query arguments when loading pages

* cache respects page info when handling cursor updates

* bidirectional e2e query tests pass 🎉

* add tests for bidirectional fragments

* tests pass

* changesets

* check pagination direction

* linter

* update snapshots

* tidy up docs

* release notes

* remove logs

* tidy up release notes

* add license
  • Loading branch information
AlecAivazis authored Jan 25, 2023
1 parent 308ed34 commit 3245233
Show file tree
Hide file tree
Showing 37 changed files with 1,553 additions and 453 deletions.
5 changes: 5 additions & 0 deletions .changeset/sharp-weeks-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini-svelte': major
---

Added support for bidirectional pagination when using connections and remove the config values for specify custom stores for a specific direction
5 changes: 5 additions & 0 deletions .changeset/yellow-bulldogs-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': patch
---

Fixed pageInfo behavior when prepending and append values
6 changes: 5 additions & 1 deletion e2e/_api/graphql.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { GraphQLYogaError } from '@graphql-yoga/node'
import { sleep } from '@kitql/helper'
import fs from 'fs-extra'
import { GraphQLScalarType, Kind } from 'graphql'
import { connectionFromArray } from 'graphql-relay'
import path from 'path'

import { connectionFromArray } from './util.mjs'

const sourceFiles = ['../_api/schema.graphql', '../_api/schema-hello.graphql']
export const typeDefs = sourceFiles.map((filepath) =>
fs.readFileSync(path.resolve(filepath), 'utf-8')
Expand Down Expand Up @@ -190,6 +191,9 @@ export const resolvers = {
friendsConnection(user, args) {
return connectionFromArray(getSnapshot(user.snapshot), args)
},
usersConnection: (user, args) => {
return connectionFromArray(getSnapshot(user.snapshot), args)
},
},

Mutation: {
Expand Down
2 changes: 2 additions & 0 deletions e2e/_api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ type Subscription {
type User implements Node {
birthDate: DateTime
friendsConnection(after: String, before: String, first: Int, last: Int): UserConnection!
"This is the same list as what's used globally. its here to tests fragments"
usersConnection(after: String, before: String, first: Int, last: Int): UserConnection!
friendsList(limit: Int, offset: Int): [User!]!
id: ID!
name: String!
Expand Down
140 changes: 140 additions & 0 deletions e2e/_api/util.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* This file is copied from graphql-relay-js: https://github.com/graphql/graphql-relay-js/blob/main/src/connection/arrayConnection.ts
* It's licensed under the MIT license found at the bottom of the file (per the project's agreement)
*/

export function connectionFromArray(data, args) {
return connectionFromArraySlice(data, args, {
sliceStart: 0,
arrayLength: data.length,
})
}

function connectionFromArraySlice(arraySlice, args, meta) {
const { after, before, first, last } = args
const { sliceStart, arrayLength } = meta
const sliceEnd = sliceStart + arraySlice.length

let startOffset = Math.max(sliceStart, 0)
let endOffset = Math.min(sliceEnd, arrayLength)

const afterOffset = getOffsetWithDefault(after, -1)
if (0 <= afterOffset && afterOffset < arrayLength) {
startOffset = Math.max(startOffset, afterOffset + 1)
}

const beforeOffset = getOffsetWithDefault(before, endOffset)
if (0 <= beforeOffset && beforeOffset < arrayLength) {
endOffset = Math.min(endOffset, beforeOffset)
}

if (typeof first === 'number') {
if (first < 0) {
throw new Error('Argument "first" must be a non-negative integer')
}

endOffset = Math.min(endOffset, startOffset + first)
}
if (typeof last === 'number') {
if (last < 0) {
throw new Error('Argument "last" must be a non-negative integer')
}

startOffset = Math.max(startOffset, endOffset - last)
}

// If supplied slice is too large, trim it down before mapping over it.
const slice = arraySlice.slice(startOffset - sliceStart, endOffset - sliceStart)

const edges = slice.map((value, index) => ({
cursor: offsetToCursor(startOffset + index),
node: value,
}))

const firstEdge = edges[0]
const lastEdge = edges[edges.length - 1]
const lowerBound = 0
const upperBound = arrayLength

return {
edges,
pageInfo: {
startCursor: firstEdge ? firstEdge.cursor : null,
endCursor: lastEdge ? lastEdge.cursor : null,
hasPreviousPage: startOffset > lowerBound,
hasNextPage: endOffset < upperBound,
},
}
}
const PREFIX = 'arrayconnection:'

/**
* Creates the cursor string from an offset.
*/
export function offsetToCursor(offset) {
return base64(PREFIX + offset.toString())
}

/**
* Extracts the offset from the cursor string.
*/
export function cursorToOffset(cursor) {
return parseInt(unbase64(cursor).substring(PREFIX.length), 10)
}

/**
* Return the cursor associated with an object in an array.
*/
export function cursorForObjectInConnection(data, object) {
const offset = data.indexOf(object)
if (offset === -1) {
return null
}
return offsetToCursor(offset)
}

/**
* Given an optional cursor and a default offset, returns the offset
* to use; if the cursor contains a valid offset, that will be used,
* otherwise it will be the default.
*/
export function getOffsetWithDefault(cursor, defaultOffset) {
if (typeof cursor !== 'string') {
return defaultOffset
}
const offset = cursorToOffset(cursor)
return isNaN(offset) ? defaultOffset : offset
}

function base64(str) {
return btoa(str)
}

function unbase64(str) {
return atob(str)
}

/**
*
MIT License
Copyright (c) GraphQL Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/
2 changes: 2 additions & 0 deletions e2e/sveltekit/src/lib/utils/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,13 @@ export const routes = {

Pagination_query_forward_cursor: '/pagination/query/forward-cursor',
Pagination_query_backwards_cursor: '/pagination/query/backwards-cursor',
Pagination_query_bidirectional_cursor: '/pagination/query/bidirectional-cursor',
Pagination_query_offset: '/pagination/query/offset',
Pagination_query_offset_variable: '/pagination/query/offset-variable',

Pagination_fragment_forward_cursor: '/pagination/fragment/forward-cursor',
Pagination_fragment_backwards_cursor: '/pagination/fragment/backwards-cursor',
Pagination_fragment_bidirectional_cursor: '/pagination/fragment/bidirectional-cursor',
Pagination_fragment_offset: '/pagination/fragment/offset',

Stores_Nested_List: '/stores/nested-list',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script lang="ts">
import { CachePolicy, paginatedFragment, graphql } from '$houdini';
const queryResult = graphql(`
query UserFragmentBidirectionalCursorQuery @load {
user(id: "1", snapshot: "pagination-fragment-backwards-cursor") {
...BidirectionalCursorFragment
}
}
`);
const fragmentResult = paginatedFragment(
$queryResult.data!.user!,
graphql(`
fragment BidirectionalCursorFragment on User {
usersConnection(after: "YXJyYXljb25uZWN0aW9uOjE=", first: 2) @paginate {
edges {
node {
name
}
}
}
}
`)
);
</script>

<div id="result">
{$fragmentResult.data?.usersConnection.edges.map(({ node }) => node?.name).join(', ')}
</div>

<div id="pageInfo">
{JSON.stringify($fragmentResult.pageInfo)}
</div>

<button id="previous" on:click={() => fragmentResult.loadPreviousPage()}>previous</button>
<button id="next" on:click={() => fragmentResult.loadNextPage()}>next</button>

<button id="refetch" on:click={() => fragmentResult.fetch({ policy: CachePolicy.NetworkOnly })}
>refetch</button
>
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { expect, test } from '@playwright/test';
import { routes } from '../../../../lib/utils/routes.js';
import {
expect_1_gql,
expect_0_gql,
expectToBe,
expectToContain,
goto
} from '../../../../lib/utils/testsHelper.js';

test.describe('bidirectional cursor paginated fragment', () => {
test('backwards and then forwards', async ({ page }) => {
await goto(page, routes.Pagination_fragment_bidirectional_cursor);

await expectToBe(page, 'Morgan Freeman, Tom Hanks');

/// Click on the previous button

// load the previous page and wait for the response
await expect_1_gql(page, 'button[id=previous]');

// make sure we got the new content
await expectToBe(page, 'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks');

// there should be a next page
await expectToContain(page, `"hasNextPage":true`);
// there should be no previous page
await expectToContain(page, `"hasPreviousPage":false`);

/// Click on the next button

// load the next page and wait for the response
await expect_1_gql(page, 'button[id=next]');

// there should be no previous page
await expectToContain(page, `"hasPreviousPage":false`);
// there should be a next page
await expectToContain(page, `"hasNextPage":true`);

// make sure we got the new content
await expectToBe(
page,
'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford'
);

/// Click on the next button

// load the next page and wait for the response
await expect_1_gql(page, 'button[id=next]');

// there should be no previous page
await expectToContain(page, `"hasPreviousPage":false`);
// there should be a next page
await expectToContain(page, `"hasNextPage":false`);

// make sure we got the new content
await expectToBe(
page,
'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood'
);
});

test('forwards then backwards and then forwards again', async ({ page }) => {
await goto(page, routes.Pagination_fragment_bidirectional_cursor);

await expectToBe(page, 'Morgan Freeman, Tom Hanks');

/// Click on the next button

// load the next page and wait for the response
await expect_1_gql(page, 'button[id=next]');

// there should be no previous page
await expectToContain(page, `"hasPreviousPage":true`);
// there should be a next page
await expectToContain(page, `"hasNextPage":true`);

// make sure we got the new content
await expectToBe(page, 'Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford');

/// Click on the previous button

// load the previous page and wait for the response
await expect_1_gql(page, 'button[id=previous]');

// make sure we got the new content
await expectToBe(
page,
'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford'
);

// there should be a next page
await expectToContain(page, `"hasNextPage":true`);
// there should be no previous page
await expectToContain(page, `"hasPreviousPage":false`);

/// Click on the next button

// load the next page and wait for the response
await expect_1_gql(page, 'button[id=next]');

// there should be no previous page
await expectToContain(page, `"hasPreviousPage":false`);
// there should be a next page
await expectToContain(page, `"hasNextPage":false`);

// make sure we got the new content
await expectToBe(
page,
'Bruce Willis, Samuel Jackson, Morgan Freeman, Tom Hanks, Will Smith, Harrison Ford, Eddie Murphy, Clint Eastwood'
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { CachePolicy, graphql } from '$houdini';
const result = graphql(`
query BidirectionalCursorPaginationQuery @load {
usersConnection(
after: "YXJyYXljb25uZWN0aW9uOjE="
first: 2
snapshot: "pagination-query-bdiriectional-cursor"
) @paginate {
edges {
node {
name
}
}
}
}
`);
</script>

<div id="result">
{$result.data?.usersConnection.edges.map(({ node }) => node?.name).join(', ')}
</div>

<div id="pageInfo">
{JSON.stringify($result.pageInfo)}
</div>

<button id="previous" on:click={() => result.loadPreviousPage()}>previous</button>
<button id="next" on:click={() => result.loadNextPage()}>next</button>

<button id="refetch" on:click={() => result.fetch({ policy: CachePolicy.NetworkOnly })}
>refetch</button
>
Loading

0 comments on commit 3245233

Please sign in to comment.