Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bidirectional cursor pagination #846

Merged
merged 27 commits into from
Jan 25, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c4ce9fd
remove direction from refetch object
AlecAivazis Jan 24, 2023
0a63dfa
more store definitions
AlecAivazis Jan 24, 2023
3a17847
fix build errors
AlecAivazis Jan 24, 2023
eed1ff5
add missing methods to query store
AlecAivazis Jan 24, 2023
add31a5
add e2e test
AlecAivazis Jan 24, 2023
32ddc5e
fix initial args in bidirectional pagination test
AlecAivazis Jan 24, 2023
f2242b6
add previous button
AlecAivazis Jan 24, 2023
8ef24a2
fix connection implementation on server
AlecAivazis Jan 24, 2023
5b7663c
tidy up docs
AlecAivazis Jan 24, 2023
d3b41c0
treat updates as a list of strings to match against
AlecAivazis Jan 25, 2023
8ca9113
start on query modification
AlecAivazis Jan 25, 2023
cbc6684
only generate pagination queries that match the api
AlecAivazis Jan 25, 2023
3d7f7cc
fix query arguments when loading pages
AlecAivazis Jan 25, 2023
42931c8
cache respects page info when handling cursor updates
AlecAivazis Jan 25, 2023
45becdc
bidirectional e2e query tests pass :tada:
AlecAivazis Jan 25, 2023
c83a198
add tests for bidirectional fragments
AlecAivazis Jan 25, 2023
edd9648
tests pass
AlecAivazis Jan 25, 2023
3d6fb74
changesets
AlecAivazis Jan 25, 2023
af4e201
check pagination direction
AlecAivazis Jan 25, 2023
5185535
linter
AlecAivazis Jan 25, 2023
ffd7421
update snapshots
AlecAivazis Jan 25, 2023
9b2e4ec
tidy up docs
AlecAivazis Jan 25, 2023
c01eb3f
release notes
AlecAivazis Jan 25, 2023
4643710
remove logs
AlecAivazis Jan 25, 2023
94d9f9b
fix release notes
AlecAivazis Jan 25, 2023
3a1309d
tidy up release notes
AlecAivazis Jan 25, 2023
7dfd768
add license
AlecAivazis Jan 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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