From 940e28ec74dba74f6c609b8acd9fdd2a0baf981c Mon Sep 17 00:00:00 2001 From: Haris Chaniotakis Date: Thu, 10 Aug 2023 18:13:19 +0300 Subject: [PATCH] feat(types,clerk-js): Introduce OrganizationSuggestion resource and types We introduce a new OrganizationSuggestion resource and the corresponding types, in order to preview user with suggestions of organizations they can join. The available methods for the time being are retrieve() which return a list of organization suggestions of the user and accept() which allows a user to request to join the organization. Also make available the user's suggestions from the useOrganizationList hook --- .changeset/orange-taxis-eat.md | 7 + .../resources/OrganizationSuggestion.test.ts | 22 +++ .../core/resources/OrganizationSuggestion.ts | 71 +++++++++ packages/clerk-js/src/core/resources/User.ts | 6 + .../OrganizationSuggestion.test.ts.snap | 19 +++ .../clerk-js/src/core/resources/internal.ts | 1 + .../shared/src/hooks/useOrganizationList.tsx | 141 ++++++++++++++---- packages/types/src/index.ts | 1 + packages/types/src/json.ts | 16 ++ packages/types/src/organizationSuggestion.ts | 19 +++ packages/types/src/user.ts | 17 +++ 11 files changed, 293 insertions(+), 27 deletions(-) create mode 100644 .changeset/orange-taxis-eat.md create mode 100644 packages/clerk-js/src/core/resources/OrganizationSuggestion.test.ts create mode 100644 packages/clerk-js/src/core/resources/OrganizationSuggestion.ts create mode 100644 packages/clerk-js/src/core/resources/__snapshots__/OrganizationSuggestion.test.ts.snap create mode 100644 packages/types/src/organizationSuggestion.ts diff --git a/.changeset/orange-taxis-eat.md b/.changeset/orange-taxis-eat.md new file mode 100644 index 00000000000..c28f1cb384c --- /dev/null +++ b/.changeset/orange-taxis-eat.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/types': minor +--- + +Introduce a new resource called OrganizationSuggestion along with retrieve() & accept() methods +Also make available the user's suggestions from the useOrganizationList hook diff --git a/packages/clerk-js/src/core/resources/OrganizationSuggestion.test.ts b/packages/clerk-js/src/core/resources/OrganizationSuggestion.test.ts new file mode 100644 index 00000000000..8dbd2a4ecbe --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationSuggestion.test.ts @@ -0,0 +1,22 @@ +import { OrganizationSuggestion } from './internal'; + +describe('OrganizationSuggestion', () => { + it('has the same initial properties', () => { + const organizationSuggestion = new OrganizationSuggestion({ + object: 'organization_suggestion', + id: 'test_id', + public_organization_data: { + id: 'test_org_id', + name: 'Test org', + slug: 'test-org', + image_url: 'test_image_url', + has_image: true, + }, + status: 'pending', + created_at: 12345, + updated_at: 5678, + }); + + expect(organizationSuggestion).toMatchSnapshot(); + }); +}); diff --git a/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts new file mode 100644 index 00000000000..879ba46719f --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationSuggestion.ts @@ -0,0 +1,71 @@ +import type { + ClerkPaginatedResponse, + GetUserOrganizationSuggestionsParams, + OrganizationSuggestionJSON, + OrganizationSuggestionResource, + OrganizationSuggestionStatus, + UserOrganizationInvitationResource, +} from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { convertPageToOffset } from '../../utils/pagesToOffset'; +import { BaseResource } from './Base'; + +export class OrganizationSuggestion extends BaseResource implements OrganizationSuggestionResource { + id!: string; + publicOrganizationData!: UserOrganizationInvitationResource['publicOrganizationData']; + status!: OrganizationSuggestionStatus; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: OrganizationSuggestionJSON) { + super(); + this.fromJSON(data); + } + + static async retrieve( + params?: GetUserOrganizationSuggestionsParams, + ): Promise> { + return await BaseResource._fetch({ + path: '/me/organization_suggestions', + method: 'GET', + search: convertPageToOffset(params) as any, + }) + .then(res => { + const { data: suggestions, total_count } = + res?.response as unknown as ClerkPaginatedResponse; + + return { + total_count, + data: suggestions.map(suggestion => new OrganizationSuggestion(suggestion)), + }; + }) + .catch(() => ({ + total_count: 0, + data: [], + })); + } + + accept = async (): Promise => { + return await this._basePost({ + path: `/me/organization_suggestions/${this.id}/accept`, + }); + }; + + protected fromJSON(data: OrganizationSuggestionJSON | null): this { + if (data) { + this.id = data.id; + this.status = data.status; + this.publicOrganizationData = { + hasImage: data.public_organization_data.has_image, + imageUrl: data.public_organization_data.image_url, + name: data.public_organization_data.name, + id: data.public_organization_data.id, + slug: data.public_organization_data.slug, + }; + this.createdAt = unixEpochToDate(data.created_at); + this.updatedAt = unixEpochToDate(data.updated_at); + } + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index d825d35e177..2bc188fdd8f 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -11,6 +11,7 @@ import type { ExternalAccountJSON, ExternalAccountResource, GetUserOrganizationInvitationsParams, + GetUserOrganizationSuggestionsParams, ImageResource, OrganizationMembershipResource, PhoneNumberResource, @@ -39,6 +40,7 @@ import { ExternalAccount, Image, OrganizationMembership, + OrganizationSuggestion, PhoneNumber, SamlAccount, SessionWithActivities, @@ -260,6 +262,10 @@ export class User extends BaseResource implements UserResource { return UserOrganizationInvitation.retrieve(params); }; + getOrganizationSuggestions = (params?: GetUserOrganizationSuggestionsParams) => { + return OrganizationSuggestion.retrieve(params); + }; + getOrganizationMemberships = async ( retrieveMembership: RetrieveMembershipsParams, ): Promise => { diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationSuggestion.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationSuggestion.test.ts.snap new file mode 100644 index 00000000000..dfa80b7f929 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationSuggestion.test.ts.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OrganizationSuggestion has the same initial properties 1`] = ` +OrganizationSuggestion { + "accept": [Function], + "createdAt": 1970-01-01T00:00:12.345Z, + "id": "test_id", + "pathRoot": "", + "publicOrganizationData": { + "hasImage": true, + "id": "test_org_id", + "imageUrl": "test_image_url", + "name": "Test org", + "slug": "test-org", + }, + "status": "pending", + "updatedAt": 1970-01-01T00:00:05.678Z, +} +`; diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 2c074362d68..e857b8bff00 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -17,6 +17,7 @@ export * from './OrganizationDomain'; export * from './OrganizationInvitation'; export * from './OrganizationMembership'; export * from './OrganizationMembershipRequest'; +export * from './OrganizationSuggestion'; export * from './SamlAccount'; export * from './Session'; export * from './SessionWithActivities'; diff --git a/packages/shared/src/hooks/useOrganizationList.tsx b/packages/shared/src/hooks/useOrganizationList.tsx index 06df21e40c5..494deee7edb 100644 --- a/packages/shared/src/hooks/useOrganizationList.tsx +++ b/packages/shared/src/hooks/useOrganizationList.tsx @@ -2,8 +2,10 @@ import type { ClerkPaginatedResponse, CreateOrganizationParams, GetUserOrganizationInvitationsParams, + GetUserOrganizationSuggestionsParams, OrganizationMembershipResource, OrganizationResource, + OrganizationSuggestionResource, SetActive, UserOrganizationInvitationResource, } from '@clerk/types'; @@ -19,6 +21,12 @@ type UseOrganizationListParams = { infinite?: boolean; keepPreviousData?: boolean; }); + userSuggestions?: + | true + | (GetUserOrganizationSuggestionsParams & { + infinite?: boolean; + keepPreviousData?: boolean; + }); }; type OrganizationList = ReturnType; @@ -30,6 +38,7 @@ type UseOrganizationListReturn = createOrganization: undefined; setActive: undefined; userInvitations: PaginatedResourcesWithDefault; + userSuggestions: PaginatedResourcesWithDefault; } | { isLoaded: boolean; @@ -37,12 +46,13 @@ type UseOrganizationListReturn = createOrganization: (params: CreateOrganizationParams) => Promise; setActive: SetActive; userInvitations: PaginatedResources; + userSuggestions: PaginatedResources; }; type UseOrganizationList = (params?: UseOrganizationListParams) => UseOrganizationListReturn; export const useOrganizationList: UseOrganizationList = params => { - const { userInvitations } = params || {}; + const { userInvitations, userSuggestions } = params || {}; const userInvitationsSafeValues = useWithSafeValues(userInvitations, { initialPage: 1, @@ -52,6 +62,14 @@ export const useOrganizationList: UseOrganizationList = params => { infinite: false, }); + const userSuggestionsSafeValues = useWithSafeValues(userSuggestions, { + initialPage: 1, + pageSize: 10, + status: 'pending', + keepPreviousData: false, + infinite: false, + }); + const clerk = useClerkInstanceContext(); const user = useUserContext(); @@ -64,22 +82,31 @@ export const useOrganizationList: UseOrganizationList = params => { status: userInvitationsSafeValues.status, }; + const userSuggestionsParams = + typeof userSuggestions === 'undefined' + ? undefined + : { + initialPage: userSuggestionsSafeValues.initialPage, + pageSize: userSuggestionsSafeValues.pageSize, + status: userSuggestionsSafeValues.status, + }; + const isClerkLoaded = !!(clerk.loaded && user); const { - data: isomorphicData, - count: isomorphicCount, - isLoading: isomorphicIsLoading, - isFetching: isomorphicIsFetching, - isError: isomorphicIsError, - page: isomorphicPage, - pageCount, - fetchPage: isomorphicSetPage, - fetchNext, - fetchPrevious, - hasNextPage, - hasPreviousPage, - unstable__mutate, + data: isomorphicDataInvitations, + count: isomorphicCountInvitations, + isLoading: isomorphicIsLoadingInvitations, + isFetching: isomorphicIsFetchingInvitations, + isError: isomorphicIsErrorInvitations, + page: isomorphicPageInvitations, + pageCount: pageCountInvitations, + fetchPage: isomorphicSetPageInvitations, + fetchNext: fetchNextInvitations, + fetchPrevious: fetchPreviousInvitations, + hasNextPage: hasNextPageInvitations, + hasPreviousPage: hasPreviousPageInvitations, + unstable__mutate: unstableMutateInvitations, } = usePagesOrInfinite< GetUserOrganizationInvitationsParams, ClerkPaginatedResponse @@ -99,6 +126,36 @@ export const useOrganizationList: UseOrganizationList = params => { }, ); + const { + data: isomorphicDataSuggestions, + count: isomorphicCountSuggestions, + isLoading: isomorphicIsLoadingSuggestions, + isFetching: isomorphicIsFetchingSuggestions, + isError: isomorphicIsErrorSuggestions, + page: isomorphicPageSuggestions, + pageCount: pageCountSuggestions, + fetchPage: isomorphicSetPageSuggestions, + fetchNext: fetchNextSuggestions, + fetchPrevious: fetchPreviousSuggestions, + hasNextPage: hasNextPageSuggestions, + hasPreviousPage: hasPreviousPageSuggestions, + unstable__mutate: unstableMutateSuggestions, + } = usePagesOrInfinite>( + { + ...userSuggestionsParams, + }, + user?.getOrganizationSuggestions, + { + keepPreviousData: userSuggestionsSafeValues.keepPreviousData, + infinite: userSuggestionsSafeValues.infinite, + enabled: !!userSuggestionsParams, + }, + { + type: 'userSuggestions', + userId: user?.id, + }, + ); + // TODO: Properly check for SSR user values if (!isClerkLoaded) { return { @@ -121,6 +178,21 @@ export const useOrganizationList: UseOrganizationList = params => { hasPreviousPage: false, unstable__mutate: undefined, }, + userSuggestions: { + 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, + }, }; } @@ -130,19 +202,34 @@ export const useOrganizationList: UseOrganizationList = params => { 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, + data: isomorphicDataInvitations, + count: isomorphicCountInvitations, + isLoading: isomorphicIsLoadingInvitations, + isFetching: isomorphicIsFetchingInvitations, + isError: isomorphicIsErrorInvitations, + page: isomorphicPageInvitations, + pageCount: pageCountInvitations, + fetchPage: isomorphicSetPageInvitations, + fetchNext: fetchNextInvitations, + fetchPrevious: fetchPreviousInvitations, + hasNextPage: hasNextPageInvitations, + hasPreviousPage: hasPreviousPageInvitations, + unstable__mutate: unstableMutateInvitations, + }, + userSuggestions: { + data: isomorphicDataSuggestions, + count: isomorphicCountSuggestions, + isLoading: isomorphicIsLoadingSuggestions, + isFetching: isomorphicIsFetchingSuggestions, + isError: isomorphicIsErrorSuggestions, + page: isomorphicPageSuggestions, + pageCount: pageCountSuggestions, + fetchPage: isomorphicSetPageSuggestions, + fetchNext: fetchNextSuggestions, + fetchPrevious: fetchPreviousSuggestions, + hasNextPage: hasNextPageSuggestions, + hasPreviousPage: hasPreviousPageSuggestions, + unstable__mutate: unstableMutateSuggestions, }, }; }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9bc8c3e4fd2..a83ebbf2e02 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -27,6 +27,7 @@ export * from './organizationInvitation'; export * from './organizationMembership'; export * from './organizationMembershipRequest'; export * from './organizationSettings'; +export * from './organizationSuggestion'; export * from './passwords'; export * from './phoneNumber'; export * from './redirects'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 6b708b8264f..c066b94b21d 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -10,6 +10,7 @@ import type { OrganizationDomainVerificationStatus, OrganizationEnrollmentMode } import type { OrganizationInvitationStatus } from './organizationInvitation'; import type { MembershipRole } from './organizationMembership'; import type { OrganizationSettingsJSON } from './organizationSettings'; +import type { OrganizationSuggestionStatus } from './organizationSuggestion'; import type { SamlIdpSlug } from './saml'; import type { SessionStatus } from './session'; import type { SignInFirstFactor, SignInJSON, SignInSecondFactor } from './signIn'; @@ -363,6 +364,21 @@ export interface OrganizationDomainJSON extends ClerkResourceJSON { updated_at: number; } +export interface OrganizationSuggestionJSON extends ClerkResourceJSON { + object: 'organization_suggestion'; + id: string; + public_organization_data: { + id: string; + name: string; + slug: string | null; + has_image: boolean; + image_url: string; + }; + status: OrganizationSuggestionStatus; + created_at: number; + updated_at: number; +} + export interface OrganizationMembershipRequestJSON extends ClerkResourceJSON { object: 'organization_membership_request'; id: string; diff --git a/packages/types/src/organizationSuggestion.ts b/packages/types/src/organizationSuggestion.ts new file mode 100644 index 00000000000..a9ad427e16e --- /dev/null +++ b/packages/types/src/organizationSuggestion.ts @@ -0,0 +1,19 @@ +import type { ClerkResource } from './resource'; + +export type OrganizationSuggestionStatus = 'pending' | 'accepted'; + +export interface OrganizationSuggestionResource extends ClerkResource { + id: string; + publicOrganizationData: { + hasImage: boolean; + imageUrl: string; + name: string; + id: string; + slug: string | null; + }; + status: OrganizationSuggestionStatus; + createdAt: Date; + updatedAt: Date; + + accept: () => Promise; +} diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 4e0c540ae43..62577475ce0 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -8,6 +8,7 @@ import type { UserJSON } from './json'; import type { OAuthScope } from './oauth'; import type { OrganizationInvitationStatus } from './organizationInvitation'; import type { OrganizationMembershipResource } from './organizationMembership'; +import type { OrganizationSuggestionResource, OrganizationSuggestionStatus } from './organizationSuggestion'; import type { PhoneNumberResource } from './phoneNumber'; import type { ClerkResource } from './resource'; import type { SamlAccountResource } from './samlAccount'; @@ -103,6 +104,9 @@ export interface UserResource extends ClerkResource { getOrganizationInvitations: ( params?: GetUserOrganizationInvitationsParams, ) => Promise>; + getOrganizationSuggestions: ( + params?: GetUserOrganizationSuggestionsParams, + ) => Promise>; createTOTP: () => Promise; verifyTOTP: (params: VerifyTOTPParams) => Promise; disableTOTP: () => Promise; @@ -173,3 +177,16 @@ export type GetUserOrganizationInvitationsParams = { status?: OrganizationInvitationStatus; }; + +export type GetUserOrganizationSuggestionsParams = { + /** + * 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?: OrganizationSuggestionStatus; +};