diff --git a/frontend/src/components/AddEmailForm.tsx b/frontend/src/components/AddEmailForm.tsx index 468f9a93..86b9785c 100644 --- a/frontend/src/components/AddEmailForm.tsx +++ b/frontend/src/components/AddEmailForm.tsx @@ -25,6 +25,9 @@ const ADD_EMAIL_MUTATION = graphql(/* GraphQL */ ` mutation AddEmail($userId: ID!, $email: String!) { addEmail(input: { userId: $userId, email: $email }) { status + user { + id + } email { id ...UserEmail_email diff --git a/frontend/src/components/UserEmailList.tsx b/frontend/src/components/UserEmailList.tsx index f9fccfb5..6925398e 100644 --- a/frontend/src/components/UserEmailList.tsx +++ b/frontend/src/components/UserEmailList.tsx @@ -14,8 +14,7 @@ import { atom, useAtomValue, useSetAtom } from "jotai"; import { atomWithQuery } from "jotai-urql"; -import { atomFamily } from "jotai/utils"; -import deepEqual from "fast-deep-equal"; +import { atomFamily, atomWithDefault } from "jotai/utils"; import { graphql } from "../gql"; import { useTransition } from "react"; @@ -24,10 +23,17 @@ import UserEmail from "./UserEmail"; import BlockList from "./BlockList"; const QUERY = graphql(/* GraphQL */ ` - query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) { + query UserEmailListQuery( + $userId: ID! + $first: Int + $after: String + $last: Int + $before: String + ) { user(id: $userId) { + __typename id - emails(first: $first, after: $after) { + emails(first: $first, after: $after, last: $last, before: $before) { edges { cursor node { @@ -35,8 +41,11 @@ const QUERY = graphql(/* GraphQL */ ` ...UserEmail_email } } + totalCount pageInfo { hasNextPage + hasPreviousPage + startCursor endCursor } } @@ -44,64 +53,145 @@ const QUERY = graphql(/* GraphQL */ ` } `); -const emailPageResultFamily = atomFamily( - ({ userId, after }: { userId: string; after: string | null }) => - atomWithQuery({ - query: QUERY, - getVariables: () => ({ userId, first: 5, after }), - }), - deepEqual -); +type ForwardPagination = { + first: int; + after: string | null; +}; -const emailPageListFamily = atomFamily((_userId: string) => - atom([null as string | null]) -); +type BackwardPagination = { + last: int; + before: string | null; +}; -const emailNextPageFamily = atomFamily((userId: string) => - atom(null, (get, set, after: string) => { - const currentList = get(emailPageListFamily(userId)); - set(emailPageListFamily(userId), [...currentList, after]); - }) -); +type Pagination = ForwardPagination | BackwardPagination; -const emailPageFamily = atomFamily((userId: string) => - atom(async (get) => { - const list = get(emailPageListFamily(userId)); - return await Promise.all( - list.map((after) => get(emailPageResultFamily({ userId, after }))) - ); - }) -); +const isForwardPagination = ( + pagination: Pagination +): pagination is ForwardPagination => { + return pagination.hasOwnProperty("first"); +}; + +const isBackwardPagination = ( + pagination: Pagination +): pagination is BackwardPagination => { + return pagination.hasOwnProperty("last"); +}; + +const pageSize = atom(6); + +const currentPagination = atomWithDefault((get) => ({ + first: get(pageSize), + after: null, +})); + +const emailPageResultFamily = atomFamily((userId: string) => { + const emailPageResult = atomWithQuery({ + query: QUERY, + getVariables: (get) => ({ userId, ...get(currentPagination) }), + }); + return emailPageResult; +}); + +const nextPagePaginationFamily = atomFamily((userId: string) => { + const nextPagePagination = atom( + async (get): Promise => { + // If we are paginating backwards, we can assume there is a next page + const pagination = get(currentPagination); + const hasProbablyNextPage = + isBackwardPagination(pagination) && pagination.before !== null; + + const result = await get(emailPageResultFamily(userId)); + const pageInfo = result.data?.user?.emails?.pageInfo; + if (pageInfo?.hasNextPage || hasProbablyNextPage) { + return { + first: get(pageSize), + after: pageInfo?.endCursor ?? null, + }; + } + + return null; + } + ); + return nextPagePagination; +}); + +const prevPagePaginationFamily = atomFamily((userId: string) => { + const prevPagePagination = atom( + async (get): Promise => { + // If we are paginating forwards, we can assume there is a previous page + const pagination = get(currentPagination); + const hasProbablyPreviousPage = + isForwardPagination(pagination) && pagination.after !== null; + + const result = await get(emailPageResultFamily(userId)); + const pageInfo = result.data?.user?.emails?.pageInfo; + if (pageInfo?.hasPreviousPage || hasProbablyPreviousPage) { + return { + last: get(pageSize), + before: pageInfo?.startCursor ?? null, + }; + } + + return null; + } + ); + return prevPagePagination; +}); const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => { const [pending, startTransition] = useTransition(); - const result = useAtomValue(emailPageFamily(userId)); - const setLoadNextPage = useSetAtom(emailNextPageFamily(userId)); - const endPageInfo = result[result.length - 1]?.data?.user?.emails?.pageInfo; + const result = useAtomValue(emailPageResultFamily(userId)); + const setPagination = useSetAtom(currentPagination); + const nextPagePagination = useAtomValue(nextPagePaginationFamily(userId)); + const prevPagePagination = useAtomValue(prevPagePaginationFamily(userId)); - const loadNextPage = () => { - if (endPageInfo?.hasNextPage && endPageInfo.endCursor) { - const cursor = endPageInfo.endCursor; - startTransition(() => { - setLoadNextPage(cursor); - }); - } + const paginate = (pagination: Pagination) => { + startTransition(() => { + setPagination(pagination); + }); }; return ( - - {result.flatMap( - (page) => - page.data?.user?.emails?.edges?.map((edge) => ( - - )) || [] - )} - {endPageInfo?.hasNextPage && ( - - )} - +
+
+ {prevPagePagination ? ( + + ) : ( + + )} +
+ Total: {result.data?.user?.emails?.totalCount} +
+ {nextPagePagination ? ( + + ) : ( + + )} +
+ + {result.data?.user?.emails?.edges?.map((edge) => ( + + ))} + +
); }; diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 8c008a7e..665a47e6 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -13,7 +13,7 @@ import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/ * Therefore it is highly recommended to use the babel or swc plugin for production. */ const documents = { - "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": + "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n user {\n id\n }\n email {\n id\n ...UserEmail_email\n }\n }\n }\n": types.AddEmailDocument, "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": types.BrowserSession_SessionFragmentDoc, @@ -29,7 +29,7 @@ const documents = { types.OAuth2SessionList_UserFragmentDoc, "\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, - "\n query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n": + "\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n __typename\n id\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.UserEmailListQueryDocument, "\n query CurrentUserQuery {\n viewer {\n ... on User {\n __typename\n id\n }\n }\n }\n": types.CurrentUserQueryDocument, @@ -61,8 +61,8 @@ export function graphql(source: string): unknown; * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n email {\n id\n ...UserEmail_email\n }\n }\n }\n" -): (typeof documents)["\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"]; + source: "\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n user {\n id\n }\n email {\n id\n ...UserEmail_email\n }\n }\n }\n" +): (typeof documents)["\n mutation AddEmail($userId: ID!, $email: String!) {\n addEmail(input: { userId: $userId, email: $email }) {\n status\n user {\n id\n }\n email {\n id\n ...UserEmail_email\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -109,8 +109,8 @@ export function graphql( * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql( - source: "\n query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n" -): (typeof documents)["\n query UserEmailListQuery($userId: ID!, $first: Int!, $after: String) {\n user(id: $userId) {\n id\n emails(first: $first, after: $after) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n pageInfo {\n hasNextPage\n endCursor\n }\n }\n }\n }\n"]; + source: "\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n __typename\n id\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" +): (typeof documents)["\n query UserEmailListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n __typename\n id\n emails(first: $first, after: $after, last: $last, before: $before) {\n edges {\n cursor\n node {\n id\n ...UserEmail_email\n }\n }\n totalCount\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts index d9ae854b..d2d9563d 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -587,6 +587,7 @@ export type AddEmailMutation = { addEmail: { __typename?: "AddEmailPayload"; status: AddEmailStatus; + user: { __typename?: "User"; id: string }; email: { __typename?: "UserEmail"; id: string } & { " $fragmentRefs"?: { UserEmail_EmailFragment: UserEmail_EmailFragment }; }; @@ -688,17 +689,20 @@ export type UserEmail_EmailFragment = { export type UserEmailListQueryQueryVariables = Exact<{ userId: Scalars["ID"]; - first: Scalars["Int"]; + first?: InputMaybe; after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; }>; export type UserEmailListQueryQuery = { __typename?: "Query"; user?: { - __typename?: "User"; + __typename: "User"; id: string; emails: { __typename?: "UserEmailConnection"; + totalCount: number; edges: Array<{ __typename?: "UserEmailEdge"; cursor: string; @@ -711,6 +715,8 @@ export type UserEmailListQueryQuery = { pageInfo: { __typename?: "PageInfo"; hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; endCursor?: string | null; }; }; @@ -1294,6 +1300,16 @@ export const AddEmailDocument = { kind: "SelectionSet", selections: [ { kind: "Field", name: { kind: "Name", value: "status" } }, + { + kind: "Field", + name: { kind: "Name", value: "user" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { kind: "Field", name: { kind: "Name", value: "id" } }, + ], + }, + }, { kind: "Field", name: { kind: "Name", value: "email" }, @@ -1358,10 +1374,7 @@ export const UserEmailListQueryDocument = { kind: "Variable", name: { kind: "Name", value: "first" }, }, - type: { - kind: "NonNullType", - type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, - }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, }, { kind: "VariableDefinition", @@ -1371,6 +1384,19 @@ export const UserEmailListQueryDocument = { }, type: { kind: "NamedType", name: { kind: "Name", value: "String" } }, }, + { + kind: "VariableDefinition", + variable: { kind: "Variable", name: { kind: "Name", value: "last" } }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "before" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "String" } }, + }, ], selectionSet: { kind: "SelectionSet", @@ -1391,6 +1417,7 @@ export const UserEmailListQueryDocument = { selectionSet: { kind: "SelectionSet", selections: [ + { kind: "Field", name: { kind: "Name", value: "__typename" } }, { kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", @@ -1412,6 +1439,22 @@ export const UserEmailListQueryDocument = { name: { kind: "Name", value: "after" }, }, }, + { + kind: "Argument", + name: { kind: "Name", value: "last" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "last" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "before" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "before" }, + }, + }, ], selectionSet: { kind: "SelectionSet", @@ -1449,6 +1492,10 @@ export const UserEmailListQueryDocument = { ], }, }, + { + kind: "Field", + name: { kind: "Name", value: "totalCount" }, + }, { kind: "Field", name: { kind: "Name", value: "pageInfo" }, @@ -1459,6 +1506,14 @@ export const UserEmailListQueryDocument = { kind: "Field", name: { kind: "Name", value: "hasNextPage" }, }, + { + kind: "Field", + name: { kind: "Name", value: "hasPreviousPage" }, + }, + { + kind: "Field", + name: { kind: "Name", value: "startCursor" }, + }, { kind: "Field", name: { kind: "Name", value: "endCursor" }, diff --git a/frontend/src/graphql.ts b/frontend/src/graphql.ts index f220fd72..8f8f8a17 100644 --- a/frontend/src/graphql.ts +++ b/frontend/src/graphql.ts @@ -16,8 +16,34 @@ import { createClient, fetchExchange } from "@urql/core"; import { cacheExchange } from "@urql/exchange-graphcache"; import schema from "./gql/schema"; +import type { MutationAddEmailArgs } from "./gql/graphql"; export const client = createClient({ url: "/graphql", - exchanges: [cacheExchange({ schema }), fetchExchange], + // XXX: else queries don't refetch on cache invalidation for some reason + requestPolicy: "cache-and-network", + exchanges: [ + cacheExchange({ + schema, + updates: { + Mutation: { + addEmail: (result, args: MutationAddEmailArgs, cache, _info) => { + const key = cache.keyOfEntity({ + __typename: "User", + id: args.input.userId, + }); + + // Invalidate the emails field on the User object so that it gets refetched + cache + .inspectFields(key) + .filter((field) => field.fieldName === "emails") + .forEach((field) => { + cache.invalidate(key, field.fieldName, field.arguments); + }); + }, + }, + }, + }), + fetchExchange, + ], });