@sanity/client 8.1.0 → 8.2.0

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.
@@ -0,0 +1,313 @@
1
+ import {getPublishedId} from '@sanity/client/csm'
2
+ import {type Observable, throwError} from 'rxjs'
3
+ import {map} from 'rxjs/operators'
4
+
5
+ import {_requestObservable, getQuerySizeLimit} from '../data/dataMethods'
6
+ import {encodeQueryString} from '../data/encodeQueryString'
7
+ import {
8
+ _connectListenEventSource,
9
+ defaultOptions as defaultListenOptions,
10
+ type ListenEventFromOptions,
11
+ MAX_URL_LENGTH,
12
+ possibleOptions as possibleListenOptions,
13
+ } from '../data/listen'
14
+ import type {ObservableSanityClient, SanityClient} from '../SanityClient'
15
+ import type {
16
+ HttpRequest,
17
+ MultipleMutationResult,
18
+ MutationOperation,
19
+ QueryParams,
20
+ ResumableListenEventNames,
21
+ } from '../types'
22
+ import defaults from '../util/defaults'
23
+ import {pick} from '../util/pick'
24
+ import {
25
+ type CollaborationCommentCreate,
26
+ type CollaborationCommentDocument,
27
+ type CollaborationCommentReactionShortName,
28
+ type CollaborationCommentsListenOptions,
29
+ type CollaborationCommentsRequestOptions,
30
+ type CollaborationCommentsWriteOptions,
31
+ type CollaborationCommentUpdate,
32
+ possibleRequestOptions,
33
+ } from './types'
34
+
35
+ type Client = SanityClient | ObservableSanityClient
36
+
37
+ function commentUrl(id: string): string {
38
+ if (!id) {
39
+ throw new Error('Comment ID must be provided')
40
+ }
41
+
42
+ return `/collaboration/comments/${encodeURIComponent(id)}`
43
+ }
44
+
45
+ function resolveCommentResource(client: Client): {type: string; id: string} {
46
+ const {resource, projectId, dataset} = client.config()
47
+
48
+ if (resource) {
49
+ return resource
50
+ }
51
+
52
+ if (projectId && dataset) {
53
+ return {type: 'dataset', id: `${projectId}.${dataset}`}
54
+ }
55
+
56
+ throw new Error(
57
+ '`resource` or `projectId` and `dataset` must be configured to use collaboration comments',
58
+ )
59
+ }
60
+
61
+ function resourceQuery(client: Client): Record<string, string> {
62
+ const {collaboration} = client.config()
63
+ const organizationId = collaboration?.organizationId
64
+
65
+ if (!organizationId) {
66
+ throw new Error(
67
+ '`collaboration.organizationId` must be configured to use collaboration comments',
68
+ )
69
+ }
70
+
71
+ const resource = resolveCommentResource(client)
72
+
73
+ return {
74
+ organizationId,
75
+ resourceId: resource.id,
76
+ resourceType: resource.type,
77
+ }
78
+ }
79
+
80
+ /** @internal */
81
+ export function _getTargetDocumentRef(
82
+ client: Client,
83
+ documentId: string,
84
+ ): CollaborationCommentDocument['target']['document']['_ref'] {
85
+ if (!documentId) {
86
+ throw new Error('Document ID must be provided')
87
+ }
88
+
89
+ const resource = resolveCommentResource(client)
90
+
91
+ return `${resource.type}:${resource.id}:${getPublishedId(documentId)}`
92
+ }
93
+
94
+ type WriteArgs = [
95
+ client: Client,
96
+ httpRequest: HttpRequest,
97
+ method: 'POST' | 'PATCH' | 'DELETE',
98
+ url: string,
99
+ body: unknown,
100
+ options?: CollaborationCommentsWriteOptions,
101
+ ]
102
+
103
+ /**
104
+ * The write endpoints pass the mutation response through as-is, mirroring
105
+ * `client.mutate`.
106
+ */
107
+ interface CommentMutationResponse {
108
+ transactionId: string
109
+ results: {id: string; operation: MutationOperation}[]
110
+ }
111
+
112
+ /**
113
+ * Writes that return a comment come back with the document, since the API
114
+ * requests documents from the org store and 404s when nothing matched. A
115
+ * status update carries one result per cascaded reply on top of the comment
116
+ * itself.
117
+ */
118
+ interface CommentDocumentMutationResponse extends CommentMutationResponse {
119
+ results: {id: string; operation: MutationOperation; document: CollaborationCommentDocument}[]
120
+ }
121
+
122
+ function write<T>(
123
+ client: Client,
124
+ httpRequest: HttpRequest,
125
+ method: 'POST' | 'PATCH' | 'DELETE',
126
+ url: string,
127
+ body: unknown,
128
+ options: CollaborationCommentsWriteOptions = {},
129
+ ): Observable<T> {
130
+ return _requestObservable<T>(client, httpRequest, {
131
+ method,
132
+ url,
133
+ body,
134
+ query: {
135
+ ...resourceQuery(client),
136
+ ...(options.transactionId ? {transactionId: options.transactionId} : {}),
137
+ },
138
+ ...pick(options, possibleRequestOptions),
139
+ })
140
+ }
141
+
142
+ /**
143
+ * `commentId` picks the written comment out of the results: a status update
144
+ * cascades to the comment's replies, and the API leaves the results unordered.
145
+ * Creates pass the requested `_id`, which is undefined when the API assigns
146
+ * one, and always come back with a single result.
147
+ */
148
+ function writeDocument(
149
+ commentId: string | undefined,
150
+ ...args: WriteArgs
151
+ ): Observable<CollaborationCommentDocument> {
152
+ return write<CommentDocumentMutationResponse>(...args).pipe(
153
+ map(({results}) => {
154
+ const result = commentId ? results.find(({id}) => id === commentId) : results[0]
155
+ if (!result?.document) {
156
+ throw new Error('Comment write did not return a comment document')
157
+ }
158
+ return result.document
159
+ }),
160
+ )
161
+ }
162
+
163
+ function writeMutationResult(...args: WriteArgs): Observable<MultipleMutationResult> {
164
+ return write<CommentMutationResponse>(...args).pipe(
165
+ map(({transactionId, results}) => ({
166
+ transactionId,
167
+ documentIds: results.map((result) => result.id),
168
+ results,
169
+ })),
170
+ )
171
+ }
172
+
173
+ /** @internal */
174
+ export function _create(
175
+ client: Client,
176
+ httpRequest: HttpRequest,
177
+ body: CollaborationCommentCreate,
178
+ options?: CollaborationCommentsWriteOptions,
179
+ ): Observable<CollaborationCommentDocument> {
180
+ return writeDocument(
181
+ body._id,
182
+ client,
183
+ httpRequest,
184
+ 'POST',
185
+ '/collaboration/comments',
186
+ body,
187
+ options,
188
+ )
189
+ }
190
+
191
+ /** @internal */
192
+ export function _update(
193
+ client: Client,
194
+ httpRequest: HttpRequest,
195
+ id: string,
196
+ body: CollaborationCommentUpdate,
197
+ options?: CollaborationCommentsWriteOptions,
198
+ ): Observable<CollaborationCommentDocument> {
199
+ return writeDocument(id, client, httpRequest, 'PATCH', commentUrl(id), body, options)
200
+ }
201
+
202
+ /** @internal */
203
+ export function _delete(
204
+ client: Client,
205
+ httpRequest: HttpRequest,
206
+ id: string,
207
+ options?: CollaborationCommentsWriteOptions,
208
+ ): Observable<MultipleMutationResult> {
209
+ return writeMutationResult(client, httpRequest, 'DELETE', commentUrl(id), undefined, options)
210
+ }
211
+
212
+ /** @internal */
213
+ export function _addReaction(
214
+ client: Client,
215
+ httpRequest: HttpRequest,
216
+ id: string,
217
+ shortName: CollaborationCommentReactionShortName,
218
+ options?: CollaborationCommentsWriteOptions,
219
+ ): Observable<CollaborationCommentDocument> {
220
+ return writeDocument(
221
+ id,
222
+ client,
223
+ httpRequest,
224
+ 'POST',
225
+ `${commentUrl(id)}/reactions`,
226
+ {shortName},
227
+ options,
228
+ )
229
+ }
230
+
231
+ /** @internal */
232
+ export function _removeReaction(
233
+ client: Client,
234
+ httpRequest: HttpRequest,
235
+ id: string,
236
+ shortName: CollaborationCommentReactionShortName,
237
+ options?: CollaborationCommentsWriteOptions,
238
+ ): Observable<CollaborationCommentDocument> {
239
+ return writeDocument(
240
+ id,
241
+ client,
242
+ httpRequest,
243
+ 'DELETE',
244
+ `${commentUrl(id)}/reactions/${encodeURIComponent(shortName)}`,
245
+ undefined,
246
+ options,
247
+ )
248
+ }
249
+
250
+ /** @internal */
251
+ export function _fetch<R>(
252
+ client: Client,
253
+ httpRequest: HttpRequest,
254
+ query: string,
255
+ params?: QueryParams,
256
+ options?: CollaborationCommentsRequestOptions,
257
+ ): Observable<R> {
258
+ const search = resourceQuery(client)
259
+
260
+ // Mirrors `client.fetch`: GET while the query fits in the URL, POST beyond that.
261
+ const useGet = encodeQueryString({query, params}).length < getQuerySizeLimit
262
+ const request = useGet
263
+ ? {
264
+ method: 'GET',
265
+ url: `/collaboration/comments/query${encodeQueryString({query, params, options: search})}`,
266
+ }
267
+ : {
268
+ method: 'POST',
269
+ url: '/collaboration/comments/query',
270
+ query: search,
271
+ body: {query, params: params ?? {}},
272
+ }
273
+
274
+ return _requestObservable<{result: R}>(client, httpRequest, {
275
+ ...request,
276
+ ...pick(options || {}, possibleRequestOptions),
277
+ }).pipe(map((response) => response.result))
278
+ }
279
+
280
+ /** @internal */
281
+ export function _listen<
282
+ Opts extends CollaborationCommentsListenOptions = CollaborationCommentsListenOptions,
283
+ >(
284
+ client: Client,
285
+ query: string,
286
+ params?: QueryParams,
287
+ options?: Opts,
288
+ ): Observable<ListenEventFromOptions<CollaborationCommentDocument, Opts>> {
289
+ const opts: CollaborationCommentsListenOptions = options ?? {}
290
+
291
+ // Mirrors `_listen` in data/listen.ts, but against the comments listen endpoint
292
+ const {requestTagPrefix} = client.config()
293
+ const tag = opts.tag && requestTagPrefix ? [requestTagPrefix, opts.tag].join('.') : opts.tag
294
+ const listenOpts = pick({...defaults(opts, defaultListenOptions), tag}, possibleListenOptions)
295
+ const qs = encodeQueryString({
296
+ query,
297
+ params,
298
+ options: {...listenOpts, ...resourceQuery(client)},
299
+ })
300
+
301
+ const uri = `${client.getUrl('/collaboration/comments/listen')}${qs}`
302
+ if (uri.length > MAX_URL_LENGTH) {
303
+ return throwError(() => new Error('Query too large for listener'))
304
+ }
305
+
306
+ const events: ResumableListenEventNames[] = opts.events ? opts.events : ['mutation']
307
+
308
+ return _connectListenEventSource<ListenEventFromOptions<CollaborationCommentDocument, Opts>>(
309
+ client,
310
+ uri,
311
+ events,
312
+ )
313
+ }
@@ -0,0 +1,252 @@
1
+ import type {
2
+ Any,
3
+ ListenOptions,
4
+ RequestOptions,
5
+ ResumableListenOptions,
6
+ SanityDocument,
7
+ } from '../types'
8
+
9
+ /** @internal */
10
+ export const possibleRequestOptions = ['headers', 'signal', 'tag', 'timeout', 'token'] as const
11
+
12
+ /**
13
+ * Request options honored by the collaboration comments methods.
14
+ *
15
+ * @alpha
16
+ */
17
+ export type CollaborationCommentsRequestOptions = Pick<
18
+ RequestOptions,
19
+ (typeof possibleRequestOptions)[number]
20
+ >
21
+
22
+ /**
23
+ * Options for collaboration comments write methods.
24
+ *
25
+ * @alpha
26
+ */
27
+ export type CollaborationCommentsWriteOptions = CollaborationCommentsRequestOptions & {
28
+ /** Transaction ID to associate the write with */
29
+ transactionId?: string
30
+ }
31
+
32
+ /**
33
+ * Listener options for `collaboration.comments.listen`.
34
+ *
35
+ * `includeAllVersions` is left out: comments are stored as `sanity.comment`
36
+ * documents with no drafts or versions, so it would never make a difference.
37
+ *
38
+ * @alpha
39
+ */
40
+ export type CollaborationCommentsListenOptions =
41
+ | Omit<ListenOptions, 'includeAllVersions'>
42
+ | Omit<ResumableListenOptions, 'includeAllVersions'>
43
+
44
+ /**
45
+ * Status of a comment thread. Replies always share the status of their parent comment.
46
+ *
47
+ * @alpha
48
+ */
49
+ export type CollaborationCommentStatus = 'open' | 'resolved'
50
+
51
+ /**
52
+ * Emoji short names that can be used as comment reactions.
53
+ *
54
+ * @alpha
55
+ */
56
+ export type CollaborationCommentReactionShortName =
57
+ | ':-1:'
58
+ | ':+1:'
59
+ | ':eyes:'
60
+ | ':heart:'
61
+ | ':heavy_plus_sign:'
62
+ | ':rocket:'
63
+
64
+ /**
65
+ * A single Portable Text block, as used in comment messages and content snapshots.
66
+ *
67
+ * @alpha
68
+ */
69
+ export interface CollaborationCommentPortableTextBlock {
70
+ _type: string
71
+ children: Array<{_type: string; [key: string]: Any}>
72
+ [key: string]: Any
73
+ }
74
+
75
+ /**
76
+ * Comment message, as an array of Portable Text blocks.
77
+ *
78
+ * @alpha
79
+ */
80
+ export type CollaborationCommentMessage = CollaborationCommentPortableTextBlock[]
81
+
82
+ /**
83
+ * The text an inline comment was anchored to, resolved by the API when the
84
+ * comment was created.
85
+ *
86
+ * Holds one entry per Portable Text block the selection spans, keyed by the
87
+ * block it came from. `text` is the plain text of that block with the selected
88
+ * part wrapped in the marker characters `\uF000` (start) and `\uF001` (end).
89
+ *
90
+ * @alpha
91
+ */
92
+ export interface CollaborationCommentSelection {
93
+ type: 'text'
94
+ value: {_key: string; text: string}[]
95
+ }
96
+
97
+ /**
98
+ * A comment document, as stored by the Comments API.
99
+ *
100
+ * @alpha
101
+ */
102
+ export interface CollaborationCommentDocument extends SanityDocument {
103
+ _type: 'sanity.comment'
104
+ _system?: {
105
+ /** ID of the user that created the comment */
106
+ createdBy?: string
107
+ }
108
+ /** ID shared by a top-level comment and all of its replies */
109
+ threadId?: string
110
+ /** Set on replies, pointing to the comment being replied to */
111
+ parentCommentId?: string
112
+ message: CollaborationCommentMessage
113
+ reactions: {
114
+ _key: string
115
+ shortName: CollaborationCommentReactionShortName
116
+ userId: string
117
+ addedAt: string
118
+ }[]
119
+ /** Arbitrary metadata stored with the comment by the creating application */
120
+ context?: Record<string, unknown>
121
+ target: {
122
+ /** Global document reference (`resourceType:resourceId:documentId`, using the published document ID) */
123
+ document: {
124
+ _ref: `${string}:${string}:${string}`
125
+ _type: 'globalDocumentReference'
126
+ _weak: true
127
+ }
128
+ documentType: string
129
+ /** The exact document ID the comment was created against, e.g. a draft or version ID */
130
+ sourceDocumentId: string
131
+ documentRevisionId?: string
132
+ /**
133
+ * Set for field and inline comments. `field` is the `path` the comment was
134
+ * created with; `selection` is set for inline comments only.
135
+ */
136
+ path?: {
137
+ field: string
138
+ selection?: CollaborationCommentSelection
139
+ }
140
+ }
141
+ /**
142
+ * Copy of the commented content, as it looked when the comment was created.
143
+ * Set for inline comments only, and holds just the selected fragment of each
144
+ * Portable Text block the selection spans.
145
+ */
146
+ contentSnapshot?: CollaborationCommentPortableTextBlock[]
147
+ status: CollaborationCommentStatus
148
+ /** Set when the message has been updated after creation */
149
+ lastEditedAt?: string
150
+ }
151
+
152
+ /**
153
+ * Inline text selection within a Portable Text field.
154
+ * Each endpoint pairs the `_key` of a Portable Text block with a character
155
+ * offset into that block's plain text.
156
+ *
157
+ * @alpha
158
+ */
159
+ export interface CollaborationCommentRange {
160
+ start: {_key: string; offset: number}
161
+ end: {_key: string; offset: number}
162
+ }
163
+
164
+ /**
165
+ * Target for a top-level comment. Inline selections require both `path` and
166
+ * `range`; field-level comments may set `path` alone.
167
+ *
168
+ * The created comment stores this in a different shape: `path` becomes
169
+ * `target.path.field`, and `range` is resolved against the document into
170
+ * `target.path.selection` and `contentSnapshot` rather than being stored.
171
+ *
172
+ * @alpha
173
+ */
174
+ export type CollaborationCommentTarget = {
175
+ documentId: string
176
+ documentType: string
177
+ documentRevisionId?: string
178
+ } & (
179
+ | {
180
+ /** Path to the field containing the inline comment selection */
181
+ path: string
182
+ range: CollaborationCommentRange
183
+ }
184
+ | {
185
+ /** Path to the commented field */
186
+ path?: string
187
+ range?: never
188
+ }
189
+ )
190
+
191
+ /**
192
+ * Comment to create with `collaboration.comments.create`.
193
+ *
194
+ * A top-level comment requires `target`; a reply requires `parentCommentId` (never both).
195
+ * Replies inherit `target`, `status`, and `threadId` from the parent comment.
196
+ *
197
+ * ### Examples
198
+ *
199
+ * #### Top-level comment
200
+ * ```ts
201
+ * // `message` is an array of Portable Text blocks
202
+ * await client.collaboration.comments.create({
203
+ * message,
204
+ * target: {documentId: 'doc-1', documentType: 'article'},
205
+ * })
206
+ * ```
207
+ *
208
+ * #### Reply
209
+ * ```ts
210
+ * await client.collaboration.comments.create({
211
+ * message,
212
+ * parentCommentId: 'comment-1',
213
+ * })
214
+ * ```
215
+ *
216
+ * @alpha
217
+ */
218
+ export type CollaborationCommentCreate = {
219
+ /** Provide to control the ID of the created comment document */
220
+ _id?: string
221
+ message: CollaborationCommentMessage
222
+ context?: Record<string, unknown>
223
+ } & (
224
+ | {
225
+ target: CollaborationCommentTarget
226
+ threadId?: string
227
+ parentCommentId?: never
228
+ }
229
+ | {
230
+ parentCommentId: string
231
+ target?: never
232
+ threadId?: never
233
+ }
234
+ )
235
+
236
+ /**
237
+ * Fields that can be updated on an existing comment.
238
+ *
239
+ * @alpha
240
+ */
241
+ export interface CollaborationCommentUpdate {
242
+ /** Replaces the current message */
243
+ message?: CollaborationCommentMessage
244
+ /** Cascades to the comment's replies */
245
+ status?: CollaborationCommentStatus
246
+ /**
247
+ * Re-anchors the comment within the field and source document it already
248
+ * targets. Pass `null` to remove the selection and leave a field-level
249
+ * comment.
250
+ */
251
+ range?: CollaborationCommentRange | null
252
+ }
@@ -73,7 +73,13 @@ const indexBy = (docs: Any[], attr: Any) =>
73
73
  return indexed
74
74
  }, Object.create(null))
75
75
 
76
- const getQuerySizeLimit = 11264
76
+ /**
77
+ * Encoded query strings longer than this are sent as a `POST` body rather than
78
+ * in the request URL.
79
+ *
80
+ * @internal
81
+ */
82
+ export const getQuerySizeLimit = 11264
77
83
 
78
84
  /**
79
85
  * Resolve the effective stega config, cleaned params, response mapper and
@@ -29,9 +29,11 @@ import {resolveEventSourceFetch} from './resolveEventSourceFetch'
29
29
  // Limit is 16K for a _request_, eg including headers. Have to account for an
30
30
  // unknown range of headers, but an average EventSource request from Chrome seems
31
31
  // to have around 700 bytes of cruft, so let us account for 1.2K to be "safe"
32
- const MAX_URL_LENGTH = 16000 - 1200
32
+ /** @internal */
33
+ export const MAX_URL_LENGTH = 16000 - 1200
33
34
 
34
- const possibleOptions = [
35
+ /** @internal */
36
+ export const possibleOptions = [
35
37
  'includePreviousRevision',
36
38
  'includeResult',
37
39
  'includeMutations',
@@ -42,7 +44,8 @@ const possibleOptions = [
42
44
  'tag',
43
45
  ]
44
46
 
45
- const defaultOptions = {
47
+ /** @internal */
48
+ export const defaultOptions = {
46
49
  includeResult: true,
47
50
  }
48
51
 
@@ -137,7 +140,7 @@ export function _listen<
137
140
  opts: Opts = {} as Opts,
138
141
  ): Observable<ListenEventFromOptions<R, Opts>> {
139
142
  const config = this.config()
140
- const {url, token, withCredentials, requestTagPrefix, headers: configHeaders} = config
143
+ const {url, requestTagPrefix} = config
141
144
  const tag = opts.tag && requestTagPrefix ? [requestTagPrefix, opts.tag].join('.') : opts.tag
142
145
  const options = {...defaults(opts, defaultOptions), tag}
143
146
  const listenOpts = pick(options, possibleOptions)
@@ -150,6 +153,18 @@ export function _listen<
150
153
 
151
154
  const listenFor = (options.events ? options.events : ['mutation']) satisfies Opts['events']
152
155
 
156
+ return _connectListenEventSource<ListenEventFromOptions<R, Opts>>(this, uri, listenFor)
157
+ }
158
+
159
+ /** @internal */
160
+ export function _connectListenEventSource<TEvent extends {type: string}>(
161
+ client: SanityClient | ObservableSanityClient,
162
+ uri: string,
163
+ listenFor: string[],
164
+ ): Observable<TEvent> {
165
+ const config = client.config()
166
+ const {token, withCredentials, headers: configHeaders} = config
167
+
153
168
  const headers: Record<string, string> = {}
154
169
  if (token) {
155
170
  headers.Authorization = `Bearer ${token}`
@@ -173,5 +188,5 @@ export function _listen<
173
188
  type: event.type,
174
189
  ...('data' in event ? (event.data as object) : {}),
175
190
  })),
176
- ) as Observable<ListenEventFromOptions<R, Opts>>
191
+ ) as Observable<TEvent>
177
192
  }