@sanity/assist 1.2.16 → 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.
Files changed (52) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +551 -30
  3. package/dist/index.cjs.mjs +1 -0
  4. package/dist/index.d.ts +333 -9
  5. package/dist/index.esm.js +2463 -390
  6. package/dist/index.esm.js.map +1 -1
  7. package/dist/index.js +2457 -383
  8. package/dist/index.js.map +1 -1
  9. package/package.json +12 -11
  10. package/src/_lib/form/DocumentForm.tsx +2 -1
  11. package/src/_lib/form/constants.ts +1 -0
  12. package/src/assistDocument/AssistDocumentInput.tsx +24 -4
  13. package/src/assistDocument/RequestRunInstructionProvider.tsx +37 -21
  14. package/src/assistDocument/components/AssistDocumentForm.tsx +65 -21
  15. package/src/assistDocument/components/instruction/InstructionInput.tsx +5 -4
  16. package/src/assistDocument/components/instruction/InstructionOutputField.tsx +45 -0
  17. package/src/assistDocument/components/instruction/InstructionOutputInput.tsx +205 -0
  18. package/src/assistDocument/hooks/useStudioAssistDocument.ts +5 -32
  19. package/src/assistFormComponents/AssistField.tsx +11 -5
  20. package/src/assistFormComponents/AssistFormBlock.tsx +2 -3
  21. package/src/assistFormComponents/validation/listItem.tsx +2 -2
  22. package/src/assistInspector/AssistInspector.tsx +6 -0
  23. package/src/assistInspector/FieldAutocomplete.tsx +1 -0
  24. package/src/assistInspector/helpers.ts +9 -11
  25. package/src/assistLayout/AssistLayout.tsx +9 -9
  26. package/src/components/ImageContext.tsx +30 -13
  27. package/src/components/SafeValueInput.tsx +4 -1
  28. package/src/fieldActions/assistFieldActions.tsx +42 -13
  29. package/src/fieldActions/generateCaptionActions.tsx +17 -6
  30. package/src/fieldActions/generateImageActions.tsx +57 -0
  31. package/src/helpers/assistSupported.ts +10 -16
  32. package/src/helpers/conditionalMembers.test.ts +200 -0
  33. package/src/helpers/conditionalMembers.ts +127 -0
  34. package/src/helpers/misc.ts +8 -4
  35. package/src/helpers/typeUtils.ts +19 -5
  36. package/src/index.ts +3 -0
  37. package/src/plugin.tsx +18 -4
  38. package/src/schemas/assistDocumentSchema.tsx +40 -1
  39. package/src/schemas/serialize/serializeSchema.test.ts +239 -8
  40. package/src/schemas/serialize/serializeSchema.ts +77 -10
  41. package/src/schemas/typeDefExtensions.ts +89 -5
  42. package/src/translate/FieldTranslationProvider.tsx +360 -0
  43. package/src/translate/getLanguageParams.ts +26 -0
  44. package/src/translate/languageStore.ts +18 -0
  45. package/src/translate/paths.test.ts +133 -0
  46. package/src/translate/paths.ts +175 -0
  47. package/src/translate/translateActions.tsx +188 -0
  48. package/src/translate/types.ts +160 -0
  49. package/src/types.ts +67 -15
  50. package/src/useApiClient.ts +134 -2
  51. package/src/assistLayout/AlphaMigration.tsx +0 -310
  52. package/src/legacy-types.ts +0 -72
@@ -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
+ }
@@ -0,0 +1,133 @@
1
+ import {describe, expect, test} from 'vitest'
2
+ import {Schema} from '@sanity/schema'
3
+ import {defineType, ObjectSchemaType, pathToString, SanityDocumentLike, typed} from 'sanity'
4
+ import {
5
+ defaultLanguageOutputs,
6
+ FieldLanguageMap,
7
+ getDocumentMembersFlat,
8
+ getFieldLanguageMap,
9
+ } from './paths'
10
+
11
+ describe('paths', () => {
12
+ test('should return internationalizedArrayString paths and find translation mappings', () => {
13
+ const docSchema: ObjectSchemaType = Schema.compile({
14
+ name: 'test',
15
+ types: [
16
+ defineType({
17
+ type: 'document',
18
+ name: 'article',
19
+ fields: [
20
+ {type: 'string', name: 'title'},
21
+ {
22
+ type: 'object',
23
+ name: 'localeTitle',
24
+ fields: [
25
+ {type: 'string', name: 'en'},
26
+ {type: 'string', name: 'no'},
27
+ ],
28
+ },
29
+ {
30
+ type: 'array',
31
+ name: 'translations',
32
+ of: [
33
+ {
34
+ type: 'object',
35
+ name: 'internationalizedArrayString',
36
+ fields: [{type: 'string', name: 'value'}],
37
+ },
38
+ ],
39
+ },
40
+ ],
41
+ }),
42
+ ],
43
+ }).get('article')
44
+
45
+ const doc: SanityDocumentLike = {
46
+ _id: 'na',
47
+ _type: 'article',
48
+ title: 'some title',
49
+ localeTitle: {
50
+ en: 'en string',
51
+ },
52
+ translations: [
53
+ {
54
+ _type: 'internationalizedArrayString',
55
+ _key: 'en',
56
+ value: 'some string',
57
+ },
58
+ ],
59
+ }
60
+
61
+ const members = getDocumentMembersFlat(doc, docSchema)
62
+ expect(members.map((p) => pathToString(p.path))).toEqual([
63
+ 'title',
64
+ 'localeTitle',
65
+ 'localeTitle.en',
66
+ // this path has no value in the document, so are not included
67
+ //'localeTitle.no',
68
+ 'translations',
69
+ 'translations[_key=="en"]',
70
+ 'translations[_key=="en"].value',
71
+ // these path has no value in the document, so are not included
72
+ //'translations[_key=="nb"]',
73
+ //'translations[_key=="nb"].value',
74
+ ])
75
+
76
+ const transMap = getFieldLanguageMap(docSchema, members, 'en', ['nb'], defaultLanguageOutputs)
77
+
78
+ expect(transMap).toEqual(
79
+ typed<FieldLanguageMap[]>([
80
+ {
81
+ inputLanguageId: 'en',
82
+ inputPath: ['translations', {_key: 'en'}],
83
+ outputs: [{id: 'nb', outputPath: ['translations', {_key: 'nb'}]}],
84
+ },
85
+ ])
86
+ )
87
+ })
88
+
89
+ test('should use first type in array when array item is missing _type', () => {
90
+ const docSchema: ObjectSchemaType = Schema.compile({
91
+ name: 'test',
92
+ types: [
93
+ defineType({
94
+ type: 'document',
95
+ name: 'article',
96
+ fields: [
97
+ {
98
+ type: 'array',
99
+ name: 'translations',
100
+ of: [
101
+ {
102
+ type: 'object',
103
+ name: 'internationalizedArrayString',
104
+ fields: [{type: 'string', name: 'value'}],
105
+ },
106
+ ],
107
+ },
108
+ ],
109
+ }),
110
+ ],
111
+ }).get('article')
112
+
113
+ const doc: SanityDocumentLike = {
114
+ _id: 'na',
115
+ _type: 'article',
116
+ translations: [
117
+ {
118
+ //assume type is missing in the data for some reason
119
+ //_type: 'internationalizedArrayString',
120
+ _key: 'en',
121
+ value: 'some string',
122
+ },
123
+ ],
124
+ }
125
+
126
+ const members = getDocumentMembersFlat(doc, docSchema)
127
+ expect(members.map((p) => pathToString(p.path))).toEqual([
128
+ 'translations',
129
+ 'translations[_key=="en"]',
130
+ 'translations[_key=="en"].value',
131
+ ])
132
+ })
133
+ })