@sanity/sdk 3.0.0-rc.1 → 3.0.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/README.md +12 -0
- package/dist/_exports/_internal.d.ts +71 -3
- package/dist/_exports/_internal.d.ts.map +1 -1
- package/dist/_exports/_internal.js +332 -24
- package/dist/_exports/_internal.js.map +1 -1
- package/dist/_exports/agent.d.ts +65 -0
- package/dist/_exports/agent.d.ts.map +1 -0
- package/dist/_exports/agent.js +71 -0
- package/dist/_exports/agent.js.map +1 -0
- package/dist/_exports/comlink.d.ts +118 -0
- package/dist/_exports/comlink.d.ts.map +1 -0
- package/dist/_exports/comlink.js +69 -0
- package/dist/_exports/comlink.js.map +1 -0
- package/dist/authStore-C0o1NOxW.d.ts +3165 -0
- package/dist/authStore-C0o1NOxW.d.ts.map +1 -0
- package/dist/authStore-CtE9bLHW.js +754 -0
- package/dist/authStore-CtE9bLHW.js.map +1 -0
- package/dist/clientStore-BtE2-q2i.js +90 -0
- package/dist/clientStore-BtE2-q2i.js.map +1 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts +278 -0
- package/dist/createSanityInstance-DUI1bGIg.d.ts.map +1 -0
- package/dist/createStateSourceAction-BOKtqFNm.js +300 -0
- package/dist/createStateSourceAction-BOKtqFNm.js.map +1 -0
- package/dist/getEnv-D5RGkQLu.js +8 -0
- package/dist/getEnv-D5RGkQLu.js.map +1 -0
- package/dist/getNodeState-CR3__B2D.js +67 -0
- package/dist/getNodeState-CR3__B2D.js.map +1 -0
- package/dist/index.d.ts +3 -2335
- package/dist/index.js +4048 -3878
- package/dist/index.js.map +1 -1
- package/dist/logger-DAtJb5RQ.js +173 -0
- package/dist/logger-DAtJb5RQ.js.map +1 -0
- package/dist/object-CxXlSVg7.js +83 -0
- package/dist/object-CxXlSVg7.js.map +1 -0
- package/dist/queryStore-C7yMHPUI.js +1346 -0
- package/dist/queryStore-C7yMHPUI.js.map +1 -0
- package/dist/rolldown-runtime-vyAXikos.js +9 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js +31 -0
- package/dist/setCleanupTimeout-BiM6q1KF.js.map +1 -0
- package/dist/telemetryManager-UIEiMtC0.js +127 -0
- package/dist/telemetryManager-UIEiMtC0.js.map +1 -0
- package/dist/version-D8RP_Q59.js +9 -0
- package/dist/version-D8RP_Q59.js.map +1 -0
- package/package.json +11 -17
- package/src/_exports/_internal.ts +1 -0
- package/src/_exports/agent.ts +19 -0
- package/src/_exports/comlink.ts +16 -0
- package/src/_exports/index.ts +45 -90
- package/src/applications/applications.test-d.ts +5 -0
- package/src/applications/applications.ts +5 -2
- package/src/auth/authMode.test.ts +0 -25
- package/src/auth/authMode.ts +3 -6
- package/src/auth/authStore.test.ts +1 -1
- package/src/auth/authStore.ts +0 -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/config/sanityConfig.ts +3 -75
- package/src/datasets/datasets.test.ts +44 -9
- package/src/datasets/datasets.ts +24 -23
- package/src/document/applyDocumentActions.test.ts +8 -12
- package/src/document/documentConstants.ts +17 -0
- package/src/document/documentStore.test.ts +173 -19
- 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 +357 -0
- package/src/document/reducers.ts +125 -27
- package/src/document/util.test.ts +46 -0
- package/src/document/util.ts +37 -1
- package/src/favorites/favorites.test.ts +64 -24
- package/src/favorites/favorites.ts +73 -22
- package/src/installations/installations.test-d.ts +5 -0
- package/src/installations/installations.ts +4 -0
- 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/preview/types.ts +0 -25
- package/src/projection/projectionStore.test.ts +0 -8
- package/src/projection/types.ts +0 -8
- package/src/query/queryStore.ts +7 -6
- package/src/store/createActionBinder.test.ts +51 -29
- package/src/store/createActionBinder.ts +16 -62
- package/src/store/createSanityInstance.test.ts +0 -68
- package/src/store/createSanityInstance.ts +0 -63
- package/src/store/createStateSourceAction.ts +1 -1
- package/src/store/fetcherStore.test.ts +23 -1
- package/src/store/fetcherStore.ts +3 -3
- package/src/users/reducers.test.ts +0 -3
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts +0 -1147
- package/dist/_chunks-dts/createGroqSearchFilter.d.ts.map +0 -1
- package/dist/_chunks-es/_internal.js +0 -134
- package/dist/_chunks-es/_internal.js.map +0 -1
- package/dist/_chunks-es/createGroqSearchFilter.js +0 -1864
- 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/index.d.ts.map +0 -1
- package/src/applications/userApplications.test.ts +0 -126
- package/src/applications/userApplications.ts +0 -261
- package/src/preview/getPreviewState.test.ts +0 -137
- package/src/preview/getPreviewState.ts +0 -69
- package/src/preview/resolvePreview.test.ts +0 -64
- package/src/preview/resolvePreview.ts +0 -43
- package/src/utils/createFetcherStore.test.ts +0 -171
- package/src/utils/createFetcherStore.ts +0 -284
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import {type SanityClient} from '@sanity/client'
|
|
2
|
+
import {
|
|
3
|
+
catchError,
|
|
4
|
+
distinctUntilChanged,
|
|
5
|
+
EMPTY,
|
|
6
|
+
filter,
|
|
7
|
+
firstValueFrom,
|
|
8
|
+
map,
|
|
9
|
+
type Observable,
|
|
10
|
+
of,
|
|
11
|
+
switchMap,
|
|
12
|
+
take,
|
|
13
|
+
} from 'rxjs'
|
|
14
|
+
|
|
15
|
+
import {getClient, getClientState} from '../client/clientStore'
|
|
16
|
+
import {
|
|
17
|
+
type DatasetResource,
|
|
18
|
+
type DocumentResource,
|
|
19
|
+
isDatasetResource,
|
|
20
|
+
} from '../config/sanityConfig'
|
|
21
|
+
import {bindActionByResource, type BoundResourceKey} from '../store/createActionBinder'
|
|
22
|
+
import {type SanityInstance} from '../store/createSanityInstance'
|
|
23
|
+
import {createStateSourceAction, type StateSource} from '../store/createStateSourceAction'
|
|
24
|
+
import {defineStore, type StoreContext} from '../store/defineStore'
|
|
25
|
+
import {
|
|
26
|
+
ADDON_DATASET_API_VERSION,
|
|
27
|
+
ADDON_DATASET_PROFILE,
|
|
28
|
+
COMMENTS_API_VERSION,
|
|
29
|
+
} from './commentsConstants'
|
|
30
|
+
|
|
31
|
+
interface AddonDatasetStoreState {
|
|
32
|
+
/**
|
|
33
|
+
* `unknown` until discovery answers. `missing` means the project has no
|
|
34
|
+
* comments dataset yet, which is the normal state until someone writes a
|
|
35
|
+
* first comment.
|
|
36
|
+
*/
|
|
37
|
+
status: 'unknown' | 'resolved' | 'missing'
|
|
38
|
+
datasetName?: string
|
|
39
|
+
/** In flight provisioning, so concurrent first comments issue one POST. */
|
|
40
|
+
provisioning?: Promise<string>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Comments need a project and a dataset. There is nowhere to put them for a
|
|
45
|
+
* media library or a canvas, and no Studio to interoperate with either.
|
|
46
|
+
*
|
|
47
|
+
* @internal
|
|
48
|
+
*/
|
|
49
|
+
export function assertDatasetResource(resource: DocumentResource): DatasetResource {
|
|
50
|
+
if (!isDatasetResource(resource)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Comments are only supported for dataset resources, received: ${JSON.stringify(resource)}`,
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
return resource
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isMissingOrForbidden(error: unknown): boolean {
|
|
59
|
+
if (typeof error !== 'object' || error === null || !('statusCode' in error)) return false
|
|
60
|
+
const {statusCode} = error as {statusCode?: unknown}
|
|
61
|
+
return statusCode === 403 || statusCode === 404
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requestAddonDataset(
|
|
65
|
+
client: SanityClient,
|
|
66
|
+
{projectId, dataset}: DatasetResource,
|
|
67
|
+
): Observable<string | undefined> {
|
|
68
|
+
return client.observable
|
|
69
|
+
.request<{name: string}[] | undefined>({
|
|
70
|
+
uri: `/projects/${projectId}/datasets?datasetProfile=${ADDON_DATASET_PROFILE}&addonFor=${dataset}`,
|
|
71
|
+
tag: 'comments.addon-dataset.list',
|
|
72
|
+
})
|
|
73
|
+
.pipe(map((datasets) => datasets?.[0]?.name))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function discover(
|
|
77
|
+
instance: SanityInstance,
|
|
78
|
+
resource: DatasetResource,
|
|
79
|
+
): Observable<string | undefined> {
|
|
80
|
+
return getClientState(instance, {
|
|
81
|
+
apiVersion: ADDON_DATASET_API_VERSION,
|
|
82
|
+
projectId: resource.projectId,
|
|
83
|
+
useProjectHostname: true,
|
|
84
|
+
}).observable.pipe(
|
|
85
|
+
switchMap((client) =>
|
|
86
|
+
requestAddonDataset(client, resource).pipe(
|
|
87
|
+
// A missing dataset and a dataset this user cannot see both mean there
|
|
88
|
+
// are no comments to read. Transient failures stay pending so a new
|
|
89
|
+
// authenticated client can retry discovery.
|
|
90
|
+
catchError((error: unknown) => (isMissingOrForbidden(error) ? of(undefined) : EMPTY)),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function discoverOnce(
|
|
97
|
+
instance: SanityInstance,
|
|
98
|
+
resource: DatasetResource,
|
|
99
|
+
): Promise<string | undefined> {
|
|
100
|
+
const client = await firstValueFrom(
|
|
101
|
+
getClientState(instance, {
|
|
102
|
+
apiVersion: ADDON_DATASET_API_VERSION,
|
|
103
|
+
projectId: resource.projectId,
|
|
104
|
+
useProjectHostname: true,
|
|
105
|
+
}).observable.pipe(take(1)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return await firstValueFrom(requestAddonDataset(client, resource))
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if (isMissingOrForbidden(error)) return undefined
|
|
112
|
+
throw error
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const addonDatasetStore = defineStore<AddonDatasetStoreState, BoundResourceKey>({
|
|
117
|
+
name: 'CommentsAddonDataset',
|
|
118
|
+
getInitialState: () => ({status: 'unknown'}),
|
|
119
|
+
initialize: ({instance, state, key}) => {
|
|
120
|
+
const resource = assertDatasetResource(key.resource)
|
|
121
|
+
|
|
122
|
+
const subscription = discover(instance, resource).subscribe((datasetName) =>
|
|
123
|
+
state.set(
|
|
124
|
+
'setAddonDataset',
|
|
125
|
+
datasetName ? {status: 'resolved', datasetName} : {status: 'missing'},
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return () => subscription.unsubscribe()
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The addon dataset's name: `undefined` while discovery is in flight, `null`
|
|
135
|
+
* when the project has none yet.
|
|
136
|
+
*
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
export const getAddonDatasetState: (
|
|
140
|
+
instance: SanityInstance,
|
|
141
|
+
options: {resource?: DocumentResource},
|
|
142
|
+
) => StateSource<string | null | undefined> = bindActionByResource(
|
|
143
|
+
addonDatasetStore,
|
|
144
|
+
createStateSourceAction(({state}: {state: AddonDatasetStoreState}) => {
|
|
145
|
+
if (state.status === 'unknown') return undefined
|
|
146
|
+
return state.datasetName ?? null
|
|
147
|
+
}),
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolves the addon dataset, creating it if the project has none.
|
|
152
|
+
*
|
|
153
|
+
* Safe to call concurrently: the first call owns the request and later ones
|
|
154
|
+
* await the same promise. Provisioning is a one-time, project-wide side effect,
|
|
155
|
+
* so this is only called from the write path, when we know there is a comment
|
|
156
|
+
* to store.
|
|
157
|
+
*
|
|
158
|
+
* @internal
|
|
159
|
+
*/
|
|
160
|
+
export const provisionAddonDataset: (
|
|
161
|
+
instance: SanityInstance,
|
|
162
|
+
options: {resource?: DocumentResource},
|
|
163
|
+
) => Promise<string> = bindActionByResource(
|
|
164
|
+
addonDatasetStore,
|
|
165
|
+
({instance, state, key}: StoreContext<AddonDatasetStoreState, BoundResourceKey>) => {
|
|
166
|
+
const current = state.get()
|
|
167
|
+
if (current.datasetName) return Promise.resolve(current.datasetName)
|
|
168
|
+
if (current.provisioning) return current.provisioning
|
|
169
|
+
|
|
170
|
+
const resource = assertDatasetResource(key.resource)
|
|
171
|
+
|
|
172
|
+
const provisioning = (async () => {
|
|
173
|
+
// Another user may have provisioned it since this client started up, in
|
|
174
|
+
// which case creating it again would fail. Ask before writing.
|
|
175
|
+
const existing = await discoverOnce(instance, resource)
|
|
176
|
+
if (existing) return existing
|
|
177
|
+
|
|
178
|
+
const client = getClient(instance, {
|
|
179
|
+
apiVersion: ADDON_DATASET_API_VERSION,
|
|
180
|
+
projectId: resource.projectId,
|
|
181
|
+
useProjectHostname: true,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
const response = await client.request<{datasetName?: string} | undefined>({
|
|
185
|
+
uri: `/comments/${resource.dataset}/setup`,
|
|
186
|
+
method: 'POST',
|
|
187
|
+
tag: 'comments.addon-dataset.setup',
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
if (!response?.datasetName) {
|
|
191
|
+
throw new Error('Creating the comments addon dataset returned no dataset name.')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return response.datasetName
|
|
195
|
+
})()
|
|
196
|
+
.then((datasetName) => {
|
|
197
|
+
state.set('setAddonDataset', {
|
|
198
|
+
status: 'resolved',
|
|
199
|
+
datasetName,
|
|
200
|
+
provisioning: undefined,
|
|
201
|
+
})
|
|
202
|
+
return datasetName
|
|
203
|
+
})
|
|
204
|
+
.catch((error: unknown) => {
|
|
205
|
+
// Clear the in-flight promise so a retry is not permanently stuck on
|
|
206
|
+
// this failure.
|
|
207
|
+
state.set('clearProvisioning', {provisioning: undefined})
|
|
208
|
+
throw error
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
state.set('startProvisioning', {provisioning})
|
|
212
|
+
|
|
213
|
+
return provisioning
|
|
214
|
+
},
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A client pointed at the addon dataset, or `null` while the project has none.
|
|
219
|
+
*
|
|
220
|
+
* Emits again whenever the dataset is discovered or provisioned, and whenever
|
|
221
|
+
* the auth token changes. Long-lived readers must follow it rather than holding
|
|
222
|
+
* a client, because the client store drops every cached client on a token
|
|
223
|
+
* change.
|
|
224
|
+
*
|
|
225
|
+
* @internal
|
|
226
|
+
*/
|
|
227
|
+
export const observeAddonDatasetClient: (
|
|
228
|
+
instance: SanityInstance,
|
|
229
|
+
options: {resource?: DocumentResource},
|
|
230
|
+
) => Observable<SanityClient | null> = bindActionByResource(
|
|
231
|
+
addonDatasetStore,
|
|
232
|
+
({instance, key}: StoreContext<AddonDatasetStoreState, BoundResourceKey>) => {
|
|
233
|
+
const {projectId} = assertDatasetResource(key.resource)
|
|
234
|
+
|
|
235
|
+
return getAddonDatasetState(instance, {resource: key.resource}).observable.pipe(
|
|
236
|
+
distinctUntilChanged(),
|
|
237
|
+
filter((datasetName): datasetName is string | null => datasetName !== undefined),
|
|
238
|
+
switchMap((datasetName) =>
|
|
239
|
+
typeof datasetName === 'string'
|
|
240
|
+
? getClientState(instance, {
|
|
241
|
+
apiVersion: COMMENTS_API_VERSION,
|
|
242
|
+
projectId,
|
|
243
|
+
dataset: datasetName,
|
|
244
|
+
}).observable
|
|
245
|
+
: of(null),
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
},
|
|
249
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {buildCommentThreads} from './buildCommentThreads'
|
|
4
|
+
import {type Comment} from './types'
|
|
5
|
+
|
|
6
|
+
function comment(overrides: Partial<Comment> & Pick<Comment, 'id'>): Comment {
|
|
7
|
+
return {
|
|
8
|
+
createdAt: '2026-01-01T00:00:00Z',
|
|
9
|
+
authorId: 'user-1',
|
|
10
|
+
message: null,
|
|
11
|
+
threadId: 'thread-1',
|
|
12
|
+
status: 'open',
|
|
13
|
+
reactions: [],
|
|
14
|
+
documentId: 'doc-1',
|
|
15
|
+
documentType: 'author',
|
|
16
|
+
fieldPath: '',
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('buildCommentThreads', () => {
|
|
22
|
+
it('returns nothing for an empty list', () => {
|
|
23
|
+
expect(buildCommentThreads([])).toEqual([])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('groups a parent with its replies, oldest reply first', () => {
|
|
27
|
+
const parent = comment({id: 'a', createdAt: '2026-01-01T00:00:00Z'})
|
|
28
|
+
const second = comment({id: 'c', createdAt: '2026-01-03T00:00:00Z', parentCommentId: 'a'})
|
|
29
|
+
const first = comment({id: 'b', createdAt: '2026-01-02T00:00:00Z', parentCommentId: 'a'})
|
|
30
|
+
|
|
31
|
+
expect(buildCommentThreads([second, first, parent])).toEqual([
|
|
32
|
+
{
|
|
33
|
+
threadId: 'thread-1',
|
|
34
|
+
fieldPath: '',
|
|
35
|
+
parentComment: parent,
|
|
36
|
+
replies: [first, second],
|
|
37
|
+
commentsCount: 3,
|
|
38
|
+
status: 'open',
|
|
39
|
+
lastActivityAt: '2026-01-03T00:00:00Z',
|
|
40
|
+
},
|
|
41
|
+
])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('takes fieldPath from the parent', () => {
|
|
45
|
+
const parent = comment({id: 'a', fieldPath: 'body[_key=="intro"].content'})
|
|
46
|
+
|
|
47
|
+
expect(buildCommentThreads([parent])[0].fieldPath).toBe('body[_key=="intro"].content')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('falls back to the parent createdAt when there are no replies', () => {
|
|
51
|
+
const parent = comment({id: 'a', createdAt: '2026-02-02T00:00:00Z'})
|
|
52
|
+
|
|
53
|
+
expect(buildCommentThreads([parent])[0]).toMatchObject({
|
|
54
|
+
commentsCount: 1,
|
|
55
|
+
lastActivityAt: '2026-02-02T00:00:00Z',
|
|
56
|
+
replies: [],
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('drops replies whose parent is missing', () => {
|
|
61
|
+
// Happens between a parent being deleted and its replies disappearing.
|
|
62
|
+
const orphan = comment({id: 'b', parentCommentId: 'gone'})
|
|
63
|
+
|
|
64
|
+
expect(buildCommentThreads([orphan])).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('keeps threads in the order their parents arrive', () => {
|
|
68
|
+
const newer = comment({id: 'a', threadId: 'thread-a', createdAt: '2026-01-05T00:00:00Z'})
|
|
69
|
+
const older = comment({id: 'b', threadId: 'thread-b', createdAt: '2026-01-01T00:00:00Z'})
|
|
70
|
+
|
|
71
|
+
expect(buildCommentThreads([newer, older]).map((thread) => thread.threadId)).toEqual([
|
|
72
|
+
'thread-a',
|
|
73
|
+
'thread-b',
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('reports the parent status for the thread', () => {
|
|
78
|
+
const parent = comment({id: 'a', status: 'resolved'})
|
|
79
|
+
const reply = comment({id: 'b', parentCommentId: 'a', status: 'resolved'})
|
|
80
|
+
|
|
81
|
+
expect(buildCommentThreads([parent, reply])[0].status).toBe('resolved')
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {type Comment, type CommentThread} from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Groups a flat comment list into threads.
|
|
5
|
+
*
|
|
6
|
+
* A thread's parent is the comment with no `parentCommentId`; replies point at
|
|
7
|
+
* the parent's `id`. Replies whose parent is missing from the input are
|
|
8
|
+
* dropped, which is what happens when a parent is deleted but its replies have
|
|
9
|
+
* not disappeared yet.
|
|
10
|
+
*
|
|
11
|
+
* Threads come back in the order their parents appear in `comments`, so passing
|
|
12
|
+
* the store's newest-first list gives newest-thread-first. Replies within a
|
|
13
|
+
* thread are always oldest first, since that is reading order.
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export function buildCommentThreads(comments: Comment[]): CommentThread[] {
|
|
18
|
+
const repliesByParent = new Map<string, Comment[]>()
|
|
19
|
+
|
|
20
|
+
for (const comment of comments) {
|
|
21
|
+
if (!comment.parentCommentId) continue
|
|
22
|
+
const existing = repliesByParent.get(comment.parentCommentId)
|
|
23
|
+
if (existing) {
|
|
24
|
+
existing.push(comment)
|
|
25
|
+
} else {
|
|
26
|
+
repliesByParent.set(comment.parentCommentId, [comment])
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const threads: CommentThread[] = []
|
|
31
|
+
|
|
32
|
+
for (const parentComment of comments) {
|
|
33
|
+
if (parentComment.parentCommentId) continue
|
|
34
|
+
|
|
35
|
+
// The array is built above and owned here, so sorting in place is safe.
|
|
36
|
+
const replies = (repliesByParent.get(parentComment.id) ?? []).sort((a, b) =>
|
|
37
|
+
a.createdAt.localeCompare(b.createdAt),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const lastReply = replies[replies.length - 1]
|
|
41
|
+
|
|
42
|
+
threads.push({
|
|
43
|
+
threadId: parentComment.threadId,
|
|
44
|
+
fieldPath: parentComment.fieldPath,
|
|
45
|
+
parentComment,
|
|
46
|
+
replies,
|
|
47
|
+
commentsCount: replies.length + 1,
|
|
48
|
+
status: parentComment.status,
|
|
49
|
+
lastActivityAt: lastReply ? lastReply.createdAt : parentComment.createdAt,
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return threads
|
|
54
|
+
}
|