@sanity/cross-dataset-duplicator 0.3.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.
Files changed (55) hide show
  1. package/.babelrc +3 -0
  2. package/.eslintignore +1 -0
  3. package/LICENSE +21 -0
  4. package/README.md +101 -0
  5. package/config.dist.json +4 -0
  6. package/lib/actions/DuplicateToAction.js +59 -0
  7. package/lib/actions/DuplicateToAction.js.map +1 -0
  8. package/lib/actions/index.js +38 -0
  9. package/lib/actions/index.js.map +1 -0
  10. package/lib/components/CrossDatasetDuplicator.js +107 -0
  11. package/lib/components/CrossDatasetDuplicator.js.map +1 -0
  12. package/lib/components/DuplicatorQuery.js +113 -0
  13. package/lib/components/DuplicatorQuery.js.map +1 -0
  14. package/lib/components/DuplicatorTool.js +557 -0
  15. package/lib/components/DuplicatorTool.js.map +1 -0
  16. package/lib/components/Feedback.js +27 -0
  17. package/lib/components/Feedback.js.map +1 -0
  18. package/lib/components/ResetSecret.js +43 -0
  19. package/lib/components/ResetSecret.js.map +1 -0
  20. package/lib/components/SelectButtons.js +109 -0
  21. package/lib/components/SelectButtons.js.map +1 -0
  22. package/lib/components/StatusBadge.js +87 -0
  23. package/lib/components/StatusBadge.js.map +1 -0
  24. package/lib/helpers/clientConfig.js +11 -0
  25. package/lib/helpers/clientConfig.js.map +1 -0
  26. package/lib/helpers/constants.js +9 -0
  27. package/lib/helpers/constants.js.map +1 -0
  28. package/lib/helpers/getDocumentsInArray.js +80 -0
  29. package/lib/helpers/getDocumentsInArray.js.map +1 -0
  30. package/lib/helpers/index.js +30 -0
  31. package/lib/helpers/index.js.map +1 -0
  32. package/lib/index.js +24 -0
  33. package/lib/index.js.map +1 -0
  34. package/lib/tool/index.js +24 -0
  35. package/lib/tool/index.js.map +1 -0
  36. package/lib/types/index.js +2 -0
  37. package/lib/types/index.js.map +1 -0
  38. package/package.json +71 -0
  39. package/sanity.json +16 -0
  40. package/src/actions/DuplicateToAction.tsx +22 -0
  41. package/src/actions/index.ts +22 -0
  42. package/src/components/CrossDatasetDuplicator.tsx +100 -0
  43. package/src/components/DuplicatorQuery.tsx +93 -0
  44. package/src/components/DuplicatorTool.tsx +460 -0
  45. package/src/components/Feedback.tsx +18 -0
  46. package/src/components/ResetSecret.tsx +27 -0
  47. package/src/components/SelectButtons.tsx +80 -0
  48. package/src/components/StatusBadge.tsx +97 -0
  49. package/src/helpers/clientConfig.ts +1 -0
  50. package/src/helpers/constants.ts +1 -0
  51. package/src/helpers/getDocumentsInArray.ts +74 -0
  52. package/src/helpers/index.ts +23 -0
  53. package/src/index.js +7 -0
  54. package/src/tool/index.ts +13 -0
  55. package/src/types/index.ts +10 -0
@@ -0,0 +1,100 @@
1
+ import React, {useEffect, useState} from 'react'
2
+ import {useSecrets, SettingsView} from 'sanity-secrets'
3
+ import {ThemeProvider, Flex, Box, Spinner} from '@sanity/ui'
4
+
5
+ import DuplicatorQuery from './DuplicatorQuery'
6
+ import DuplicatorTool from './DuplicatorTool'
7
+ import ResetSecret from './ResetSecret'
8
+ import Feedback from './Feedback'
9
+ import {SanityDocument} from '../types'
10
+ import {SECRET_NAMESPACE} from '../helpers/constants'
11
+
12
+ // Check for auth secret (required for asset uploads)
13
+ const secretConfigKeys = [
14
+ {
15
+ key: 'bearerToken',
16
+ title:
17
+ 'An "Auth Token" is required to duplicate the original files of assets, and will be used for all Duplications. You can retrieve yours using the Sanity CLI `sanity debug --secrets`.',
18
+ description: '',
19
+ },
20
+ ]
21
+
22
+ type CrossDatasetDuplicatorProps = {
23
+ mode: 'tool' | 'action'
24
+ docs: SanityDocument[]
25
+ }
26
+
27
+ type Secrets = {
28
+ bearerToken?: string
29
+ }
30
+
31
+ export default function CrossDatasetDuplicator(props: CrossDatasetDuplicatorProps) {
32
+ const {mode = `tool`, docs = []} = props
33
+
34
+ const secretsData = useSecrets(SECRET_NAMESPACE)
35
+ const {loading, secrets}: {loading: boolean; secrets: Secrets} = secretsData
36
+ const [showSecretsPrompt, setShowSecretsPrompt] = useState(false)
37
+
38
+ useEffect(() => {
39
+ if (secrets) {
40
+ setShowSecretsPrompt(!secrets?.bearerToken)
41
+ }
42
+ }, [secrets])
43
+
44
+ if (!secretsData) {
45
+ return (
46
+ <Feedback>
47
+ Could not query for Secrets. You may have insufficient permissions on your account.
48
+ </Feedback>
49
+ )
50
+ }
51
+
52
+ if (loading) {
53
+ return (
54
+ <ThemeProvider>
55
+ <Flex justify="center" align="center">
56
+ <Box padding={5}>
57
+ <Spinner />
58
+ </Box>
59
+ </Flex>
60
+ </ThemeProvider>
61
+ )
62
+ }
63
+
64
+ if ((!loading && showSecretsPrompt) || !secrets?.bearerToken) {
65
+ return (
66
+ <ThemeProvider>
67
+ <SettingsView
68
+ title="Token Required"
69
+ namespace={SECRET_NAMESPACE}
70
+ keys={secretConfigKeys}
71
+ // eslint-disable-next-line react/jsx-no-bind
72
+ onClose={() => setShowSecretsPrompt(false)}
73
+ />
74
+ </ThemeProvider>
75
+ )
76
+ }
77
+
78
+ if (mode === 'tool') {
79
+ return (
80
+ <ThemeProvider>
81
+ <DuplicatorQuery token={secrets?.bearerToken} />
82
+ <ResetSecret />
83
+ </ThemeProvider>
84
+ )
85
+ }
86
+
87
+ if (!docs?.length) {
88
+ return (
89
+ <ThemeProvider>
90
+ <Feedback>No docs passed into Duplicator Tool</Feedback>
91
+ </ThemeProvider>
92
+ )
93
+ }
94
+
95
+ return (
96
+ <ThemeProvider>
97
+ <DuplicatorTool docs={docs} token={secrets?.bearerToken} />
98
+ </ThemeProvider>
99
+ )
100
+ }
@@ -0,0 +1,93 @@
1
+ import React, {useEffect, useState} from 'react'
2
+ import sanityClient from 'part:@sanity/base/client'
3
+ import schema from 'part:@sanity/base/schema'
4
+ import {Button, Stack, Box, Label, Text, Card, Flex, Grid, Container, TextInput} from '@sanity/ui'
5
+
6
+ import DuplicatorTool from './DuplicatorTool'
7
+ import { clientConfig } from '../helpers/clientConfig'
8
+
9
+ const originClient = sanityClient.withConfig(clientConfig)
10
+
11
+ type DuplicatorQueryProps = {
12
+ token: string
13
+ }
14
+
15
+ export default function DuplicatorQuery(props: DuplicatorQueryProps) {
16
+ const {token} = props
17
+
18
+ const [value, setValue] = useState(``)
19
+ const [docs, setDocs] = useState([])
20
+
21
+ function handleSubmit(e?: any) {
22
+ if (e) e.preventDefault()
23
+
24
+ originClient
25
+ .fetch(value)
26
+ .then((res) => {
27
+ // Ensure queried docs are registered to the schema
28
+ const registeredDocs = res.length ? res.filter((doc) => schema.get(doc._type)) : []
29
+
30
+ setDocs(registeredDocs)
31
+ })
32
+ .catch((err) => console.error(err))
33
+ }
34
+
35
+ // Auto-load initial textinput value
36
+ useEffect(() => {
37
+ if (!docs?.length && value) {
38
+ handleSubmit()
39
+ }
40
+ }, [])
41
+
42
+ return (
43
+ <Container width={[1, 1, 1, 3]} padding={[0, 0, 0, 5]}>
44
+ <Grid columns={[1, 1, 1, 2]} gap={[1, 1, 1, 4]}>
45
+ <Box padding={[2, 2, 2, 0]}>
46
+ <Card padding={4} scheme="dark" radius={3}>
47
+ <Stack space={4}>
48
+ <Box>
49
+ <Label>Initial Documents Query</Label>
50
+ </Box>
51
+ <Box>
52
+ <Text>
53
+ Start with a valid GROQ query to load initial documents. The query will need to return an Array of Objects.
54
+ </Text>
55
+ </Box>
56
+ <form onSubmit={handleSubmit}>
57
+ <Flex>
58
+ <Box flex={1} paddingRight={2}>
59
+ <TextInput
60
+ style={{fontFamily: 'monospace'}}
61
+ fontSize={2}
62
+ // eslint-disable-next-line react/jsx-no-bind
63
+ onChange={(event) => setValue(event.currentTarget.value)}
64
+ padding={4}
65
+ placeholder={`*[_type == "article"]`}
66
+ value={value ?? ``}
67
+ />
68
+ </Box>
69
+ <Button
70
+ padding={2}
71
+ paddingX={4}
72
+ tone="primary"
73
+ onClick={handleSubmit}
74
+ text="Query"
75
+ disabled={!value}
76
+ />
77
+ </Flex>
78
+ </form>
79
+ </Stack>
80
+ </Card>
81
+ </Box>
82
+ {!docs?.length || docs.length < 1 && (
83
+ <Container width={1}>
84
+ <Card padding={5}>
85
+ {value ? `No Documents registered to the Schema match this query` : `Start with a valid GROQ query`}
86
+ </Card>
87
+ </Container>
88
+ )}
89
+ {docs?.length > 0 && <DuplicatorTool docs={docs} token={token} />}
90
+ </Grid>
91
+ </Container>
92
+ )
93
+ }
@@ -0,0 +1,460 @@
1
+ /* eslint-disable react/jsx-no-bind */
2
+ import React, {useState, useEffect} from 'react'
3
+ import mapLimit from 'async/mapLimit'
4
+ import asyncify from 'async/asyncify'
5
+ import {extract, extractWithPath} from '@sanity/mutator'
6
+ import {dset} from 'dset'
7
+ import {Card, Container, Text, Box, Button, Label, Stack, Select, Flex, Checkbox} from '@sanity/ui'
8
+ import {ArrowRightIcon, SearchIcon, LaunchIcon} from '@sanity/icons'
9
+ import sanityClient from 'part:@sanity/base/client'
10
+ import Preview from 'part:@sanity/base/preview'
11
+ import schema from 'part:@sanity/base/schema'
12
+ import config from 'config:sanity'
13
+
14
+ import {typeIsAsset, stickyStyles, createInitialMessage} from '../helpers'
15
+ import {getDocumentsInArray} from '../helpers/getDocumentsInArray'
16
+ import SelectButtons from './SelectButtons'
17
+ import StatusBadge from './StatusBadge'
18
+ import Feedback from './Feedback'
19
+ import {SanityDocument} from '../types'
20
+ import {clientConfig} from '../helpers/clientConfig'
21
+
22
+ type DuplicatorToolProps = {
23
+ docs: SanityDocument[]
24
+ token: string
25
+ }
26
+
27
+ export default function DuplicatorTool(props: DuplicatorToolProps) {
28
+ const {docs, token} = props
29
+
30
+ // Prepare origin (this Studio) client
31
+ // In function-scope so it is up to date on every render
32
+ const originClient = sanityClient.withConfig(clientConfig)
33
+
34
+ // Create list of dataset options
35
+ // and set initial value of dropdown
36
+ const spacesOptions = config?.__experimental_spaces?.length
37
+ ? config.__experimental_spaces.map((space) => ({
38
+ ...space,
39
+ disabled: space.api.dataset === originClient.config().dataset,
40
+ }))
41
+ : []
42
+
43
+ const [destination, setDestination] = useState(
44
+ spacesOptions.length ? spacesOptions.find((space) => !space.disabled) : {}
45
+ )
46
+ const [message, setMessage] = useState({})
47
+ const [payload, setPayload] = useState(
48
+ docs.length
49
+ ? docs.map((item) => ({
50
+ doc: item,
51
+ include: true,
52
+ status: null,
53
+ }))
54
+ : []
55
+ )
56
+ const [hasReferences, setHasReferences] = useState(false)
57
+ const [isDuplicating, setIsDuplicating] = useState(false)
58
+ const [isGathering, setIsGathering] = useState(false)
59
+ const [progress, setProgress] = useState([0, 0])
60
+
61
+ // Check for References and update message
62
+ useEffect(() => {
63
+ const expr = `.._ref`
64
+ const initialRefs = []
65
+ const initialPayload = []
66
+
67
+ docs.forEach((doc) => {
68
+ initialRefs.push(...extract(expr, doc))
69
+ initialPayload.push({include: true, doc})
70
+ })
71
+
72
+ setPayload(initialPayload)
73
+
74
+ const docCount = docs.length
75
+ const refsCount = initialRefs.length
76
+
77
+ if (initialRefs.length) {
78
+ setHasReferences(true)
79
+
80
+ setMessage({
81
+ tone: `caution`,
82
+ text: createInitialMessage(docCount, refsCount),
83
+ })
84
+ }
85
+ }, [docs])
86
+
87
+ // Re-check payload on destination when value changes
88
+ // (On initial render + select change)
89
+ useEffect(() => {
90
+ updatePayloadStatuses()
91
+ }, [destination, docs])
92
+
93
+ // Check if payload documents exist at destination
94
+ async function updatePayloadStatuses(newPayload = []) {
95
+ const payloadActual = newPayload.length ? newPayload : payload
96
+
97
+ if (!payloadActual.length || !destination?.name) {
98
+ return
99
+ }
100
+
101
+ const payloadIds = payloadActual.map(({doc}) => doc._id)
102
+ const destinationClient = sanityClient.withConfig({
103
+ ...clientConfig,
104
+ dataset: destination.api.dataset,
105
+ projectId: destination.api.projectId,
106
+ })
107
+ const destinationData = await destinationClient.fetch(
108
+ `*[_id in $payloadIds]{ _id, _updatedAt }`,
109
+ {payloadIds}
110
+ )
111
+
112
+ const updatedPayload = payloadActual.map((item) => {
113
+ const existingDoc = destinationData.find((doc) => doc._id === item.doc._id)
114
+
115
+ if (existingDoc?._updatedAt && item?.doc?._updatedAt) {
116
+ if (existingDoc._updatedAt === item.doc._updatedAt) {
117
+ // Exact same document exists at destination
118
+ // We don't compare by _rev because that is updated in a transaction
119
+ item.status = `EXISTS`
120
+ } else if (existingDoc._updatedAt && item.doc._updatedAt) {
121
+ item.status =
122
+ new Date(existingDoc._updatedAt) > new Date(item.doc._updatedAt)
123
+ ? // Document at destination is newer
124
+ `OVERWRITE`
125
+ : // Document at destination is older
126
+ `UPDATE`
127
+ }
128
+ } else {
129
+ item.status = 'CREATE'
130
+ }
131
+
132
+ return item
133
+ })
134
+
135
+ setPayload(updatedPayload)
136
+ }
137
+
138
+ function handleCheckbox(_id) {
139
+ const updatedPayload = payload.map((item) => {
140
+ if (item.doc._id === _id) {
141
+ item.include = !item.include
142
+ }
143
+
144
+ return item
145
+ })
146
+
147
+ setPayload(updatedPayload)
148
+ }
149
+
150
+ // Find and recursively follow references beginning with this document
151
+ async function handleReferences() {
152
+ setIsGathering(true)
153
+ const docIds = docs.map((doc) => doc._id)
154
+
155
+ const payloadDocs = await getDocumentsInArray(docIds, originClient, null)
156
+
157
+ // Shape it up
158
+ const payloadShaped = payloadDocs.map((doc) => ({
159
+ doc,
160
+ // Include this in the transaction?
161
+ include: true,
162
+ // Does it exist at the destination?
163
+ status: '',
164
+ }))
165
+
166
+ setPayload(payloadShaped)
167
+ updatePayloadStatuses(payloadShaped)
168
+ setIsGathering(false)
169
+ }
170
+
171
+ // Duplicate payload to destination dataset
172
+ async function handleDuplicate() {
173
+ setIsDuplicating(true)
174
+
175
+ const assetsCount = payload.filter(({doc, include}) => include && typeIsAsset(doc._type)).length
176
+ let currentProgress = 0
177
+ setProgress([currentProgress, assetsCount])
178
+
179
+ setMessage({text: 'Duplicating...'})
180
+
181
+ const destinationClient = sanityClient.withConfig({
182
+ ...clientConfig,
183
+ dataset: destination.api.dataset,
184
+ projectId: destination.api.projectId,
185
+ })
186
+
187
+ const transactionDocs = []
188
+ const svgMaps = []
189
+
190
+ // Upload assets and then add to transaction
191
+ async function fetchDoc(doc) {
192
+ if (typeIsAsset(doc._type)) {
193
+ // Download and upload asset
194
+ // Get the *original* image with this dlRaw param to create the same determenistic _id
195
+ const uploadType = doc._type.split('.').pop().replace('Asset', '')
196
+ const downloadUrl = uploadType === 'image' ? `${doc.url}?dlRaw=true` : doc.url
197
+ const downloadConfig =
198
+ uploadType === 'image' ? {headers: {Authorization: token ? `Bearer ${token}` : ``}} : {}
199
+
200
+ await fetch(downloadUrl, downloadConfig).then(async (res) => {
201
+ const assetData = await res.blob()
202
+
203
+ const options = {filename: doc.originalFilename}
204
+ const assetDoc = await destinationClient.assets.upload(uploadType, assetData, options)
205
+
206
+ // SVG _id's need remapping before transaction
207
+ if (doc?.extension === 'svg') {
208
+ svgMaps.push({old: doc._id, new: assetDoc._id})
209
+ }
210
+
211
+ transactionDocs.push(assetDoc)
212
+ })
213
+
214
+ currentProgress += 1
215
+ setMessage({
216
+ text: `Duplicating ${currentProgress}/${assetsCount} ${
217
+ assetsCount === 1 ? `Assets` : `Assets`
218
+ }`,
219
+ })
220
+
221
+ return setProgress([currentProgress, assetsCount])
222
+ }
223
+
224
+ return transactionDocs.push(doc)
225
+ }
226
+
227
+ // Promises are limited to three at once
228
+ const result = new Promise((resolve, reject) => {
229
+ const payloadIncludedDocs = payload.filter((item) => item.include).map((item) => item.doc)
230
+
231
+ mapLimit(payloadIncludedDocs, 3, asyncify(fetchDoc), (err) => {
232
+ if (err) {
233
+ setIsDuplicating(false)
234
+ setMessage({tone: 'critical', text: `Duplication Failed`})
235
+ console.error(err)
236
+ reject(new Error('Duplication Failed'))
237
+ }
238
+
239
+ resolve()
240
+ })
241
+ })
242
+
243
+ await result
244
+
245
+ // Remap SVG references to new _id's
246
+ const transactionDocsMapped = transactionDocs.map((doc) => {
247
+ const expr = `.._ref`
248
+ const references = extractWithPath(expr, doc)
249
+
250
+ if (!references.length) {
251
+ return doc
252
+ }
253
+
254
+ // For every found _ref, search for an SVG asset _id and update
255
+ references.forEach((ref) => {
256
+ const newRefValue = svgMaps.find((asset) => asset.old === ref.value)?.new
257
+
258
+ if (newRefValue) {
259
+ const refPath = ref.path.join('.')
260
+
261
+ dset(doc, refPath, newRefValue)
262
+ }
263
+ })
264
+
265
+ return doc
266
+ })
267
+
268
+ // Create transaction
269
+ const transaction = destinationClient.transaction()
270
+
271
+ transactionDocsMapped.forEach((doc) => {
272
+ transaction.createOrReplace(doc)
273
+ })
274
+
275
+ await transaction
276
+ .commit()
277
+ .then((res) => {
278
+ setMessage({tone: 'positive', text: 'Duplication complete!'})
279
+
280
+ updatePayloadStatuses()
281
+ })
282
+ .catch((err) => {
283
+ setMessage({tone: 'critical', text: err.details.description})
284
+ })
285
+
286
+ setIsDuplicating(false)
287
+ setProgress(0)
288
+ }
289
+
290
+ function handleChange(e) {
291
+ setDestination(spacesOptions.find((space) => space.name === e.currentTarget.value))
292
+ }
293
+
294
+ if (!spacesOptions.length) {
295
+ return (
296
+ <Feedback tone="critical">
297
+ <code>__experimental_spaces</code> not found in <code>sanity.json</code>
298
+ </Feedback>
299
+ )
300
+ }
301
+
302
+ const payloadCount = payload.length
303
+ const firstSvgIndex = payload.findIndex(({doc}) => doc.extension === 'svg')
304
+ const selectedDocumentsCount = payload.filter(
305
+ (item) => item.include && !typeIsAsset(item.doc._type)
306
+ ).length
307
+ const selectedAssetsCount = payload.filter(
308
+ (item) => item.include && typeIsAsset(item.doc._type)
309
+ ).length
310
+ const selectedTotal = selectedDocumentsCount + selectedAssetsCount
311
+ const destinationTitle = destination?.title ?? destination?.name
312
+ const hasMultipleProjectIds =
313
+ new Set(spacesOptions.map((space) => space?.api?.projectId).filter(Boolean)).size > 1
314
+
315
+ const headingText = [selectedTotal, `/`, payloadCount, `Documents and Assets selected`].join(` `)
316
+
317
+ const buttonText = React.useMemo(() => {
318
+ let text = [`Duplicate`]
319
+
320
+ if (selectedDocumentsCount > 1) {
321
+ text.push(selectedDocumentsCount, selectedDocumentsCount === 1 ? `Document` : `Documents`)
322
+ }
323
+
324
+ if (selectedAssetsCount > 1) {
325
+ text.push(`and`, selectedAssetsCount, selectedAssetsCount === 1 ? `Asset` : `Assets`)
326
+ }
327
+
328
+ if (originClient.config().projectId !== destination.api.projectId) {
329
+ text.push(`between Projects`)
330
+ }
331
+
332
+ text.push(`to`, destinationTitle)
333
+
334
+ return text.join(` `)
335
+ }, [selectedDocumentsCount, selectedAssetsCount, destinationTitle])
336
+
337
+ return (
338
+ <Container width={1}>
339
+ <Card>
340
+ <Stack>
341
+ <>
342
+ <Card borderBottom padding={4} style={stickyStyles}>
343
+ <Stack space={4}>
344
+ <Flex space={3}>
345
+ <Stack style={{flex: 1}} space={3}>
346
+ <Label>Duplicate from</Label>
347
+ <Select readOnly value={spacesOptions.find((space) => space.disabled)?.name}>
348
+ {spacesOptions
349
+ .filter((space) => space.disabled)
350
+ .map((space) => (
351
+ <option key={space.name} value={space.name} disabled={space.disabled}>
352
+ {space.title ?? space.name}
353
+ {hasMultipleProjectIds ? ` (${space.api.projectId})` : ``}
354
+ </option>
355
+ ))}
356
+ </Select>
357
+ </Stack>
358
+ <Box padding={4} paddingTop={5} paddingBottom={0}>
359
+ <Text size={3}>
360
+ <ArrowRightIcon />
361
+ </Text>
362
+ </Box>
363
+ <Stack style={{flex: 1}} space={3}>
364
+ <Label>To Destination</Label>
365
+ <Select onChange={handleChange}>
366
+ {spacesOptions.map((space) => (
367
+ <option key={space.name} value={space.name} disabled={space.disabled}>
368
+ {space.title ?? space.name}
369
+ {hasMultipleProjectIds ? ` (${space.api.projectId})` : ``}
370
+ {space.disabled ? ` (Current)` : ``}
371
+ </option>
372
+ ))}
373
+ </Select>
374
+ </Stack>
375
+ </Flex>
376
+
377
+ {isDuplicating && (
378
+ <Card border radius={2}>
379
+ <Card
380
+ style={{
381
+ width: '100%',
382
+ transform: `scaleX(${progress[0] / progress[1]})`,
383
+ transformOrigin: 'left',
384
+ transition: 'transform .2s ease',
385
+ boxSizing: 'border-box',
386
+ }}
387
+ padding={1}
388
+ tone="positive"
389
+ />
390
+ </Card>
391
+ )}
392
+ {payload.length > 0 && (
393
+ <>
394
+ <Label>{headingText}</Label>
395
+ <SelectButtons payload={payload} setPayload={setPayload} />
396
+ </>
397
+ )}
398
+ </Stack>
399
+ </Card>
400
+ {message?.text && (
401
+ <Box paddingX={4} paddingTop={4}>
402
+ <Card padding={3} radius={2} shadow={1} tone={message?.tone ?? 'transparent'}>
403
+ <Text size={1}>{message.text}</Text>
404
+ </Card>
405
+ </Box>
406
+ )}
407
+ {payload.length > 0 && (
408
+ <Stack padding={4} space={3}>
409
+ {payload.map(({doc, include, status}, index) => (
410
+ <React.Fragment key={doc._id}>
411
+ <Flex align="center">
412
+ <Checkbox checked={include} onChange={() => handleCheckbox(doc._id)} />
413
+ <Box style={{flex: 1}} paddingX={3}>
414
+ <Preview value={doc} type={schema.get(doc._type)} />
415
+ </Box>
416
+ <StatusBadge status={status} isAsset={typeIsAsset(doc._type)} />
417
+ </Flex>
418
+ {doc?.extension === 'svg' && index === firstSvgIndex && (
419
+ <Card padding={3} radius={2} shadow={1} tone="caution">
420
+ <Text size={1}>
421
+ Due to how SVGs are sanitized after first uploaded, duplicated SVG assets may have new{' '}
422
+ <code>_id</code>'s at the destination. The newly generated <code>_id</code>{' '}
423
+ will be the same in each duplication, but it will never be the same{' '}
424
+ <code>_id</code> as the first time this Asset was uploaded. References to the asset will be updated to use the new <code>_id</code>.
425
+ </Text>
426
+ </Card>
427
+ )}
428
+ </React.Fragment>
429
+ ))}
430
+ </Stack>
431
+ )}
432
+ <Stack space={2} padding={4} paddingTop={0}>
433
+ {hasReferences && (
434
+ <Button
435
+ fontSize={2}
436
+ padding={4}
437
+ tone="positive"
438
+ mode="ghost"
439
+ icon={SearchIcon}
440
+ onClick={handleReferences}
441
+ text="Gather References"
442
+ disabled={isDuplicating || !selectedTotal || isGathering}
443
+ />
444
+ )}
445
+ <Button
446
+ fontSize={2}
447
+ padding={4}
448
+ tone="positive"
449
+ icon={LaunchIcon}
450
+ onClick={handleDuplicate}
451
+ text={buttonText}
452
+ disabled={isDuplicating || !selectedTotal || isGathering}
453
+ />
454
+ </Stack>
455
+ </>
456
+ </Stack>
457
+ </Card>
458
+ </Container>
459
+ )
460
+ }
@@ -0,0 +1,18 @@
1
+ import React from 'react'
2
+ import {Card, Text} from '@sanity/ui'
3
+ import type {BadgeTone} from '@sanity/ui'
4
+
5
+ type FeedbackProps = {
6
+ children?: React.ReactNode
7
+ tone?: BadgeTone
8
+ }
9
+
10
+ export default function Feedback(props: FeedbackProps) {
11
+ const {children, tone = `caution`} = props
12
+
13
+ return (
14
+ <Card padding={3} radius={2} shadow={1} tone={tone}>
15
+ <Text size={1}>{children}</Text>
16
+ </Card>
17
+ )
18
+ }
@@ -0,0 +1,27 @@
1
+ import React from 'react'
2
+ import {Button, Flex} from '@sanity/ui'
3
+ import sanityClient from 'part:@sanity/base/client'
4
+
5
+ import { clientConfig } from '../helpers/clientConfig'
6
+ import { SECRET_NAMESPACE } from '../helpers/constants'
7
+
8
+ const client = sanityClient.withConfig(clientConfig)
9
+
10
+ function handleClick() {
11
+ client.delete({query: `*[_id == "secrets.${SECRET_NAMESPACE}"]`})
12
+ }
13
+
14
+ export default function ResetSecret() {
15
+ return (
16
+ <Flex align="center" justify="flex-end" paddingX={[2, 2, 2, 5]} paddingY={5}>
17
+ <Button
18
+ text="Reset Secret"
19
+ onClick={() => handleClick()}
20
+ mode="ghost"
21
+ tone="critical"
22
+ fontSize={1}
23
+ padding={2}
24
+ />
25
+ </Flex>
26
+ )
27
+ }