diff --git a/.changeset/orange-tigers-act.md b/.changeset/orange-tigers-act.md new file mode 100644 index 0000000000..4352bfc2e7 --- /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. + diff --git a/package-lock.json b/package-lock.json index e473d9fcc4..ad35540a8d 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,15 +42129,15 @@ }, "packages/shared": { "name": "@clerk/shared", - "version": "0.20.0", + "version": "0.21.0", "license": "ISC", "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", + "@clerk/types": "^3.49.0", "@types/glob-to-regexp": "0.4.1", "@types/js-cookie": "3.0.2", "tsup": "*", @@ -42150,7 +42152,7 @@ "version": "1.7.5", "license": "MIT", "devDependencies": { - "@clerk/types": "^3.48.1", + "@clerk/types": "^3.49.0", "typescript": "*" }, "engines": { @@ -42162,7 +42164,7 @@ }, "packages/types": { "name": "@clerk/types", - "version": "3.48.1", + "version": "3.49.0", "license": "MIT", "dependencies": { "csstype": "3.1.1" diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 378f5f6350..52e7f42df0 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -8,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 { @@ -32,7 +33,7 @@ export class UserOrganizationInvitation extends BaseResource implements UserOrga 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/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 8709f987f2..0d427f39ae 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 93041c60a3..5ece45ac17 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/hooks/__tests__/useCoreOrganizationList.test.tsx b/packages/clerk-js/src/ui/hooks/__tests__/useCoreOrganizationList.test.tsx new file mode 100644 index 0000000000..6a0d62489b --- /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: { + pageSize: 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: { + pageSize: 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', + }), + ]), + ); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index 7284b2fabc..40418434aa 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/clerk-js/src/utils/pagesToOffset.ts b/packages/clerk-js/src/utils/pagesToOffset.ts new file mode 100644 index 0000000000..131c27b342 --- /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, + }; +} diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index fd6a576345..5973c95576 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 4dfad56e2d..13ed6bf2e2 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/package.json b/packages/shared/package.json index 1c8c589b8e..697f38b608 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/contexts.tsx b/packages/shared/src/hooks/contexts.tsx index b9d08790c7..bd5c2e08da 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/hooks/types.ts b/packages/shared/src/hooks/types.ts new file mode 100644 index 0000000000..a2614cb214 --- /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 23bbcbab0a..990e3c2643 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -1,39 +1,221 @@ 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'; +import type { PaginatedResources, PaginatedResourcesWithDefault, ValueOrSetter } from './types'; + +type UseOrganizationListParams = { + userInvitations?: + | true + | (GetUserOrganizationInvitationsParams & { + infinite?: boolean; + keepPreviousData?: boolean; + }); +}; type OrganizationList = ReturnType; type UseOrganizationListReturn = - | { isLoaded: false; organizationList: undefined; createOrganization: undefined; setActive: undefined } | { - isLoaded: true; + isLoaded: false; + organizationList: undefined; + createOrganization: undefined; + setActive: undefined; + userInvitations: PaginatedResourcesWithDefault; + } + | { + isLoaded: boolean; organizationList: OrganizationList; createOrganization: (params: CreateOrganizationParams) => Promise; setActive: SetActive; + userInvitations: PaginatedResources; }; -type UseOrganizationList = () => UseOrganizationListReturn; +type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn; + +export const useOrganizationList: UseOrganizationList = params => { + const { userInvitations } = params || {}; + + const shouldUseDefaultOptions = userInvitations === true; + const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaultOptions ? 1 : userInvitations?.initialPage ?? 1); + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(shouldUseDefaultOptions ? 1 : userInvitations?.initialPage ?? 1); + const pageSizeRef = useRef(shouldUseDefaultOptions ? 10 : userInvitations?.pageSize ?? 10); + + const triggerInfinite = shouldUseDefaultOptions ? false : !!userInvitations?.infinite; + const internalKeepPreviousData = shouldUseDefaultOptions ? false : !!userInvitations?.keepPreviousData; + const internalStatus = shouldUseDefaultOptions ? 'pending' : userInvitations?.status ?? 'pending'; -export const useOrganizationList: UseOrganizationList = () => { const clerk = useClerkInstanceContext(); const user = useUserContext(); + const paginatedParams = + typeof userInvitations === 'undefined' + ? undefined + : { + initialPage: paginatedPage, + pageSize: pageSizeRef.current, + status: internalStatus, + }; + + const canFetch = !!(clerk.loaded && user); + + const fetchInvitations = () => user!.getOrganizationInvitations(paginatedParams); + + const { + data: userInvitationsData, + isValidating: userInvitationsValidating, + isLoading: userInvitationsLoading, + error: userInvitationsError, + mutate: userInvitationsMutate, + } = 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; + } + + return cacheKey('userInvitations', user, { + initialPage: initialPageRef.current + pageIndex, + pageSize: pageSizeRef.current, + status: internalStatus, + }); + }; + + const { + data: userInvitationsDataInfinite, + isLoading: userInvitationsLoadingInfinite, + isValidating: userInvitationsInfiniteValidating, + error: userInvitationsInfiniteError, + size, + setSize, + mutate: userInvitationsInfiniteMutate, + } = useSWRInfinite(getInfiniteKey, ({ initialPage, pageSize, status }) => { + return user!.getOrganizationInvitations({ + initialPage, + pageSize, + status, + }); + }); + + const isomorphicPage = useMemo(() => { + if (triggerInfinite) { + return size; + } + return paginatedPage; + }, [triggerInfinite, size, paginatedPage]); + + const isomorphicSetPage: ValueOrSetter = 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]); + + const isomorphicIsLoading = triggerInfinite ? userInvitationsLoadingInfinite : userInvitationsLoading; + const isomorphicIsFetching = triggerInfinite ? userInvitationsInfiniteValidating : userInvitationsValidating; + const isomorphicIsError = !!(triggerInfinite ? userInvitationsInfiniteError : userInvitationsError); + /** + * Helpers + */ + const fetchNext = useCallback(() => { + isomorphicSetPage(n => Math.max(0, n + 1)); + }, [isomorphicSetPage]); + + const fetchPrevious = useCallback(() => { + isomorphicSetPage(n => Math.max(0, n - 1)); + }, [isomorphicSetPage]); + + const offsetCount = (initialPageRef.current - 1) * pageSizeRef.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; + // 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, + isLoading: false, + isFetching: false, + isError: false, + page: undefined, + pageCount: undefined, + fetchPage: undefined, + fetchNext: undefined, + fetchPrevious: undefined, + hasNextPage: false, + hasPreviousPage: false, + unstable__mutate: undefined, + }, + }; } return { - isLoaded: true, + isLoaded: canFetch, organizationList: createOrganizationList(user.organizationMemberships), setActive: clerk.setActive, createOrganization: clerk.createOrganization, + userInvitations: { + data: isomorphicData, + count: isomorphicCount, + isLoading: isomorphicIsLoading, + isFetching: isomorphicIsFetching, + isError: isomorphicIsError, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + unstable__mutate, + }, }; }; @@ -43,3 +225,13 @@ function createOrganizationList(organizationMemberships: OrganizationMembershipR organization: organizationMembership.organization, })); } + +function cacheKey(type: 'userInvitations', user: UserResource, pagination: GetUserOrganizationInvitationsParams) { + return { + type, + userId: user.id, + initialPage: pagination.initialPage, + pageSize: pagination.pageSize, + status: pagination.status, + }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cbd4bd2c26..e2fbe82050 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, diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 242531a77c..4e0c540ae4 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'; @@ -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'; @@ -160,4 +161,15 @@ 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 + */ + pageSize?: number; + + status?: OrganizationInvitationStatus; +};