From 1ec036054e04de3cde9d256d7466c96d62c139c5 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 25 Jul 2023 14:05:07 +0300 Subject: [PATCH 01/10] feat(shared): Support infinite fetching for `userInvitations` from `useOrganizationList` - Improves DX of useOrganizationList --- package-lock.json | 13 +- packages/shared/package.json | 2 +- .../shared/src/hooks/useOrganizationList.tsx | 234 +++++++++++++++++- 3 files changed, 241 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index e473d9fcc4d..50875101dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42132,7 +42132,7 @@ "dependencies": { "glob-to-regexp": "0.4.1", "js-cookie": "3.0.1", - "swr": "1.3.0" + "swr": "2.2.0" }, "devDependencies": { "@clerk/types": "^3.48.1", @@ -42145,6 +42145,17 @@ "react": ">=16" } }, + "packages/shared/node_modules/swr": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", + "integrity": "sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "packages/themes": { "name": "@clerk/themes", "version": "1.7.5", diff --git a/packages/shared/package.json b/packages/shared/package.json index 1c8c589b8e5..697f38b608e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -52,6 +52,6 @@ "dependencies": { "glob-to-regexp": "0.4.1", "js-cookie": "3.0.1", - "swr": "1.3.0" + "swr": "2.2.0" } } diff --git a/packages/shared/src/hooks/useOrganizationList.tsx b/packages/shared/src/hooks/useOrganizationList.tsx index 23bbcbab0ab..6a072319e1b 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -1,39 +1,252 @@ import type { + ClerkPaginatedResponse, CreateOrganizationParams, + GetUserOrganizationInvitationsParams, OrganizationMembershipResource, OrganizationResource, SetActive, + UserOrganizationInvitationResource, + UserResource, } from '@clerk/types'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import useSWR from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { useClerkInstanceContext, useUserContext } from './contexts'; +type UseOrganizationListParams = { + userInvitations?: + | true + | (GetUserOrganizationInvitationsParams & { + infinite?: boolean; + keepPreviousData?: boolean; + }); +}; + type OrganizationList = ReturnType; +type CustomSetAction = (size: T | ((_size: T) => T)) => void; +type PaginatedDataAPI = { + data: T[]; + count: number; + isLoadingInitial: boolean; + isLoading: boolean; + isError: boolean; + page: number; + pageCount: number; + fetchPage: CustomSetAction; + fetchPrevious: () => void; + fetchNext: () => void; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +// Utility type to convert PaginatedDataAPI to properties as undefined, except booleans set to false +type PaginatedDataAPIWithDefaults = { + [K in keyof PaginatedDataAPI]: PaginatedDataAPI[K] extends boolean ? false : PaginatedDataAPI[K] | undefined; +}; + type UseOrganizationListReturn = - | { isLoaded: false; organizationList: undefined; createOrganization: undefined; setActive: undefined } | { - isLoaded: true; + isLoaded: false; + organizationList: undefined; + createOrganization: undefined; + setActive: undefined; + userInvitations: PaginatedDataAPIWithDefaults; + } + | { + isLoaded: boolean; organizationList: OrganizationList; createOrganization: (params: CreateOrganizationParams) => Promise; setActive: SetActive; + userInvitations: PaginatedDataAPI; }; -type UseOrganizationList = () => UseOrganizationListReturn; +type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn; + +export const useOrganizationList: UseOrganizationList = params => { + const { userInvitations } = params || {}; + const [paginatedPage, setPaginatedPage] = useState(1); + + // Cache limit and offset until unmount + const offsetRef = useRef(userInvitations !== true ? userInvitations?.offset ?? 0 : 0); + const limitRef = useRef(userInvitations !== true ? userInvitations?.limit ?? 10 : 10); + + const triggerInfinite = userInvitations !== true ? userInvitations?.infinite ?? false : false; + const internalKeepPreviousData = userInvitations !== true ? userInvitations?.keepPreviousData ?? false : false; -export const useOrganizationList: UseOrganizationList = () => { const clerk = useClerkInstanceContext(); const user = useUserContext(); + const paginatedParams = + typeof userInvitations === 'undefined' + ? undefined + : { + limit: limitRef.current, + offset: offsetRef.current + (paginatedPage - 1) * limitRef.current, + }; + + const canFetch = !!(clerk.loaded && user); + + // Some gymnastics to adhere to the rules of hooks + // We need to make sure useSWR is called on every render + const fetchInvitations = !clerk.loaded + ? () => ({ data: [], total_count: 0 } as ClerkPaginatedResponse) + : () => user?.getOrganizationInvitations(paginatedParams); + + const { + data: userInvitationsData, + isValidating: userInvitationsValidating, + isLoading: userInvitationsLoading, + error: userInvitationsError, + } = useSWR( + !triggerInfinite && canFetch && paginatedParams ? cacheKey('userInvitations', user, paginatedParams) : null, + fetchInvitations, + { keepPreviousData: internalKeepPreviousData }, + ); + + const getInfiniteKey = ( + pageIndex: number, + previousPageData: ClerkPaginatedResponse | null, + ) => { + if (!canFetch || !paginatedParams || !triggerInfinite) { + return null; + } + + const limit = limitRef.current; + const offset = offsetRef.current + pageIndex * limit; + + const param = { + limit, + offset, + }; + + return cacheKey('userInvitations', user, param); + }; + + const { + data: userInvitationsDataInfinite, + isLoading: userInvitationsLoadingInfinite, + isValidating: userInvitationsInfiniteValidating, + error: userInvitationsInfiniteError, + size, + setSize, + } = useSWRInfinite(getInfiniteKey, str => { + const { offset, limit } = JSON.parse(str); + + return !clerk.loaded || !user + ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) + : user.getOrganizationInvitations({ + offset: parseInt(offset), + limit: parseInt(limit), + }); + }); + + const isomorphicPage = useMemo(() => { + if (triggerInfinite) { + return size; + } + return paginatedPage; + }, [triggerInfinite, size, paginatedPage]); + + const isomorphicSetPage: CustomSetAction = useCallback( + numberOrgFn => { + if (triggerInfinite) { + void setSize(numberOrgFn); + return; + } + return setPaginatedPage(numberOrgFn); + }, + [setSize], + ); + + const isomorphicData = useMemo(() => { + if (triggerInfinite) { + return userInvitationsDataInfinite?.map(a => a?.data).flat() ?? []; + } + return userInvitationsData?.data ?? []; + }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); + + const isomorphicCount = useMemo(() => { + if (triggerInfinite) { + return userInvitationsDataInfinite?.[userInvitationsDataInfinite?.length - 1]?.total_count || 0; + } + return userInvitationsData?.total_count ?? 0; + }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); + + /** + * Helpers + */ + const fetchNext = useCallback(() => { + isomorphicSetPage(n => n + 1); + }, [isomorphicSetPage]); + + const fetchPrevious = useCallback(() => { + isomorphicSetPage(n => n - 1); + }, [isomorphicSetPage]); + + const pageCount = Math.ceil((isomorphicCount - offsetRef.current) / limitRef.current); + const hasNextPage = isomorphicCount - offsetRef.current > isomorphicPage * limitRef.current; + const hasPreviousPage = (isomorphicPage - 1) * limitRef.current > offsetRef.current; + // TODO: Properly check for SSR user values if (!clerk.loaded || !user) { - return { isLoaded: false, organizationList: undefined, createOrganization: undefined, setActive: undefined }; + return { + isLoaded: false, + organizationList: undefined, + createOrganization: undefined, + setActive: undefined, + userInvitations: { + data: undefined, + count: undefined, + isLoadingInitial: false, + isLoading: false, + isError: false, + page: undefined, + pageCount: undefined, + fetchPage: undefined, + fetchNext: undefined, + fetchPrevious: undefined, + hasNextPage: false, + hasPreviousPage: false, + }, + }; } return { - isLoaded: true, + isLoaded: canFetch, organizationList: createOrganizationList(user.organizationMemberships), setActive: clerk.setActive, createOrganization: clerk.createOrganization, + userInvitations: triggerInfinite + ? { + data: isomorphicData, + count: isomorphicCount, + isLoadingInitial: userInvitationsLoadingInfinite, + isLoading: userInvitationsInfiniteValidating, + isError: !!userInvitationsInfiniteError, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + } + : { + data: isomorphicData, + count: isomorphicCount, + isLoadingInitial: userInvitationsLoading, + isError: !!userInvitationsError, + isLoading: userInvitationsValidating, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + }, }; }; @@ -43,3 +256,12 @@ function createOrganizationList(organizationMemberships: OrganizationMembershipR organization: organizationMembership.organization, })); } + +function cacheKey(type: 'userInvitations', user: UserResource, pagination: GetUserOrganizationInvitationsParams) { + return JSON.stringify({ + type, + userId: user.id, + offset: pagination.offset, + limit: pagination.limit, + }); +} From 8b84a57fc2d31217b9f23a0c54fb1611219f367e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 25 Jul 2023 21:37:48 +0300 Subject: [PATCH 02/10] chore(clerk-js,shared,clerk-react): Deprecate OrganizationContext and introduce OrganizationProvider We needed to wrap with SWRConfig provider so that useSWR hooks can read from the correct context in tests. --- .../ui/contexts/CoreClerkContextWrapper.tsx | 10 ++-- .../ui/contexts/CoreOrganizationContext.tsx | 4 +- .../src/ui/utils/test/createFixtures.tsx | 41 ++++++++-------- .../src/contexts/ClerkContextProvider.tsx | 6 +-- .../src/contexts/OrganizationContext.tsx | 2 +- packages/shared/src/hooks/contexts.tsx | 47 ++++++++++++++++++- packages/shared/src/index.ts | 1 + 7 files changed, 80 insertions(+), 31 deletions(-) diff --git a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 8709f987f23..0d427f39aef 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { CoreClerkContext } from './CoreClerkContext'; import { CoreClientContext } from './CoreClientContext'; -import { CoreOrganizationContext } from './CoreOrganizationContext'; +import { CoreOrganizationProvider } from './CoreOrganizationContext'; import { CoreSessionContext } from './CoreSessionContext'; import { CoreUserContext } from './CoreUserContext'; import { assertClerkSingletonExists } from './utils'; @@ -11,6 +11,7 @@ import { assertClerkSingletonExists } from './utils'; type CoreClerkContextWrapperProps = { clerk: Clerk; children: React.ReactNode; + swrConfig?: any; }; type CoreClerkContextProviderState = Resources; @@ -51,9 +52,12 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS - + {props.children} - + diff --git a/packages/clerk-js/src/ui/contexts/CoreOrganizationContext.tsx b/packages/clerk-js/src/ui/contexts/CoreOrganizationContext.tsx index 93041c60a3d..5ece45ac17f 100644 --- a/packages/clerk-js/src/ui/contexts/CoreOrganizationContext.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreOrganizationContext.tsx @@ -1,6 +1,6 @@ -import { OrganizationContext, useOrganization, useOrganizationList, useOrganizations } from '@clerk/shared'; +import { OrganizationProvider, useOrganization, useOrganizationList, useOrganizations } from '@clerk/shared'; -export const CoreOrganizationContext = OrganizationContext; +export const CoreOrganizationProvider = OrganizationProvider; export const useCoreOrganization = useOrganization; export const useCoreOrganizationList = useOrganizationList; export const useCoreOrganizations = useOrganizations; diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index 7284b2fabcc..40418434aae 100644 --- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx +++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx @@ -1,7 +1,6 @@ import type { ClerkOptions, ClientJSON, EnvironmentJSON, LoadedClerk } from '@clerk/types'; import { jest } from '@jest/globals'; import React from 'react'; -import { SWRConfig } from 'swr'; import { default as ClerkCtor } from '../../../core/clerk'; import { Client, Environment } from '../../../core/resources'; @@ -85,25 +84,27 @@ const unboundCreateFixtures = [ const MockClerkProvider = (props: any) => { const { children } = props; return ( - new Map(), dedupingInterval: 0 }}> - - - - - - - - - {children} - - - - - - - - - + new Map() }} + > + + + + + + + + {children} + + + + + + + + ); }; diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index fd6a576345f..5973c955761 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -7,7 +7,7 @@ import { deriveState } from '../utils/deriveState'; import { AuthContext } from './AuthContext'; import { ClientContext } from './ClientContext'; import { IsomorphicClerkContext } from './IsomorphicClerkContext'; -import { OrganizationContext } from './OrganizationContext'; +import { OrganizationProvider } from './OrganizationContext'; import { SessionContext } from './SessionContext'; import { UserContext } from './UserContext'; @@ -74,11 +74,11 @@ export function ClerkContextProvider(props: ClerkContextProvider): JSX.Element | - + {children} - + diff --git a/packages/react/src/contexts/OrganizationContext.tsx b/packages/react/src/contexts/OrganizationContext.tsx index 4dfad56e2d1..13ed6bf2e28 100644 --- a/packages/react/src/contexts/OrganizationContext.tsx +++ b/packages/react/src/contexts/OrganizationContext.tsx @@ -1 +1 @@ -export { OrganizationContext, useOrganizationContext } from '@clerk/shared'; +export { OrganizationProvider, OrganizationContext, useOrganizationContext } from '@clerk/shared'; diff --git a/packages/shared/src/hooks/contexts.tsx b/packages/shared/src/hooks/contexts.tsx index b9d08790c73..bd5c2e08dad 100644 --- a/packages/shared/src/hooks/contexts.tsx +++ b/packages/shared/src/hooks/contexts.tsx @@ -7,6 +7,9 @@ import type { OrganizationResource, UserResource, } from '@clerk/types'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; +import { SWRConfig } from 'swr'; import { createContextAndHook } from './createContextAndHook'; @@ -16,16 +19,56 @@ const [ClientContext, useClientContext] = createContextAndHook( 'SessionContext', ); -const [OrganizationContext, useOrganizationContext] = createContextAndHook<{ + +type OrganizationContextProps = { + organization: OrganizationResource | null | undefined; + lastOrganizationInvitation: OrganizationInvitationResource | null | undefined; + lastOrganizationMember: OrganizationMembershipResource | null | undefined; +}; +const [OrganizationContextInternal, useOrganizationContext] = createContextAndHook<{ organization: OrganizationResource | null | undefined; lastOrganizationInvitation: OrganizationInvitationResource | null | undefined; lastOrganizationMember: OrganizationMembershipResource | null | undefined; }>('OrganizationContext'); +const OrganizationProvider = ({ + children, + organization, + lastOrganizationMember, + lastOrganizationInvitation, + swrConfig, +}: PropsWithChildren< + OrganizationContextProps & { + // Exporting inferred types directly from SWR will result in error while building declarations + swrConfig?: any; + } +>) => { + return ( + + + {children} + + + ); +}; + +/** + * @deprecated use OrganizationProvider instead + */ +export const OrganizationContext = OrganizationProvider; + export { ClientContext, useClientContext, - OrganizationContext, + OrganizationProvider, useOrganizationContext, UserContext, useUserContext, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cbd4bd2c262..e2fbe82050d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,6 +23,7 @@ export { ClerkInstanceContext, ClientContext, OrganizationContext, + OrganizationProvider, SessionContext, useClerkInstanceContext, useClientContext, From ee1b11b2ea9a41abe0e65eec53ad888f1b4f20e6 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 3 Aug 2023 14:18:55 +0300 Subject: [PATCH 03/10] feat(shared,types,clerk-js): Abstract limit and offset behind page and pageSize --- .../resources/UserOrganizationInvitation.ts | 14 ++++- .../shared/src/hooks/useOrganizationList.tsx | 56 +++++++++---------- packages/types/src/user.ts | 13 ++++- 3 files changed, 51 insertions(+), 32 deletions(-) diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 378f5f6350f..3699e79a2ac 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -1,5 +1,6 @@ import type { ClerkPaginatedResponse, + ClerkPaginationParams, GetUserOrganizationInvitationsParams, MembershipRole, OrganizationInvitationStatus, @@ -29,10 +30,21 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga static async retrieve( params?: GetUserOrganizationInvitationsParams, ): Promise> { + function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { + const initialPageSize = pageParams?.initialPageSize ?? 10; + + const initialPage = pageParams?.initialPage ?? 1; + + return { + limit: initialPageSize, + offset: (initialPage - 1) * initialPageSize, + }; + } + return await BaseResource._fetch({ path: '/me/organization_invitations', method: 'GET', - search: params as any, + search: convertPageToOffset(params) as any, }) .then(res => { const { data: invites, total_count } = diff --git a/packages/shared/src/hooks/useOrganizationList.tsx b/packages/shared/src/hooks/useOrganizationList.tsx index 6a072319e1b..8cd0449f451 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -66,14 +66,16 @@ type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizati export const useOrganizationList: UseOrganizationList = params => { const { userInvitations } = params || {}; - const [paginatedPage, setPaginatedPage] = useState(1); - // Cache limit and offset until unmount - const offsetRef = useRef(userInvitations !== true ? userInvitations?.offset ?? 0 : 0); - const limitRef = useRef(userInvitations !== true ? userInvitations?.limit ?? 10 : 10); + const shouldUseDefaults = typeof userInvitations === 'boolean' && userInvitations; + const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaults ? 1 : userInvitations?.initialPage ?? 1); - const triggerInfinite = userInvitations !== true ? userInvitations?.infinite ?? false : false; - const internalKeepPreviousData = userInvitations !== true ? userInvitations?.keepPreviousData ?? false : false; + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(shouldUseDefaults ? 1 : userInvitations?.initialPage ?? 1); + const initialPageSizeRef = useRef(shouldUseDefaults ? 10 : userInvitations?.initialPageSize ?? 10); + + const triggerInfinite = shouldUseDefaults ? false : !!userInvitations?.infinite; + const internalKeepPreviousData = shouldUseDefaults ? false : !!userInvitations?.keepPreviousData; const clerk = useClerkInstanceContext(); const user = useUserContext(); @@ -82,8 +84,8 @@ export const useOrganizationList: UseOrganizationList = params => { typeof userInvitations === 'undefined' ? undefined : { - limit: limitRef.current, - offset: offsetRef.current + (paginatedPage - 1) * limitRef.current, + initialPage: paginatedPage, + initialPageSize: initialPageSizeRef.current, }; const canFetch = !!(clerk.loaded && user); @@ -113,15 +115,10 @@ export const useOrganizationList: UseOrganizationList = params => { return null; } - const limit = limitRef.current; - const offset = offsetRef.current + pageIndex * limit; - - const param = { - limit, - offset, - }; - - return cacheKey('userInvitations', user, param); + return cacheKey('userInvitations', user, { + initialPage: initialPageRef.current + pageIndex, + initialPageSize: initialPageSizeRef.current, + }); }; const { @@ -131,14 +128,12 @@ export const useOrganizationList: UseOrganizationList = params => { error: userInvitationsInfiniteError, size, setSize, - } = useSWRInfinite(getInfiniteKey, str => { - const { offset, limit } = JSON.parse(str); - + } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize }) => { return !clerk.loaded || !user ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) : user.getOrganizationInvitations({ - offset: parseInt(offset), - limit: parseInt(limit), + initialPage, + initialPageSize, }); }); @@ -185,9 +180,12 @@ export const useOrganizationList: UseOrganizationList = params => { isomorphicSetPage(n => n - 1); }, [isomorphicSetPage]); - const pageCount = Math.ceil((isomorphicCount - offsetRef.current) / limitRef.current); - const hasNextPage = isomorphicCount - offsetRef.current > isomorphicPage * limitRef.current; - const hasPreviousPage = (isomorphicPage - 1) * limitRef.current > offsetRef.current; + const offsetCount = (initialPageRef.current - 1) * initialPageSizeRef.current; + + const pageCount = Math.ceil((isomorphicCount - offsetCount) / initialPageSizeRef.current); + const hasNextPage = + isomorphicCount - offsetCount * initialPageSizeRef.current > isomorphicPage * initialPageSizeRef.current; + const hasPreviousPage = (isomorphicPage - 1) * initialPageSizeRef.current > offsetCount * initialPageSizeRef.current; // TODO: Properly check for SSR user values if (!clerk.loaded || !user) { @@ -258,10 +256,10 @@ function createOrganizationList(organizationMemberships: OrganizationMembershipR } function cacheKey(type: 'userInvitations', user: UserResource, pagination: GetUserOrganizationInvitationsParams) { - return JSON.stringify({ + return { type, userId: user.id, - offset: pagination.offset, - limit: pagination.limit, - }); + initialPage: pagination.initialPage, + initialPageSize: pagination.initialPageSize, + }; } diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 242531a77cf..57224c1a987 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -1,4 +1,4 @@ -import type { ClerkPaginatedResponse, ClerkPaginationParams } from './api'; +import type { ClerkPaginatedResponse } from './api'; import type { BackupCodeResource } from './backupCode'; import type { DeletedObjectResource } from './deletedObject'; import type { EmailAddressResource } from './emailAddress'; @@ -160,4 +160,13 @@ export type UpdateUserPasswordParams = { export type RemoveUserPasswordParams = Pick; -export type GetUserOrganizationInvitationsParams = ClerkPaginationParams; +export type GetUserOrganizationInvitationsParams = { + /** + * This the starting point for your fetched results. The initial value persists between re-renders + */ + initialPage?: number; + /** + * Maximum number of items returned per request. The initial value persists between re-renders + */ + initialPageSize?: number; +}; From 8dcef3ba55308f11ce553545a12d14c72fd3ae3e Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 3 Aug 2023 14:21:25 +0300 Subject: [PATCH 04/10] feat(shared): Add unstable_mutate, status as params to useOrganizationList + expose isLoading and isFetching --- .../resources/UserOrganizationInvitation.ts | 9 +-- .../shared/src/hooks/useOrganizationList.tsx | 63 +++++++++---------- packages/types/src/user.ts | 3 + 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 3699e79a2ac..d5e45901edd 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -31,13 +31,14 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga params?: GetUserOrganizationInvitationsParams, ): Promise> { function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { - const initialPageSize = pageParams?.initialPageSize ?? 10; - - const initialPage = pageParams?.initialPage ?? 1; + const { initialPageSize, initialPage, ...restParams } = pageParams || {}; + const _initialPageSize = initialPageSize ?? 10; + const _initialPage = initialPage ?? 1; return { + ...restParams, limit: initialPageSize, - offset: (initialPage - 1) * initialPageSize, + offset: (_initialPage - 1) * _initialPageSize, }; } diff --git a/packages/shared/src/hooks/useOrganizationList.tsx b/packages/shared/src/hooks/useOrganizationList.tsx index 8cd0449f451..708754556bc 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -29,8 +29,8 @@ type CustomSetAction = (size: T | ((_size: T) => T)) => void; type PaginatedDataAPI = { data: T[]; count: number; - isLoadingInitial: boolean; isLoading: boolean; + isFetching: boolean; isError: boolean; page: number; pageCount: number; @@ -76,6 +76,7 @@ export const useOrganizationList: UseOrganizationList = params => { const triggerInfinite = shouldUseDefaults ? false : !!userInvitations?.infinite; const internalKeepPreviousData = shouldUseDefaults ? false : !!userInvitations?.keepPreviousData; + const internalStatus = shouldUseDefaults ? 'pending' : userInvitations?.status ?? 'pending'; const clerk = useClerkInstanceContext(); const user = useUserContext(); @@ -86,6 +87,7 @@ export const useOrganizationList: UseOrganizationList = params => { : { initialPage: paginatedPage, initialPageSize: initialPageSizeRef.current, + status: internalStatus, }; const canFetch = !!(clerk.loaded && user); @@ -101,6 +103,7 @@ export const useOrganizationList: UseOrganizationList = params => { isValidating: userInvitationsValidating, isLoading: userInvitationsLoading, error: userInvitationsError, + mutate: userInvitationsMutate, } = useSWR( !triggerInfinite && canFetch && paginatedParams ? cacheKey('userInvitations', user, paginatedParams) : null, fetchInvitations, @@ -118,6 +121,7 @@ export const useOrganizationList: UseOrganizationList = params => { return cacheKey('userInvitations', user, { initialPage: initialPageRef.current + pageIndex, initialPageSize: initialPageSizeRef.current, + status: internalStatus, }); }; @@ -128,12 +132,14 @@ export const useOrganizationList: UseOrganizationList = params => { error: userInvitationsInfiniteError, size, setSize, - } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize }) => { + mutate: userInvitationsInfiniteMutate, + } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize, status }) => { return !clerk.loaded || !user ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) : user.getOrganizationInvitations({ initialPage, initialPageSize, + status, }); }); @@ -169,6 +175,9 @@ export const useOrganizationList: UseOrganizationList = params => { return userInvitationsData?.total_count ?? 0; }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); + const isomorphicIsLoading = triggerInfinite ? userInvitationsLoadingInfinite : userInvitationsLoading; + const isomorphicIsFetching = triggerInfinite ? userInvitationsInfiniteValidating : userInvitationsValidating; + const isomorphicIsError = !!(triggerInfinite ? userInvitationsInfiniteError : userInvitationsError); /** * Helpers */ @@ -187,6 +196,8 @@ export const useOrganizationList: UseOrganizationList = params => { isomorphicCount - offsetCount * initialPageSizeRef.current > isomorphicPage * initialPageSizeRef.current; const hasPreviousPage = (isomorphicPage - 1) * initialPageSizeRef.current > offsetCount * initialPageSizeRef.current; + const unstable__mutate = triggerInfinite ? userInvitationsInfiniteMutate : userInvitationsMutate; + // TODO: Properly check for SSR user values if (!clerk.loaded || !user) { return { @@ -197,8 +208,8 @@ export const useOrganizationList: UseOrganizationList = params => { userInvitations: { data: undefined, count: undefined, - isLoadingInitial: false, isLoading: false, + isFetching: false, isError: false, page: undefined, pageCount: undefined, @@ -207,6 +218,7 @@ export const useOrganizationList: UseOrganizationList = params => { fetchPrevious: undefined, hasNextPage: false, hasPreviousPage: false, + unstable__mutate: undefined, }, }; } @@ -216,35 +228,21 @@ export const useOrganizationList: UseOrganizationList = params => { organizationList: createOrganizationList(user.organizationMemberships), setActive: clerk.setActive, createOrganization: clerk.createOrganization, - userInvitations: triggerInfinite - ? { - data: isomorphicData, - count: isomorphicCount, - isLoadingInitial: userInvitationsLoadingInfinite, - isLoading: userInvitationsInfiniteValidating, - isError: !!userInvitationsInfiniteError, - page: isomorphicPage, - pageCount, - fetchPage: isomorphicSetPage, - fetchNext, - fetchPrevious, - hasNextPage, - hasPreviousPage, - } - : { - data: isomorphicData, - count: isomorphicCount, - isLoadingInitial: userInvitationsLoading, - isError: !!userInvitationsError, - isLoading: userInvitationsValidating, - page: isomorphicPage, - pageCount, - fetchPage: isomorphicSetPage, - fetchNext, - fetchPrevious, - hasNextPage, - hasPreviousPage, - }, + userInvitations: { + data: isomorphicData, + count: isomorphicCount, + isLoading: isomorphicIsLoading, + isFetching: isomorphicIsFetching, + isError: isomorphicIsError, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + unstable__mutate, + }, }; }; @@ -261,5 +259,6 @@ function cacheKey(type: 'userInvitations', user: UserResource, pagination: GetUs userId: user.id, initialPage: pagination.initialPage, initialPageSize: pagination.initialPageSize, + status: pagination.status, }; } diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 57224c1a987..abec0cfee6b 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -6,6 +6,7 @@ import type { ExternalAccountResource } from './externalAccount'; import type { ImageResource } from './image'; import type { UserJSON } from './json'; import type { OAuthScope } from './oauth'; +import type { OrganizationInvitationStatus } from './organizationInvitation'; import type { OrganizationMembershipResource } from './organizationMembership'; import type { PhoneNumberResource } from './phoneNumber'; import type { ClerkResource } from './resource'; @@ -169,4 +170,6 @@ export type GetUserOrganizationInvitationsParams = { * Maximum number of items returned per request. The initial value persists between re-renders */ initialPageSize?: number; + + status?: OrganizationInvitationStatus; }; From 02e9d73e3d16bbe5c314a99f897ccf29af1e7578 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 14:21:29 +0300 Subject: [PATCH 05/10] chore(repo): Add changeset --- .changeset/orange-tigers-act.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/orange-tigers-act.md diff --git a/.changeset/orange-tigers-act.md b/.changeset/orange-tigers-act.md new file mode 100644 index 00000000000..4352bfc2e78 --- /dev/null +++ b/.changeset/orange-tigers-act.md @@ -0,0 +1,11 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Introduces userInvitations from `useOrganizationList` + +`userInvitations` is a paginated list of data. It can be used to create Paginated tables or Infinite lists. + From 0d43a2d9225e3d42d22b6a0eb10660ea0c78ab60 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 21:31:49 +0300 Subject: [PATCH 06/10] test(clerk-js): Introduce tests for useCoreOrganizationList --- .../useCoreOrganizationList.test.tsx | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx new file mode 100644 index 00000000000..cf4265088cb --- /dev/null +++ b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx @@ -0,0 +1,267 @@ +import type { MembershipRole, OrganizationInvitationStatus, UserOrganizationInvitationResource } from '@clerk/types'; +import { describe, jest } from '@jest/globals'; +import React from 'react'; + +import { act, bindCreateFixtures, renderHook, waitFor } from '../../../testUtils'; +import { useCoreOrganizationList } from '../../contexts'; + +const { createFixtures } = bindCreateFixtures('OrganizationSwitcher'); + +const defaultRenderer = () => + useCoreOrganizationList({ + userInvitations: { + initialPageSize: 2, + }, + }); + +type FakeOrganizationParams = { + id: string; + createdAt?: Date; + emailAddress: string; + role?: MembershipRole; + status?: OrganizationInvitationStatus; +}; + +const createFakeUserOrganizationInvitations = (params: FakeOrganizationParams): UserOrganizationInvitationResource => { + return { + pathRoot: '', + emailAddress: params.emailAddress, + publicOrganizationData: { hasImage: false, id: '', imageUrl: '', name: '', slug: '' }, + role: params.role || 'basic_member', + status: params.status || 'pending', + id: params.id, + createdAt: params?.createdAt || new Date(), + updatedAt: new Date(), + publicMetadata: {}, + accept: jest.fn() as any, + reload: jest.fn() as any, + }; +}; + +describe('useOrganizationList', () => { + it('opens organization profile when "Manage Organization" is clicked', async () => { + const { wrapper } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + const { result } = renderHook(useCoreOrganizationList, { wrapper }); + + expect(result.current.isLoaded).toBe(true); + expect(result.current.setActive).toBeDefined(); + expect(result.current.createOrganization).toBeDefined(); + expect(result.current.organizationList).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + membership: expect.objectContaining({ + role: 'basic_member', + }), + }), + ]), + ); + + expect(result.current.userInvitations).toEqual( + expect.objectContaining({ + data: [], + count: 0, + isLoading: false, + isFetching: false, + isError: false, + page: 1, + pageCount: 0, + hasNextPage: false, + hasPreviousPage: false, + }), + ); + }); + + it.only('opens organization profile when "Manage Organization" is clicked', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitations({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook(defaultRenderer, { wrapper }); + expect(result.current.userInvitations.isLoading).toBe(true); + expect(result.current.userInvitations.count).toBe(0); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.count).toBe(4); + expect(result.current.userInvitations.page).toBe(1); + expect(result.current.userInvitations.pageCount).toBe(2); + expect(result.current.userInvitations.hasNextPage).toBe(true); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationInvitations({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userInvitations.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.page).toBe(2); + expect(result.current.userInvitations.hasNextPage).toBe(false); + expect(result.current.userInvitations.data).toEqual( + expect.arrayContaining([ + expect.not.objectContaining({ + id: '1', + }), + expect.not.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); + + it.only('infinite', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withUser({ + email_addresses: ['test@clerk.dev'], + organization_memberships: [{ name: 'Org1', role: 'basic_member' }], + }); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValue( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitations({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + const { result } = renderHook( + () => + useCoreOrganizationList({ + userInvitations: { + initialPageSize: 2, + infinite: true, + }, + }), + { wrapper }, + ); + expect(result.current.userInvitations.isLoading).toBe(true); + expect(result.current.userInvitations.isFetching).toBe(true); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.isFetching).toBe(false); + }); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '1', + emailAddress: 'one@clerk.com', + }), + createFakeUserOrganizationInvitations({ + id: '2', + emailAddress: 'two@clerk.com', + }), + ], + total_count: 4, + }), + ); + + fixtures.clerk.user?.getOrganizationInvitations.mockReturnValueOnce( + Promise.resolve({ + data: [ + createFakeUserOrganizationInvitations({ + id: '3', + emailAddress: 'three@clerk.com', + }), + createFakeUserOrganizationInvitations({ + id: '4', + emailAddress: 'four@clerk.com', + }), + ], + total_count: 4, + }), + ); + + act(() => { + result.current.userInvitations.fetchNext?.(); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isLoading).toBe(false); + expect(result.current.userInvitations.isFetching).toBe(true); + }); + + await waitFor(() => { + expect(result.current.userInvitations.isFetching).toBe(false); + expect(result.current.userInvitations.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '1', + }), + expect.objectContaining({ + id: '2', + }), + expect.objectContaining({ + id: '3', + }), + expect.objectContaining({ + id: '4', + }), + ]), + ); + }); + }); +}); From e107cc4d159cb2653dec23e84864fe625edb48db Mon Sep 17 00:00:00 2001 From: panteliselef Date: Fri, 4 Aug 2023 21:41:33 +0300 Subject: [PATCH 07/10] chore(repo): Update lock file --- package-lock.json | 107 +++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index 50875101dcd..ad35540a8de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37884,9 +37884,12 @@ } }, "node_modules/swr": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-1.3.0.tgz", - "integrity": "sha512-dkghQrOl2ORX9HYrMDtPa7LTVHJjCTeZoB1dqTbnnEDlSvN8JEKpYIYurDfvbQFUUS8Cg8PceFVZNkW0KNNYPw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", + "integrity": "sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0" } @@ -39901,7 +39904,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", - "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } @@ -41402,10 +41404,10 @@ }, "packages/backend": { "name": "@clerk/backend", - "version": "0.26.0", + "version": "0.27.0", "license": "MIT", "dependencies": { - "@clerk/types": "^3.48.1", + "@clerk/types": "^3.49.0", "@peculiar/webcrypto": "1.4.1", "@types/node": "16.18.6", "cookie": "0.5.0", @@ -41448,11 +41450,11 @@ }, "packages/chrome-extension": { "name": "@clerk/chrome-extension", - "version": "0.3.25", + "version": "0.3.26", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "^4.54.2", - "@clerk/clerk-react": "^4.23.1" + "@clerk/clerk-js": "^4.55.0", + "@clerk/clerk-react": "^4.23.2" }, "devDependencies": { "@types/chrome": "*", @@ -41468,12 +41470,12 @@ }, "packages/clerk-js": { "name": "@clerk/clerk-js", - "version": "4.54.2", + "version": "4.55.0", "license": "MIT", "dependencies": { - "@clerk/localizations": "^1.24.0", - "@clerk/shared": "^0.20.0", - "@clerk/types": "^3.48.1", + "@clerk/localizations": "^1.24.1", + "@clerk/shared": "^0.21.0", + "@clerk/types": "^3.49.0", "@emotion/cache": "11.10.5", "@emotion/react": "11.10.5", "@floating-ui/react": "0.19.0", @@ -41890,16 +41892,16 @@ }, "packages/expo": { "name": "@clerk/clerk-expo", - "version": "0.18.16", + "version": "0.18.17", "license": "MIT", "dependencies": { - "@clerk/clerk-js": "^4.54.2", - "@clerk/clerk-react": "^4.23.1", + "@clerk/clerk-js": "^4.55.0", + "@clerk/clerk-react": "^4.23.2", "base-64": "1.0.0", "react-native-url-polyfill": "1.3.0" }, "devDependencies": { - "@clerk/types": "^3.48.1", + "@clerk/types": "^3.49.0", "@types/base-64": "^1.0.0", "@types/node": "^16.11.55", "@types/react": "*", @@ -41918,11 +41920,11 @@ }, "packages/fastify": { "name": "@clerk/fastify", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "dependencies": { - "@clerk/backend": "^0.26.0", - "@clerk/types": "^3.48.1", + "@clerk/backend": "^0.27.0", + "@clerk/types": "^3.49.0", "cookies": "0.8.0" }, "devDependencies": { @@ -41938,13 +41940,13 @@ } }, "packages/gatsby-plugin-clerk": { - "version": "4.4.3", + "version": "4.4.4", "license": "MIT", "dependencies": { - "@clerk/backend": "^0.26.0", - "@clerk/clerk-react": "^4.23.1", - "@clerk/clerk-sdk-node": "^4.12.1", - "@clerk/types": "^3.48.1", + "@clerk/backend": "^0.27.0", + "@clerk/clerk-react": "^4.23.2", + "@clerk/clerk-sdk-node": "^4.12.2", + "@clerk/types": "^3.49.0", "cookie": "0.5.0", "tslib": "2.4.1" }, @@ -41967,10 +41969,10 @@ }, "packages/localizations": { "name": "@clerk/localizations", - "version": "1.24.0", + "version": "1.24.1", "license": "MIT", "dependencies": { - "@clerk/types": "^3.48.1" + "@clerk/types": "^3.49.0" }, "devDependencies": { "tsup": "*", @@ -41985,13 +41987,13 @@ }, "packages/nextjs": { "name": "@clerk/nextjs", - "version": "4.23.1", + "version": "4.23.2", "license": "MIT", "dependencies": { - "@clerk/backend": "^0.26.0", - "@clerk/clerk-react": "^4.23.1", - "@clerk/clerk-sdk-node": "^4.12.1", - "@clerk/types": "^3.48.1", + "@clerk/backend": "^0.27.0", + "@clerk/clerk-react": "^4.23.2", + "@clerk/clerk-sdk-node": "^4.12.2", + "@clerk/types": "^3.49.0", "path-to-regexp": "6.2.1", "tslib": "2.4.1" }, @@ -42024,11 +42026,11 @@ }, "packages/react": { "name": "@clerk/clerk-react", - "version": "4.23.1", + "version": "4.23.2", "license": "MIT", "dependencies": { - "@clerk/shared": "^0.20.0", - "@clerk/types": "^3.48.1", + "@clerk/shared": "^0.21.0", + "@clerk/types": "^3.49.0", "tslib": "2.4.1" }, "devDependencies": { @@ -42051,13 +42053,13 @@ }, "packages/remix": { "name": "@clerk/remix", - "version": "2.9.0", + "version": "2.9.1", "license": "MIT", "dependencies": { - "@clerk/backend": "^0.26.0", - "@clerk/clerk-react": "^4.23.1", - "@clerk/shared": "^0.20.0", - "@clerk/types": "^3.48.1", + "@clerk/backend": "^0.27.0", + "@clerk/clerk-react": "^4.23.2", + "@clerk/shared": "^0.21.0", + "@clerk/types": "^3.49.0", "cookie": "0.5.0", "tslib": "2.4.1" }, @@ -42085,11 +42087,11 @@ }, "packages/sdk-node": { "name": "@clerk/clerk-sdk-node", - "version": "4.12.1", + "version": "4.12.2", "license": "MIT", "dependencies": { - "@clerk/backend": "^0.26.0", - "@clerk/types": "^3.48.1", + "@clerk/backend": "^0.27.0", + "@clerk/types": "^3.49.0", "@types/cookies": "0.7.7", "@types/express": "4.17.14", "@types/node-fetch": "2.6.2", @@ -42127,7 +42129,7 @@ }, "packages/shared": { "name": "@clerk/shared", - "version": "0.20.0", + "version": "0.21.0", "license": "ISC", "dependencies": { "glob-to-regexp": "0.4.1", @@ -42135,7 +42137,7 @@ "swr": "2.2.0" }, "devDependencies": { - "@clerk/types": "^3.48.1", + "@clerk/types": "^3.49.0", "@types/glob-to-regexp": "0.4.1", "@types/js-cookie": "3.0.2", "tsup": "*", @@ -42145,23 +42147,12 @@ "react": ">=16" } }, - "packages/shared/node_modules/swr": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", - "integrity": "sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==", - "dependencies": { - "use-sync-external-store": "^1.2.0" - }, - "peerDependencies": { - "react": "^16.11.0 || ^17.0.0 || ^18.0.0" - } - }, "packages/themes": { "name": "@clerk/themes", "version": "1.7.5", "license": "MIT", "devDependencies": { - "@clerk/types": "^3.48.1", + "@clerk/types": "^3.49.0", "typescript": "*" }, "engines": { @@ -42173,7 +42164,7 @@ }, "packages/types": { "name": "@clerk/types", - "version": "3.48.1", + "version": "3.49.0", "license": "MIT", "dependencies": { "csstype": "3.1.1" From affc0276b348c4ebfe62e0d040bc615c438d78f1 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 13:00:25 +0300 Subject: [PATCH 08/10] fix(shared): Fixes after review --- .../resources/UserOrganizationInvitation.ts | 8 +- packages/shared/src/hooks/types.ts | 22 +++++ .../shared/src/hooks/useOrganizationList.tsx | 81 +++++++------------ packages/types/src/user.ts | 2 +- 4 files changed, 54 insertions(+), 59 deletions(-) create mode 100644 packages/shared/src/hooks/types.ts diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index d5e45901edd..c08c9e73ced 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -31,14 +31,14 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga params?: GetUserOrganizationInvitationsParams, ): Promise> { function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { - const { initialPageSize, initialPage, ...restParams } = pageParams || {}; - const _initialPageSize = initialPageSize ?? 10; + const { pageSize, initialPage, ...restParams } = pageParams || {}; + const _pageSize = pageSize ?? 10; const _initialPage = initialPage ?? 1; return { ...restParams, - limit: initialPageSize, - offset: (_initialPage - 1) * _initialPageSize, + limit: _pageSize, + offset: (_initialPage - 1) * _pageSize, }; } diff --git a/packages/shared/src/hooks/types.ts b/packages/shared/src/hooks/types.ts new file mode 100644 index 00000000000..a2614cb214d --- /dev/null +++ b/packages/shared/src/hooks/types.ts @@ -0,0 +1,22 @@ +export type ValueOrSetter = (size: T | ((_size: T) => T)) => void; +export type PaginatedResources = { + data: T[]; + count: number; + isLoading: boolean; + isFetching: boolean; + isError: boolean; + page: number; + pageCount: number; + fetchPage: ValueOrSetter; + fetchPrevious: () => void; + fetchNext: () => void; + hasNextPage: boolean; + hasPreviousPage: boolean; +}; + +// Utility type to convert PaginatedDataAPI to properties as undefined, except booleans set to false +export type PaginatedResourcesWithDefault = { + [K in keyof PaginatedResources]: PaginatedResources[K] extends boolean + ? false + : PaginatedResources[K] | undefined; +}; diff --git a/packages/shared/src/hooks/useOrganizationList.tsx b/packages/shared/src/hooks/useOrganizationList.tsx index 708754556bc..990e3c2643d 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -13,6 +13,7 @@ import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite'; import { useClerkInstanceContext, useUserContext } from './contexts'; +import type { PaginatedResources, PaginatedResourcesWithDefault, ValueOrSetter } from './types'; type UseOrganizationListParams = { userInvitations?: @@ -25,41 +26,20 @@ type UseOrganizationListParams = { type OrganizationList = ReturnType; -type CustomSetAction = (size: T | ((_size: T) => T)) => void; -type PaginatedDataAPI = { - data: T[]; - count: number; - isLoading: boolean; - isFetching: boolean; - isError: boolean; - page: number; - pageCount: number; - fetchPage: CustomSetAction; - fetchPrevious: () => void; - fetchNext: () => void; - hasNextPage: boolean; - hasPreviousPage: boolean; -}; - -// Utility type to convert PaginatedDataAPI to properties as undefined, except booleans set to false -type PaginatedDataAPIWithDefaults = { - [K in keyof PaginatedDataAPI]: PaginatedDataAPI[K] extends boolean ? false : PaginatedDataAPI[K] | undefined; -}; - type UseOrganizationListReturn = | { isLoaded: false; organizationList: undefined; createOrganization: undefined; setActive: undefined; - userInvitations: PaginatedDataAPIWithDefaults; + userInvitations: PaginatedResourcesWithDefault; } | { isLoaded: boolean; organizationList: OrganizationList; createOrganization: (params: CreateOrganizationParams) => Promise; setActive: SetActive; - userInvitations: PaginatedDataAPI; + userInvitations: PaginatedResources; }; type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn; @@ -67,16 +47,16 @@ type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizati export const useOrganizationList: UseOrganizationList = params => { const { userInvitations } = params || {}; - const shouldUseDefaults = typeof userInvitations === 'boolean' && userInvitations; - const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaults ? 1 : userInvitations?.initialPage ?? 1); + const shouldUseDefaultOptions = userInvitations === true; + const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaultOptions ? 1 : userInvitations?.initialPage ?? 1); // Cache initialPage and initialPageSize until unmount - const initialPageRef = useRef(shouldUseDefaults ? 1 : userInvitations?.initialPage ?? 1); - const initialPageSizeRef = useRef(shouldUseDefaults ? 10 : userInvitations?.initialPageSize ?? 10); + const initialPageRef = useRef(shouldUseDefaultOptions ? 1 : userInvitations?.initialPage ?? 1); + const pageSizeRef = useRef(shouldUseDefaultOptions ? 10 : userInvitations?.pageSize ?? 10); - const triggerInfinite = shouldUseDefaults ? false : !!userInvitations?.infinite; - const internalKeepPreviousData = shouldUseDefaults ? false : !!userInvitations?.keepPreviousData; - const internalStatus = shouldUseDefaults ? 'pending' : userInvitations?.status ?? 'pending'; + const triggerInfinite = shouldUseDefaultOptions ? false : !!userInvitations?.infinite; + const internalKeepPreviousData = shouldUseDefaultOptions ? false : !!userInvitations?.keepPreviousData; + const internalStatus = shouldUseDefaultOptions ? 'pending' : userInvitations?.status ?? 'pending'; const clerk = useClerkInstanceContext(); const user = useUserContext(); @@ -86,17 +66,13 @@ export const useOrganizationList: UseOrganizationList = params => { ? undefined : { initialPage: paginatedPage, - initialPageSize: initialPageSizeRef.current, + pageSize: pageSizeRef.current, status: internalStatus, }; const canFetch = !!(clerk.loaded && user); - // Some gymnastics to adhere to the rules of hooks - // We need to make sure useSWR is called on every render - const fetchInvitations = !clerk.loaded - ? () => ({ data: [], total_count: 0 } as ClerkPaginatedResponse) - : () => user?.getOrganizationInvitations(paginatedParams); + const fetchInvitations = () => user!.getOrganizationInvitations(paginatedParams); const { data: userInvitationsData, @@ -120,7 +96,7 @@ export const useOrganizationList: UseOrganizationList = params => { return cacheKey('userInvitations', user, { initialPage: initialPageRef.current + pageIndex, - initialPageSize: initialPageSizeRef.current, + pageSize: pageSizeRef.current, status: internalStatus, }); }; @@ -133,14 +109,12 @@ export const useOrganizationList: UseOrganizationList = params => { size, setSize, mutate: userInvitationsInfiniteMutate, - } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize, status }) => { - return !clerk.loaded || !user - ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) - : user.getOrganizationInvitations({ - initialPage, - initialPageSize, - status, - }); + } = useSWRInfinite(getInfiniteKey, ({ initialPage, pageSize, status }) => { + return user!.getOrganizationInvitations({ + initialPage, + pageSize, + status, + }); }); const isomorphicPage = useMemo(() => { @@ -150,7 +124,7 @@ export const useOrganizationList: UseOrganizationList = params => { return paginatedPage; }, [triggerInfinite, size, paginatedPage]); - const isomorphicSetPage: CustomSetAction = useCallback( + const isomorphicSetPage: ValueOrSetter = useCallback( numberOrgFn => { if (triggerInfinite) { void setSize(numberOrgFn); @@ -182,19 +156,18 @@ export const useOrganizationList: UseOrganizationList = params => { * Helpers */ const fetchNext = useCallback(() => { - isomorphicSetPage(n => n + 1); + isomorphicSetPage(n => Math.max(0, n + 1)); }, [isomorphicSetPage]); const fetchPrevious = useCallback(() => { - isomorphicSetPage(n => n - 1); + isomorphicSetPage(n => Math.max(0, n - 1)); }, [isomorphicSetPage]); - const offsetCount = (initialPageRef.current - 1) * initialPageSizeRef.current; + const offsetCount = (initialPageRef.current - 1) * pageSizeRef.current; - const pageCount = Math.ceil((isomorphicCount - offsetCount) / initialPageSizeRef.current); - const hasNextPage = - isomorphicCount - offsetCount * initialPageSizeRef.current > isomorphicPage * initialPageSizeRef.current; - const hasPreviousPage = (isomorphicPage - 1) * initialPageSizeRef.current > offsetCount * initialPageSizeRef.current; + const pageCount = Math.ceil((isomorphicCount - offsetCount) / pageSizeRef.current); + const hasNextPage = isomorphicCount - offsetCount * pageSizeRef.current > isomorphicPage * pageSizeRef.current; + const hasPreviousPage = (isomorphicPage - 1) * pageSizeRef.current > offsetCount * pageSizeRef.current; const unstable__mutate = triggerInfinite ? userInvitationsInfiniteMutate : userInvitationsMutate; @@ -258,7 +231,7 @@ function cacheKey(type: 'userInvitations', user: UserResource, pagination: GetUs type, userId: user.id, initialPage: pagination.initialPage, - initialPageSize: pagination.initialPageSize, + pageSize: pagination.pageSize, status: pagination.status, }; } diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index abec0cfee6b..4e0c540ae43 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -169,7 +169,7 @@ export type GetUserOrganizationInvitationsParams = { /** * Maximum number of items returned per request. The initial value persists between re-renders */ - initialPageSize?: number; + pageSize?: number; status?: OrganizationInvitationStatus; }; From 29dc7efdebef76ad88e5f8786f37ed3b0e4c1fea Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 13:00:54 +0300 Subject: [PATCH 09/10] test(clerk-js): Fixes after review --- .../src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx index cf4265088cb..6a0d62489be 100644 --- a/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx +++ b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx @@ -10,7 +10,7 @@ const { createFixtures } = bindCreateFixtures('OrganizationSwitcher'); const defaultRenderer = () => useCoreOrganizationList({ userInvitations: { - initialPageSize: 2, + pageSize: 2, }, }); @@ -189,7 +189,7 @@ describe('useOrganizationList', () => { () => useCoreOrganizationList({ userInvitations: { - initialPageSize: 2, + pageSize: 2, infinite: true, }, }), From 34484b6efb6021d329c5757028a7c8d138a696dd Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 15:29:45 +0300 Subject: [PATCH 10/10] chore(clerk-js): Move convertPageToOffset to utils --- .../resources/UserOrganizationInvitation.ts | 14 +------------- packages/clerk-js/src/utils/pagesToOffset.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 13 deletions(-) create mode 100644 packages/clerk-js/src/utils/pagesToOffset.ts diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index c08c9e73ced..52e7f42df02 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -1,6 +1,5 @@ import type { ClerkPaginatedResponse, - ClerkPaginationParams, GetUserOrganizationInvitationsParams, MembershipRole, OrganizationInvitationStatus, @@ -9,6 +8,7 @@ import type { } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; +import { convertPageToOffset } from '../../utils/pagesToOffset'; import { BaseResource } from './internal'; export class UserOrganizationInvitation extends BaseResource implements UserOrganizationInvitationResource { @@ -30,18 +30,6 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga static async retrieve( params?: GetUserOrganizationInvitationsParams, ): Promise> { - function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { - const { pageSize, initialPage, ...restParams } = pageParams || {}; - const _pageSize = pageSize ?? 10; - const _initialPage = initialPage ?? 1; - - return { - ...restParams, - limit: _pageSize, - offset: (_initialPage - 1) * _pageSize, - }; - } - return await BaseResource._fetch({ path: '/me/organization_invitations', method: 'GET', diff --git a/packages/clerk-js/src/utils/pagesToOffset.ts b/packages/clerk-js/src/utils/pagesToOffset.ts new file mode 100644 index 00000000000..131c27b3425 --- /dev/null +++ b/packages/clerk-js/src/utils/pagesToOffset.ts @@ -0,0 +1,18 @@ +import type { ClerkPaginationParams } from '@clerk/types'; + +type Pages = { + initialPage?: number; + pageSize?: number; +}; + +export function convertPageToOffset(pageParams: T): ClerkPaginationParams { + const { pageSize, initialPage, ...restParams } = pageParams || {}; + const _pageSize = pageSize ?? 10; + const _initialPage = initialPage ?? 1; + + return { + ...restParams, + limit: _pageSize, + offset: (_initialPage - 1) * _pageSize, + }; +}