UNPKG

@sanity/sdk

Version:
444 lines (411 loc) 16.1 kB
import {type SanityUser as SanityUserFromClient} from '@sanity/client' import {createSelector} from 'reselect' import { catchError, combineLatest, distinctUntilChanged, EMPTY, filter, first, firstValueFrom, groupBy, map, mergeMap, NEVER, Observable, of, pairwise, race, skip, startWith, switchMap, tap, throwError, withLatestFrom, } from 'rxjs' import {getDashboardOrganizationId} from '../auth/authStore' import {getClient, getClientState} from '../client/clientStore' import {bindActionGlobally} from '../store/createActionBinder' import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction' import {type StoreState} from '../store/createStoreState' import {defineStore, type StoreContext} from '../store/defineStore' import {insecureRandomId} from '../utils/ids' import { addSubscription, cancelRequest, getUsersKey, initializeRequest, parseUsersKey, removeSubscription, setUsersData, setUsersError, updateLastLoadMoreRequest, } from './reducers' import { type GetUserOptions, type GetUsersOptions, type ResolveUserOptions, type ResolveUsersOptions, type SanityUser, type SanityUserResponse, type UsersStoreState, } from './types' import {API_VERSION, PROJECT_API_VERSION, USERS_STATE_CLEAR_DELAY} from './usersConstants' /** @internal */ export type PatchedSanityUserFromClient = Omit<SanityUserFromClient, 'id'> & { id: string sanityUserId: string email: string provider: string } /** * The users store resource that manages user data fetching and state. * * This store handles fetching, caching, and managing user data. It provides functionality for * retrieving users associated with specific resources and supports pagination through the * `loadMoreUsers` action. * * @internal */ const usersStore = defineStore<UsersStoreState>({ name: 'UsersStore', getInitialState: () => ({users: {}}), initialize: (context) => { const subscription = listenForLoadMoreAndFetch(context) return () => subscription.unsubscribe() }, }) const errorHandler = (state: StoreState<{error?: unknown}>) => (error: unknown): void => state.set('setError', {error}) /** * Internal action that listens for new user subscriptions and load more requests. * Fetches user data when new subscriptions are added or when loadMoreUsers is called. */ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreState>) => { return state.observable .pipe( map((s) => new Set(Object.keys(s.users))), distinctUntilChanged((curr, next) => { if (curr.size !== next.size) return false return Array.from(next).every((i) => curr.has(i)) }), startWith(new Set<string>()), pairwise(), mergeMap(([curr, next]) => { const added = Array.from(next).filter((i) => !curr.has(i)) const removed = Array.from(curr).filter((i) => !next.has(i)) return [ ...added.map((key) => ({key, added: true})), ...removed.map((key) => ({key, added: false})), ] }), groupBy((i) => i.key), mergeMap((group$) => group$.pipe( switchMap((e) => { if (!e.added) return EMPTY const {userId, batchSize, ...options} = parseUsersKey(group$.key) if (userId) { // In the future we will be able to fetch a user from the global API using the resourceUserId, // but for now we need to use the project subdomain to fetch a user if the userId is a project user (starts with "p") if (userId.startsWith('p')) { const client = getClient(instance, { apiVersion: PROJECT_API_VERSION, // this is a global store, so we need to use the projectId from the options when we're fetching // users from a project subdomain projectId: options.projectId, useProjectHostname: true, }) return client.observable .request<PatchedSanityUserFromClient>({ method: 'GET', uri: `/users/${userId}`, }) .pipe( map((user) => { // We need to convert the user to the format we expect const convertedUser: SanityUser = { sanityUserId: user.sanityUserId, profile: { id: user.id, displayName: user.displayName, familyName: user.familyName ?? undefined, givenName: user.givenName ?? undefined, middleName: user.middleName ?? undefined, imageUrl: user.imageUrl ?? undefined, createdAt: user.createdAt, updatedAt: user.updatedAt, isCurrentUser: user.isCurrentUser, email: user.email, provider: user.provider, }, memberships: [], } return { data: [convertedUser], totalCount: 1, nextCursor: null, } }), catchError((error) => { state.set('setUsersError', setUsersError(group$.key, error)) return EMPTY }), tap((response) => state.set('setUsersData', setUsersData(group$.key, response)), ), ) } // Fetch the user from the global API const scope = userId.startsWith('g') ? 'global' : undefined const client = getClient(instance, { scope, apiVersion: API_VERSION, }) const resourceType = options.resourceType || 'project' const resourceId = resourceType === 'organization' ? options.organizationId : options.projectId if (!resourceId) { return throwError(() => new Error('An organizationId or a projectId is required')) } return client.observable .request<SanityUser | SanityUserResponse>({ method: 'GET', uri: `access/${resourceType}/${resourceId}/users/${userId}`, }) .pipe( map((response) => { // If the response is a single user object (has sanityUserId), wrap it in the expected format if ('sanityUserId' in response) { return { data: [response], totalCount: 1, nextCursor: null, } as SanityUserResponse } return response as SanityUserResponse }), catchError((error) => { state.set('setUsersError', setUsersError(group$.key, error)) return EMPTY }), tap((response) => state.set('setUsersData', setUsersData(group$.key, response))), ) } const projectId = options.projectId // the resource type this request will use // If resourceType is explicitly provided, use it // Otherwise, infer from context: organization if organizationId exists, // project if projectId exists, or default to organization const resourceType = options.resourceType ?? (options.organizationId ? 'organization' : projectId ? 'project' : 'organization') const organizationId$ = options.organizationId ? of(options.organizationId) : getDashboardOrganizationId(instance).observable.pipe( filter((i) => typeof i === 'string'), ) const resource$: Observable<{ type: 'project' | 'organization' id: string }> = resourceType === 'project' ? projectId ? of({type: 'project', id: projectId}) : throwError(() => new Error('Project ID required for this API.')) : organizationId$.pipe(map((id) => ({type: 'organization', id}))) const client$ = getClientState(instance, { scope: 'global', apiVersion: API_VERSION, }).observable const loadMore$ = state.observable.pipe( map((s) => s.users[group$.key]?.lastLoadMoreRequest), distinctUntilChanged(), ) const cursor$ = state.observable.pipe( map((s) => s.users[group$.key]?.nextCursor), distinctUntilChanged(), filter((cursor) => cursor !== null), ) return combineLatest([resource$, client$, loadMore$]).pipe( withLatestFrom(cursor$), switchMap(([[resource, client], cursor]) => client.observable.request<SanityUserResponse>({ method: 'GET', uri: `access/${resource.type}/${resource.id}/users`, query: cursor ? {nextCursor: cursor, limit: batchSize.toString()} : {limit: batchSize.toString()}, }), ), catchError((error) => { state.set('setUsersError', setUsersError(group$.key, error)) return EMPTY }), tap((response) => state.set('setUsersData', setUsersData(group$.key, response))), ) }), ), ), ) .subscribe({error: errorHandler(state)}) } /** * Returns the state source for users associated with a specific resource. * * This function returns a state source that represents the current list of users for a given * resource. Subscribing to the state source will instruct the SDK to fetch the users (if not * already fetched) and will load more from this state source as well. When the last subscriber is * removed, the users state is automatically cleaned up from the store after a delay. * * Note: This functionality is for advanced users who want to build their own framework * integrations. Our SDK also provides a React integration for convenient usage. * * @beta */ export const getUsersState = bindActionGlobally( usersStore, createStateSourceAction({ selector: createSelector( [ ({instance, state}: SelectorContext<UsersStoreState>, options?: GetUsersOptions) => state.error ?? state.users[getUsersKey(instance, options)]?.error, ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) => state.users[getUsersKey(instance, options)]?.users, ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) => state.users[getUsersKey(instance, options)]?.totalCount, ({instance, state}: SelectorContext<UsersStoreState>, options: GetUsersOptions) => state.users[getUsersKey(instance, options)]?.nextCursor, ], (error, data, totalCount, nextCursor) => { if (error) throw error if (data === undefined || totalCount === undefined || nextCursor === undefined) { return undefined } return {data, totalCount, hasMore: nextCursor !== null} }, ), onSubscribe: ({instance, state}, options?: GetUsersOptions) => { const subscriptionId = insecureRandomId() const key = getUsersKey(instance, options) state.set('addSubscription', addSubscription(subscriptionId, key)) return () => { setTimeout( () => state.set('removeSubscription', removeSubscription(subscriptionId, key)), USERS_STATE_CLEAR_DELAY, ) } }, }), ) /** * Resolves the users for a specific resource without registering a lasting subscriber. * * This function fetches the users for a given resource and returns a promise that resolves with * the users result. Unlike `getUsersState`, which registers subscribers to keep the data live and * performs automatic cleanup, `resolveUsers` does not track subscribers. This makes it ideal for * use with React Suspense, where the returned promise is thrown to delay rendering until the users * result becomes available. Once the promise resolves, it is expected that a real subscriber will * be added via `getUsersState` to manage ongoing updates. * * Additionally, an optional AbortSignal can be provided to cancel the request and immediately * clear the associated state if there are no active subscribers. * * @beta */ export const resolveUsers = bindActionGlobally( usersStore, async ({state, instance}, {signal, ...options}: ResolveUsersOptions) => { const key = getUsersKey(instance, options) const {getCurrent} = getUsersState(instance, options) const aborted$ = signal ? new Observable<never>((observer) => { const cleanup = () => { signal.removeEventListener('abort', listener) } const listener = () => { observer.error(new DOMException('The operation was aborted.', 'AbortError')) observer.complete() cleanup() } signal.addEventListener('abort', listener) return cleanup }).pipe( catchError((error) => { if (error instanceof Error && error.name === 'AbortError') { state.set('cancelRequest', cancelRequest(key)) } throw error }), ) : NEVER state.set('initializeRequest', initializeRequest(key)) const resolved$ = state.observable.pipe( map(getCurrent), first((i) => i !== undefined), ) return firstValueFrom(race([resolved$, aborted$])) }, ) /** * Loads more users for a specific resource. * * This function triggers a request to fetch the next page of users for a given resource. It * requires that users have already been loaded for the resource (via `resolveUsers` or * `getUsersState`), and that there are more users available to load (as indicated by the `hasMore` * property). * * The function returns a promise that resolves when the next page of users has been loaded. * * @beta */ export const loadMoreUsers = bindActionGlobally( usersStore, async ({state, instance}, options?: GetUsersOptions) => { const key = getUsersKey(instance, options) const users = getUsersState(instance, options) const usersState = users.getCurrent() if (!usersState) { throw new Error('Users not loaded for specified resource. Please call resolveUsers first.') } if (!usersState.hasMore) { throw new Error('No more users available to load for this resource.') } const promise = firstValueFrom( users.observable.pipe( filter((i) => i !== undefined), skip(1), ), ) const timestamp = new Date().toISOString() state.set('updateLastLoadMoreRequest', updateLastLoadMoreRequest(timestamp, key)) return await promise }, ) /** * @beta */ export const getUserState = bindActionGlobally( usersStore, ({instance}, {userId, ...options}: GetUserOptions) => { return getUsersState(instance, {userId, ...options}).observable.pipe( map((res) => res?.data[0]), distinctUntilChanged((a, b) => a?.profile.updatedAt === b?.profile.updatedAt), ) }, ) /** * @beta */ export const resolveUser = bindActionGlobally( usersStore, async ({instance}, {signal, ...options}: ResolveUserOptions) => { const result = await resolveUsers(instance, { signal, ...options, }) return result?.data[0] }, )