@sanity/assist 1.2.15 → 2.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/LICENSE +1 -1
- package/README.md +551 -30
- package/dist/index.cjs.mjs +1 -0
- package/dist/index.d.ts +253 -11
- package/dist/index.esm.js +2405 -392
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2399 -385
- package/dist/index.js.map +1 -1
- package/package.json +15 -14
- package/src/_lib/form/DocumentForm.tsx +3 -2
- package/src/_lib/form/constants.ts +1 -0
- package/src/assistDocument/AssistDocumentInput.tsx +24 -4
- package/src/assistDocument/RequestRunInstructionProvider.tsx +37 -21
- package/src/assistDocument/components/instruction/InstructionInput.tsx +5 -4
- package/src/assistDocument/components/instruction/InstructionOutputField.tsx +45 -0
- package/src/assistDocument/components/instruction/InstructionOutputInput.tsx +205 -0
- package/src/assistDocument/hooks/useStudioAssistDocument.ts +5 -32
- package/src/assistFormComponents/AssistField.tsx +11 -5
- package/src/assistFormComponents/AssistFormBlock.tsx +2 -3
- package/src/assistFormComponents/validation/listItem.tsx +2 -2
- package/src/assistInspector/AssistInspector.tsx +6 -0
- package/src/assistInspector/FieldAutocomplete.tsx +1 -0
- package/src/assistInspector/InstructionTaskHistoryButton.tsx +2 -3
- package/src/assistInspector/helpers.ts +9 -11
- package/src/assistLayout/AssistLayout.tsx +9 -9
- package/src/components/ImageContext.tsx +19 -9
- package/src/components/SafeValueInput.tsx +4 -1
- package/src/fieldActions/assistFieldActions.tsx +42 -13
- package/src/fieldActions/generateCaptionActions.tsx +2 -2
- package/src/fieldActions/generateImageActions.tsx +57 -0
- package/src/helpers/assistSupported.ts +10 -16
- package/src/helpers/conditionalMembers.test.ts +200 -0
- package/src/helpers/conditionalMembers.ts +127 -0
- package/src/helpers/typeUtils.ts +19 -5
- package/src/index.ts +3 -0
- package/src/plugin.tsx +14 -5
- package/src/presence/AssistAvatar.tsx +1 -1
- package/src/schemas/assistDocumentSchema.tsx +40 -1
- package/src/schemas/serialize/serializeSchema.test.ts +239 -8
- package/src/schemas/serialize/serializeSchema.ts +77 -10
- package/src/schemas/typeDefExtensions.ts +89 -5
- package/src/translate/FieldTranslationProvider.tsx +360 -0
- package/src/translate/getLanguageParams.ts +26 -0
- package/src/translate/languageStore.ts +18 -0
- package/src/translate/paths.test.ts +133 -0
- package/src/translate/paths.ts +175 -0
- package/src/translate/translateActions.tsx +188 -0
- package/src/translate/types.ts +160 -0
- package/src/types.ts +33 -12
- package/src/useApiClient.ts +130 -2
- package/src/assistLayout/AlphaMigration.tsx +0 -310
- package/src/legacy-types.ts +0 -72
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ArraySchemaType,
|
|
3
3
|
ImageOptions,
|
|
4
|
+
isArraySchemaType,
|
|
4
5
|
ObjectSchemaType,
|
|
6
|
+
ReferenceOptions,
|
|
5
7
|
ReferenceSchemaType,
|
|
6
8
|
Schema,
|
|
7
9
|
SchemaType,
|
|
8
10
|
typed,
|
|
9
11
|
} from 'sanity'
|
|
10
12
|
import {
|
|
13
|
+
assistSchemaIdPrefix,
|
|
11
14
|
assistSerializedFieldTypeName,
|
|
12
15
|
assistSerializedTypeName,
|
|
13
16
|
SerializedSchemaMember,
|
|
14
17
|
SerializedSchemaType,
|
|
15
|
-
assistSchemaIdPrefix,
|
|
16
18
|
} from '../../types'
|
|
17
19
|
import {hiddenTypes} from './schemaUtils'
|
|
18
20
|
import {isAssistSupported} from '../../helpers/assistSupported'
|
|
21
|
+
import {isType} from '../../helpers/typeUtils'
|
|
19
22
|
|
|
20
23
|
interface Options {
|
|
21
24
|
leanFormat?: boolean
|
|
@@ -31,7 +34,6 @@ export function serializeSchema(schema: Schema, options?: Options): SerializedSc
|
|
|
31
34
|
.filter((t): t is SchemaType => !!t)
|
|
32
35
|
// because a field can override exclude at the type level, we have to also serialize excluded types
|
|
33
36
|
// so don't do this: .filter((t) => isAssistSupported(t))
|
|
34
|
-
.filter((t) => !t.hidden && !t.readOnly)
|
|
35
37
|
.map((t) => getSchemaStub(t, schema, options))
|
|
36
38
|
.filter((t) => {
|
|
37
39
|
if ('to' in t && t.to && !t.to.length) {
|
|
@@ -78,13 +80,12 @@ function getBaseFields(
|
|
|
78
80
|
typeName: string,
|
|
79
81
|
options: Options | undefined
|
|
80
82
|
) {
|
|
81
|
-
const
|
|
83
|
+
const schemaOptions: SerializedSchemaType['options'] = removeUndef({
|
|
84
|
+
imagePromptField: (type.options as ImageOptions)?.aiAssist?.imageInstructionField,
|
|
85
|
+
embeddingsIndex: (type.options as ReferenceOptions)?.aiAssist?.embeddingsIndex,
|
|
86
|
+
})
|
|
82
87
|
return removeUndef({
|
|
83
|
-
options:
|
|
84
|
-
? {
|
|
85
|
-
imagePromptField: imagePromptField,
|
|
86
|
-
}
|
|
87
|
-
: undefined,
|
|
88
|
+
options: Object.keys(schemaOptions).length ? schemaOptions : undefined,
|
|
88
89
|
values: Array.isArray(type?.options?.list)
|
|
89
90
|
? type?.options?.list.map((v: string | {value: string; title: string}) =>
|
|
90
91
|
typeof v === 'string' ? v : v.value ?? `${v.title}`
|
|
@@ -99,6 +100,22 @@ function getBaseFields(
|
|
|
99
100
|
'fields' in type && inlineTypes.includes(typeName)
|
|
100
101
|
? serializeFields(schema, type, options)
|
|
101
102
|
: undefined,
|
|
103
|
+
annotations:
|
|
104
|
+
typeName === 'block' && 'fields' in type
|
|
105
|
+
? serializeAnnotations(type, schema, options)
|
|
106
|
+
: undefined,
|
|
107
|
+
inlineOf:
|
|
108
|
+
typeName === 'block' && 'fields' in type
|
|
109
|
+
? serializeInlineOf(type, schema, options)
|
|
110
|
+
: undefined,
|
|
111
|
+
hidden:
|
|
112
|
+
typeof type.hidden === 'function' ? ('function' as const) : type.hidden ? true : undefined,
|
|
113
|
+
readOnly:
|
|
114
|
+
typeof type.readOnly === 'function'
|
|
115
|
+
? ('function' as const)
|
|
116
|
+
: type.readOnly
|
|
117
|
+
? true
|
|
118
|
+
: undefined,
|
|
102
119
|
})
|
|
103
120
|
}
|
|
104
121
|
|
|
@@ -107,10 +124,27 @@ function serializeFields(
|
|
|
107
124
|
schemaType: ObjectSchemaType,
|
|
108
125
|
options: Options | undefined
|
|
109
126
|
) {
|
|
110
|
-
|
|
127
|
+
const fields = schemaType.fieldsets
|
|
128
|
+
? schemaType.fieldsets.flatMap((fs) =>
|
|
129
|
+
fs.single
|
|
130
|
+
? fs.field
|
|
131
|
+
: fs.fields.map((f) => ({
|
|
132
|
+
...f,
|
|
133
|
+
type: {
|
|
134
|
+
...f.type,
|
|
135
|
+
// if fieldset is (conditionally) hidden, the field must be considered the same way
|
|
136
|
+
// ie, if the field does not show up in conditionalMembers, it is hidden
|
|
137
|
+
// regardless of weather or not it is the field function or the fieldset function that hides it
|
|
138
|
+
hidden:
|
|
139
|
+
typeof fs.hidden === 'function' ? fs.hidden : fs.hidden ? true : f.type.hidden,
|
|
140
|
+
},
|
|
141
|
+
}))
|
|
142
|
+
)
|
|
143
|
+
: schemaType.fields
|
|
144
|
+
|
|
145
|
+
return fields
|
|
111
146
|
.filter((f) => !['sanity.imageHotspot', 'sanity.imageCrop'].includes(f.type?.name ?? ''))
|
|
112
147
|
.filter((f) => isAssistSupported(f.type))
|
|
113
|
-
.filter((f) => !f.type.hidden && !f.type.readOnly)
|
|
114
148
|
.map((field) => serializeMember(schema, field.type, field.name, options))
|
|
115
149
|
}
|
|
116
150
|
|
|
@@ -131,6 +165,39 @@ function serializeMember(
|
|
|
131
165
|
})
|
|
132
166
|
}
|
|
133
167
|
|
|
168
|
+
function serializeInlineOf(
|
|
169
|
+
blockSchemaType: ObjectSchemaType,
|
|
170
|
+
schema: Schema,
|
|
171
|
+
options: Options | undefined
|
|
172
|
+
): SerializedSchemaMember[] | undefined {
|
|
173
|
+
const childrenField = blockSchemaType.fields.find((f) => f.name === 'children')
|
|
174
|
+
const childrenType = childrenField?.type
|
|
175
|
+
if (!childrenType || !isArraySchemaType(childrenType)) {
|
|
176
|
+
return undefined
|
|
177
|
+
}
|
|
178
|
+
return arrayOf(
|
|
179
|
+
{
|
|
180
|
+
...childrenType,
|
|
181
|
+
of: childrenType.of.filter((t) => !isType(t, 'span')),
|
|
182
|
+
},
|
|
183
|
+
schema,
|
|
184
|
+
options
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function serializeAnnotations(
|
|
189
|
+
blockSchemaType: ObjectSchemaType,
|
|
190
|
+
schema: Schema,
|
|
191
|
+
options: Options | undefined
|
|
192
|
+
): SerializedSchemaMember[] | undefined {
|
|
193
|
+
const markDefs = blockSchemaType.fields.find((f) => f.name === 'markDefs')
|
|
194
|
+
const marksType = markDefs?.type
|
|
195
|
+
if (!marksType || !isArraySchemaType(marksType)) {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
return arrayOf(marksType, schema, options)
|
|
199
|
+
}
|
|
200
|
+
|
|
134
201
|
function arrayOf(
|
|
135
202
|
arrayType: ArraySchemaType,
|
|
136
203
|
schema: Schema,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/* eslint-disable no-unused-vars */
|
|
2
2
|
export interface AssistOptions {
|
|
3
|
-
|
|
3
|
+
aiAssist?: {
|
|
4
4
|
/** Set to true to disable assistance for this field or type */
|
|
5
5
|
exclude?: boolean
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Set to true to add translation field-action to the field.
|
|
9
|
+
* Only has an effect in document types configured for document or field level translations.
|
|
10
|
+
*/
|
|
11
|
+
translateAction?: boolean
|
|
6
12
|
}
|
|
7
13
|
}
|
|
8
14
|
|
|
@@ -16,13 +22,91 @@ declare module 'sanity' {
|
|
|
16
22
|
interface DocumentOptions extends AssistOptions {}
|
|
17
23
|
interface FileOptions extends AssistOptions {}
|
|
18
24
|
interface GeopointOptions extends AssistOptions {}
|
|
19
|
-
interface ImageOptions
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
interface ImageOptions {
|
|
26
|
+
aiAssist?: AssistOptions['aiAssist'] & {
|
|
27
|
+
/**
|
|
28
|
+
* When set, an image will be created whenever the `imageInstructionField` is written to by
|
|
29
|
+
* an AI Assist instruction.
|
|
30
|
+
*
|
|
31
|
+
* The value output by AI Assist will be use as an image prompt for an generative image AI.
|
|
32
|
+
*
|
|
33
|
+
* This means that instructions directly for the field or instructions that visit the field when running,
|
|
34
|
+
* will result in the image being changed.
|
|
35
|
+
*
|
|
36
|
+
* `imageInstructionField` must be a child-path relative to the image field, ie:
|
|
37
|
+
* * field
|
|
38
|
+
* * path.to.field
|
|
39
|
+
*
|
|
40
|
+
* ### Example
|
|
41
|
+
* ```ts
|
|
42
|
+
* defineType({
|
|
43
|
+
* type: 'image',
|
|
44
|
+
* name: 'articleImage',
|
|
45
|
+
* fields: [
|
|
46
|
+
* defineField({
|
|
47
|
+
* type: 'text',
|
|
48
|
+
* name: 'imagePrompt',
|
|
49
|
+
* title: 'Image prompt',
|
|
50
|
+
* rows: 2,
|
|
51
|
+
* }),
|
|
52
|
+
* ],
|
|
53
|
+
* options: {
|
|
54
|
+
* aiAssist: {
|
|
55
|
+
* imageInstructionField: 'imagePrompt',
|
|
56
|
+
* }
|
|
57
|
+
* },
|
|
58
|
+
* })
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
imageInstructionField?: string
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* When set, an image description will be automatically created for the image.
|
|
65
|
+
*
|
|
66
|
+
* `imageDescriptionField` must be a child-path relative to the image field, ie:
|
|
67
|
+
* * field
|
|
68
|
+
* * path.to.field
|
|
69
|
+
*
|
|
70
|
+
* Whenever the image asset for the field is changed in the Studio,
|
|
71
|
+
* an image description is generated and set into the `imageDescriptionField`.
|
|
72
|
+
*
|
|
73
|
+
* ### Example
|
|
74
|
+
* ```ts
|
|
75
|
+
* defineType({
|
|
76
|
+
* type: 'image',
|
|
77
|
+
* name: 'articleImage',
|
|
78
|
+
* fields: [
|
|
79
|
+
* defineField({
|
|
80
|
+
* type: 'string',
|
|
81
|
+
* name: 'altText',
|
|
82
|
+
* title: 'Alt text',
|
|
83
|
+
* }),
|
|
84
|
+
* ],
|
|
85
|
+
* options: {
|
|
86
|
+
* aiAssist: {
|
|
87
|
+
* imageDescriptionField: 'altText',
|
|
88
|
+
* }
|
|
89
|
+
* },
|
|
90
|
+
* })
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
imageDescriptionField?: string
|
|
94
|
+
}
|
|
22
95
|
}
|
|
23
96
|
interface NumberOptions extends AssistOptions {}
|
|
24
97
|
interface ObjectOptions extends AssistOptions {}
|
|
25
|
-
interface ReferenceBaseOptions
|
|
98
|
+
interface ReferenceBaseOptions {
|
|
99
|
+
aiAssist?: {
|
|
100
|
+
/** Set to true to disable assistance for this field or type */
|
|
101
|
+
exclude?: boolean
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* When set, the reference field will allow instructions to be added to it.
|
|
105
|
+
* Should be the name of the embeddings-index where assist will look for contextually relevant documents
|
|
106
|
+
* */
|
|
107
|
+
embeddingsIndex?: string
|
|
108
|
+
}
|
|
109
|
+
}
|
|
26
110
|
interface SlugOptions extends AssistOptions {}
|
|
27
111
|
interface StringOptions extends AssistOptions {}
|
|
28
112
|
interface TextOptions extends AssistOptions {}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
PropsWithChildren,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useId,
|
|
7
|
+
useMemo,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react'
|
|
10
|
+
import {ObjectSchemaType, Path, pathToString, SanityDocumentLike, useClient} from 'sanity'
|
|
11
|
+
import {useAiAssistanceConfig} from '../assistLayout/AiAssistanceConfigContext'
|
|
12
|
+
import {useApiClient, useTranslate} from '../useApiClient'
|
|
13
|
+
import {Box, Button, Checkbox, Dialog, Flex, Radio, Spinner, Stack, Text, Tooltip} from '@sanity/ui'
|
|
14
|
+
import {
|
|
15
|
+
defaultLanguageOutputs,
|
|
16
|
+
FieldLanguageMap,
|
|
17
|
+
getDocumentMembersFlat,
|
|
18
|
+
getFieldLanguageMap,
|
|
19
|
+
} from './paths'
|
|
20
|
+
import {PlayIcon} from '@sanity/icons'
|
|
21
|
+
import {Language} from './types'
|
|
22
|
+
import {getLanguageParams} from './getLanguageParams'
|
|
23
|
+
import {getPreferredToFieldLanguages, setPreferredToFieldLanguages} from './languageStore'
|
|
24
|
+
import {ConditionalMemberState} from '../helpers/conditionalMembers'
|
|
25
|
+
|
|
26
|
+
interface FieldTranslationParams {
|
|
27
|
+
document: SanityDocumentLike
|
|
28
|
+
documentSchema: ObjectSchemaType
|
|
29
|
+
translatePath: Path
|
|
30
|
+
conditionalMembers: ConditionalMemberState[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FieldTranslationContextValue {
|
|
34
|
+
openFieldTranslation: (args: FieldTranslationParams) => void
|
|
35
|
+
translationLoading: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const FieldTranslationContext = createContext<FieldTranslationContextValue>({
|
|
39
|
+
openFieldTranslation: () => {},
|
|
40
|
+
translationLoading: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export function useFieldTranslation() {
|
|
44
|
+
return useContext(FieldTranslationContext)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasValuesToTranslate(
|
|
48
|
+
fieldLanguageMaps: FieldLanguageMap[] | undefined,
|
|
49
|
+
fromLanguage: Language | undefined,
|
|
50
|
+
basePath: Path
|
|
51
|
+
) {
|
|
52
|
+
return fieldLanguageMaps?.some(
|
|
53
|
+
(map) =>
|
|
54
|
+
map.inputLanguageId === fromLanguage?.id &&
|
|
55
|
+
map.inputPath &&
|
|
56
|
+
pathToString(map.inputPath).startsWith(pathToString(basePath))
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function FieldTranslationProvider(props: PropsWithChildren<{}>) {
|
|
61
|
+
const {config: assistConfig} = useAiAssistanceConfig()
|
|
62
|
+
const apiClient = useApiClient(assistConfig.__customApiClient)
|
|
63
|
+
const config = assistConfig.translate?.field
|
|
64
|
+
const {translate: runTranslate} = useTranslate(apiClient)
|
|
65
|
+
|
|
66
|
+
const [dialogOpen, setDialogOpen] = useState(false)
|
|
67
|
+
|
|
68
|
+
const [fieldTranslationParams, setFieldTranslationParams] = useState<
|
|
69
|
+
FieldTranslationParams | undefined
|
|
70
|
+
>()
|
|
71
|
+
const [languages, setLanguages] = useState<Language[] | undefined>()
|
|
72
|
+
const [fromLanguage, setFromLanguage] = useState<Language | undefined>(undefined)
|
|
73
|
+
const [toLanguages, setToLanguages] = useState<Language[] | undefined>(undefined)
|
|
74
|
+
const [fieldLanguageMaps, setFieldLanguageMaps] = useState<FieldLanguageMap[] | undefined>()
|
|
75
|
+
|
|
76
|
+
const close = useCallback(() => {
|
|
77
|
+
setDialogOpen(false)
|
|
78
|
+
setLanguages(undefined)
|
|
79
|
+
setFieldTranslationParams(undefined)
|
|
80
|
+
}, [])
|
|
81
|
+
const languageClient = useClient({apiVersion: config?.apiVersion ?? '2022-11-27'})
|
|
82
|
+
const documentId = fieldTranslationParams?.document?._id
|
|
83
|
+
const id = useId()
|
|
84
|
+
|
|
85
|
+
const selectFromLanguage = useCallback(
|
|
86
|
+
(
|
|
87
|
+
from: Language,
|
|
88
|
+
languages: Language[] | undefined,
|
|
89
|
+
params: FieldTranslationParams | undefined
|
|
90
|
+
) => {
|
|
91
|
+
const {document, documentSchema} = params ?? {}
|
|
92
|
+
setFromLanguage(from)
|
|
93
|
+
if (!document || !documentSchema || !params || !languages) {
|
|
94
|
+
setFieldLanguageMaps(undefined)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const preferred = getPreferredToFieldLanguages(from.id)
|
|
99
|
+
const allToLanguages = languages.filter((l) => l.id !== from?.id)
|
|
100
|
+
const filteredToLanguages = allToLanguages.filter(
|
|
101
|
+
(l) => !preferred.length || preferred.includes(l.id)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
setToLanguages(filteredToLanguages)
|
|
105
|
+
const fromId = from?.id
|
|
106
|
+
const allToIds = allToLanguages?.map((l) => l.id) ?? []
|
|
107
|
+
const docMembers = getDocumentMembersFlat(document, documentSchema)
|
|
108
|
+
if (fromId && allToIds?.length) {
|
|
109
|
+
const transMap = getFieldLanguageMap(
|
|
110
|
+
documentSchema,
|
|
111
|
+
docMembers,
|
|
112
|
+
fromId,
|
|
113
|
+
allToIds.filter((toId) => fromId !== toId),
|
|
114
|
+
config?.translationOutputs ?? defaultLanguageOutputs
|
|
115
|
+
)
|
|
116
|
+
setFieldLanguageMaps(transMap)
|
|
117
|
+
} else {
|
|
118
|
+
setFieldLanguageMaps(undefined)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[config]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const toggleToLanguage = useCallback(
|
|
125
|
+
(
|
|
126
|
+
toggledLang: Language,
|
|
127
|
+
toLanguages: Language[] | undefined,
|
|
128
|
+
languages: Language[] | undefined
|
|
129
|
+
) => {
|
|
130
|
+
if (!languages || !fromLanguage) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const wasSelected = !!toLanguages?.find((l) => l.id === toggledLang.id)
|
|
134
|
+
const newToLangs = languages.filter(
|
|
135
|
+
(anyLang) =>
|
|
136
|
+
!!toLanguages?.find(
|
|
137
|
+
(selectedLang) => toggledLang.id !== selectedLang.id && selectedLang.id === anyLang.id
|
|
138
|
+
) ||
|
|
139
|
+
(toggledLang.id === anyLang.id && !wasSelected)
|
|
140
|
+
)
|
|
141
|
+
setToLanguages(newToLangs)
|
|
142
|
+
setPreferredToFieldLanguages(
|
|
143
|
+
fromLanguage.id,
|
|
144
|
+
newToLangs.map((l) => l.id)
|
|
145
|
+
)
|
|
146
|
+
},
|
|
147
|
+
[fromLanguage]
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
const openFieldTranslation = useCallback(
|
|
151
|
+
async (params: FieldTranslationParams) => {
|
|
152
|
+
setDialogOpen(true)
|
|
153
|
+
const languageParams = getLanguageParams(config?.selectLanguageParams, params.document)
|
|
154
|
+
const languages: Language[] | undefined = await (typeof config?.languages === 'function'
|
|
155
|
+
? config?.languages(languageClient, languageParams)
|
|
156
|
+
: Promise.resolve(config?.languages))
|
|
157
|
+
setLanguages(languages)
|
|
158
|
+
setFieldTranslationParams(params)
|
|
159
|
+
const fromLanguage = languages?.[0]
|
|
160
|
+
if (fromLanguage) {
|
|
161
|
+
selectFromLanguage(fromLanguage, languages, params)
|
|
162
|
+
} else {
|
|
163
|
+
console.error('No languages available for selected language params', languageParams)
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[selectFromLanguage, config, languageClient]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const contextValue: FieldTranslationContextValue = useMemo(() => {
|
|
170
|
+
return {
|
|
171
|
+
openFieldTranslation,
|
|
172
|
+
translationLoading: false,
|
|
173
|
+
}
|
|
174
|
+
}, [openFieldTranslation])
|
|
175
|
+
|
|
176
|
+
const runDisabled =
|
|
177
|
+
!fromLanguage ||
|
|
178
|
+
!toLanguages?.length ||
|
|
179
|
+
!fieldLanguageMaps?.length ||
|
|
180
|
+
!documentId ||
|
|
181
|
+
!hasValuesToTranslate(fieldLanguageMaps, fromLanguage, fieldTranslationParams.translatePath)
|
|
182
|
+
|
|
183
|
+
const onRunTranslation = useCallback(() => {
|
|
184
|
+
const translatePath = fieldTranslationParams?.translatePath
|
|
185
|
+
if (fieldLanguageMaps && documentId && translatePath) {
|
|
186
|
+
runTranslate({
|
|
187
|
+
documentId,
|
|
188
|
+
translatePath: translatePath,
|
|
189
|
+
fieldLanguageMap: fieldLanguageMaps.map((map) => ({
|
|
190
|
+
...map,
|
|
191
|
+
// eslint-disable-next-line max-nested-callbacks
|
|
192
|
+
outputs: map.outputs.filter((out) => !!toLanguages?.find((l) => l.id === out.id)),
|
|
193
|
+
})),
|
|
194
|
+
conditionalMembers: fieldTranslationParams?.conditionalMembers,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
close()
|
|
198
|
+
}, [
|
|
199
|
+
fieldLanguageMaps,
|
|
200
|
+
documentId,
|
|
201
|
+
runTranslate,
|
|
202
|
+
close,
|
|
203
|
+
toLanguages,
|
|
204
|
+
fieldTranslationParams?.translatePath,
|
|
205
|
+
fieldTranslationParams?.conditionalMembers,
|
|
206
|
+
])
|
|
207
|
+
|
|
208
|
+
const runButton = (
|
|
209
|
+
<Button
|
|
210
|
+
text={`Translate`}
|
|
211
|
+
tone="primary"
|
|
212
|
+
icon={PlayIcon}
|
|
213
|
+
style={{width: '100%'}}
|
|
214
|
+
disabled={runDisabled}
|
|
215
|
+
onClick={onRunTranslation}
|
|
216
|
+
/>
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<FieldTranslationContext.Provider value={contextValue}>
|
|
221
|
+
{dialogOpen ? (
|
|
222
|
+
<Dialog
|
|
223
|
+
id={id}
|
|
224
|
+
width={1}
|
|
225
|
+
open={dialogOpen}
|
|
226
|
+
onClose={close}
|
|
227
|
+
header="Translate fields"
|
|
228
|
+
footer={
|
|
229
|
+
<Flex justify="space-between" padding={2} flex={1}>
|
|
230
|
+
{runDisabled ? (
|
|
231
|
+
<Tooltip
|
|
232
|
+
content={
|
|
233
|
+
<Flex padding={2}>
|
|
234
|
+
<Text>There is nothing to translate in the selected from-language.</Text>
|
|
235
|
+
</Flex>
|
|
236
|
+
}
|
|
237
|
+
placement="top"
|
|
238
|
+
>
|
|
239
|
+
<Flex flex={1}>{runButton}</Flex>
|
|
240
|
+
</Tooltip>
|
|
241
|
+
) : (
|
|
242
|
+
runButton
|
|
243
|
+
)}
|
|
244
|
+
</Flex>
|
|
245
|
+
}
|
|
246
|
+
>
|
|
247
|
+
{languages ? (
|
|
248
|
+
<Flex padding={4} gap={5} align="flex-start" justify="center">
|
|
249
|
+
<Stack space={2}>
|
|
250
|
+
<Box marginBottom={2}>
|
|
251
|
+
<Text weight="semibold">From</Text>
|
|
252
|
+
</Box>
|
|
253
|
+
{languages?.map((radioLanguage) => (
|
|
254
|
+
<FromLanguageRadio
|
|
255
|
+
key={radioLanguage.id}
|
|
256
|
+
{...{
|
|
257
|
+
radioLanguage,
|
|
258
|
+
fromLanguage,
|
|
259
|
+
selectFromLanguage,
|
|
260
|
+
languages,
|
|
261
|
+
fieldTranslationParams,
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
))}
|
|
265
|
+
</Stack>
|
|
266
|
+
|
|
267
|
+
<Stack space={2}>
|
|
268
|
+
<Box marginBottom={2}>
|
|
269
|
+
<Text weight="semibold">To</Text>
|
|
270
|
+
</Box>
|
|
271
|
+
{languages.map((checkboxLanguage) => (
|
|
272
|
+
<ToLanguageCheckbox
|
|
273
|
+
key={checkboxLanguage.id}
|
|
274
|
+
{...{checkboxLanguage, fromLanguage, toLanguages, toggleToLanguage, languages}}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
</Stack>
|
|
278
|
+
</Flex>
|
|
279
|
+
) : (
|
|
280
|
+
<Flex padding={4} gap={2} align="flex-start" justify="center">
|
|
281
|
+
<Box>
|
|
282
|
+
<Spinner />
|
|
283
|
+
</Box>
|
|
284
|
+
<Text>Loading languages...</Text>
|
|
285
|
+
</Flex>
|
|
286
|
+
)}
|
|
287
|
+
</Dialog>
|
|
288
|
+
) : null}
|
|
289
|
+
{props.children}
|
|
290
|
+
</FieldTranslationContext.Provider>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function ToLanguageCheckbox(props: {
|
|
295
|
+
checkboxLanguage: Language
|
|
296
|
+
fromLanguage: Language | undefined
|
|
297
|
+
toLanguages: Language[] | undefined
|
|
298
|
+
toggleToLanguage: (
|
|
299
|
+
toggledLang: Language,
|
|
300
|
+
toLanguages: Language[] | undefined,
|
|
301
|
+
languages: Language[] | undefined
|
|
302
|
+
) => void
|
|
303
|
+
languages: Language[]
|
|
304
|
+
}) {
|
|
305
|
+
const {checkboxLanguage, fromLanguage, toLanguages, toggleToLanguage, languages} = props
|
|
306
|
+
const langId = checkboxLanguage.id
|
|
307
|
+
const onChange = useCallback(
|
|
308
|
+
() => toggleToLanguage(checkboxLanguage, toLanguages, languages),
|
|
309
|
+
[toggleToLanguage, checkboxLanguage, toLanguages, languages]
|
|
310
|
+
)
|
|
311
|
+
return (
|
|
312
|
+
<Flex
|
|
313
|
+
key={langId}
|
|
314
|
+
gap={3}
|
|
315
|
+
align="center"
|
|
316
|
+
as={'label'}
|
|
317
|
+
style={langId === fromLanguage?.id ? {opacity: 0.5} : undefined}
|
|
318
|
+
>
|
|
319
|
+
<Checkbox
|
|
320
|
+
name="toLang"
|
|
321
|
+
value={langId}
|
|
322
|
+
checked={langId !== fromLanguage?.id && !!toLanguages?.find((tl) => tl.id === langId)}
|
|
323
|
+
onChange={onChange}
|
|
324
|
+
disabled={langId === fromLanguage?.id}
|
|
325
|
+
/>
|
|
326
|
+
<Text muted={langId === fromLanguage?.id}>{checkboxLanguage.title ?? langId}</Text>
|
|
327
|
+
</Flex>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function FromLanguageRadio(props: {
|
|
332
|
+
radioLanguage: Language
|
|
333
|
+
fromLanguage: Language | undefined
|
|
334
|
+
selectFromLanguage: (
|
|
335
|
+
from: Language,
|
|
336
|
+
languages: Language[] | undefined,
|
|
337
|
+
params: FieldTranslationParams | undefined
|
|
338
|
+
) => void
|
|
339
|
+
languages: Language[] | undefined
|
|
340
|
+
fieldTranslationParams: FieldTranslationParams | undefined
|
|
341
|
+
}) {
|
|
342
|
+
const {languages, radioLanguage, selectFromLanguage, fromLanguage, fieldTranslationParams} = props
|
|
343
|
+
const langId = radioLanguage.id
|
|
344
|
+
|
|
345
|
+
const onChange = useCallback(
|
|
346
|
+
() => selectFromLanguage(radioLanguage, languages, fieldTranslationParams),
|
|
347
|
+
[selectFromLanguage, radioLanguage, languages, fieldTranslationParams]
|
|
348
|
+
)
|
|
349
|
+
return (
|
|
350
|
+
<Flex key={langId} gap={3} align="center" as={'label'}>
|
|
351
|
+
<Radio
|
|
352
|
+
name="fromLang"
|
|
353
|
+
value={langId}
|
|
354
|
+
checked={langId === fromLanguage?.id}
|
|
355
|
+
onChange={onChange}
|
|
356
|
+
/>
|
|
357
|
+
<Text>{radioLanguage.title ?? radioLanguage.id}</Text>
|
|
358
|
+
</Flex>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {SanityDocumentLike} from 'sanity'
|
|
2
|
+
import get from 'lodash/get'
|
|
3
|
+
|
|
4
|
+
export const getLanguageParams = (
|
|
5
|
+
select: Record<string, string> | undefined,
|
|
6
|
+
document: SanityDocumentLike | undefined
|
|
7
|
+
): Record<string, unknown> => {
|
|
8
|
+
if (!select || !document) {
|
|
9
|
+
return {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const selection: Record<string, string> = select || {}
|
|
13
|
+
const selectedValue: Record<string, unknown> = {}
|
|
14
|
+
for (const [key, path] of Object.entries(selection)) {
|
|
15
|
+
let value = get(document, path)
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
// If there are references in the array, ensure they have `_ref` set, otherwise they are considered empty and can safely be ignored
|
|
18
|
+
value = value.filter((item) =>
|
|
19
|
+
typeof item === 'object' ? item?._type !== 'reference' || '_ref' in item : true
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
selectedValue[key] = value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return selectedValue
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export const toFieldLanguagesKeyPrefix = 'sanityStudio:assist:field-languages:from:'
|
|
2
|
+
|
|
3
|
+
export function getPreferredToFieldLanguages(fromLanguageId: string): string[] {
|
|
4
|
+
if (typeof localStorage === 'undefined') {
|
|
5
|
+
return []
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const value = localStorage.getItem(`${toFieldLanguagesKeyPrefix}${fromLanguageId}`)
|
|
9
|
+
return value ? (JSON.parse(value) as string[]) : []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setPreferredToFieldLanguages(fromLanguageId: string, languageIds: string[]) {
|
|
13
|
+
if (typeof localStorage === 'undefined') {
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
localStorage.setItem(`${toFieldLanguagesKeyPrefix}${fromLanguageId}`, JSON.stringify(languageIds))
|
|
18
|
+
}
|