Skip to content

Commit 210d48e

Browse files
feat(core): allow a userId option for the user store (#580)
* feat(core): allow a userId option for the user store --------- Co-authored-by: Carolina Gonzalez <[email protected]>
1 parent be0211b commit 210d48e

File tree

8 files changed

+285
-19
lines changed

8 files changed

+285
-19
lines changed

knip.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const baseConfig = {
1212
config: 'tsconfig.tsdoc.json',
1313
},
1414
// Knip doesn't support pnpm version
15-
ignoreBinaries: ['version', 'sed'],
15+
ignoreBinaries: ['version', 'sed', 'open'],
1616
entry: ['package.config.ts', 'vitest.config.mts'],
1717
},
1818
'scripts/*': {

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"prepare": "husky",
3535
"test": "vitest run",
3636
"test:coverage": "vitest run --coverage",
37+
"test:coverage:open": "open coverage/index.html",
3738
"test:e2e": "turbo run test:e2e",
3839
"test:watch": "vitest watch",
3940
"ts:check": "turbo run ts:check --filter='./packages/*' --filter='./apps/*'",

packages/core/src/_exports/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,24 @@ export {createSanityInstance, type SanityInstance} from '../store/createSanityIn
127127
export {type Selector, type StateSource} from '../store/createStateSourceAction'
128128
export {getUsersKey, parseUsersKey} from '../users/reducers'
129129
export {
130+
type GetUserOptions,
130131
type GetUsersOptions,
131132
type Membership,
133+
type ResolveUserOptions,
132134
type ResolveUsersOptions,
133135
type SanityUser,
136+
type SanityUserResponse,
134137
type UserProfile,
138+
type UsersGroupState,
139+
type UsersStoreState,
135140
} from '../users/types'
136-
export {getUsersState, loadMoreUsers, resolveUsers} from '../users/usersStore'
141+
export {
142+
getUsersState,
143+
getUserState,
144+
loadMoreUsers,
145+
resolveUser,
146+
resolveUsers,
147+
} from '../users/usersStore'
137148
export {type FetcherStore, type FetcherStoreState} from '../utils/createFetcherStore'
138149
export {createGroqSearchFilter} from '../utils/createGroqSearchFilter'
139150
export {CORE_SDK_VERSION} from '../version'

packages/core/src/users/reducers.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,16 @@ export const getUsersKey = (
1212
organizationId,
1313
batchSize = DEFAULT_USERS_BATCH_SIZE,
1414
projectId = instance.config.projectId,
15+
userId,
1516
}: GetUsersOptions = {},
1617
): string =>
17-
JSON.stringify({resourceType, organizationId, batchSize, projectId} satisfies ReturnType<
18-
typeof parseUsersKey
19-
>)
18+
JSON.stringify({
19+
resourceType,
20+
organizationId,
21+
batchSize,
22+
projectId,
23+
userId,
24+
} satisfies ReturnType<typeof parseUsersKey>)
2025

2126
/** @internal */
2227
export const parseUsersKey = (
@@ -26,6 +31,7 @@ export const parseUsersKey = (
2631
resourceType?: 'organization' | 'project'
2732
projectId?: string
2833
organizationId?: string
34+
userId?: string
2935
} => JSON.parse(key)
3036

3137
export const addSubscription =

packages/core/src/users/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface GetUsersOptions extends ProjectHandle {
4646
resourceType?: 'organization' | 'project'
4747
batchSize?: number
4848
organizationId?: string
49+
userId?: string
4950
}
5051

5152
/**
@@ -83,3 +84,19 @@ export interface UsersStoreState {
8384
export interface ResolveUsersOptions extends GetUsersOptions {
8485
signal?: AbortSignal
8586
}
87+
88+
/**
89+
* @public
90+
*/
91+
export interface GetUserOptions extends ProjectHandle {
92+
userId: string
93+
resourceType?: 'organization' | 'project'
94+
organizationId?: string
95+
}
96+
97+
/**
98+
* @public
99+
*/
100+
export interface ResolveUserOptions extends GetUserOptions {
101+
signal?: AbortSignal
102+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// NOTE: currently this API is only available on vX
22
export const API_VERSION = 'vX'
3+
export const PROJECT_API_VERSION = '2025-07-18'
34
export const USERS_STATE_CLEAR_DELAY = 5000
45
export const DEFAULT_USERS_BATCH_SIZE = 100

packages/core/src/users/usersStore.test.ts

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import {type SanityClient} from '@sanity/client'
1+
import {type SanityClient, type SanityUser as SanityUserFromClient} from '@sanity/client'
22
import {delay, filter, firstValueFrom, Observable, of} from 'rxjs'
33
import {beforeEach, describe, expect, it, vi} from 'vitest'
44

5-
import {getClientState} from '../client/clientStore'
5+
import {getClient, getClientState} from '../client/clientStore'
66
import {createSanityInstance} from '../store/createSanityInstance'
77
import {type StateSource} from '../store/createStateSourceAction'
88
import {type GetUsersOptions, type SanityUser, type SanityUserResponse} from './types'
9-
import {getUsersState, loadMoreUsers, resolveUsers} from './usersStore'
9+
import {getUsersState, getUserState, loadMoreUsers, resolveUser, resolveUsers} from './usersStore'
1010

1111
vi.mock('./usersConstants', async (importOriginal) => ({
1212
...(await importOriginal<typeof import('./usersConstants')>()),
@@ -64,13 +64,19 @@ describe('usersStore', () => {
6464
beforeEach(() => {
6565
request = vi.fn().mockReturnValue(of(mockResponse).pipe(delay(0)))
6666

67-
vi.mocked(getClientState).mockReturnValue({
68-
observable: of({
69-
observable: {
70-
request,
71-
},
72-
} as SanityClient),
73-
} as StateSource<SanityClient>)
67+
vi.mocked(getClientState).mockImplementation(() => {
68+
const client = {
69+
observable: {request},
70+
} as unknown as SanityClient
71+
return {
72+
observable: of(client),
73+
} as StateSource<SanityClient>
74+
})
75+
vi.mocked(getClient).mockReturnValue({
76+
observable: {
77+
request,
78+
},
79+
} as unknown as SanityClient)
7480
})
7581

7682
it('initializes users state and cleans up after unsubscribe', async () => {
@@ -391,4 +397,108 @@ describe('usersStore', () => {
391397
unsubscribe2()
392398
instance.dispose()
393399
})
400+
401+
describe('getUserState', () => {
402+
beforeEach(() => {
403+
// Clear all mocks between tests
404+
vi.clearAllMocks()
405+
})
406+
407+
it('fetches a single user with a project-scoped ID', async () => {
408+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
409+
const projectUserId = 'p12345'
410+
const mockProjectUser: SanityUserFromClient = {
411+
id: projectUserId,
412+
displayName: 'Project User',
413+
createdAt: '2023-01-01T00:00:00Z',
414+
updatedAt: '2023-01-01T00:00:00Z',
415+
isCurrentUser: false,
416+
projectId: 'project1',
417+
familyName: null,
418+
givenName: null,
419+
middleName: null,
420+
imageUrl: null,
421+
}
422+
423+
const specificRequest = vi.fn().mockReturnValue(of(mockProjectUser).pipe(delay(0)))
424+
vi.mocked(getClient).mockReturnValue({
425+
observable: {
426+
request: specificRequest,
427+
},
428+
} as unknown as SanityClient)
429+
430+
const user$ = getUserState(instance, {userId: projectUserId, projectId: 'project1'})
431+
432+
const result = await firstValueFrom(user$.pipe(filter((i) => i !== undefined)))
433+
434+
expect(getClient).toHaveBeenCalledWith(
435+
instance,
436+
expect.objectContaining({
437+
projectId: 'project1',
438+
useProjectHostname: true,
439+
}),
440+
)
441+
expect(specificRequest).toHaveBeenCalledWith({
442+
method: 'GET',
443+
uri: `/users/${projectUserId}`,
444+
})
445+
446+
const expectedUser: SanityUser = {
447+
sanityUserId: projectUserId,
448+
profile: {
449+
id: projectUserId,
450+
displayName: 'Project User',
451+
familyName: undefined,
452+
givenName: undefined,
453+
middleName: undefined,
454+
imageUrl: undefined,
455+
createdAt: '2023-01-01T00:00:00Z',
456+
updatedAt: '2023-01-01T00:00:00Z',
457+
isCurrentUser: false,
458+
email: '',
459+
provider: '',
460+
},
461+
memberships: [],
462+
}
463+
expect(result).toEqual(expectedUser)
464+
465+
instance.dispose()
466+
})
467+
468+
it('fetches a single user with a global-scoped ID', async () => {
469+
const instance = createSanityInstance({
470+
projectId: 'test',
471+
dataset: 'test',
472+
})
473+
const globalUserId = 'g12345'
474+
const mockGlobalUser: SanityUser = {
475+
sanityUserId: globalUserId,
476+
profile: {
477+
id: 'profile-g1',
478+
displayName: 'Global User',
479+
480+
provider: 'google',
481+
createdAt: '2023-01-01T00:00:00Z',
482+
},
483+
memberships: [],
484+
}
485+
const mockGlobalUserResponse: SanityUserResponse = {
486+
data: [mockGlobalUser],
487+
totalCount: 1,
488+
nextCursor: null,
489+
}
490+
491+
// Mock the request to return the global user response
492+
vi.mocked(request).mockReturnValue(of(mockGlobalUserResponse))
493+
494+
const result = await resolveUser(instance, {
495+
userId: globalUserId,
496+
projectId: 'project1',
497+
})
498+
499+
expect(result).toEqual(mockGlobalUser)
500+
501+
instance.dispose()
502+
})
503+
})
394504
})

0 commit comments

Comments
 (0)