diff --git a/.changeset/fast-books-kiss.md b/.changeset/fast-books-kiss.md new file mode 100644 index 00000000000..133fb2133f4 --- /dev/null +++ b/.changeset/fast-books-kiss.md @@ -0,0 +1,6 @@ +--- +'@clerk/shared': patch +--- + +Introduce `invitations` in useOrganization, which enables to fetch invitations as paginated lists. +Deprecate `invitationList` in favor of the above introduction. diff --git a/.changeset/three-carrots-remain.md b/.changeset/three-carrots-remain.md new file mode 100644 index 00000000000..67235f527cf --- /dev/null +++ b/.changeset/three-carrots-remain.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/types': patch +--- + +Introduces a new method for fetching organization invitations called `Organization.getInvitations`. +Deprecate `Organization.getPendingInvitations` diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 9368c71a77e..c60077d46f9 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -1,9 +1,11 @@ +import { deprecated } from '@clerk/shared'; import type { AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, CreateOrganizationParams, GetDomainsParams, + GetInvitationsParams, GetMembershipRequestParams, GetMemberships, GetPendingInvitationsParams, @@ -12,6 +14,7 @@ import type { OrganizationDomainJSON, OrganizationDomainResource, OrganizationInvitationJSON, + OrganizationInvitationResource, OrganizationJSON, OrganizationMembershipJSON, OrganizationMembershipRequestJSON, @@ -193,6 +196,7 @@ export class Organization extends BaseResource implements OrganizationResource { getPendingInvitations = async ( getPendingInvitationsParams?: GetPendingInvitationsParams, ): Promise => { + deprecated('getPendingInvitations', 'Use the `getInvitations` method instead.'); return await BaseResource._fetch({ path: `/organizations/${this.id}/invitations/pending`, method: 'GET', @@ -205,6 +209,29 @@ export class Organization extends BaseResource implements OrganizationResource { .catch(() => []); }; + getInvitations = async ( + getInvitationsParams?: GetInvitationsParams, + ): Promise> => { + return await BaseResource._fetch({ + path: `/organizations/${this.id}/invitations`, + method: 'GET', + search: convertPageToOffset(getInvitationsParams) as any, + }) + .then(res => { + const { data: requests, total_count } = + res?.response as unknown as ClerkPaginatedResponse; + + return { + total_count, + data: requests.map(request => new OrganizationInvitation(request)), + }; + }) + .catch(() => ({ + total_count: 0, + data: [], + })); + }; + addMember = async ({ userId, role }: AddMemberParams) => { const newMember = await BaseResource._fetch({ method: 'POST', diff --git a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap index 522b944a0cf..184db5902e2 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap @@ -9,6 +9,7 @@ Organization { "destroy": [Function], "getDomain": [Function], "getDomains": [Function], + "getInvitations": [Function], "getMembershipRequests": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap index a572c4e82f4..a3bbcee34d5 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap @@ -13,6 +13,7 @@ OrganizationMembership { "destroy": [Function], "getDomain": [Function], "getDomains": [Function], + "getInvitations": [Function], "getMembershipRequests": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/InvitedMembersList.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/InvitedMembersList.tsx index 17e96f12bf8..7214def4bad 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/InvitedMembersList.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/InvitedMembersList.tsx @@ -2,45 +2,36 @@ import type { OrganizationInvitationResource } from '@clerk/types'; import { useCoreOrganization } from '../../contexts'; import { localizationKeys, Td, Text } from '../../customizables'; -import { ThreeDotsMenu, useCardState, usePagination, UserPreview } from '../../elements'; +import { ThreeDotsMenu, useCardState, UserPreview } from '../../elements'; import { handleError, roleLocalizationKey } from '../../utils'; import { DataTable, RowContainer } from './MemberListTable'; -const ITEMS_PER_PAGE = 10; - export const InvitedMembersList = () => { const card = useCardState(); - const { page, changePage } = usePagination(); - const { organization, invitationList, ...rest } = useCoreOrganization({ - invitationList: { offset: (page - 1) * ITEMS_PER_PAGE, limit: ITEMS_PER_PAGE }, + const { organization, invitations } = useCoreOrganization({ + invitations: true, }); - const mutateSwrState = () => { - const unstable__mutate = (rest as any).unstable__mutate; - if (unstable__mutate && typeof unstable__mutate === 'function') { - unstable__mutate(); - } - }; - if (!organization) { return null; } const revoke = (invitation: OrganizationInvitationResource) => () => { return card - .runAsync(invitation.revoke) - .then(mutateSwrState) - .then(() => changePage(1)) + .runAsync(async () => { + await invitation.revoke(); + await (invitations as any).unstable__mutate?.(); + }) .catch(err => handleError(err, [], card.setError)); }; return ( null)} + itemCount={invitations?.count || 0} + pageCount={invitations?.pageCount || 0} + isLoading={invitations?.isLoading} emptyStateLocalizationKey={localizationKeys('organizationProfile.membersPage.invitationsTab.table__emptyRow')} headers={[ localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__user'), @@ -48,7 +39,7 @@ export const InvitedMembersList = () => { localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__role'), localizationKeys('organizationProfile.membersPage.activeMembersTab.tableHeader__actions'), ]} - rows={(invitationList || []).map(i => ( + rows={(invitations?.data || []).map(i => ( { await waitFor(() => { expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled(); - expect(fixtures.clerk.organization?.getPendingInvitations).not.toHaveBeenCalled(); + expect(fixtures.clerk.organization?.getInvitations).not.toHaveBeenCalled(); expect(fixtures.clerk.organization?.getMembershipRequests).not.toHaveBeenCalled(); expect(queryByText('test_user1')).toBeInTheDocument(); expect(queryByText('First1 Last1')).toBeInTheDocument(); @@ -258,14 +258,19 @@ describe('OrganizationMembers', () => { }); }); - fixtures.clerk.organization?.getPendingInvitations.mockReturnValue(Promise.resolve(invitationList)); + fixtures.clerk.organization?.getInvitations.mockReturnValue( + Promise.resolve({ + data: invitationList, + total_count: 2, + }), + ); const { queryByText, getByRole } = render(, { wrapper }); await userEvent.click(getByRole('tab', { name: 'Invitations' })); - expect(fixtures.clerk.organization?.getPendingInvitations).toHaveBeenCalled(); - expect(queryByText('admin1@clerk.dev')).toBeDefined(); - expect(queryByText('Admin')).toBeDefined(); - expect(queryByText('member2@clerk.dev')).toBeDefined(); - expect(queryByText('Member')).toBeDefined(); + expect(fixtures.clerk.organization?.getInvitations).toHaveBeenCalled(); + expect(queryByText('admin1@clerk.dev')).toBeInTheDocument(); + expect(queryByText('Admin')).toBeInTheDocument(); + expect(queryByText('member2@clerk.dev')).toBeInTheDocument(); + expect(queryByText('Member')).toBeInTheDocument(); }); it('changes tab and renders pending requests', async () => { @@ -342,6 +347,6 @@ describe('OrganizationMembers', () => { ); const { findByText } = render(, { wrapper }); await waitFor(() => expect(fixtures.clerk.organization?.getMemberships).toHaveBeenCalled()); - expect(await findByText('You')).toBeDefined(); + expect(await findByText('You')).toBeInTheDocument(); }); }); diff --git a/packages/shared/src/hooks/useOrganization.tsx b/packages/shared/src/hooks/useOrganization.tsx index a29d68ef95b..d8354846813 100644 --- a/packages/shared/src/hooks/useOrganization.tsx +++ b/packages/shared/src/hooks/useOrganization.tsx @@ -1,6 +1,7 @@ import type { ClerkPaginationParams, GetDomainsParams, + GetInvitationsParams, GetMembershipRequestParams, GetMembershipsParams, GetPendingInvitationsParams, @@ -14,13 +15,18 @@ import type { ClerkPaginatedResponse } from '@clerk/types'; import type { GetMembersParams } from '@clerk/types'; import { disableSWRDevtools } from './clerk-swr'; + disableSWRDevtools(); import { useSWR } from './clerk-swr'; import { useClerkInstanceContext, useOrganizationContext, useSessionContext } from './contexts'; import type { PaginatedResources, PaginatedResourcesWithDefault } from './types'; import { usePagesOrInfinite, useWithSafeValues } from './usePagesOrInfinite'; +import { deprecated } from '../utils'; type UseOrganizationParams = { + /** + * @deprecated Use `invitations` instead + */ invitationList?: GetPendingInvitationsParams; /** * @deprecated Use `memberships` instead @@ -44,12 +50,22 @@ type UseOrganizationParams = { infinite?: boolean; keepPreviousData?: boolean; }); + + invitations?: + | true + | (GetInvitationsParams & { + infinite?: boolean; + keepPreviousData?: boolean; + }); }; type UseOrganizationReturn = | { isLoaded: false; organization: undefined; + /** + * @deprecated Use `invitations` instead + */ invitationList: undefined; /** * @deprecated Use `memberships` instead @@ -59,10 +75,14 @@ type UseOrganizationReturn = domains: PaginatedResourcesWithDefault; membershipRequests: PaginatedResourcesWithDefault; memberships: PaginatedResourcesWithDefault; + invitations: PaginatedResourcesWithDefault; } | { isLoaded: true; organization: OrganizationResource; + /** + * @deprecated Use `invitations` instead + */ invitationList: undefined; /** * @deprecated Use `memberships` instead @@ -72,10 +92,14 @@ type UseOrganizationReturn = domains: PaginatedResourcesWithDefault; membershipRequests: PaginatedResourcesWithDefault; memberships: PaginatedResourcesWithDefault; + invitations: PaginatedResourcesWithDefault; } | { isLoaded: boolean; organization: OrganizationResource | null; + /** + * @deprecated Use `invitations` instead + */ invitationList: OrganizationInvitationResource[] | null | undefined; /** * @deprecated Use `memberships` instead @@ -85,6 +109,7 @@ type UseOrganizationReturn = domains: PaginatedResources | null; membershipRequests: PaginatedResources | null; memberships: PaginatedResources | null; + invitations: PaginatedResources | null; }; type UseOrganization = (params?: UseOrganizationParams) => UseOrganizationReturn; @@ -111,6 +136,7 @@ export const useOrganization: UseOrganization = params => { domains: domainListParams, membershipRequests: membershipRequestsListParams, memberships: membersListParams, + invitations: invitationsListParams, } = params || {}; const { organization, lastOrganizationMember, lastOrganizationInvitation } = useOrganizationContext(); const session = useSessionContext(); @@ -139,6 +165,14 @@ export const useOrganization: UseOrganization = params => { infinite: false, }); + const invitationsSafeValues = useWithSafeValues(invitationsListParams, { + initialPage: 1, + pageSize: 10, + status: ['pending'], + keepPreviousData: false, + infinite: false, + }); + const clerk = useClerkInstanceContext(); const shouldFetch = !!(clerk.loaded && session && organization); @@ -170,6 +204,15 @@ export const useOrganization: UseOrganization = params => { role: membersSafeValues.role, }; + const invitationsParams = + typeof invitationsListParams === 'undefined' + ? undefined + : { + initialPage: invitationsSafeValues.initialPage, + pageSize: invitationsSafeValues.pageSize, + status: invitationsSafeValues.status, + }; + const domains = usePagesOrInfinite>( { ...domainParams, @@ -222,6 +265,22 @@ export const useOrganization: UseOrganization = params => { }, ); + const invitations = usePagesOrInfinite>( + { + ...invitationsParams, + }, + organization?.getInvitations, + { + keepPreviousData: membersSafeValues.keepPreviousData, + infinite: membersSafeValues.infinite, + enabled: !!invitationsParams, + }, + { + type: 'invitations', + organizationId: organization?.id, + }, + ); + // Some gymnastics to adhere to the rules of hooks // We need to make sure useSWR is called on every render const pendingInvitations = !clerk.loaded @@ -232,6 +291,10 @@ export const useOrganization: UseOrganization = params => { ? () => [] as OrganizationMembershipResource[] : () => clerk.organization?.getMemberships(membershipListParams); + if (invitationListParams) { + deprecated('invitationList in useOrganization', 'Use the `invitations` property and return value instead.'); + } + const { data: invitationList, isValidating: isInvitationsLoading, @@ -243,6 +306,10 @@ export const useOrganization: UseOrganization = params => { pendingInvitations, ); + if (membershipListParams) { + deprecated('membershipList in useOrganization', 'Use the `memberships` property and return value instead.'); + } + const { data: membershipList, isValidating: isMembershipsLoading, @@ -264,6 +331,7 @@ export const useOrganization: UseOrganization = params => { domains: undefinedPaginatedResource, membershipRequests: undefinedPaginatedResource, memberships: undefinedPaginatedResource, + invitations: undefinedPaginatedResource, }; } @@ -277,6 +345,7 @@ export const useOrganization: UseOrganization = params => { domains: null, membershipRequests: null, memberships: null, + invitations: null, }; } @@ -291,6 +360,7 @@ export const useOrganization: UseOrganization = params => { domains: undefinedPaginatedResource, membershipRequests: undefinedPaginatedResource, memberships: undefinedPaginatedResource, + invitations: undefinedPaginatedResource, }; } @@ -307,6 +377,7 @@ export const useOrganization: UseOrganization = params => { domains, membershipRequests, memberships, + invitations, }; }; diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index a6d335a6784..9b42c86a3a6 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -44,7 +44,11 @@ export interface OrganizationResource extends ClerkResource { updatedAt: Date; update: (params: UpdateOrganizationParams) => Promise; getMemberships: GetMemberships; + /** + * @deprecated Use `getInvitations` instead + */ getPendingInvitations: (params?: GetPendingInvitationsParams) => Promise; + getInvitations: (params?: GetInvitationsParams) => Promise>; getDomains: (params?: GetDomainsParams) => Promise>; getMembershipRequests: ( params?: GetMembershipRequestParams, @@ -80,6 +84,9 @@ export type GetMembersParams = { role?: MembershipRole[]; }; +/** + * @deprecated use `getInvitations` instead + */ export type GetPendingInvitationsParams = ClerkPaginationParams; export type GetDomainsParams = { /** @@ -94,6 +101,19 @@ export type GetDomainsParams = { enrollmentMode?: OrganizationEnrollmentMode; }; +export type GetInvitationsParams = { + /** + * This is 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[]; +}; + export type GetMembershipRequestParams = { /** * This is the starting point for your fetched results. The initial value persists between re-renders