@sanity/document-internationalization 3.0.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sanity/document-internationalization",
3
- "version": "3.0.1",
3
+ "version": "3.2.0",
4
4
  "description": "Create unique translations of a document based on its language, joined by a shared reference document.",
5
5
  "keywords": [
6
6
  "sanity",
@@ -0,0 +1,252 @@
1
+ import {CopyIcon} from '@sanity/icons'
2
+ import {useToast} from '@sanity/ui'
3
+ import {uuid} from '@sanity/uuid'
4
+ import {useCallback, useMemo, useState} from 'react'
5
+ import {filter, firstValueFrom} from 'rxjs'
6
+ import {
7
+ DEFAULT_STUDIO_CLIENT_OPTIONS,
8
+ type DocumentActionComponent,
9
+ type Id,
10
+ InsufficientPermissionsMessage,
11
+ type PatchOperations,
12
+ useClient,
13
+ useCurrentUser,
14
+ useDocumentOperation,
15
+ useDocumentPairPermissions,
16
+ useDocumentStore,
17
+ useTranslation,
18
+ } from 'sanity'
19
+ import {useRouter} from 'sanity/router'
20
+ import {structureLocaleNamespace} from 'sanity/structure'
21
+
22
+ import {METADATA_SCHEMA_NAME, TRANSLATIONS_ARRAY_NAME} from '../constants'
23
+ import {useTranslationMetadata} from '../hooks/useLanguageMetadata'
24
+ import {documenti18nLocaleNamespace} from '../i18n'
25
+
26
+ const DISABLED_REASON_KEY = {
27
+ METADATA_NOT_FOUND: 'action.duplicate.disabled.missing-metadata',
28
+ MULTIPLE_METADATA: 'action.duplicate.disabled.multiple-metadata',
29
+ NOTHING_TO_DUPLICATE: 'action.duplicate.disabled.nothing-to-duplicate',
30
+ NOT_READY: 'action.duplicate.disabled.not-ready',
31
+ }
32
+
33
+ export const DuplicateWithTranslationsAction: DocumentActionComponent = ({
34
+ id,
35
+ type,
36
+ onComplete,
37
+ }) => {
38
+ const documentStore = useDocumentStore()
39
+ const {duplicate} = useDocumentOperation(id, type)
40
+ const {navigateIntent} = useRouter()
41
+ const [isDuplicating, setDuplicating] = useState(false)
42
+ const [permissions, isPermissionsLoading] = useDocumentPairPermissions({
43
+ id,
44
+ type,
45
+ permission: 'duplicate',
46
+ })
47
+ const {data, loading: isMetadataDocumentLoading} = useTranslationMetadata(id)
48
+ const hasOneMetadataDocument = useMemo(() => {
49
+ return Array.isArray(data) && data.length <= 1
50
+ }, [data])
51
+ const metadataDocument = Array.isArray(data) && data.length ? data[0] : null
52
+ const client = useClient(DEFAULT_STUDIO_CLIENT_OPTIONS)
53
+ const toast = useToast()
54
+ const {t: s} = useTranslation(structureLocaleNamespace)
55
+ const {t: d} = useTranslation(documenti18nLocaleNamespace)
56
+ const currentUser = useCurrentUser()
57
+
58
+ const handle = useCallback(async () => {
59
+ setDuplicating(true)
60
+
61
+ try {
62
+ if (!metadataDocument) {
63
+ throw new Error('Metadata document not found')
64
+ }
65
+
66
+ // 1. Duplicate the document and its localized versions
67
+ const translations = new Map<string, Id>()
68
+ await Promise.all(
69
+ metadataDocument[TRANSLATIONS_ARRAY_NAME].map(async (translation) => {
70
+ const dupeId = uuid()
71
+ const locale = translation._key
72
+ const docId = translation.value?._ref
73
+
74
+ if (!docId) {
75
+ throw new Error('Translation document not found')
76
+ }
77
+
78
+ const {duplicate: duplicateTranslation} = await firstValueFrom(
79
+ documentStore.pair
80
+ .editOperations(docId, type)
81
+ .pipe(filter((op) => op.duplicate.disabled !== 'NOT_READY'))
82
+ )
83
+
84
+ if (duplicateTranslation.disabled) {
85
+ throw new Error('Cannot duplicate document')
86
+ }
87
+
88
+ const duplicateTranslationSuccess = firstValueFrom(
89
+ documentStore.pair
90
+ .operationEvents(docId, type)
91
+ .pipe(filter((e) => e.op === 'duplicate' && e.type === 'success'))
92
+ )
93
+ duplicateTranslation.execute(dupeId)
94
+ await duplicateTranslationSuccess
95
+
96
+ translations.set(locale, dupeId)
97
+ })
98
+ )
99
+
100
+ // 2. Duplicate the metadata document
101
+ const {duplicate: duplicateMetadata} = await firstValueFrom(
102
+ documentStore.pair
103
+ .editOperations(metadataDocument._id, METADATA_SCHEMA_NAME)
104
+ .pipe(filter((op) => op.duplicate.disabled !== 'NOT_READY'))
105
+ )
106
+
107
+ if (duplicateMetadata.disabled) {
108
+ throw new Error('Cannot duplicate document')
109
+ }
110
+
111
+ const duplicateMetadataSuccess = firstValueFrom(
112
+ documentStore.pair
113
+ .operationEvents(metadataDocument._id, METADATA_SCHEMA_NAME)
114
+ .pipe(filter((e) => e.op === 'duplicate' && e.type === 'success'))
115
+ )
116
+ const dupeId = uuid()
117
+ duplicateMetadata.execute(dupeId)
118
+ await duplicateMetadataSuccess
119
+
120
+ // 3. Patch the duplicated metadata document to update the references
121
+ // TODO: use document store
122
+ // const {patch: patchMetadata} = await firstValueFrom(
123
+ // documentStore.pair
124
+ // .editOperations(dupeId, METADATA_SCHEMA_NAME)
125
+ // .pipe(filter((op) => op.patch.disabled !== 'NOT_READY'))
126
+ // )
127
+
128
+ // if (patchMetadata.disabled) {
129
+ // throw new Error('Cannot patch document')
130
+ // }
131
+
132
+ // await firstValueFrom(
133
+ // documentStore.pair
134
+ // .consistencyStatus(dupeId, METADATA_SCHEMA_NAME)
135
+ // .pipe(filter((isConsistant) => isConsistant))
136
+ // )
137
+
138
+ // const patchMetadataSuccess = firstValueFrom(
139
+ // documentStore.pair
140
+ // .operationEvents(dupeId, METADATA_SCHEMA_NAME)
141
+ // .pipe(filter((e) => e.op === 'patch' && e.type === 'success'))
142
+ // )
143
+
144
+ const patch: PatchOperations = {
145
+ set: Object.fromEntries(
146
+ Array.from(translations.entries()).map(([locale, documentId]) => [
147
+ `${TRANSLATIONS_ARRAY_NAME}[_key == "${locale}"].value._ref`,
148
+ documentId,
149
+ ])
150
+ ),
151
+ }
152
+
153
+ // patchMetadata.execute([patch])
154
+ // await patchMetadataSuccess
155
+ await client.transaction().patch(dupeId, patch).commit()
156
+
157
+ // 4. Navigate to the duplicated document
158
+ navigateIntent('edit', {
159
+ id: Array.from(translations.values()).at(0),
160
+ type,
161
+ })
162
+
163
+ onComplete()
164
+ } catch (error) {
165
+ console.error(error)
166
+ toast.push({
167
+ status: 'error',
168
+ title: 'Error duplicating document',
169
+ description:
170
+ error instanceof Error
171
+ ? error.message
172
+ : 'Failed to duplicate document',
173
+ })
174
+ } finally {
175
+ setDuplicating(false)
176
+ }
177
+ }, [
178
+ client,
179
+ documentStore.pair,
180
+ metadataDocument,
181
+ navigateIntent,
182
+ onComplete,
183
+ toast,
184
+ type,
185
+ ])
186
+
187
+ return useMemo(() => {
188
+ if (!isPermissionsLoading && !permissions?.granted) {
189
+ return {
190
+ icon: CopyIcon,
191
+ disabled: true,
192
+ label: d('action.duplicate.label'),
193
+ title: (
194
+ <InsufficientPermissionsMessage
195
+ context="duplicate-document"
196
+ currentUser={currentUser}
197
+ />
198
+ ),
199
+ }
200
+ }
201
+
202
+ if (!isMetadataDocumentLoading && !metadataDocument) {
203
+ return {
204
+ icon: CopyIcon,
205
+ disabled: true,
206
+ label: d('action.duplicate.label'),
207
+ title: d(DISABLED_REASON_KEY.METADATA_NOT_FOUND),
208
+ }
209
+ }
210
+
211
+ if (!hasOneMetadataDocument) {
212
+ return {
213
+ icon: CopyIcon,
214
+ disabled: true,
215
+ label: d('action.duplicate.label'),
216
+ title: d(DISABLED_REASON_KEY.MULTIPLE_METADATA),
217
+ }
218
+ }
219
+
220
+ return {
221
+ icon: CopyIcon,
222
+ disabled:
223
+ isDuplicating ||
224
+ Boolean(duplicate.disabled) ||
225
+ isPermissionsLoading ||
226
+ isMetadataDocumentLoading,
227
+ label: isDuplicating
228
+ ? s('action.duplicate.running.label')
229
+ : d('action.duplicate.label'),
230
+ title: duplicate.disabled
231
+ ? s(DISABLED_REASON_KEY[duplicate.disabled])
232
+ : '',
233
+ onHandle: handle,
234
+ }
235
+ }, [
236
+ currentUser,
237
+ duplicate.disabled,
238
+ handle,
239
+ hasOneMetadataDocument,
240
+ isDuplicating,
241
+ isMetadataDocumentLoading,
242
+ isPermissionsLoading,
243
+ metadataDocument,
244
+ permissions?.granted,
245
+ s,
246
+ d,
247
+ ])
248
+ }
249
+
250
+ DuplicateWithTranslationsAction.action = 'duplicate'
251
+ // @ts-expect-error `displayName` is used by React DevTools
252
+ DuplicateWithTranslationsAction.displayName = 'DuplicateWithTranslationsAction'
@@ -95,7 +95,13 @@ export function DocumentInternationalizationMenu(
95
95
  </Card>
96
96
  ) : (
97
97
  <Stack space={1}>
98
- <LanguageManage id={metadata?._id} />
98
+ <LanguageManage
99
+ id={metadata?._id}
100
+ documentId={documentId}
101
+ metadataId={metadataId}
102
+ schemaType={schemaType}
103
+ sourceLanguageId={sourceLanguageId}
104
+ />
99
105
  {supportedLanguages.length > 4 ? (
100
106
  <TextInput
101
107
  onChange={handleQuery}
@@ -1,40 +1,106 @@
1
1
  import {CogIcon} from '@sanity/icons'
2
2
  import {Box, Button, Stack, Text, Tooltip} from '@sanity/ui'
3
+ import {useCallback, useState} from 'react'
4
+ import {type ObjectSchemaType, useClient} from 'sanity'
3
5
 
4
6
  import {METADATA_SCHEMA_NAME} from '../constants'
5
7
  import {useOpenInNewPane} from '../hooks/useOpenInNewPane'
8
+ import {createReference} from '../utils/createReference'
9
+ import {useDocumentInternationalizationContext} from './DocumentInternationalizationContext'
6
10
 
7
11
  type LanguageManageProps = {
8
12
  id?: string
13
+ metadataId?: string | null
14
+ schemaType: ObjectSchemaType
15
+ documentId: string
16
+ sourceLanguageId?: string
9
17
  }
10
18
 
11
19
  export default function LanguageManage(props: LanguageManageProps) {
12
- const {id} = props
20
+ const {id, metadataId, schemaType, documentId, sourceLanguageId} = props
13
21
  const open = useOpenInNewPane(id, METADATA_SCHEMA_NAME)
22
+ const openCreated = useOpenInNewPane(metadataId, METADATA_SCHEMA_NAME)
23
+ const {allowCreateMetaDoc, apiVersion, weakReferences} =
24
+ useDocumentInternationalizationContext()
25
+ const client = useClient({apiVersion})
26
+ const [userHasClicked, setUserHasClicked] = useState(false)
27
+
28
+ const canCreate = !id && Boolean(metadataId) && allowCreateMetaDoc
29
+
30
+ const handleClick = useCallback(() => {
31
+ if (!id && metadataId && sourceLanguageId) {
32
+ /* Disable button while this request is pending */
33
+ setUserHasClicked(true)
34
+
35
+ // handle creation of meta document
36
+ const transaction = client.transaction()
37
+
38
+ const sourceReference = createReference(
39
+ sourceLanguageId,
40
+ documentId,
41
+ schemaType.name,
42
+ !weakReferences
43
+ )
44
+ const newMetadataDocument = {
45
+ _id: metadataId,
46
+ _type: METADATA_SCHEMA_NAME,
47
+ schemaTypes: [schemaType.name],
48
+ translations: [sourceReference],
49
+ }
50
+
51
+ transaction.createIfNotExists(newMetadataDocument)
52
+
53
+ transaction
54
+ .commit()
55
+ .then(() => {
56
+ setUserHasClicked(false)
57
+ openCreated()
58
+ })
59
+ .catch((err) => {
60
+ console.error(err)
61
+ setUserHasClicked(false)
62
+ })
63
+ } else {
64
+ open()
65
+ }
66
+ }, [
67
+ id,
68
+ metadataId,
69
+ sourceLanguageId,
70
+ client,
71
+ documentId,
72
+ schemaType.name,
73
+ weakReferences,
74
+ openCreated,
75
+ open,
76
+ ])
77
+
78
+ const disabled =
79
+ (!id && !canCreate) || (canCreate && !sourceLanguageId) || userHasClicked
14
80
 
15
81
  return (
16
82
  <Tooltip
17
83
  animate
18
84
  content={
19
- id ? null : (
20
- <Box padding={2}>
21
- <Text muted size={1}>
22
- Document has no other translations
23
- </Text>
24
- </Box>
25
- )
85
+ <Box padding={2}>
86
+ <Text muted size={1}>
87
+ Document has no other translations
88
+ </Text>
89
+ </Box>
26
90
  }
27
91
  fallbackPlacements={['right', 'left']}
28
92
  placement="top"
29
93
  portal
94
+ disabled={Boolean(id) || canCreate}
30
95
  >
31
96
  <Stack>
32
97
  <Button
33
- disabled={!id}
98
+ disabled={disabled}
34
99
  mode="ghost"
35
100
  text="Manage Translations"
36
101
  icon={CogIcon}
37
- onClick={() => open()}
102
+ loading={userHasClicked}
103
+ onClick={handleClick}
38
104
  />
39
105
  </Stack>
40
106
  </Tooltip>
package/src/constants.ts CHANGED
@@ -11,4 +11,5 @@ export const DEFAULT_CONFIG: PluginConfigContext = {
11
11
  bulkPublish: false,
12
12
  metadataFields: [],
13
13
  apiVersion: API_VERSION,
14
+ allowCreateMetaDoc: false,
14
15
  }
@@ -2,7 +2,7 @@ import {useCallback, useContext} from 'react'
2
2
  import {RouterContext} from 'sanity/router'
3
3
  import {usePaneRouter} from 'sanity/structure'
4
4
 
5
- export function useOpenInNewPane(id?: string, type?: string) {
5
+ export function useOpenInNewPane(id?: string | null, type?: string) {
6
6
  const routerContext = useContext(RouterContext)
7
7
  const {routerPanesState, groupIndex} = usePaneRouter()
8
8
 
@@ -0,0 +1,21 @@
1
+ import {defineLocaleResourceBundle} from 'sanity'
2
+
3
+ /**
4
+ * The locale namespace for the document internationalization plugin.
5
+ *
6
+ * @public
7
+ */
8
+ export const documenti18nLocaleNamespace =
9
+ 'document-internationalization' as const
10
+
11
+ /**
12
+ * The default locale bundle for the document internationalization plugin, which is US English.
13
+ *
14
+ * @internal
15
+ */
16
+ export const documentInternationalizationUsEnglishLocaleBundle =
17
+ defineLocaleResourceBundle({
18
+ locale: 'en-US',
19
+ namespace: documenti18nLocaleNamespace,
20
+ resources: () => import('./resources'),
21
+ })
@@ -0,0 +1,7 @@
1
+ export default {
2
+ 'action.duplicate.label': 'Duplicate with translations',
3
+ 'action.duplicate.disabled.missing-metadata':
4
+ 'The document cannot be duplicated because the metadata document is missing',
5
+ 'action.duplicate.disabled.multiple-metadata':
6
+ 'The document cannot be duplicated because there are multiple metadata documents',
7
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export {DeleteTranslationAction} from './actions/DeleteTranslationAction'
2
+ export {DuplicateWithTranslationsAction} from './actions/DuplicateWithTranslationsAction'
2
3
  export {useDocumentInternationalizationContext} from './components/DocumentInternationalizationContext'
3
4
  export {DocumentInternationalizationMenu} from './components/DocumentInternationalizationMenu'
4
5
  export {documentInternationalization} from './plugin'
package/src/plugin.tsx CHANGED
@@ -9,6 +9,7 @@ import {DocumentInternationalizationProvider} from './components/DocumentInterna
9
9
  import {DocumentInternationalizationMenu} from './components/DocumentInternationalizationMenu'
10
10
  import OptimisticallyStrengthen from './components/OptimisticallyStrengthen'
11
11
  import {API_VERSION, DEFAULT_CONFIG, METADATA_SCHEMA_NAME} from './constants'
12
+ import {documentInternationalizationUsEnglishLocaleBundle} from './i18n'
12
13
  import metadata from './schema/translation/metadata'
13
14
  import type {PluginConfig, TranslationReference} from './types'
14
15
 
@@ -39,6 +40,10 @@ export const documentInternationalization = definePlugin<PluginConfig>(
39
40
  },
40
41
  },
41
42
 
43
+ i18n: {
44
+ bundles: [documentInternationalizationUsEnglishLocaleBundle],
45
+ },
46
+
42
47
  // Adds:
43
48
  // - A bulk-publishing UI component to the form
44
49
  // - Will only work for projects on a compatible plan
package/src/types.ts CHANGED
@@ -25,6 +25,7 @@ export type PluginConfig = {
25
25
  bulkPublish?: boolean
26
26
  metadataFields?: FieldDefinition[]
27
27
  apiVersion?: string
28
+ allowCreateMetaDoc?: boolean
28
29
  }
29
30
 
30
31
  // Context version of config