@sanity/sdk 2.18.0 → 2.20.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.
- package/dist/_exports/_internal.d.ts +2 -2
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +196 -22
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/authStore-CWTAuieY.js +1072 -0
- package/dist/authStore-CWTAuieY.js.map +1 -0
- package/dist/clientStore-CMzJ7XhZ.js +168 -0
- package/dist/clientStore-CMzJ7XhZ.js.map +1 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js +1222 -0
- package/dist/createGroqSearchFilter-B0NdA-EP.js.map +1 -0
- package/dist/getEnv-76AjZd_f.js +178 -0
- package/dist/getEnv-76AjZd_f.js.map +1 -0
- package/dist/index.d.ts +2 -1921
- package/dist/index.js +4165 -3711
- package/dist/index.js.map +1 -1
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/telemetryManager-Bd3O8z2S.js +127 -0
- package/dist/telemetryManager-Bd3O8z2S.js.map +1 -0
- package/dist/utils-BMwA8Kg9.d.ts +3334 -0
- package/dist/utils-BMwA8Kg9.d.ts.map +1 -0
- package/dist/version-DL2l07HG.js +9 -0
- package/dist/version-DL2l07HG.js.map +1 -0
- package/package.json +13 -21
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/index.ts +37 -1
- package/src/comments/addonDatasetStore.test.ts +215 -0
- package/src/comments/addonDatasetStore.ts +249 -0
- package/src/comments/buildCommentThreads.test.ts +83 -0
- package/src/comments/buildCommentThreads.ts +54 -0
- package/src/comments/commentActions.test.ts +540 -0
- package/src/comments/commentActions.ts +555 -0
- package/src/comments/commentFieldPath.test.ts +40 -0
- package/src/comments/commentFieldPath.ts +44 -0
- package/src/comments/commentsConstants.ts +80 -0
- package/src/comments/commentsStore.test.ts +419 -0
- package/src/comments/commentsStore.ts +483 -0
- package/src/comments/normalizeComment.test.ts +116 -0
- package/src/comments/normalizeComment.ts +45 -0
- package/src/comments/observeComments.test.ts +193 -0
- package/src/comments/observeComments.ts +113 -0
- package/src/comments/reducers.test.ts +277 -0
- package/src/comments/reducers.ts +316 -0
- package/src/comments/types.ts +249 -0
- package/src/comments/weakenReferences.test.ts +47 -0
- package/src/comments/weakenReferences.ts +29 -0
- package/src/document/documentConstants.ts +8 -0
- package/src/document/documentStore.concurrency.test.ts +313 -22
- package/src/document/documentStore.test.ts +98 -1
- package/src/document/documentStore.ts +36 -5
- package/src/document/patchOperations.test.ts +27 -0
- package/src/document/patchOperations.ts +10 -1
- package/src/document/permissions.test.ts +3 -1
- package/src/document/reducers.test.ts +133 -0
- package/src/document/reducers.ts +18 -3
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/presence/bifurTransport.test.ts +155 -3
- package/src/presence/bifurTransport.ts +79 -12
- package/src/presence/paths.test.ts +70 -0
- package/src/presence/paths.ts +41 -0
- package/src/presence/presenceStore.test.ts +822 -2
- package/src/presence/presenceStore.ts +518 -102
- package/src/presence/types.ts +175 -3
- package/src/projection/projectionStore.test.ts +0 -8
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -926
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -133
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1586
- package/dist/_chunks-es/createGroqSearchFilter.js.map +0 -1
- package/dist/_chunks-es/telemetryManager.js +0 -93
- package/dist/_chunks-es/telemetryManager.js.map +0 -1
- package/dist/_chunks-es/version.js +0 -7
- package/dist/_chunks-es/version.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {DocumentId, getPublishedId} from '@sanity/id-utils'
|
|
3
|
+
import {type Path} from '@sanity/types'
|
|
4
|
+
import {filter, firstValueFrom, take} from 'rxjs'
|
|
5
|
+
|
|
6
|
+
import {getCurrentUserState} from '../auth/authStore'
|
|
7
|
+
import {getClient} from '../client/clientStore'
|
|
8
|
+
import {
|
|
9
|
+
type DatasetHandle,
|
|
10
|
+
type DatasetResource,
|
|
11
|
+
type DocumentHandle,
|
|
12
|
+
type DocumentResource,
|
|
13
|
+
} from '../config/sanityConfig'
|
|
14
|
+
import {isReleasePerspective} from '../releases/utils/isReleasePerspective'
|
|
15
|
+
import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
|
|
16
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
17
|
+
import {type StoreState} from '../store/createStoreState'
|
|
18
|
+
import {type StoreContext} from '../store/defineStore'
|
|
19
|
+
import {
|
|
20
|
+
assertDatasetResource,
|
|
21
|
+
getAddonDatasetState,
|
|
22
|
+
provisionAddonDataset,
|
|
23
|
+
} from './addonDatasetStore'
|
|
24
|
+
import {toCommentFieldPath} from './commentFieldPath'
|
|
25
|
+
import {COMMENTS_API_VERSION} from './commentsConstants'
|
|
26
|
+
import {type CommentsOptions, commentsStore, toCommentsKeyParts} from './commentsStore'
|
|
27
|
+
import {normalizeComment} from './normalizeComment'
|
|
28
|
+
import {
|
|
29
|
+
addComment,
|
|
30
|
+
applyCommentUpdate,
|
|
31
|
+
clearPendingTransaction,
|
|
32
|
+
type CommentsStoreState,
|
|
33
|
+
getCommentsKey,
|
|
34
|
+
receiveComment,
|
|
35
|
+
removeCommentById,
|
|
36
|
+
restoreComments,
|
|
37
|
+
rollbackCommentUpdate,
|
|
38
|
+
setCommentCreateError,
|
|
39
|
+
setPendingTransaction,
|
|
40
|
+
} from './reducers'
|
|
41
|
+
import {
|
|
42
|
+
type Comment,
|
|
43
|
+
type CommentMessage,
|
|
44
|
+
type CommentPostPayload,
|
|
45
|
+
type CommentStatus,
|
|
46
|
+
type CommentTextSelection,
|
|
47
|
+
type StoredComment,
|
|
48
|
+
} from './types'
|
|
49
|
+
import {weakenReferencesInContentSnapshot} from './weakenReferences'
|
|
50
|
+
|
|
51
|
+
/** @beta */
|
|
52
|
+
export interface CreateCommentOptions extends DocumentHandle {
|
|
53
|
+
message: CommentMessage
|
|
54
|
+
/**
|
|
55
|
+
* Which field the thread hangs off, for example `title` or
|
|
56
|
+
* `body[_key=="intro"].content`.
|
|
57
|
+
*
|
|
58
|
+
* Required, and it has to resolve to a real path. There is no such thing as a
|
|
59
|
+
* comment on a document as a whole: the Studio only ever creates field
|
|
60
|
+
* comments, and its comment inspector throws on an empty path rather than
|
|
61
|
+
* ignoring it, so a pathless comment takes the inspector down for everyone
|
|
62
|
+
* looking at that document.
|
|
63
|
+
*/
|
|
64
|
+
fieldPath: string | Path
|
|
65
|
+
/**
|
|
66
|
+
* Anchors the comment to a run of text inside a Portable Text field. Building
|
|
67
|
+
* one needs a live editor, so this comes from
|
|
68
|
+
* `@portabletext/plugin-sdk-value` rather than being constructed by hand.
|
|
69
|
+
*/
|
|
70
|
+
selection?: CommentTextSelection
|
|
71
|
+
/** Reuse the id of a failed comment to retry it. Defaults to a new id. */
|
|
72
|
+
commentId?: string
|
|
73
|
+
/** Defaults to a new id, which starts a new thread. */
|
|
74
|
+
threadId?: string
|
|
75
|
+
/** @defaultValue `'open'` */
|
|
76
|
+
status?: CommentStatus
|
|
77
|
+
/**
|
|
78
|
+
* The `_rev` of the document when the comment was written.
|
|
79
|
+
*
|
|
80
|
+
* Not filled in automatically: the SDK's local document revision is replaced
|
|
81
|
+
* by a transaction id while an edit is in flight, so it can name a revision
|
|
82
|
+
* that never reached the server. Nothing in the Studio reads this today.
|
|
83
|
+
*/
|
|
84
|
+
documentRevisionId?: string
|
|
85
|
+
/**
|
|
86
|
+
* Ambient information stored alongside the comment, merged over the defaults.
|
|
87
|
+
*
|
|
88
|
+
* The Studio writes `tool`, `payload`, `intent`, and `notification` here for
|
|
89
|
+
* its notification backend. The SDK has no source for any of them, so
|
|
90
|
+
* mentions in comments created this way do not send email unless you supply
|
|
91
|
+
* `notification` yourself. Deliberately loose: the organization-level store
|
|
92
|
+
* treats this as free-form, so no shape is worth promising.
|
|
93
|
+
*/
|
|
94
|
+
context?: Record<string, unknown>
|
|
95
|
+
/** A copy of the content being discussed. References in it are weakened. */
|
|
96
|
+
contentSnapshot?: unknown
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @beta */
|
|
100
|
+
export interface ReplyToCommentOptions extends DocumentHandle {
|
|
101
|
+
/** The comment being replied to. Replies to a reply join the same thread. */
|
|
102
|
+
parentCommentId: string
|
|
103
|
+
message: CommentMessage
|
|
104
|
+
commentId?: string
|
|
105
|
+
/** Only needed when the parent is not loaded, such as following a deep link. */
|
|
106
|
+
threadId?: string
|
|
107
|
+
/** Only needed when the parent is not loaded. */
|
|
108
|
+
fieldPath?: string | Path
|
|
109
|
+
/** Required when the parent is not loaded. */
|
|
110
|
+
status?: CommentStatus
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @beta */
|
|
114
|
+
export interface UpdateCommentOptions extends DatasetHandle {
|
|
115
|
+
commentId: string
|
|
116
|
+
message: CommentMessage
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @beta */
|
|
120
|
+
export interface SetCommentStatusOptions extends DatasetHandle {
|
|
121
|
+
/** The thread's first comment. Replies follow their parent. */
|
|
122
|
+
commentId: string
|
|
123
|
+
status: CommentStatus
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @beta */
|
|
127
|
+
export interface RemoveCommentOptions extends DatasetHandle {
|
|
128
|
+
commentId: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Refuses to write a comment that points at nothing.
|
|
133
|
+
*
|
|
134
|
+
* The type already requires `fieldPath`, but an empty string or an empty path
|
|
135
|
+
* array both survive that and normalise to `''`. Such a comment is not merely
|
|
136
|
+
* useless: the Studio's comment inspector calls `fromString` on the stored
|
|
137
|
+
* path, which throws on `''`, so the inspector crashes for everyone viewing
|
|
138
|
+
* that document until the comment is deleted. Cheaper to refuse the write.
|
|
139
|
+
*/
|
|
140
|
+
function requireFieldPath(fieldPath: string | Path): string {
|
|
141
|
+
const normalized = toCommentFieldPath(fieldPath)
|
|
142
|
+
if (!normalized) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
'A comment needs a field path. Comments attach to a field, not to a document as a whole.',
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
return normalized
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function requireCurrentUserId(instance: SanityInstance): string {
|
|
151
|
+
const userId = getCurrentUserState(instance).getCurrent()?.id
|
|
152
|
+
if (!userId) {
|
|
153
|
+
throw new Error('Writing a comment requires a logged in user.')
|
|
154
|
+
}
|
|
155
|
+
return userId
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Waits out discovery, then insists the addon dataset exists. */
|
|
159
|
+
async function requireAddonDataset(
|
|
160
|
+
instance: SanityInstance,
|
|
161
|
+
resource: DocumentResource,
|
|
162
|
+
): Promise<string> {
|
|
163
|
+
const datasetName = await firstValueFrom(
|
|
164
|
+
getAddonDatasetState(instance, {resource}).observable.pipe(
|
|
165
|
+
filter((value) => value !== undefined),
|
|
166
|
+
take(1),
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if (!datasetName) {
|
|
171
|
+
throw new Error('This project has no comments dataset, so there is no comment to change.')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return datasetName
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function getWritableClient(
|
|
178
|
+
instance: SanityInstance,
|
|
179
|
+
resource: DocumentResource,
|
|
180
|
+
{createIfMissing}: {createIfMissing: boolean},
|
|
181
|
+
): Promise<SanityClient> {
|
|
182
|
+
const {projectId} = assertDatasetResource(resource)
|
|
183
|
+
|
|
184
|
+
// Provisioning is a project-wide side effect, so only the create path may
|
|
185
|
+
// trigger it. Editing or deleting a comment implies the dataset is there.
|
|
186
|
+
const dataset = createIfMissing
|
|
187
|
+
? await provisionAddonDataset(instance, {resource})
|
|
188
|
+
: await requireAddonDataset(instance, resource)
|
|
189
|
+
|
|
190
|
+
return getClient(instance, {apiVersion: COMMENTS_API_VERSION, projectId, dataset})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function buildCommentPayload(options: {
|
|
194
|
+
authorId: string
|
|
195
|
+
commentId: string
|
|
196
|
+
contentSnapshot?: unknown
|
|
197
|
+
context?: Record<string, unknown>
|
|
198
|
+
documentRevisionId?: string
|
|
199
|
+
handle: DocumentHandle
|
|
200
|
+
message: CommentMessage
|
|
201
|
+
parentCommentId?: string
|
|
202
|
+
resource: DatasetResource
|
|
203
|
+
fieldPath: string
|
|
204
|
+
selection?: CommentTextSelection
|
|
205
|
+
status: CommentStatus
|
|
206
|
+
threadId: string
|
|
207
|
+
documentVersionId?: string
|
|
208
|
+
}): CommentPostPayload {
|
|
209
|
+
const {handle, resource} = options
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
_id: options.commentId,
|
|
213
|
+
_type: 'comment',
|
|
214
|
+
authorId: options.authorId,
|
|
215
|
+
message: options.message,
|
|
216
|
+
threadId: options.threadId,
|
|
217
|
+
...(options.parentCommentId ? {parentCommentId: options.parentCommentId} : {}),
|
|
218
|
+
status: options.status,
|
|
219
|
+
reactions: null,
|
|
220
|
+
// The Studio writes an empty tool name when no tool is active, and reads
|
|
221
|
+
// this nowhere in its UI.
|
|
222
|
+
context: {tool: '', ...options.context},
|
|
223
|
+
...(options.contentSnapshot === undefined
|
|
224
|
+
? {}
|
|
225
|
+
: {contentSnapshot: weakenReferencesInContentSnapshot(options.contentSnapshot)}),
|
|
226
|
+
target: {
|
|
227
|
+
documentRevisionId: options.documentRevisionId ?? '',
|
|
228
|
+
path: {
|
|
229
|
+
field: options.fieldPath,
|
|
230
|
+
...(options.selection ? {selection: options.selection} : {}),
|
|
231
|
+
},
|
|
232
|
+
// The comment lives in the addon dataset, so this points across to the
|
|
233
|
+
// dataset holding the document. Weak, or Content Lake would refuse to
|
|
234
|
+
// delete a commented document.
|
|
235
|
+
document: {
|
|
236
|
+
_dataset: resource.dataset,
|
|
237
|
+
_projectId: resource.projectId,
|
|
238
|
+
_ref: getPublishedId(DocumentId(handle.documentId)),
|
|
239
|
+
_type: 'crossDatasetReference',
|
|
240
|
+
_weak: true,
|
|
241
|
+
},
|
|
242
|
+
documentType: handle.documentType,
|
|
243
|
+
...(options.documentVersionId ? {documentVersionId: options.documentVersionId} : {}),
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function findComment(
|
|
249
|
+
state: StoreState<CommentsStoreState>,
|
|
250
|
+
commentsKey: string,
|
|
251
|
+
commentId: string,
|
|
252
|
+
): StoredComment | undefined {
|
|
253
|
+
const comments = state.get().entries[commentsKey]?.comments
|
|
254
|
+
return comments && Object.hasOwn(comments, commentId) ? comments[commentId] : undefined
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function findCommentById(
|
|
258
|
+
state: StoreState<CommentsStoreState>,
|
|
259
|
+
commentId: string,
|
|
260
|
+
): StoredComment | undefined {
|
|
261
|
+
for (const entry of Object.values(state.get().entries)) {
|
|
262
|
+
const comments = entry?.comments
|
|
263
|
+
const comment = comments && Object.hasOwn(comments, commentId) ? comments[commentId] : undefined
|
|
264
|
+
if (comment) return comment
|
|
265
|
+
}
|
|
266
|
+
return undefined
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function postComment(
|
|
270
|
+
context: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
271
|
+
handle: DocumentHandle & CommentsOptions,
|
|
272
|
+
payload: CommentPostPayload,
|
|
273
|
+
): Promise<Comment> {
|
|
274
|
+
const {state, instance, key} = context
|
|
275
|
+
const commentsKey = getCommentsKey(toCommentsKeyParts(instance, handle))
|
|
276
|
+
|
|
277
|
+
// Show it before the round trip, so a thread feels immediate.
|
|
278
|
+
state.set('addComment', addComment(commentsKey, payload))
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
const client = await getWritableClient(instance, key.resource, {createIfMissing: true})
|
|
282
|
+
// `createIfNotExists` makes duplicate writes of the same id harmless, which
|
|
283
|
+
// covers both a retry after a lost response and two concurrent calls with
|
|
284
|
+
// the same comment id.
|
|
285
|
+
const created = await client.createIfNotExists(payload, {tag: 'comments.create'})
|
|
286
|
+
state.set('receiveComment', receiveComment(commentsKey, created))
|
|
287
|
+
return normalizeComment(created)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// Keep it on screen carrying the failure, so an app can offer a retry with
|
|
290
|
+
// the same id rather than silently losing what someone typed.
|
|
291
|
+
state.set(
|
|
292
|
+
'setCommentCreateError',
|
|
293
|
+
setCommentCreateError(payload._id, error instanceof Error ? error : new Error(String(error))),
|
|
294
|
+
)
|
|
295
|
+
throw error
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Starts a thread on a document, or on one of its fields.
|
|
301
|
+
*
|
|
302
|
+
* Creates the project's comments dataset if this is the first comment anywhere
|
|
303
|
+
* in it. The comment appears locally before the server confirms it; if the
|
|
304
|
+
* write fails it stays, carrying `_state.createError`, and retrying with the
|
|
305
|
+
* same `commentId` replaces it.
|
|
306
|
+
*
|
|
307
|
+
* @beta
|
|
308
|
+
*/
|
|
309
|
+
export const createComment: (
|
|
310
|
+
instance: SanityInstance,
|
|
311
|
+
options: CreateCommentOptions,
|
|
312
|
+
) => Promise<Comment> = bindActionByResource(
|
|
313
|
+
commentsStore,
|
|
314
|
+
// `async` so a validation failure rejects rather than throwing at the call
|
|
315
|
+
// site, which would slip past a `.catch()`.
|
|
316
|
+
async (
|
|
317
|
+
context: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
318
|
+
options: CreateCommentOptions,
|
|
319
|
+
) => {
|
|
320
|
+
const {instance, key} = context
|
|
321
|
+
const perspective = options.perspective ?? instance.config.perspective
|
|
322
|
+
const fieldPath = requireFieldPath(options.fieldPath)
|
|
323
|
+
|
|
324
|
+
return postComment(
|
|
325
|
+
context,
|
|
326
|
+
options,
|
|
327
|
+
buildCommentPayload({
|
|
328
|
+
authorId: requireCurrentUserId(instance),
|
|
329
|
+
commentId: options.commentId ?? crypto.randomUUID(),
|
|
330
|
+
contentSnapshot: options.contentSnapshot,
|
|
331
|
+
context: options.context,
|
|
332
|
+
documentRevisionId: options.documentRevisionId,
|
|
333
|
+
handle: options,
|
|
334
|
+
message: options.message,
|
|
335
|
+
resource: assertDatasetResource(key.resource),
|
|
336
|
+
fieldPath,
|
|
337
|
+
selection: options.selection,
|
|
338
|
+
status: options.status ?? 'open',
|
|
339
|
+
threadId: options.threadId ?? crypto.randomUUID(),
|
|
340
|
+
...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
|
|
341
|
+
}),
|
|
342
|
+
)
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Adds a reply to an existing thread.
|
|
348
|
+
*
|
|
349
|
+
* The thread and field are taken from the parent comment, so a reply always
|
|
350
|
+
* matches the same list as the comment it answers. Supply them only when the
|
|
351
|
+
* parent is not loaded.
|
|
352
|
+
*
|
|
353
|
+
* @beta
|
|
354
|
+
*/
|
|
355
|
+
export const replyToComment: (
|
|
356
|
+
instance: SanityInstance,
|
|
357
|
+
options: ReplyToCommentOptions,
|
|
358
|
+
) => Promise<Comment> = bindActionByResource(
|
|
359
|
+
commentsStore,
|
|
360
|
+
async (
|
|
361
|
+
context: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
362
|
+
options: ReplyToCommentOptions,
|
|
363
|
+
) => {
|
|
364
|
+
const {instance, key, state} = context
|
|
365
|
+
const commentsKey = getCommentsKey(toCommentsKeyParts(instance, options))
|
|
366
|
+
const parent = findComment(state, commentsKey, options.parentCommentId)
|
|
367
|
+
|
|
368
|
+
const threadId = options.threadId ?? parent?.threadId
|
|
369
|
+
if (!threadId) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`Cannot reply to "${options.parentCommentId}": it is not loaded, so pass its threadId.`,
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const status = parent?.status ?? options.status
|
|
376
|
+
if (!status) {
|
|
377
|
+
throw new Error(
|
|
378
|
+
`Cannot reply to "${options.parentCommentId}": it is not loaded, so pass its status.`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const perspective = options.perspective ?? instance.config.perspective
|
|
383
|
+
|
|
384
|
+
return postComment(
|
|
385
|
+
context,
|
|
386
|
+
options,
|
|
387
|
+
buildCommentPayload({
|
|
388
|
+
authorId: requireCurrentUserId(instance),
|
|
389
|
+
commentId: options.commentId ?? crypto.randomUUID(),
|
|
390
|
+
handle: options,
|
|
391
|
+
message: options.message,
|
|
392
|
+
// Replies to a reply belong to the thread's first comment, matching how
|
|
393
|
+
// the Studio flattens threads.
|
|
394
|
+
parentCommentId: parent?.parentCommentId ?? options.parentCommentId,
|
|
395
|
+
resource: assertDatasetResource(key.resource),
|
|
396
|
+
// Inherited from the parent unless the caller names one, and checked
|
|
397
|
+
// either way: a reply with no path crashes the Studio inspector exactly
|
|
398
|
+
// as a pathless thread would.
|
|
399
|
+
fieldPath: requireFieldPath(options.fieldPath ?? parent?.target.path?.field ?? ''),
|
|
400
|
+
// A reply into a resolved thread stays consistent with its parent.
|
|
401
|
+
status,
|
|
402
|
+
threadId,
|
|
403
|
+
...(isReleasePerspective(perspective) ? {documentVersionId: perspective.releaseName} : {}),
|
|
404
|
+
}),
|
|
405
|
+
)
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Rewrites a comment's message and stamps `lastEditedAt`.
|
|
411
|
+
* @beta
|
|
412
|
+
*/
|
|
413
|
+
export const updateComment: (
|
|
414
|
+
instance: SanityInstance,
|
|
415
|
+
options: UpdateCommentOptions,
|
|
416
|
+
) => Promise<void> = bindActionByResource(
|
|
417
|
+
commentsStore,
|
|
418
|
+
async (
|
|
419
|
+
{state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
420
|
+
{commentId, message}: UpdateCommentOptions,
|
|
421
|
+
) => {
|
|
422
|
+
const lastEditedAt = new Date().toISOString()
|
|
423
|
+
const transactionId = crypto.randomUUID()
|
|
424
|
+
const previous = findCommentById(state, commentId)
|
|
425
|
+
|
|
426
|
+
if (previous) {
|
|
427
|
+
state.set('setPendingTransaction', setPendingTransaction(commentId, transactionId))
|
|
428
|
+
state.set('updateComment', applyCommentUpdate(commentId, {message, lastEditedAt}))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
|
|
433
|
+
await client
|
|
434
|
+
.transaction()
|
|
435
|
+
.transactionId(transactionId)
|
|
436
|
+
.patch(client.patch(commentId).set({message, lastEditedAt}))
|
|
437
|
+
.commit({tag: 'comments.update'})
|
|
438
|
+
if (previous) {
|
|
439
|
+
state.set('clearPendingTransaction', clearPendingTransaction(commentId))
|
|
440
|
+
}
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (previous) {
|
|
443
|
+
state.set(
|
|
444
|
+
'rollbackCommentUpdate',
|
|
445
|
+
rollbackCommentUpdate(commentId, transactionId, previous),
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
throw error
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Resolves or reopens a thread.
|
|
455
|
+
*
|
|
456
|
+
* Pass the thread's first comment: replies follow it, patched server-side in
|
|
457
|
+
* one query so a reply created by someone else at the same moment is caught
|
|
458
|
+
* too.
|
|
459
|
+
*
|
|
460
|
+
* @beta
|
|
461
|
+
*/
|
|
462
|
+
export const setCommentStatus: (
|
|
463
|
+
instance: SanityInstance,
|
|
464
|
+
options: SetCommentStatusOptions,
|
|
465
|
+
) => Promise<void> = bindActionByResource(
|
|
466
|
+
commentsStore,
|
|
467
|
+
async (
|
|
468
|
+
{state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
469
|
+
{commentId, status}: SetCommentStatusOptions,
|
|
470
|
+
) => {
|
|
471
|
+
const transactionId = crypto.randomUUID()
|
|
472
|
+
const previousComments: StoredComment[] = []
|
|
473
|
+
|
|
474
|
+
for (const entry of Object.values(state.get().entries)) {
|
|
475
|
+
for (const candidate of Object.values(entry?.comments ?? {})) {
|
|
476
|
+
if (candidate._id !== commentId && candidate.parentCommentId !== commentId) continue
|
|
477
|
+
previousComments.push(candidate)
|
|
478
|
+
state.set('setPendingTransaction', setPendingTransaction(candidate._id, transactionId))
|
|
479
|
+
state.set('updateCommentStatus', applyCommentUpdate(candidate._id, {status}))
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
try {
|
|
484
|
+
const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
|
|
485
|
+
|
|
486
|
+
await client.mutate(
|
|
487
|
+
[
|
|
488
|
+
{patch: {id: commentId, set: {status}}},
|
|
489
|
+
{
|
|
490
|
+
patch: {
|
|
491
|
+
query: '*[_type == "comment" && parentCommentId == $commentId]',
|
|
492
|
+
params: {commentId},
|
|
493
|
+
set: {status},
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
{transactionId, tag: 'comments.set-status'},
|
|
498
|
+
)
|
|
499
|
+
for (const previous of previousComments) {
|
|
500
|
+
state.set('clearPendingTransaction', clearPendingTransaction(previous._id))
|
|
501
|
+
}
|
|
502
|
+
} catch (error) {
|
|
503
|
+
for (const previous of previousComments) {
|
|
504
|
+
state.set(
|
|
505
|
+
'rollbackCommentUpdate',
|
|
506
|
+
rollbackCommentUpdate(previous._id, transactionId, previous),
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
throw error
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Deletes a comment, and its replies when it starts a thread.
|
|
516
|
+
* @beta
|
|
517
|
+
*/
|
|
518
|
+
export const removeComment: (
|
|
519
|
+
instance: SanityInstance,
|
|
520
|
+
options: RemoveCommentOptions,
|
|
521
|
+
) => Promise<void> = bindActionByResource(
|
|
522
|
+
commentsStore,
|
|
523
|
+
async (
|
|
524
|
+
{state, instance, key}: StoreContext<CommentsStoreState, BoundResourceKey>,
|
|
525
|
+
{commentId}: RemoveCommentOptions,
|
|
526
|
+
) => {
|
|
527
|
+
const removed = Object.entries(state.get().entries).flatMap(([entryKey, entry]) => {
|
|
528
|
+
const comments = Object.values(entry?.comments ?? {}).filter(
|
|
529
|
+
(comment) => comment._id === commentId || comment.parentCommentId === commentId,
|
|
530
|
+
)
|
|
531
|
+
return comments.length ? [{key: entryKey, comments}] : []
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
state.set('removeComment', removeCommentById(commentId))
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const client = await getWritableClient(instance, key.resource, {createIfMissing: false})
|
|
538
|
+
await client.mutate(
|
|
539
|
+
[
|
|
540
|
+
{
|
|
541
|
+
delete: {
|
|
542
|
+
query: '*[_type == "comment" && parentCommentId == $commentId]',
|
|
543
|
+
params: {commentId},
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
{delete: {id: commentId}},
|
|
547
|
+
],
|
|
548
|
+
{tag: 'comments.remove'},
|
|
549
|
+
)
|
|
550
|
+
} catch (error) {
|
|
551
|
+
state.set('restoreComments', restoreComments(removed))
|
|
552
|
+
throw error
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {toCommentFieldPath} from './commentFieldPath'
|
|
4
|
+
|
|
5
|
+
describe('toCommentFieldPath', () => {
|
|
6
|
+
it('produces the strings the Studio writes', () => {
|
|
7
|
+
// Golden values. These are the interop contract: a comment the SDK writes is
|
|
8
|
+
// only visible in the Studio if `target.path.field` matches what the Studio
|
|
9
|
+
// would have written for the same field.
|
|
10
|
+
expect(toCommentFieldPath(['title'])).toBe('title')
|
|
11
|
+
expect(toCommentFieldPath(['body', {_key: 'intro'}, 'content'])).toBe(
|
|
12
|
+
'body[_key=="intro"].content',
|
|
13
|
+
)
|
|
14
|
+
expect(toCommentFieldPath(['arr', 0, 'field'])).toBe('arr[0].field')
|
|
15
|
+
expect(toCommentFieldPath(['a', {_key: 'b'}, 'c', 0, 'd'])).toBe('a[_key=="b"].c[0].d')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('treats a missing or empty path as document level', () => {
|
|
19
|
+
expect(toCommentFieldPath()).toBe('')
|
|
20
|
+
expect(toCommentFieldPath([])).toBe('')
|
|
21
|
+
expect(toCommentFieldPath('')).toBe('')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('canonicalises a string that is already a path', () => {
|
|
25
|
+
expect(toCommentFieldPath('body[_key=="intro"].content')).toBe('body[_key=="intro"].content')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('quotes nested field names that GROQ treats as values', () => {
|
|
29
|
+
expect(toCommentFieldPath(['object', 'true'])).toBe('object["true"]')
|
|
30
|
+
expect(toCommentFieldPath(['object', 'false'])).toBe('object["false"]')
|
|
31
|
+
expect(toCommentFieldPath(['object', 'null'])).toBe('object["null"]')
|
|
32
|
+
expect(toCommentFieldPath('object.true')).toBe('object["true"]')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('does not rewrite reserved words inside keyed segments', () => {
|
|
36
|
+
expect(toCommentFieldPath(['body', {_key: 'section.true.child'}, 'null'])).toBe(
|
|
37
|
+
'body[_key=="section.true.child"]["null"]',
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {stringifyPath} from '@sanity/json-match'
|
|
2
|
+
import {type Path} from '@sanity/types'
|
|
3
|
+
|
|
4
|
+
/** A dotted segment GROQ would read as a literal rather than a field name. */
|
|
5
|
+
const RESERVED_SEGMENT = /\.(true|false|null)(?=$|[.[])/g
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One quoted string, or one run of text containing no quote at all. Splitting on
|
|
9
|
+
* this is what keeps the rewrite below out of `[_key=="..."]` values.
|
|
10
|
+
*/
|
|
11
|
+
const QUOTED_OR_PLAIN = /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"']+/g
|
|
12
|
+
|
|
13
|
+
function quoteReservedFieldSegments(path: string): string {
|
|
14
|
+
return path.replace(QUOTED_OR_PLAIN, (chunk) =>
|
|
15
|
+
chunk.startsWith('"') || chunk.startsWith("'")
|
|
16
|
+
? chunk
|
|
17
|
+
: chunk.replace(RESERVED_SEGMENT, '["$1"]'),
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalises a field path to the string form comments are stored in.
|
|
23
|
+
*
|
|
24
|
+
* Comments keep `target.path.field` as a string rather than a path array,
|
|
25
|
+
* because that is what the Studio writes and reading it back has to match
|
|
26
|
+
* exactly. A string given here is parsed and re-stringified, so both a path
|
|
27
|
+
* array and a hand-written string come out in the same canonical form.
|
|
28
|
+
*
|
|
29
|
+
* `undefined` and `[]` both produce `''`. Callers writing a comment reject that
|
|
30
|
+
* rather than passing it on, since a comment has to point at a field.
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```ts
|
|
34
|
+
* toCommentFieldPath(['body', {_key: 'intro'}, 'content'])
|
|
35
|
+
* // 'body[_key=="intro"].content'
|
|
36
|
+
* toCommentFieldPath(undefined)
|
|
37
|
+
* // ''
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function toCommentFieldPath(path?: string | Path): string {
|
|
43
|
+
return quoteReservedFieldSegments(stringifyPath(path))
|
|
44
|
+
}
|