@sanity/cross-dataset-duplicator 1.5.1 → 2.0.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.
@@ -1,568 +0,0 @@
1
- /* eslint-disable react/jsx-no-bind */
2
- import React, {useState, useEffect} from 'react'
3
- import {
4
- useClient,
5
- Preview,
6
- useSchema,
7
- useWorkspaces,
8
- WorkspaceSummary,
9
- SanityDocument,
10
- } from 'sanity'
11
- // @ts-ignore
12
- import mapLimit from 'async/mapLimit'
13
- // @ts-ignore
14
- import asyncify from 'async/asyncify'
15
- import {extractWithPath} from '@sanity/mutator'
16
- import {dset} from 'dset'
17
- import {
18
- Card,
19
- Container,
20
- Text,
21
- Box,
22
- Button,
23
- Label,
24
- Stack,
25
- Select,
26
- Flex,
27
- Checkbox,
28
- CardTone,
29
- useTheme,
30
- Spinner,
31
- } from '@sanity/ui'
32
- import {ArrowRightIcon, SearchIcon, LaunchIcon} from '@sanity/icons'
33
- import {SanityAssetDocument} from '@sanity/client'
34
- import {isAssetId, isSanityFileAsset} from '@sanity/asset-utils'
35
-
36
- import {stickyStyles, createInitialMessage} from '../helpers'
37
- import {getDocumentsInArray} from '../helpers/getDocumentsInArray'
38
- import SelectButtons from './SelectButtons'
39
- import StatusBadge, {MessageTypes} from './StatusBadge'
40
- import Feedback from './Feedback'
41
- import {PluginConfig} from '../types'
42
-
43
- export type DuplicatorProps = {
44
- docs: SanityDocument[]
45
- // TODO: Find out if this is even used?
46
- // draftIds: string[]
47
- token: string
48
- pluginConfig: Required<PluginConfig>
49
- onDuplicated?: () => Promise<void>
50
- }
51
-
52
- export type PayloadItem = {
53
- doc: SanityDocument
54
- include: boolean
55
- status?: keyof MessageTypes
56
- hasDraft?: boolean
57
- }
58
-
59
- type WorkspaceOption = WorkspaceSummary & {
60
- disabled: boolean
61
- }
62
-
63
- type Message = {
64
- text: string
65
- tone: CardTone
66
- }
67
-
68
- export default function Duplicator(props: DuplicatorProps) {
69
- const {docs, token, pluginConfig, onDuplicated} = props
70
- const isDarkMode = useTheme().sanity.color.dark
71
-
72
- // Prepare origin (this Studio) client
73
- const originClient = useClient({apiVersion: pluginConfig.apiVersion})
74
-
75
- const schema = useSchema()
76
-
77
- // Create list of dataset options
78
- // and set initial value of dropdown
79
- const workspaces = useWorkspaces()
80
- const workspacesOptions: WorkspaceOption[] = workspaces.map((workspace) => ({
81
- ...workspace,
82
- disabled:
83
- workspace.dataset === originClient.config().dataset &&
84
- workspace.projectId === originClient.config().projectId,
85
- }))
86
-
87
- const [destination, setDestination] = useState<WorkspaceOption | null>(
88
- workspaces.length ? workspacesOptions.find((space) => !space.disabled) ?? null : null
89
- )
90
- const [message, setMessage] = useState<Message | null>(null)
91
- const [payload, setPayload] = useState<PayloadItem[]>([])
92
-
93
- const [hasReferences, setHasReferences] = useState(false)
94
- const [isDuplicating, setIsDuplicating] = useState(false)
95
- const [isGathering, setIsGathering] = useState(false)
96
- const [progress, setProgress] = useState<number[]>([0, 0])
97
-
98
- // Check for References and update message
99
- useEffect(() => {
100
- const expr = `.._ref`
101
- const initialRefs = []
102
- const initialPayload: PayloadItem[] = []
103
-
104
- docs.forEach((doc) => {
105
- const refs = extractWithPath(expr, doc).map((ref) => ref.value)
106
- initialRefs.push(...refs)
107
- initialPayload.push({include: true, doc})
108
- })
109
-
110
- updatePayloadStatuses(initialPayload)
111
-
112
- const docCount = docs.length
113
- const refsCount = initialRefs.length
114
-
115
- if (initialRefs.length) {
116
- setHasReferences(true)
117
-
118
- setMessage({
119
- tone: `caution`,
120
- text: createInitialMessage(docCount, refsCount),
121
- })
122
- }
123
- }, [docs])
124
-
125
- // Re-check payload on destination when value changes
126
- // (On initial render + select change)
127
- useEffect(() => {
128
- updatePayloadStatuses()
129
- }, [destination])
130
-
131
- // Check if payload documents exist at destination
132
- async function updatePayloadStatuses(newPayload: PayloadItem[] = []) {
133
- const payloadActual = newPayload.length ? newPayload : payload
134
-
135
- if (!payloadActual.length || !destination?.name) {
136
- return
137
- }
138
-
139
- const payloadIds = payloadActual.map(({doc}) => doc._id)
140
- const destinationClient = originClient.withConfig({
141
- dataset: destination.dataset,
142
- projectId: destination.projectId,
143
- })
144
- const destinationData: SanityDocument[] = await destinationClient.fetch(
145
- `*[_id in $payloadIds]{ _id, _updatedAt }`,
146
- {payloadIds}
147
- )
148
-
149
- const updatedPayload = payloadActual.map((item) => {
150
- const existingDoc = destinationData.find((doc) => doc._id === item.doc._id)
151
-
152
- if (existingDoc?._updatedAt && item?.doc?._updatedAt) {
153
- if (existingDoc._updatedAt === item.doc._updatedAt) {
154
- // Exact same document exists at destination
155
- // We don't compare by _rev because that is updated in a transaction
156
- item.status = `EXISTS`
157
- } else if (existingDoc._updatedAt && item.doc._updatedAt) {
158
- item.status =
159
- new Date(existingDoc._updatedAt) > new Date(item.doc._updatedAt)
160
- ? // Document at destination is newer
161
- `OVERWRITE`
162
- : // Document at destination is older
163
- `UPDATE`
164
- }
165
- } else {
166
- item.status = 'CREATE'
167
- }
168
-
169
- return item
170
- })
171
-
172
- setPayload(updatedPayload)
173
- }
174
-
175
- function handleCheckbox(_id: string) {
176
- const updatedPayload = payload.map((item) => {
177
- if (item.doc._id === _id) {
178
- item.include = !item.include
179
- }
180
-
181
- return item
182
- })
183
-
184
- setPayload(updatedPayload)
185
- }
186
-
187
- // Find and recursively follow references beginning with this document
188
- async function handleReferences() {
189
- setIsGathering(true)
190
- const docIds = docs.map((doc) => doc._id)
191
-
192
- const payloadDocs = await getDocumentsInArray({
193
- fetchIds: docIds,
194
- client: originClient,
195
- pluginConfig,
196
- })
197
- const draftDocs = await getDocumentsInArray({
198
- fetchIds: docIds.map((id) => `drafts.${id}`),
199
- client: originClient,
200
- projection: `{_id}`,
201
- pluginConfig,
202
- })
203
- const draftDocsIds = new Set(draftDocs.map(({_id}) => _id))
204
-
205
- // Shape it up
206
- const payloadShaped = payloadDocs.map((doc) => ({
207
- doc,
208
- // Include this in the transaction?
209
- include: true,
210
- // Does it exist at the destination?
211
- status: undefined,
212
- // Does it have any drafts?
213
- hasDraft: draftDocsIds.has(`drafts.${doc._id}`),
214
- }))
215
-
216
- setPayload(payloadShaped)
217
- updatePayloadStatuses(payloadShaped)
218
- setIsGathering(false)
219
- }
220
-
221
- // Duplicate payload to destination dataset
222
- async function handleDuplicate() {
223
- if (!destination) {
224
- return
225
- }
226
-
227
- setIsDuplicating(true)
228
-
229
- const assetsCount = payload.filter(({doc, include}) => include && isAssetId(doc._id)).length
230
- let currentProgress = 0
231
- setProgress([currentProgress, assetsCount])
232
-
233
- setMessage({text: 'Duplicating...', tone: `transparent`})
234
-
235
- const destinationClient = originClient.withConfig({
236
- apiVersion: pluginConfig.apiVersion,
237
- dataset: destination.dataset,
238
- projectId: destination.projectId,
239
- })
240
-
241
- const transactionDocs: SanityDocument[] = []
242
- const svgMaps: {old: string; new: string}[] = []
243
-
244
- // Upload assets and then add to transaction
245
- async function fetchDoc(doc: SanityAssetDocument) {
246
- if (isAssetId(doc._id)) {
247
- // Download and upload asset
248
- // Get the *original* image with this dlRaw param to create the same deterministic _id
249
- const typeIsFile = isSanityFileAsset(doc)
250
- const downloadUrl = typeIsFile ? doc.url : `${doc.url}?dlRaw=true`
251
- const downloadConfig = typeIsFile ? {} : {headers: {Authorization: `Bearer ${token}`}}
252
-
253
- await fetch(downloadUrl, downloadConfig).then(async (res) => {
254
- const assetData = await res.blob()
255
-
256
- const options = {filename: doc.originalFilename}
257
- const assetDoc = await destinationClient.assets.upload(
258
- typeIsFile ? `file` : `image`,
259
- assetData,
260
- options
261
- )
262
-
263
- // SVG _id's need remapping before transaction
264
- if (doc?.extension === 'svg') {
265
- svgMaps.push({old: doc._id, new: assetDoc._id})
266
- }
267
-
268
- // This adds the newly created asset document to the transaction but ...
269
- // it doesn't have some of the original asset's metadata like `altText` or `title`
270
- transactionDocs.push(assetDoc)
271
-
272
- // So the original `doc` is added to the transaction as well below
273
- // However, we don't want to retain `url` or `path` keys
274
- // because these strings contain the origin's dataset name
275
- doc.url = assetDoc.url
276
- doc.path = assetDoc.path
277
- })
278
-
279
- currentProgress += 1
280
- setMessage({
281
- text: `Duplicating ${currentProgress}/${assetsCount} ${
282
- assetsCount === 1 ? `Assets` : `Assets`
283
- }`,
284
- tone: 'default',
285
- })
286
-
287
- setProgress([currentProgress, assetsCount])
288
- }
289
-
290
- return transactionDocs.push(doc)
291
- }
292
-
293
- // Promises are limited to three at once
294
- const result = new Promise((resolve, reject) => {
295
- const payloadIncludedDocs = payload.filter((item) => item.include).map((item) => item.doc)
296
-
297
- mapLimit(payloadIncludedDocs, 3, asyncify(fetchDoc), (err: Error) => {
298
- if (err) {
299
- setIsDuplicating(false)
300
- setMessage({tone: 'critical', text: `Duplication Failed`})
301
- console.error(err)
302
- reject(new Error('Duplication Failed'))
303
- }
304
-
305
- // @ts-ignore
306
- resolve()
307
- })
308
- })
309
-
310
- await result
311
-
312
- // Remap SVG references to new _id's
313
- const transactionDocsMapped = transactionDocs.map((doc) => {
314
- const expr = `.._ref`
315
- const references = extractWithPath(expr, doc)
316
-
317
- if (!references.length) {
318
- return doc
319
- }
320
-
321
- // For every found _ref, search for an SVG asset _id and update
322
- references.forEach((ref) => {
323
- const newRefValue = svgMaps.find((asset) => asset.old === ref.value)?.new
324
-
325
- if (newRefValue) {
326
- const refPath = ref.path.join('.')
327
-
328
- dset(doc, refPath, newRefValue)
329
- }
330
- })
331
-
332
- return doc
333
- })
334
-
335
- // Create transaction
336
- const transaction = destinationClient.transaction()
337
-
338
- transactionDocsMapped.forEach((doc) => {
339
- transaction.createOrReplace(doc)
340
- })
341
-
342
- await transaction
343
- .commit()
344
- .then((res) => {
345
- setMessage({tone: 'positive', text: 'Duplication complete!'})
346
-
347
- updatePayloadStatuses()
348
- })
349
- .catch((err) => {
350
- setMessage({tone: 'critical', text: err.details.description})
351
- })
352
-
353
- setIsDuplicating(false)
354
- setProgress([0, 0])
355
- if (onDuplicated) {
356
- try {
357
- await onDuplicated()
358
- } catch (error) {
359
- setMessage({tone: 'critical', text: `Error in onDuplicated hook: ${error}`})
360
- }
361
- }
362
- }
363
-
364
- function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
365
- if (!workspacesOptions.length) {
366
- return
367
- }
368
-
369
- const targeted = workspacesOptions.find((space) => space.name === e.currentTarget.value)
370
-
371
- if (targeted) {
372
- setDestination(targeted)
373
- }
374
- }
375
-
376
- const payloadCount = payload.length
377
- const firstSvgIndex = payload.findIndex(({doc}) => doc.extension === 'svg')
378
- const selectedDocumentsCount = payload.filter(
379
- (item) => item.include && !isAssetId(item.doc._id)
380
- ).length
381
- const selectedAssetsCount = payload.filter(
382
- (item) => item.include && isAssetId(item.doc._id)
383
- ).length
384
- const selectedTotal = selectedDocumentsCount + selectedAssetsCount
385
- const destinationTitle = destination?.title ?? destination?.name
386
- const hasMultipleProjectIds =
387
- new Set(workspacesOptions.map((space) => space?.projectId).filter(Boolean)).size > 1
388
-
389
- const headingText = [selectedTotal, `/`, payloadCount, `Documents and Assets selected`].join(` `)
390
-
391
- const buttonText = React.useMemo(() => {
392
- const text = [`Duplicate`]
393
-
394
- if (selectedDocumentsCount > 1) {
395
- text.push(
396
- String(selectedDocumentsCount),
397
- selectedDocumentsCount === 1 ? `Document` : `Documents`
398
- )
399
- }
400
-
401
- if (selectedAssetsCount > 1) {
402
- text.push(`and`, String(selectedAssetsCount), selectedAssetsCount === 1 ? `Asset` : `Assets`)
403
- }
404
-
405
- if (originClient.config().projectId !== destination?.projectId) {
406
- text.push(`between Projects`)
407
- }
408
-
409
- text.push(`to`, String(destinationTitle))
410
-
411
- return text.join(` `)
412
- }, [
413
- selectedDocumentsCount,
414
- selectedAssetsCount,
415
- originClient,
416
- destination?.projectId,
417
- destinationTitle,
418
- ])
419
-
420
- if (workspacesOptions.length < 2) {
421
- return (
422
- <Feedback tone="critical">
423
- <code>sanity.config.ts</code> must contain at least two Workspaces to use this plugin.
424
- </Feedback>
425
- )
426
- }
427
-
428
- return (
429
- <Container width={1}>
430
- <Card border>
431
- <Stack>
432
- <Card padding={4} style={stickyStyles(isDarkMode)}>
433
- <Stack space={4}>
434
- <Flex gap={3}>
435
- <Stack style={{flex: 1}} space={3}>
436
- <Label>Duplicate from</Label>
437
- <Select readOnly value={workspacesOptions.find((space) => space.disabled)?.name}>
438
- {workspacesOptions
439
- .filter((space) => space.disabled)
440
- .map((space) => (
441
- <option key={space.name} value={space.name} disabled={space.disabled}>
442
- {space.title ?? space.name}
443
- {hasMultipleProjectIds ? ` (${space.projectId})` : ``}
444
- </option>
445
- ))}
446
- </Select>
447
- </Stack>
448
- <Box padding={4} paddingTop={5} paddingBottom={0}>
449
- <Text size={3}>
450
- <ArrowRightIcon />
451
- </Text>
452
- </Box>
453
- <Stack style={{flex: 1}} space={3}>
454
- <Label>To Destination</Label>
455
- <Select onChange={handleChange}>
456
- {workspacesOptions.map((space) => (
457
- <option key={space.name} value={space.name} disabled={space.disabled}>
458
- {space.title ?? space.name}
459
- {hasMultipleProjectIds ? ` (${space.projectId})` : ``}
460
- {space.disabled ? ` (Current)` : ``}
461
- </option>
462
- ))}
463
- </Select>
464
- </Stack>
465
- </Flex>
466
-
467
- {isDuplicating && (
468
- <Card border radius={2}>
469
- <Card
470
- style={{
471
- width: '100%',
472
- transform: `scaleX(${progress[0] / progress[1]})`,
473
- transformOrigin: 'left',
474
- transition: 'transform .2s ease',
475
- boxSizing: 'border-box',
476
- }}
477
- padding={1}
478
- tone="positive"
479
- />
480
- </Card>
481
- )}
482
- {payload.length > 0 && (
483
- <>
484
- <Label>{headingText}</Label>
485
- <SelectButtons payload={payload} setPayload={setPayload} />
486
- </>
487
- )}
488
- </Stack>
489
- </Card>
490
- <Card borderTop padding={4}>
491
- <Stack space={3}>
492
- {message && (
493
- <Card padding={3} radius={2} shadow={1} tone={message.tone}>
494
- <Text size={1}>{message.text}</Text>
495
- </Card>
496
- )}
497
- {payload.length > 0 ? (
498
- <Stack>
499
- {payload.map(({doc, include, status, hasDraft}, index) => {
500
- const schemaType = schema.get(doc._type)
501
-
502
- return (
503
- <React.Fragment key={doc._id}>
504
- <Flex align="center">
505
- <Checkbox checked={include} onChange={() => handleCheckbox(doc._id)} />
506
- <Box flex={1} paddingX={3}>
507
- {schemaType ? (
508
- <Preview value={doc} schemaType={schemaType} />
509
- ) : (
510
- <Card tone="caution">Invalid schema type</Card>
511
- )}
512
- </Box>
513
- <Flex align="center" gap={2}>
514
- {hasDraft ? <StatusBadge status="UNPUBLISHED" isAsset={false} /> : null}
515
- <StatusBadge status={status} isAsset={isAssetId(doc._id)} />
516
- </Flex>
517
- </Flex>
518
- {doc?.extension === 'svg' && index === firstSvgIndex && (
519
- <Card padding={3} radius={2} shadow={1} tone="caution">
520
- <Text size={1}>
521
- Due to how SVGs are sanitized after first uploaded, duplicated SVG
522
- assets may have new <code>_id</code>'s at the destination. The newly
523
- generated <code>_id</code> will be the same in each duplication, but
524
- it will never be the same <code>_id</code> as the first time this
525
- Asset was uploaded. References to the asset will be updated to use the
526
- new <code>_id</code>.
527
- </Text>
528
- </Card>
529
- )}
530
- </React.Fragment>
531
- )
532
- })}
533
- </Stack>
534
- ) : (
535
- <Flex padding={4} align="center" justify="center">
536
- <Spinner />
537
- </Flex>
538
- )}
539
- <Stack space={2}>
540
- {hasReferences && (
541
- <Button
542
- fontSize={2}
543
- padding={4}
544
- tone="positive"
545
- mode="ghost"
546
- icon={SearchIcon}
547
- onClick={handleReferences}
548
- text="Gather References"
549
- disabled={isDuplicating || !selectedTotal || isGathering}
550
- />
551
- )}
552
- <Button
553
- fontSize={2}
554
- padding={4}
555
- tone="positive"
556
- icon={LaunchIcon}
557
- onClick={handleDuplicate}
558
- text={buttonText}
559
- disabled={isDuplicating || !selectedTotal || isGathering}
560
- />
561
- </Stack>
562
- </Stack>
563
- </Card>
564
- </Stack>
565
- </Card>
566
- </Container>
567
- )
568
- }
@@ -1,144 +0,0 @@
1
- import {useEffect, useState} from 'react'
2
- import {Button, Stack, Box, Label, Text, Card, Flex, Grid, Container, TextInput} from '@sanity/ui'
3
- import {useSchema, useClient, SanityDocument} from 'sanity'
4
-
5
- import Duplicator from './Duplicator'
6
- import {PluginConfig} from '../types'
7
-
8
- type DuplicatorQueryProps = {
9
- token: string
10
- pluginConfig: Required<PluginConfig>
11
- }
12
-
13
- type InitialData = {
14
- docs: SanityDocument[]
15
- }
16
-
17
- export default function DuplicatorQuery(props: DuplicatorQueryProps) {
18
- const {token, pluginConfig} = props
19
-
20
- const {queries: preDefinedQueries, apiVersion} = pluginConfig
21
- const originClient = useClient({apiVersion})
22
-
23
- const schema = useSchema()
24
- const schemaTypes = schema.getTypeNames()
25
-
26
- const [value, setValue] = useState(``)
27
- const [fetched, setFetched] = useState(false)
28
- const [initialData, setInitialData] = useState<InitialData>({
29
- docs: [],
30
- })
31
- function handleSubmit(e?: any) {
32
- if (e) e.preventDefault()
33
-
34
- originClient
35
- .fetch(value)
36
- .then((res: SanityDocument[]) => {
37
- // Ensure queried docs are registered to the schema
38
- const registeredAndPublishedDocs = res.length
39
- ? res
40
- .filter((doc) => schemaTypes.includes(doc._type))
41
- .filter((doc) => !doc._id.startsWith(`drafts.`))
42
- : []
43
-
44
- setInitialData({
45
- docs: registeredAndPublishedDocs,
46
- })
47
- setFetched(true)
48
- })
49
- .catch((err) => console.error(err))
50
- }
51
-
52
- // Auto-load initial textinput value
53
- useEffect(() => {
54
- if (!initialData.docs?.length && value) {
55
- handleSubmit()
56
- }
57
- // eslint-disable-next-line react-hooks/exhaustive-deps
58
- }, [])
59
-
60
- return (
61
- <Card padding={[0, 0, 0, 5]}>
62
- <Container>
63
- <Grid columns={[1, 1, 1, 2]} gap={[1, 1, 1, 4]}>
64
- <Box padding={[2, 2, 2, 0]}>
65
- <Card padding={4} radius={3} border>
66
- <Stack space={4}>
67
- <Box>
68
- <Label>Initial Documents Query</Label>
69
- </Box>
70
- <Box>
71
- <Text>
72
- Start with a valid GROQ query to load initial documents. The query will need to
73
- return an Array of Objects. Drafts will be removed from the results.
74
- </Text>
75
- </Box>
76
- <form onSubmit={handleSubmit}>
77
- <Flex>
78
- <Box flex={1} paddingRight={2}>
79
- <TextInput
80
- style={{fontFamily: 'monospace'}}
81
- fontSize={2}
82
- // eslint-disable-next-line react/jsx-no-bind
83
- onChange={(event) => setValue(event.currentTarget.value)}
84
- padding={4}
85
- placeholder={`*[_type == "article"]`}
86
- value={value ?? ``}
87
- />
88
- </Box>
89
- <Button
90
- padding={2}
91
- paddingX={4}
92
- tone="primary"
93
- onClick={handleSubmit}
94
- text="Query"
95
- disabled={!value}
96
- />
97
- </Flex>
98
- </form>
99
- </Stack>
100
- </Card>
101
- {preDefinedQueries && preDefinedQueries?.length > 0 && (
102
- <Card marginTop={2} padding={4} radius={3} border>
103
- <Box>
104
- <Stack space={4}>
105
- <Box>
106
- <Label>Predefined Queries</Label>
107
- </Box>
108
- <Stack space={2}>
109
- {preDefinedQueries.map((query) => (
110
- <Button
111
- key={query.label.replace(/\s+/g, '-')}
112
- padding={2}
113
- paddingX={4}
114
- tone="primary"
115
- onClick={() => setValue(`*[${query.query}]`)}
116
- text={query.label}
117
- />
118
- ))}
119
- </Stack>
120
- </Stack>
121
- </Box>
122
- </Card>
123
- )}
124
- </Box>
125
- {fetched && initialData.docs.length < 1 && (
126
- <Container width={1}>
127
- <Card padding={5}>
128
- {value ? `No documents match this query` : `Start with a valid GROQ query`}
129
- </Card>
130
- </Container>
131
- )}
132
- {initialData.docs?.length > 0 && (
133
- <Duplicator
134
- docs={initialData.docs}
135
- // draftIds={initialData.draftIds}
136
- token={token}
137
- pluginConfig={pluginConfig}
138
- />
139
- )}
140
- </Grid>
141
- </Container>
142
- </Card>
143
- )
144
- }