@sanity/sdk 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1705 -2163
- package/dist/index.js +497 -187
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/src/_exports/index.ts +22 -1
- package/src/auth/authStore.ts +1 -0
- package/src/auth/refreshStampedToken.test.ts +225 -6
- package/src/auth/refreshStampedToken.ts +95 -30
- package/src/presence/bifurTransport.test.ts +257 -0
- package/src/presence/bifurTransport.ts +108 -0
- package/src/presence/presenceStore.test.ts +247 -0
- package/src/presence/presenceStore.ts +163 -0
- package/src/presence/types.ts +70 -0
- package/src/query/queryStore.test.ts +4 -1
- package/src/releases/releasesStore.test.ts +5 -2
- package/src/users/reducers.ts +9 -3
- package/src/users/types.ts +17 -0
- package/src/users/usersConstants.ts +1 -0
- package/src/users/usersStore.test.ts +129 -9
- package/src/users/usersStore.ts +132 -4
- package/src/utils/defineIntent.test.ts +477 -0
- package/src/utils/defineIntent.ts +244 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {createSelector} from 'reselect'
|
|
3
|
+
import {combineLatest, distinctUntilChanged, filter, map, of, Subscription, switchMap} from 'rxjs'
|
|
4
|
+
|
|
5
|
+
import {getTokenState} from '../auth/authStore'
|
|
6
|
+
import {getClient} from '../client/clientStore'
|
|
7
|
+
import {bindActionByDataset} from '../store/createActionBinder'
|
|
8
|
+
import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
|
|
9
|
+
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
10
|
+
import {type SanityUser} from '../users/types'
|
|
11
|
+
import {getUserState} from '../users/usersStore'
|
|
12
|
+
import {createBifurTransport} from './bifurTransport'
|
|
13
|
+
import {type PresenceLocation, type TransportEvent, type UserPresence} from './types'
|
|
14
|
+
|
|
15
|
+
type PresenceStoreState = {
|
|
16
|
+
locations: Map<string, {userId: string; locations: PresenceLocation[]}>
|
|
17
|
+
users: Record<string, SanityUser | undefined>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const getInitialState = (): PresenceStoreState => ({
|
|
21
|
+
locations: new Map<string, {userId: string; locations: PresenceLocation[]}>(),
|
|
22
|
+
users: {},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
/** @public */
|
|
26
|
+
export const presenceStore = defineStore<PresenceStoreState>({
|
|
27
|
+
name: 'presence',
|
|
28
|
+
getInitialState,
|
|
29
|
+
initialize: (context: StoreContext<PresenceStoreState>) => {
|
|
30
|
+
const {instance, state} = context
|
|
31
|
+
const sessionId = crypto.randomUUID()
|
|
32
|
+
|
|
33
|
+
const client = getClient(instance, {
|
|
34
|
+
apiVersion: '2022-06-30',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const token$ = getTokenState(instance).observable.pipe(distinctUntilChanged())
|
|
38
|
+
|
|
39
|
+
const [incomingEvents$, dispatch] = createBifurTransport({
|
|
40
|
+
client: client as SanityClient,
|
|
41
|
+
token$,
|
|
42
|
+
sessionId,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const subscription = new Subscription()
|
|
46
|
+
|
|
47
|
+
subscription.add(
|
|
48
|
+
incomingEvents$.subscribe((event: TransportEvent) => {
|
|
49
|
+
if ('sessionId' in event && event.sessionId === sessionId) {
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (event.type === 'state') {
|
|
54
|
+
state.set('presence/state', (prevState: PresenceStoreState) => {
|
|
55
|
+
const newLocations = new Map(prevState.locations)
|
|
56
|
+
newLocations.set(event.sessionId, {
|
|
57
|
+
userId: event.userId,
|
|
58
|
+
locations: event.locations,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
...prevState,
|
|
63
|
+
locations: newLocations,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
} else if (event.type === 'disconnect') {
|
|
67
|
+
state.set('presence/disconnect', (prevState: PresenceStoreState) => {
|
|
68
|
+
const newLocations = new Map(prevState.locations)
|
|
69
|
+
newLocations.delete(event.sessionId)
|
|
70
|
+
return {...prevState, locations: newLocations}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
dispatch({type: 'rollCall'}).subscribe()
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
dispatch({type: 'disconnect'}).subscribe()
|
|
80
|
+
subscription.unsubscribe()
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const selectLocations = (state: PresenceStoreState) => state.locations
|
|
86
|
+
const selectUsers = (state: PresenceStoreState) => state.users
|
|
87
|
+
|
|
88
|
+
const selectPresence = createSelector(
|
|
89
|
+
selectLocations,
|
|
90
|
+
selectUsers,
|
|
91
|
+
(locations, users): UserPresence[] => {
|
|
92
|
+
return Array.from(locations.entries()).map(([sessionId, {userId, locations: locs}]) => {
|
|
93
|
+
const user = users[userId]
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
user:
|
|
97
|
+
user ||
|
|
98
|
+
({
|
|
99
|
+
id: userId,
|
|
100
|
+
displayName: 'Unknown user',
|
|
101
|
+
name: 'Unknown user',
|
|
102
|
+
email: '',
|
|
103
|
+
} as unknown as SanityUser),
|
|
104
|
+
sessionId,
|
|
105
|
+
locations: locs,
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
/** @public */
|
|
112
|
+
export const getPresence = bindActionByDataset(
|
|
113
|
+
presenceStore,
|
|
114
|
+
createStateSourceAction({
|
|
115
|
+
selector: (context: SelectorContext<PresenceStoreState>): UserPresence[] =>
|
|
116
|
+
selectPresence(context.state),
|
|
117
|
+
onSubscribe: (context) => {
|
|
118
|
+
const userIds$ = context.state.observable.pipe(
|
|
119
|
+
map((state) =>
|
|
120
|
+
Array.from(state.locations.values())
|
|
121
|
+
.map((l) => l.userId)
|
|
122
|
+
.filter((id): id is string => !!id),
|
|
123
|
+
),
|
|
124
|
+
distinctUntilChanged((a, b) => a.length === b.length && a.every((v, i) => v === b[i])),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const subscription = userIds$
|
|
128
|
+
.pipe(
|
|
129
|
+
switchMap((userIds) => {
|
|
130
|
+
if (userIds.length === 0) {
|
|
131
|
+
return of([])
|
|
132
|
+
}
|
|
133
|
+
const userObservables = userIds.map((userId) =>
|
|
134
|
+
getUserState(context.instance, {
|
|
135
|
+
userId,
|
|
136
|
+
resourceType: 'project',
|
|
137
|
+
projectId: context.instance.config.projectId,
|
|
138
|
+
}).pipe(filter((v): v is NonNullable<typeof v> => !!v)),
|
|
139
|
+
)
|
|
140
|
+
return combineLatest(userObservables)
|
|
141
|
+
}),
|
|
142
|
+
)
|
|
143
|
+
.subscribe((users) => {
|
|
144
|
+
if (!users) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
context.state.set('presence/users', (prevState) => ({
|
|
148
|
+
...prevState,
|
|
149
|
+
users: {
|
|
150
|
+
...prevState.users,
|
|
151
|
+
...users.reduce<Record<string, SanityUser>>((acc, user) => {
|
|
152
|
+
if (user) {
|
|
153
|
+
acc[user.profile.id] = user
|
|
154
|
+
}
|
|
155
|
+
return acc
|
|
156
|
+
}, {}),
|
|
157
|
+
},
|
|
158
|
+
}))
|
|
159
|
+
})
|
|
160
|
+
return () => subscription.unsubscribe()
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {type Observable} from 'rxjs'
|
|
3
|
+
|
|
4
|
+
import {type SanityUser} from '../users/types'
|
|
5
|
+
|
|
6
|
+
/** @public */
|
|
7
|
+
export interface PresenceLocation {
|
|
8
|
+
type: 'document'
|
|
9
|
+
documentId: string
|
|
10
|
+
path: string[]
|
|
11
|
+
lastActiveAt: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @public */
|
|
15
|
+
export interface UserPresence {
|
|
16
|
+
user: SanityUser
|
|
17
|
+
locations: PresenceLocation[]
|
|
18
|
+
sessionId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @public */
|
|
22
|
+
export type PresenceTransport = [
|
|
23
|
+
incomingEvents$: Observable<TransportEvent>,
|
|
24
|
+
dispatchMessage: (message: TransportMessage) => Observable<void>,
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
/** @public */
|
|
28
|
+
export type TransportEvent = RollCallEvent | StateEvent | DisconnectEvent
|
|
29
|
+
|
|
30
|
+
/** @public */
|
|
31
|
+
export interface RollCallEvent {
|
|
32
|
+
type: 'rollCall'
|
|
33
|
+
userId: string
|
|
34
|
+
sessionId: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @public */
|
|
38
|
+
export interface StateEvent {
|
|
39
|
+
type: 'state'
|
|
40
|
+
userId: string
|
|
41
|
+
sessionId: string
|
|
42
|
+
timestamp: string
|
|
43
|
+
locations: PresenceLocation[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @public */
|
|
47
|
+
export interface DisconnectEvent {
|
|
48
|
+
type: 'disconnect'
|
|
49
|
+
userId: string
|
|
50
|
+
sessionId: string
|
|
51
|
+
timestamp: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @public */
|
|
55
|
+
export type TransportMessage =
|
|
56
|
+
| {type: 'rollCall'}
|
|
57
|
+
| {type: 'state'; locations: PresenceLocation[]}
|
|
58
|
+
| {type: 'disconnect'}
|
|
59
|
+
|
|
60
|
+
/** @public */
|
|
61
|
+
export interface BifurTransportOptions {
|
|
62
|
+
client: SanityClient
|
|
63
|
+
token$: Observable<string | null>
|
|
64
|
+
sessionId: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @public */
|
|
68
|
+
export interface PresenceStore {
|
|
69
|
+
locations$: Observable<PresenceLocation[]>
|
|
70
|
+
}
|
|
@@ -20,6 +20,7 @@ describe('queryStore', () => {
|
|
|
20
20
|
let instance: SanityInstance
|
|
21
21
|
let liveEvents: Subject<LiveEvent>
|
|
22
22
|
let fetch: SanityClient['observable']['fetch']
|
|
23
|
+
let listen: SanityClient['observable']['listen']
|
|
23
24
|
// Mock data for testing
|
|
24
25
|
const mockData = {
|
|
25
26
|
movies: [
|
|
@@ -37,6 +38,8 @@ describe('queryStore', () => {
|
|
|
37
38
|
of({result: mockData.movies, syncTags: []}).pipe(delay(0)),
|
|
38
39
|
) as SanityClient['observable']['fetch']
|
|
39
40
|
|
|
41
|
+
listen = vi.fn().mockReturnValue(of(mockData.movies))
|
|
42
|
+
|
|
40
43
|
liveEvents = new Subject<LiveEvent>()
|
|
41
44
|
|
|
42
45
|
const events = vi.fn().mockReturnValue(liveEvents) as SanityClient['live']['events']
|
|
@@ -47,7 +50,7 @@ describe('queryStore', () => {
|
|
|
47
50
|
observable: of({
|
|
48
51
|
config,
|
|
49
52
|
live: {events},
|
|
50
|
-
observable: {fetch},
|
|
53
|
+
observable: {fetch, listen},
|
|
51
54
|
} as SanityClient),
|
|
52
55
|
} as StateSource<SanityClient>)
|
|
53
56
|
})
|
|
@@ -17,7 +17,7 @@ vi.mock('../utils/listenQuery', () => ({
|
|
|
17
17
|
}))
|
|
18
18
|
|
|
19
19
|
// Mock console.error to prevent test runner noise and allow verification
|
|
20
|
-
|
|
20
|
+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
|
21
21
|
|
|
22
22
|
describe('releasesStore', () => {
|
|
23
23
|
let instance: SanityInstance
|
|
@@ -25,7 +25,7 @@ describe('releasesStore', () => {
|
|
|
25
25
|
|
|
26
26
|
beforeEach(() => {
|
|
27
27
|
vi.resetAllMocks()
|
|
28
|
-
consoleErrorSpy.
|
|
28
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
29
29
|
|
|
30
30
|
instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
31
31
|
|
|
@@ -36,6 +36,7 @@ describe('releasesStore', () => {
|
|
|
36
36
|
|
|
37
37
|
afterEach(() => {
|
|
38
38
|
instance.dispose()
|
|
39
|
+
consoleErrorSpy.mockRestore()
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
it('should set active releases state when listenQuery succeeds', async () => {
|
|
@@ -63,6 +64,8 @@ describe('releasesStore', () => {
|
|
|
63
64
|
|
|
64
65
|
expect(state.getCurrent()).toEqual(mockReleases.reverse())
|
|
65
66
|
expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|
67
|
+
|
|
68
|
+
vi.useRealTimers()
|
|
66
69
|
})
|
|
67
70
|
|
|
68
71
|
it('should update active releases state when listenQuery emits new data', async () => {
|
package/src/users/reducers.ts
CHANGED
|
@@ -12,11 +12,16 @@ export const getUsersKey = (
|
|
|
12
12
|
organizationId,
|
|
13
13
|
batchSize = DEFAULT_USERS_BATCH_SIZE,
|
|
14
14
|
projectId = instance.config.projectId,
|
|
15
|
+
userId,
|
|
15
16
|
}: GetUsersOptions = {},
|
|
16
17
|
): string =>
|
|
17
|
-
JSON.stringify({
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
resourceType,
|
|
20
|
+
organizationId,
|
|
21
|
+
batchSize,
|
|
22
|
+
projectId,
|
|
23
|
+
userId,
|
|
24
|
+
} satisfies ReturnType<typeof parseUsersKey>)
|
|
20
25
|
|
|
21
26
|
/** @internal */
|
|
22
27
|
export const parseUsersKey = (
|
|
@@ -26,6 +31,7 @@ export const parseUsersKey = (
|
|
|
26
31
|
resourceType?: 'organization' | 'project'
|
|
27
32
|
projectId?: string
|
|
28
33
|
organizationId?: string
|
|
34
|
+
userId?: string
|
|
29
35
|
} => JSON.parse(key)
|
|
30
36
|
|
|
31
37
|
export const addSubscription =
|
package/src/users/types.ts
CHANGED
|
@@ -46,6 +46,7 @@ export interface GetUsersOptions extends ProjectHandle {
|
|
|
46
46
|
resourceType?: 'organization' | 'project'
|
|
47
47
|
batchSize?: number
|
|
48
48
|
organizationId?: string
|
|
49
|
+
userId?: string
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -83,3 +84,19 @@ export interface UsersStoreState {
|
|
|
83
84
|
export interface ResolveUsersOptions extends GetUsersOptions {
|
|
84
85
|
signal?: AbortSignal
|
|
85
86
|
}
|
|
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
|
+
}
|
|
@@ -2,11 +2,18 @@ import {type SanityClient} from '@sanity/client'
|
|
|
2
2
|
import {delay, filter, firstValueFrom, Observable, of} from 'rxjs'
|
|
3
3
|
import {beforeEach, describe, expect, it, vi} from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {getClientState} from '../client/clientStore'
|
|
5
|
+
import {getClient, getClientState} from '../client/clientStore'
|
|
6
6
|
import {createSanityInstance} from '../store/createSanityInstance'
|
|
7
7
|
import {type StateSource} from '../store/createStateSourceAction'
|
|
8
8
|
import {type GetUsersOptions, type SanityUser, type SanityUserResponse} from './types'
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
getUsersState,
|
|
11
|
+
getUserState,
|
|
12
|
+
loadMoreUsers,
|
|
13
|
+
type PatchedSanityUserFromClient,
|
|
14
|
+
resolveUser,
|
|
15
|
+
resolveUsers,
|
|
16
|
+
} from './usersStore'
|
|
10
17
|
|
|
11
18
|
vi.mock('./usersConstants', async (importOriginal) => ({
|
|
12
19
|
...(await importOriginal<typeof import('./usersConstants')>()),
|
|
@@ -64,13 +71,19 @@ describe('usersStore', () => {
|
|
|
64
71
|
beforeEach(() => {
|
|
65
72
|
request = vi.fn().mockReturnValue(of(mockResponse).pipe(delay(0)))
|
|
66
73
|
|
|
67
|
-
vi.mocked(getClientState).
|
|
68
|
-
|
|
69
|
-
observable: {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
+
vi.mocked(getClientState).mockImplementation(() => {
|
|
75
|
+
const client = {
|
|
76
|
+
observable: {request},
|
|
77
|
+
} as unknown as SanityClient
|
|
78
|
+
return {
|
|
79
|
+
observable: of(client),
|
|
80
|
+
} as StateSource<SanityClient>
|
|
81
|
+
})
|
|
82
|
+
vi.mocked(getClient).mockReturnValue({
|
|
83
|
+
observable: {
|
|
84
|
+
request,
|
|
85
|
+
},
|
|
86
|
+
} as unknown as SanityClient)
|
|
74
87
|
})
|
|
75
88
|
|
|
76
89
|
it('initializes users state and cleans up after unsubscribe', async () => {
|
|
@@ -391,4 +404,111 @@ describe('usersStore', () => {
|
|
|
391
404
|
unsubscribe2()
|
|
392
405
|
instance.dispose()
|
|
393
406
|
})
|
|
407
|
+
|
|
408
|
+
describe('getUserState', () => {
|
|
409
|
+
beforeEach(() => {
|
|
410
|
+
// Clear all mocks between tests
|
|
411
|
+
vi.clearAllMocks()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('fetches a single user with a project-scoped ID', async () => {
|
|
415
|
+
const instance = createSanityInstance({projectId: 'test', dataset: 'test'})
|
|
416
|
+
const projectUserId = 'p12345'
|
|
417
|
+
const mockProjectUser: PatchedSanityUserFromClient = {
|
|
418
|
+
id: projectUserId,
|
|
419
|
+
sanityUserId: projectUserId,
|
|
420
|
+
displayName: 'Project User',
|
|
421
|
+
createdAt: '2023-01-01T00:00:00Z',
|
|
422
|
+
updatedAt: '2023-01-01T00:00:00Z',
|
|
423
|
+
isCurrentUser: false,
|
|
424
|
+
projectId: 'project1',
|
|
425
|
+
familyName: null,
|
|
426
|
+
givenName: null,
|
|
427
|
+
middleName: null,
|
|
428
|
+
imageUrl: null,
|
|
429
|
+
email: 'project@example.com',
|
|
430
|
+
provider: 'google',
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const specificRequest = vi.fn().mockReturnValue(of(mockProjectUser).pipe(delay(0)))
|
|
434
|
+
vi.mocked(getClient).mockReturnValue({
|
|
435
|
+
observable: {
|
|
436
|
+
request: specificRequest,
|
|
437
|
+
},
|
|
438
|
+
} as unknown as SanityClient)
|
|
439
|
+
|
|
440
|
+
const user$ = getUserState(instance, {userId: projectUserId, projectId: 'project1'})
|
|
441
|
+
|
|
442
|
+
const result = await firstValueFrom(user$.pipe(filter((i) => i !== undefined)))
|
|
443
|
+
|
|
444
|
+
expect(getClient).toHaveBeenCalledWith(
|
|
445
|
+
instance,
|
|
446
|
+
expect.objectContaining({
|
|
447
|
+
projectId: 'project1',
|
|
448
|
+
useProjectHostname: true,
|
|
449
|
+
}),
|
|
450
|
+
)
|
|
451
|
+
expect(specificRequest).toHaveBeenCalledWith({
|
|
452
|
+
method: 'GET',
|
|
453
|
+
uri: `/users/${projectUserId}`,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const expectedUser: SanityUser = {
|
|
457
|
+
sanityUserId: projectUserId,
|
|
458
|
+
profile: {
|
|
459
|
+
id: projectUserId,
|
|
460
|
+
displayName: 'Project User',
|
|
461
|
+
familyName: undefined,
|
|
462
|
+
givenName: undefined,
|
|
463
|
+
middleName: undefined,
|
|
464
|
+
imageUrl: undefined,
|
|
465
|
+
createdAt: '2023-01-01T00:00:00Z',
|
|
466
|
+
updatedAt: '2023-01-01T00:00:00Z',
|
|
467
|
+
isCurrentUser: false,
|
|
468
|
+
email: 'project@example.com',
|
|
469
|
+
provider: 'google',
|
|
470
|
+
},
|
|
471
|
+
memberships: [],
|
|
472
|
+
}
|
|
473
|
+
expect(result).toEqual(expectedUser)
|
|
474
|
+
|
|
475
|
+
instance.dispose()
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('fetches a single user with a global-scoped ID', async () => {
|
|
479
|
+
const instance = createSanityInstance({
|
|
480
|
+
projectId: 'test',
|
|
481
|
+
dataset: 'test',
|
|
482
|
+
})
|
|
483
|
+
const globalUserId = 'g12345'
|
|
484
|
+
const mockGlobalUser: SanityUser = {
|
|
485
|
+
sanityUserId: globalUserId,
|
|
486
|
+
profile: {
|
|
487
|
+
id: 'profile-g1',
|
|
488
|
+
displayName: 'Global User',
|
|
489
|
+
email: 'global@example.com',
|
|
490
|
+
provider: 'google',
|
|
491
|
+
createdAt: '2023-01-01T00:00:00Z',
|
|
492
|
+
},
|
|
493
|
+
memberships: [],
|
|
494
|
+
}
|
|
495
|
+
const mockGlobalUserResponse: SanityUserResponse = {
|
|
496
|
+
data: [mockGlobalUser],
|
|
497
|
+
totalCount: 1,
|
|
498
|
+
nextCursor: null,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Mock the request to return the global user response
|
|
502
|
+
vi.mocked(request).mockReturnValue(of(mockGlobalUserResponse))
|
|
503
|
+
|
|
504
|
+
const result = await resolveUser(instance, {
|
|
505
|
+
userId: globalUserId,
|
|
506
|
+
projectId: 'project1',
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
expect(result).toEqual(mockGlobalUser)
|
|
510
|
+
|
|
511
|
+
instance.dispose()
|
|
512
|
+
})
|
|
513
|
+
})
|
|
394
514
|
})
|
package/src/users/usersStore.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {type SanityUser as SanityUserFromClient} from '@sanity/client'
|
|
1
2
|
import {createSelector} from 'reselect'
|
|
2
3
|
import {
|
|
3
4
|
catchError,
|
|
@@ -24,7 +25,7 @@ import {
|
|
|
24
25
|
} from 'rxjs'
|
|
25
26
|
|
|
26
27
|
import {getDashboardOrganizationId} from '../auth/authStore'
|
|
27
|
-
import {getClientState} from '../client/clientStore'
|
|
28
|
+
import {getClient, getClientState} from '../client/clientStore'
|
|
28
29
|
import {bindActionGlobally} from '../store/createActionBinder'
|
|
29
30
|
import {createStateSourceAction, type SelectorContext} from '../store/createStateSourceAction'
|
|
30
31
|
import {type StoreState} from '../store/createStoreState'
|
|
@@ -42,12 +43,23 @@ import {
|
|
|
42
43
|
updateLastLoadMoreRequest,
|
|
43
44
|
} from './reducers'
|
|
44
45
|
import {
|
|
46
|
+
type GetUserOptions,
|
|
45
47
|
type GetUsersOptions,
|
|
48
|
+
type ResolveUserOptions,
|
|
46
49
|
type ResolveUsersOptions,
|
|
50
|
+
type SanityUser,
|
|
47
51
|
type SanityUserResponse,
|
|
48
52
|
type UsersStoreState,
|
|
49
53
|
} from './types'
|
|
50
|
-
import {API_VERSION, USERS_STATE_CLEAR_DELAY} from './usersConstants'
|
|
54
|
+
import {API_VERSION, PROJECT_API_VERSION, USERS_STATE_CLEAR_DELAY} from './usersConstants'
|
|
55
|
+
|
|
56
|
+
/** @internal */
|
|
57
|
+
export type PatchedSanityUserFromClient = Omit<SanityUserFromClient, 'id'> & {
|
|
58
|
+
id: string
|
|
59
|
+
sanityUserId: string
|
|
60
|
+
email: string
|
|
61
|
+
provider: string
|
|
62
|
+
}
|
|
51
63
|
|
|
52
64
|
/**
|
|
53
65
|
* The users store resource that manages user data fetching and state.
|
|
@@ -100,9 +112,98 @@ const listenForLoadMoreAndFetch = ({state, instance}: StoreContext<UsersStoreSta
|
|
|
100
112
|
group$.pipe(
|
|
101
113
|
switchMap((e) => {
|
|
102
114
|
if (!e.added) return EMPTY
|
|
103
|
-
const {batchSize, ...options} = parseUsersKey(group$.key)
|
|
115
|
+
const {userId, batchSize, ...options} = parseUsersKey(group$.key)
|
|
116
|
+
|
|
117
|
+
if (userId) {
|
|
118
|
+
// In the future we will be able to fetch a user from the global API using the resourceUserId,
|
|
119
|
+
// but for now we need to use the project subdomain to fetch a user if the userId is a project user (starts with "p")
|
|
120
|
+
if (userId.startsWith('p')) {
|
|
121
|
+
const client = getClient(instance, {
|
|
122
|
+
apiVersion: PROJECT_API_VERSION,
|
|
123
|
+
// this is a global store, so we need to use the projectId from the options when we're fetching
|
|
124
|
+
// users from a project subdomain
|
|
125
|
+
projectId: options.projectId,
|
|
126
|
+
useProjectHostname: true,
|
|
127
|
+
})
|
|
104
128
|
|
|
105
|
-
|
|
129
|
+
return client.observable
|
|
130
|
+
.request<PatchedSanityUserFromClient>({
|
|
131
|
+
method: 'GET',
|
|
132
|
+
uri: `/users/${userId}`,
|
|
133
|
+
})
|
|
134
|
+
.pipe(
|
|
135
|
+
map((user) => {
|
|
136
|
+
// We need to convert the user to the format we expect
|
|
137
|
+
const convertedUser: SanityUser = {
|
|
138
|
+
sanityUserId: user.sanityUserId,
|
|
139
|
+
profile: {
|
|
140
|
+
id: user.id,
|
|
141
|
+
displayName: user.displayName,
|
|
142
|
+
familyName: user.familyName ?? undefined,
|
|
143
|
+
givenName: user.givenName ?? undefined,
|
|
144
|
+
middleName: user.middleName ?? undefined,
|
|
145
|
+
imageUrl: user.imageUrl ?? undefined,
|
|
146
|
+
createdAt: user.createdAt,
|
|
147
|
+
updatedAt: user.updatedAt,
|
|
148
|
+
isCurrentUser: user.isCurrentUser,
|
|
149
|
+
email: user.email,
|
|
150
|
+
provider: user.provider,
|
|
151
|
+
},
|
|
152
|
+
memberships: [],
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
data: [convertedUser],
|
|
156
|
+
totalCount: 1,
|
|
157
|
+
nextCursor: null,
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
catchError((error) => {
|
|
161
|
+
state.set('setUsersError', setUsersError(group$.key, error))
|
|
162
|
+
return EMPTY
|
|
163
|
+
}),
|
|
164
|
+
tap((response) =>
|
|
165
|
+
state.set('setUsersData', setUsersData(group$.key, response)),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Fetch the user from the global API
|
|
171
|
+
const scope = userId.startsWith('g') ? 'global' : undefined
|
|
172
|
+
const client = getClient(instance, {
|
|
173
|
+
scope,
|
|
174
|
+
apiVersion: API_VERSION,
|
|
175
|
+
})
|
|
176
|
+
const resourceType = options.resourceType || 'project'
|
|
177
|
+
const resourceId =
|
|
178
|
+
resourceType === 'organization' ? options.organizationId : options.projectId
|
|
179
|
+
if (!resourceId) {
|
|
180
|
+
return throwError(() => new Error('An organizationId or a projectId is required'))
|
|
181
|
+
}
|
|
182
|
+
return client.observable
|
|
183
|
+
.request<SanityUser | SanityUserResponse>({
|
|
184
|
+
method: 'GET',
|
|
185
|
+
uri: `access/${resourceType}/${resourceId}/users/${userId}`,
|
|
186
|
+
})
|
|
187
|
+
.pipe(
|
|
188
|
+
map((response) => {
|
|
189
|
+
// If the response is a single user object (has sanityUserId), wrap it in the expected format
|
|
190
|
+
if ('sanityUserId' in response) {
|
|
191
|
+
return {
|
|
192
|
+
data: [response],
|
|
193
|
+
totalCount: 1,
|
|
194
|
+
nextCursor: null,
|
|
195
|
+
} as SanityUserResponse
|
|
196
|
+
}
|
|
197
|
+
return response as SanityUserResponse
|
|
198
|
+
}),
|
|
199
|
+
catchError((error) => {
|
|
200
|
+
state.set('setUsersError', setUsersError(group$.key, error))
|
|
201
|
+
return EMPTY
|
|
202
|
+
}),
|
|
203
|
+
tap((response) => state.set('setUsersData', setUsersData(group$.key, response))),
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
const projectId = options.projectId
|
|
106
207
|
|
|
107
208
|
// the resource type this request will use
|
|
108
209
|
// If resourceType is explicitly provided, use it
|
|
@@ -313,3 +414,30 @@ export const loadMoreUsers = bindActionGlobally(
|
|
|
313
414
|
return await promise
|
|
314
415
|
},
|
|
315
416
|
)
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @beta
|
|
420
|
+
*/
|
|
421
|
+
export const getUserState = bindActionGlobally(
|
|
422
|
+
usersStore,
|
|
423
|
+
({instance}, {userId, ...options}: GetUserOptions) => {
|
|
424
|
+
return getUsersState(instance, {userId, ...options}).observable.pipe(
|
|
425
|
+
map((res) => res?.data[0]),
|
|
426
|
+
distinctUntilChanged((a, b) => a?.profile.updatedAt === b?.profile.updatedAt),
|
|
427
|
+
)
|
|
428
|
+
},
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* @beta
|
|
433
|
+
*/
|
|
434
|
+
export const resolveUser = bindActionGlobally(
|
|
435
|
+
usersStore,
|
|
436
|
+
async ({instance}, {signal, ...options}: ResolveUserOptions) => {
|
|
437
|
+
const result = await resolveUsers(instance, {
|
|
438
|
+
signal,
|
|
439
|
+
...options,
|
|
440
|
+
})
|
|
441
|
+
return result?.data[0]
|
|
442
|
+
},
|
|
443
|
+
)
|