diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fd2bc2d65..4a89186bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,8 @@ "@urql/core": "^4.0.7", "@urql/devtools": "^2.0.3", "@urql/exchange-graphcache": "^6.0.3", + "@urql/exchange-refocus": "^1.0.2", + "@urql/exchange-request-policy": "^1.0.2", "date-fns": "^2.29.3", "graphql": "^16.6.0", "jotai": "^2.0.4", @@ -6463,6 +6465,24 @@ "wonka": "^6.3.2" } }, + "node_modules/@urql/exchange-refocus": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-refocus/-/exchange-refocus-1.0.2.tgz", + "integrity": "sha512-Lrsx33pGiC/cwysGvtE8c+ERUymZFq9pXUE40sivhLTHgMrHdKcinEFvjk9XgUb+6FtYw0d0DDJVe42KQZ5z1g==", + "dependencies": { + "@urql/core": ">=4.0.0", + "wonka": "^6.3.2" + } + }, + "node_modules/@urql/exchange-request-policy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@urql/exchange-request-policy/-/exchange-request-policy-1.0.2.tgz", + "integrity": "sha512-IEC7BQGe6y5Wx3XY5PUUKSaB2TQcYkdBPw1pvgQFd09HxHyizGiB1u2Ziupz2Rmf29Eu/ZlJyxIIHcpebIliWg==", + "dependencies": { + "@urql/core": ">=4.0.0", + "wonka": "^6.3.2" + } + }, "node_modules/@urql/introspection": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@urql/introspection/-/introspection-0.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 108ba20d4..6c4ac603d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,8 @@ "@urql/core": "^4.0.7", "@urql/devtools": "^2.0.3", "@urql/exchange-graphcache": "^6.0.3", + "@urql/exchange-refocus": "^1.0.2", + "@urql/exchange-request-policy": "^1.0.2", "date-fns": "^2.29.3", "graphql": "^16.6.0", "jotai": "^2.0.4", diff --git a/frontend/src/components/BrowserSessionList.tsx b/frontend/src/components/BrowserSessionList.tsx index 0ad37427b..2ed8d9fe6 100644 --- a/frontend/src/components/BrowserSessionList.tsx +++ b/frontend/src/components/BrowserSessionList.tsx @@ -12,20 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { useTransition } from "react"; +import { atomFamily, atomWithDefault } from "jotai/utils"; +import { atomWithQuery } from "jotai-urql"; +import { atom, useAtomValue, useSetAtom } from "jotai"; + import BlockList from "./BlockList"; import BrowserSession from "./BrowserSession"; import { Title } from "./Typography"; +import PaginationControls from "./PaginationControls"; import { graphql } from "../gql"; -import { atomFamily } from "jotai/utils"; -import { atomWithQuery } from "jotai-urql"; -import { useAtomValue } from "jotai"; import { currentBrowserSessionIdAtom } from "../atoms"; +import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination"; +import { PageInfo } from "../gql/graphql"; const QUERY = graphql(/* GraphQL */ ` - query BrowserSessionList($userId: ID!) { + query BrowserSessionList( + $userId: ID! + $first: Int + $after: String + $last: Int + $before: String + ) { user(id: $userId) { id - browserSessions(first: 10) { + browserSessions( + first: $first + after: $after + last: $last + before: $before + ) { edges { cursor node { @@ -45,23 +61,59 @@ const QUERY = graphql(/* GraphQL */ ` } `); +const currentPagination = atomWithDefault((get) => ({ + first: get(pageSizeAtom), + after: null, +})); + const browserSessionListFamily = atomFamily((userId: string) => { const browserSessionList = atomWithQuery({ query: QUERY, - getVariables: () => ({ userId }), + getVariables: (get) => ({ userId, ...get(currentPagination) }), }); return browserSessionList; }); +const pageInfoFamily = atomFamily((userId: string) => { + const pageInfoAtom = atom(async (get): Promise => { + const result = await get(browserSessionListFamily(userId)); + return result.data?.user?.browserSessions?.pageInfo ?? null; + }); + return pageInfoAtom; +}); + +const paginationFamily = atomFamily((userId: string) => { + const paginationAtom = atomWithPagination( + currentPagination, + pageInfoFamily(userId) + ); + + return paginationAtom; +}); + const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { - const result = useAtomValue(browserSessionListFamily(userId)); const currentSessionId = useAtomValue(currentBrowserSessionIdAtom); + const [pending, startTransition] = useTransition(); + const result = useAtomValue(browserSessionListFamily(userId)); + const setPagination = useSetAtom(currentPagination); + const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); + + const paginate = (pagination: Pagination) => { + startTransition(() => { + setPagination(pagination); + }); + }; if (result.data?.user?.browserSessions) { const data = result.data.user.browserSessions; return ( List of browser sessions: + paginate(prevPage) : null} + onNext={nextPage ? () => paginate(nextPage) : null} + disabled={pending} + /> {data.edges.map((n) => ( ((get) => ({ + first: get(pageSizeAtom), + after: null, +})); + const compatSsoLoginListFamily = atomFamily((userId: string) => { const compatSsoLoginList = atomWithQuery({ query: QUERY, - getVariables: () => ({ userId }), + getVariables: (get) => ({ userId, ...get(currentPagination) }), }); return compatSsoLoginList; }); +const pageInfoFamily = atomFamily((userId: string) => { + const pageInfoAtom = atom(async (get): Promise => { + const result = await get(compatSsoLoginListFamily(userId)); + return result.data?.user?.oauth2Sessions?.pageInfo ?? null; + }); + + return pageInfoAtom; +}); + +const paginationFamily = atomFamily((userId: string) => { + const paginationAtom = atomWithPagination( + currentPagination, + pageInfoFamily(userId) + ); + return paginationAtom; +}); + const CompatSsoLoginList: React.FC<{ userId: string }> = ({ userId }) => { + const [pending, startTransition] = useTransition(); const result = useAtomValue(compatSsoLoginListFamily(userId)); + const setPagination = useSetAtom(currentPagination); + const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); + + const paginate = (pagination: Pagination) => { + startTransition(() => { + setPagination(pagination); + }); + }; if (result.data?.user?.compatSsoLogins) { const data = result.data.user.compatSsoLogins; return ( List of compatibility sessions: + paginate(prevPage) : null} + onNext={nextPage ? () => paginate(nextPage) : null} + disabled={pending} + /> {data.edges.map((n) => ( ))} diff --git a/frontend/src/components/OAuth2SessionList.tsx b/frontend/src/components/OAuth2SessionList.tsx index 68e511a14..39d20a4fb 100644 --- a/frontend/src/components/OAuth2SessionList.tsx +++ b/frontend/src/components/OAuth2SessionList.tsx @@ -12,21 +12,36 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { atomFamily } from "jotai/utils"; +import { atomFamily, atomWithDefault } from "jotai/utils"; import { atomWithQuery } from "jotai-urql"; -import { useAtomValue } from "jotai"; +import { useAtomValue, atom, useSetAtom } from "jotai"; import BlockList from "./BlockList"; import OAuth2Session from "./OAuth2Session"; import { Title } from "./Typography"; import { graphql } from "../gql"; +import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination"; +import { PageInfo } from "../gql/graphql"; +import PaginationControls from "./PaginationControls"; +import { useTransition } from "react"; const QUERY = graphql(/* GraphQL */ ` - query OAuth2SessionListQuery($userId: ID!) { + query OAuth2SessionListQuery( + $userId: ID! + $first: Int + $after: String + $last: Int + $before: String + ) { user(id: $userId) { id - oauth2Sessions(first: 10) { + oauth2Sessions( + first: $first + after: $after + last: $last + before: $before + ) { edges { cursor node { @@ -34,32 +49,75 @@ const QUERY = graphql(/* GraphQL */ ` ...OAuth2Session_session } } + + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } } } } `); +const currentPagination = atomWithDefault((get) => ({ + first: get(pageSizeAtom), + after: null, +})); + const oauth2SessionListFamily = atomFamily((userId: string) => { const oauth2SessionList = atomWithQuery({ query: QUERY, - getVariables: () => ({ userId }), + getVariables: (get) => ({ userId, ...get(currentPagination) }), }); return oauth2SessionList; }); +const pageInfoFamily = atomFamily((userId: string) => { + const pageInfoAtom = atom(async (get): Promise => { + const result = await get(oauth2SessionListFamily(userId)); + return result.data?.user?.oauth2Sessions?.pageInfo ?? null; + }); + + return pageInfoAtom; +}); + +const paginationFamily = atomFamily((userId: string) => { + const paginationAtom = atomWithPagination( + currentPagination, + pageInfoFamily(userId) + ); + return paginationAtom; +}); + type Props = { userId: string; }; const OAuth2SessionList: React.FC = ({ userId }) => { + const [pending, startTransition] = useTransition(); const result = useAtomValue(oauth2SessionListFamily(userId)); + const setPagination = useSetAtom(currentPagination); + const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); + + const paginate = (pagination: Pagination) => { + startTransition(() => { + setPagination(pagination); + }); + }; if (result.data?.user?.oauth2Sessions) { const data = result.data.user.oauth2Sessions; return ( List of OAuth 2.0 sessions: + paginate(prevPage) : null} + onNext={nextPage ? () => paginate(nextPage) : null} + disabled={pending} + /> {data.edges.map((n) => ( ))} diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/PaginationControls.tsx similarity index 91% rename from frontend/src/components/Pagination.tsx rename to frontend/src/components/PaginationControls.tsx index 6f48e7fdf..a847a918c 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/PaginationControls.tsx @@ -21,7 +21,12 @@ type Props = { disabled?: boolean; }; -const Pagination: React.FC = ({ onNext, onPrev, count, disabled }) => { +const PaginationControls: React.FC = ({ + onNext, + onPrev, + count, + disabled, +}) => { return (
{onPrev ? ( @@ -51,4 +56,4 @@ const Pagination: React.FC = ({ onNext, onPrev, count, disabled }) => { ); }; -export default Pagination; +export default PaginationControls; diff --git a/frontend/src/components/UserEmailList.tsx b/frontend/src/components/UserEmailList.tsx index 5611cd9e0..f5fcc5f15 100644 --- a/frontend/src/components/UserEmailList.tsx +++ b/frontend/src/components/UserEmailList.tsx @@ -20,7 +20,9 @@ import { graphql } from "../gql"; import { useTransition } from "react"; import UserEmail from "./UserEmail"; import BlockList from "./BlockList"; -import Pagination from "./Pagination"; +import PaginationControls from "./PaginationControls"; +import { atomWithPagination, pageSizeAtom, Pagination } from "../pagination"; +import { PageInfo } from "../gql/graphql"; const QUERY = graphql(/* GraphQL */ ` query UserEmailListQuery( @@ -52,34 +54,8 @@ const QUERY = graphql(/* GraphQL */ ` } `); -type ForwardPagination = { - first: number; - after: string | null; -}; - -type BackwardPagination = { - last: number; - before: string | null; -}; - -type Pagination = ForwardPagination | BackwardPagination; - -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), + first: get(pageSizeAtom), after: null, })); @@ -91,58 +67,28 @@ export const emailPageResultFamily = atomFamily((userId: string) => { 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 pageInfoFamily = atomFamily((userId: string) => { + const pageInfoAtom = atom(async (get): Promise => { + const result = await get(emailPageResultFamily(userId)); + return result.data?.user?.emails?.pageInfo ?? 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; + return pageInfoAtom; }); -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; - } +const paginationFamily = atomFamily((userId: string) => { + const paginationAtom = atomWithPagination( + currentPagination, + pageInfoFamily(userId) ); - return prevPagePagination; + return paginationAtom; }); const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => { const [pending, startTransition] = useTransition(); const result = useAtomValue(emailPageResultFamily(userId)); const setPagination = useSetAtom(currentPagination); - const nextPagePagination = useAtomValue(nextPagePaginationFamily(userId)); - const prevPagePagination = useAtomValue(prevPagePaginationFamily(userId)); + const [prevPage, nextPage] = useAtomValue(paginationFamily(userId)); const paginate = (pagination: Pagination) => { startTransition(() => { @@ -152,10 +98,10 @@ const UserEmailList: React.FC<{ userId: string }> = ({ userId }) => { return ( - paginate(prevPagePagination) : null} - onNext={nextPagePagination ? () => paginate(nextPagePagination) : null} + onPrev={prevPage ? () => paginate(prevPage) : null} + onNext={nextPage ? () => paginate(nextPage) : null} disabled={pending} /> {result.data?.user?.emails?.edges?.map((edge) => ( diff --git a/frontend/src/gql/gql.ts b/frontend/src/gql/gql.ts index 9b0c02665..9e3914971 100644 --- a/frontend/src/gql/gql.ts +++ b/frontend/src/gql/gql.ts @@ -21,15 +21,15 @@ const documents = { types.AddEmailDocument, "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n lastAuthentication {\n id\n createdAt\n }\n }\n": types.BrowserSession_SessionFragmentDoc, - "\n query BrowserSessionList($userId: ID!) {\n user(id: $userId) {\n id\n browserSessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": + "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.BrowserSessionListDocument, "\n fragment CompatSsoLogin_login on CompatSsoLogin {\n id\n redirectUri\n createdAt\n session {\n id\n createdAt\n deviceId\n finishedAt\n }\n }\n": types.CompatSsoLogin_LoginFragmentDoc, - "\n query CompatSsoLoginList($userId: ID!) {\n user(id: $userId) {\n id\n compatSsoLogins(first: 10) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n }\n": + "\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n }\n": types.CompatSsoLoginListDocument, "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n client {\n id\n clientId\n clientName\n clientUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc, - "\n query OAuth2SessionListQuery($userId: ID!) {\n user(id: $userId) {\n id\n oauth2Sessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n }\n": + "\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n": types.OAuth2SessionListQueryDocument, "\n fragment UserEmail_email on UserEmail {\n id\n email\n createdAt\n confirmedAt\n }\n": types.UserEmail_EmailFragmentDoc, @@ -85,8 +85,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 BrowserSessionList($userId: ID!) {\n user(id: $userId) {\n id\n browserSessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" -): (typeof documents)["\n query BrowserSessionList($userId: ID!) {\n user(id: $userId) {\n id\n browserSessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n"]; + source: "\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" +): (typeof documents)["\n query BrowserSessionList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n browserSessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...BrowserSession_session\n }\n }\n\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. */ @@ -97,8 +97,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 CompatSsoLoginList($userId: ID!) {\n user(id: $userId) {\n id\n compatSsoLogins(first: 10) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n }\n" -): (typeof documents)["\n query CompatSsoLoginList($userId: ID!) {\n user(id: $userId) {\n id\n compatSsoLogins(first: 10) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n }\n"]; + source: "\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\n }\n }\n }\n" +): (typeof documents)["\n query CompatSsoLoginList(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n compatSsoLogins(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n node {\n id\n ...CompatSsoLogin_login\n }\n }\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 OAuth2SessionListQuery($userId: ID!) {\n user(id: $userId) {\n id\n oauth2Sessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n }\n" -): (typeof documents)["\n query OAuth2SessionListQuery($userId: ID!) {\n user(id: $userId) {\n id\n oauth2Sessions(first: 10) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n }\n }\n }\n"]; + source: "\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n" +): (typeof documents)["\n query OAuth2SessionListQuery(\n $userId: ID!\n $first: Int\n $after: String\n $last: Int\n $before: String\n ) {\n user(id: $userId) {\n id\n oauth2Sessions(\n first: $first\n after: $after\n last: $last\n before: $before\n ) {\n edges {\n cursor\n node {\n id\n ...OAuth2Session_session\n }\n }\n\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 d8b0f1678..6a0615a6b 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -626,6 +626,10 @@ export type BrowserSession_SessionFragment = { export type BrowserSessionListQueryVariables = Exact<{ userId: Scalars["ID"]; + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; }>; export type BrowserSessionListQuery = { @@ -671,6 +675,10 @@ export type CompatSsoLogin_LoginFragment = { export type CompatSsoLoginListQueryVariables = Exact<{ userId: Scalars["ID"]; + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; }>; export type CompatSsoLoginListQuery = { @@ -707,6 +715,10 @@ export type OAuth2Session_SessionFragment = { export type OAuth2SessionListQueryQueryVariables = Exact<{ userId: Scalars["ID"]; + first?: InputMaybe; + after?: InputMaybe; + last?: InputMaybe; + before?: InputMaybe; }>; export type OAuth2SessionListQueryQuery = { @@ -725,6 +737,13 @@ export type OAuth2SessionListQueryQuery = { }; }; }>; + pageInfo: { + __typename?: "PageInfo"; + hasNextPage: boolean; + hasPreviousPage: boolean; + startCursor?: string | null; + endCursor?: string | null; + }; }; } | null; }; @@ -1176,6 +1195,35 @@ export const BrowserSessionListDocument = { type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, }, }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "after" }, + }, + 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", @@ -1204,7 +1252,34 @@ export const BrowserSessionListDocument = { { kind: "Argument", name: { kind: "Name", value: "first" }, - value: { kind: "IntValue", value: "10" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "after" }, + value: { + kind: "Variable", + 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: { @@ -1327,6 +1402,35 @@ export const CompatSsoLoginListDocument = { type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, }, }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "after" }, + }, + 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", @@ -1355,7 +1459,34 @@ export const CompatSsoLoginListDocument = { { kind: "Argument", name: { kind: "Name", value: "first" }, - value: { kind: "IntValue", value: "10" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "after" }, + value: { + kind: "Variable", + 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: { @@ -1452,6 +1583,35 @@ export const OAuth2SessionListQueryDocument = { type: { kind: "NamedType", name: { kind: "Name", value: "ID" } }, }, }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + type: { kind: "NamedType", name: { kind: "Name", value: "Int" } }, + }, + { + kind: "VariableDefinition", + variable: { + kind: "Variable", + name: { kind: "Name", value: "after" }, + }, + 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", @@ -1480,7 +1640,34 @@ export const OAuth2SessionListQueryDocument = { { kind: "Argument", name: { kind: "Name", value: "first" }, - value: { kind: "IntValue", value: "10" }, + value: { + kind: "Variable", + name: { kind: "Name", value: "first" }, + }, + }, + { + kind: "Argument", + name: { kind: "Name", value: "after" }, + value: { + kind: "Variable", + 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: { @@ -1519,6 +1706,31 @@ export const OAuth2SessionListQueryDocument = { ], }, }, + { + kind: "Field", + name: { kind: "Name", value: "pageInfo" }, + selectionSet: { + kind: "SelectionSet", + selections: [ + { + 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 09c71aeb4..c184106d7 100644 --- a/frontend/src/graphql.ts +++ b/frontend/src/graphql.ts @@ -14,10 +14,12 @@ import { createClient, fetchExchange } from "@urql/core"; import { cacheExchange } from "@urql/exchange-graphcache"; +import { devtoolsExchange } from "@urql/devtools"; +import { refocusExchange } from "@urql/exchange-refocus"; import schema from "./gql/schema"; import type { MutationAddEmailArgs } from "./gql/graphql"; -import { devtoolsExchange } from "@urql/devtools"; +import { requestPolicyExchange } from "@urql/exchange-request-policy"; const cache = cacheExchange({ schema, @@ -41,9 +43,24 @@ const cache = cacheExchange({ }, }); +const exchanges = [ + // This sets the policy to 'cache-and-network' after 5 minutes + requestPolicyExchange({ + ttl: 1000 * 60 * 5, // 5 minute + }), + + // This refetches all queries when the tab is refocused + refocusExchange(), + + // The unified cache + cache, + + // Use `fetch` to execute the requests + fetchExchange, +]; + export const client = createClient({ url: "/graphql", - exchanges: import.meta.env.DEV - ? [devtoolsExchange, cache, fetchExchange] - : [cache, fetchExchange], + // Add the devtools exchange in development + exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges, }); diff --git a/frontend/src/pagination.ts b/frontend/src/pagination.ts new file mode 100644 index 000000000..ea51217b3 --- /dev/null +++ b/frontend/src/pagination.ts @@ -0,0 +1,87 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { atom, Atom } from "jotai"; +import { PageInfo } from "./gql/graphql"; + +export type ForwardPagination = { + first: number; + after: string | null; +}; + +export type BackwardPagination = { + last: number; + before: string | null; +}; + +export type Pagination = ForwardPagination | BackwardPagination; + +// Check if the pagination is forward pagination. +export const isForwardPagination = ( + pagination: Pagination +): pagination is ForwardPagination => { + return pagination.hasOwnProperty("first"); +}; + +// Check if the pagination is backward pagination. +export const isBackwardPagination = ( + pagination: Pagination +): pagination is BackwardPagination => { + return pagination.hasOwnProperty("last"); +}; + +// This atom sets the default page size for pagination. +export const pageSizeAtom = atom(6); + +// This atom is used to create a pagination atom that gives the previous and +// next pagination objects, given the current pagination and the page info. +export const atomWithPagination = ( + currentPaginationAtom: Atom, + pageInfoAtom: Atom> +): Atom> => { + const paginationAtom = atom( + async ( + get + ): Promise<[BackwardPagination | null, ForwardPagination | null]> => { + const currentPagination = get(currentPaginationAtom); + const pageInfo = await get(pageInfoAtom); + const hasProbablyPreviousPage = + isForwardPagination(currentPagination) && + currentPagination.after !== null; + const hasProbablyNextPage = + isBackwardPagination(currentPagination) && + currentPagination.before !== null; + + let previousPagination: BackwardPagination | null = null; + let nextPagination: ForwardPagination | null = null; + if (pageInfo?.hasPreviousPage || hasProbablyPreviousPage) { + previousPagination = { + last: get(pageSizeAtom), + before: pageInfo?.startCursor ?? null, + }; + } + + if (pageInfo?.hasNextPage || hasProbablyNextPage) { + nextPagination = { + first: get(pageSizeAtom), + after: pageInfo?.endCursor ?? null, + }; + } + + return [previousPagination, nextPagination]; + } + ); + + return paginationAtom; +};