sanity-plugin-internationalized-array 1.7.0 → 1.9.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-plugin-internationalized-array",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Store localized fields in an array to save on attributes",
5
5
  "keywords": [
6
6
  "sanity",
@@ -83,7 +83,7 @@
83
83
  "react-dom": "^18",
84
84
  "react-is": "^18",
85
85
  "rimraf": "^4.1.2",
86
- "sanity": "^3.3.1",
86
+ "sanity": "^3.14.1",
87
87
  "semantic-release": "^20.1.0",
88
88
  "typescript": "^4.9.5"
89
89
  },
@@ -0,0 +1,39 @@
1
+ import {AddIcon} from '@sanity/icons'
2
+ import {Button, Grid} from '@sanity/ui'
3
+ import React from 'react'
4
+
5
+ import {MAX_COLUMNS} from '../constants'
6
+ import {Language, Value} from '../types'
7
+
8
+ type AddButtonsProps = {
9
+ languages: Language[]
10
+ readOnly: boolean
11
+ value: Value[] | undefined
12
+ onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
13
+ }
14
+
15
+ export default function AddButtons(props: AddButtonsProps) {
16
+ const {languages, readOnly, value, onClick} = props
17
+
18
+ return languages.length > 0 ? (
19
+ <Grid columns={Math.min(languages.length, MAX_COLUMNS)} gap={2}>
20
+ {languages.map((language) => (
21
+ <Button
22
+ key={language.id}
23
+ tone="primary"
24
+ mode="ghost"
25
+ fontSize={1}
26
+ disabled={
27
+ readOnly ||
28
+ Boolean(value?.find((item) => item._key === language.id))
29
+ }
30
+ text={language.id.toUpperCase()}
31
+ // Only show plus icon if there's one row or less
32
+ icon={languages.length > MAX_COLUMNS ? undefined : AddIcon}
33
+ value={language.id}
34
+ onClick={onClick}
35
+ />
36
+ ))}
37
+ </Grid>
38
+ ) : null
39
+ }
@@ -0,0 +1,112 @@
1
+ import {Box, Stack, Text, useToast} from '@sanity/ui'
2
+ import React, {useCallback, useMemo} from 'react'
3
+ import {
4
+ insert,
5
+ isSanityDocument,
6
+ ObjectSchemaType,
7
+ PatchEvent,
8
+ setIfMissing,
9
+ } from 'sanity'
10
+ import {useDocumentPane} from 'sanity/desk'
11
+
12
+ import {createValueSchemaTypeName} from '../utils/createValueSchemaTypeName'
13
+ import AddButtons from './AddButtons'
14
+ import {useInternationalizedArrayContext} from './InternationalizedArrayContext'
15
+
16
+ type DocumentAddButtonsProps = {
17
+ schemaType: ObjectSchemaType
18
+ value: Record<string, any> | undefined
19
+ }
20
+
21
+ export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
22
+ const {filteredLanguages} = useInternationalizedArrayContext()
23
+ const {fields} = props.schemaType
24
+ const value = isSanityDocument(props.value) ? props.value : undefined
25
+
26
+ const toast = useToast()
27
+ const {onChange} = useDocumentPane()
28
+
29
+ // Find every internationalizedArray field at the document root
30
+ // TODO: This should be a recursive search through nested fields
31
+ const internationalizedArrayFields = useMemo(
32
+ () =>
33
+ fields.filter((field) =>
34
+ field.type.name.startsWith('internationalizedArray')
35
+ ),
36
+ [fields]
37
+ )
38
+
39
+ const handleDocumentButtonClick = useCallback(
40
+ (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
41
+ const languageId = event.currentTarget.value
42
+ if (!languageId) {
43
+ toast.push({
44
+ status: 'error',
45
+ title: 'No language selected',
46
+ })
47
+ return
48
+ }
49
+
50
+ if (internationalizedArrayFields.length === 0) {
51
+ toast.push({
52
+ status: 'error',
53
+ title: 'No internationalizedArray fields found in document root',
54
+ })
55
+ return
56
+ }
57
+
58
+ // Find every internationalizedArray field that is empty for the selected language
59
+ const emptyLanguageFields = internationalizedArrayFields.filter(
60
+ (field) => {
61
+ const fieldValue = value?.[field.name]
62
+ const fieldValueLanguage =
63
+ fieldValue && Array.isArray(fieldValue)
64
+ ? fieldValue.find((v) => v._key === languageId)
65
+ : undefined
66
+
67
+ return !fieldValueLanguage
68
+ }
69
+ )
70
+
71
+ // Write a new patch for each empty field
72
+ const patches = emptyLanguageFields
73
+ .map((field) => {
74
+ const fieldKey = field.name
75
+
76
+ return [
77
+ setIfMissing([], [fieldKey]),
78
+ insert(
79
+ [
80
+ {
81
+ _key: languageId,
82
+ _type: createValueSchemaTypeName(field.type),
83
+ },
84
+ ],
85
+ 'after',
86
+ [fieldKey, -1]
87
+ ),
88
+ ]
89
+ })
90
+ .flat()
91
+
92
+ onChange(PatchEvent.from(patches))
93
+ },
94
+ [internationalizedArrayFields, onChange, toast, value]
95
+ )
96
+
97
+ return (
98
+ <Stack space={3}>
99
+ <Box>
100
+ <Text size={1} weight="semibold">
101
+ Add translation to internationalized fields
102
+ </Text>
103
+ </Box>
104
+ <AddButtons
105
+ languages={filteredLanguages}
106
+ readOnly={false}
107
+ value={undefined}
108
+ onClick={handleDocumentButtonClick}
109
+ />
110
+ </Stack>
111
+ )
112
+ }
@@ -1,31 +1,28 @@
1
1
  import {AddIcon} from '@sanity/icons'
2
2
  import {useLanguageFilterStudioContext} from '@sanity/language-filter'
3
- import {Button, Grid, Stack, useToast} from '@sanity/ui'
4
- import equal from 'fast-deep-equal'
5
- import React, {useCallback, useDeferredValue, useEffect, useMemo} from 'react'
3
+ import {Button, Card, Grid, Stack, Text, useToast} from '@sanity/ui'
4
+ import React, {useCallback, useEffect, useMemo} from 'react'
6
5
  import {
7
6
  ArrayOfObjectsInputProps,
8
7
  ArrayOfObjectsItem,
9
- insert,
8
+ ArraySchemaType,
10
9
  set,
11
10
  setIfMissing,
12
- useClient,
13
- useFormBuilder,
14
11
  useFormValue,
15
12
  } from 'sanity'
16
- import {suspend} from 'suspend-react'
17
13
 
18
- import {namespace, version} from '../cache'
19
14
  import {MAX_COLUMNS} from '../constants'
20
- import type {ArraySchemaWithLanguageOptions, Value} from '../types'
15
+ import type {Value} from '../types'
16
+ import {checkAllLanguagesArePresent} from '../utils/checkAllLanguagesArePresent'
17
+ import {createAddAllTitle} from '../utils/createAddAllTitle'
18
+ import {createAddLanguagePatches} from '../utils/createAddLanguagePatches'
19
+ import AddButtons from './AddButtons'
21
20
  import Feedback from './Feedback'
22
- import {getSelectedValue} from './getSelectedValue'
23
- // TODO: Move this provider to the root component
24
- import {LanguageProvider} from './languageContext'
21
+ import {useInternationalizedArrayContext} from './InternationalizedArrayContext'
25
22
 
26
23
  export type InternationalizedArrayProps = ArrayOfObjectsInputProps<
27
24
  Value,
28
- ArraySchemaWithLanguageOptions
25
+ ArraySchemaType
29
26
  >
30
27
 
31
28
  export default function InternationalizedArray(
@@ -35,30 +32,15 @@ export default function InternationalizedArray(
35
32
 
36
33
  const readOnly =
37
34
  typeof schemaType.readOnly === 'boolean' ? schemaType.readOnly : false
38
- const {options} = schemaType
39
35
  const toast = useToast()
40
- const {value: document} = useFormBuilder()
41
- const deferredDocument = useDeferredValue(document)
42
- const selectedValue = useMemo(
43
- () => getSelectedValue(options.select, deferredDocument),
44
- [options.select, deferredDocument]
45
- )
46
36
 
47
- const {apiVersion, defaultLanguages} = options
48
- const client = useClient({apiVersion})
49
- const languages = Array.isArray(options.languages)
50
- ? options.languages
51
- : suspend(
52
- // eslint-disable-next-line require-await
53
- async () => {
54
- if (typeof options.languages === 'function') {
55
- return options.languages(client, selectedValue)
56
- }
57
- return options.languages
58
- },
59
- [version, namespace, selectedValue],
60
- {equal}
61
- )
37
+ const {
38
+ languages,
39
+ filteredLanguages,
40
+ defaultLanguages,
41
+ buttonAddAll,
42
+ buttonLocations,
43
+ } = useInternationalizedArrayContext()
62
44
 
63
45
  // Support updating the UI if languageFilter is installed
64
46
  const {selectedLanguageIds, options: languageFilterOptions} =
@@ -96,74 +78,27 @@ export default function InternationalizedArray(
96
78
  [languageFilterEnabled, members, languageFilterOptions, selectedLanguageIds]
97
79
  )
98
80
 
99
- const filteredLanguages = useMemo(
100
- () =>
101
- languageFilterEnabled
102
- ? languages.filter((language) =>
103
- selectedLanguageIds.includes(language.id)
104
- )
105
- : languages,
106
- [languageFilterEnabled, languages, selectedLanguageIds]
107
- )
108
-
109
81
  const handleAddLanguage = useCallback(
110
82
  (param?: React.MouseEvent<HTMLButtonElement, MouseEvent> | string[]) => {
111
83
  if (!filteredLanguages?.length) {
112
84
  return
113
85
  }
114
86
 
115
- const languageIds: string[] = Array.isArray(param)
87
+ const addLanguageKeys: string[] = Array.isArray(param)
116
88
  ? param
117
89
  : ([param?.currentTarget?.value].filter(Boolean) as string[])
118
- const itemBase = {_type: `${schemaType.name}Value`}
119
-
120
- // Create new items
121
- const newItems =
122
- Array.isArray(languageIds) && languageIds.length > 0
123
- ? // Just one for this language
124
- languageIds.map((id) => ({...itemBase, _key: id}))
125
- : // Or one for every missing language
126
- filteredLanguages
127
- .filter((language) =>
128
- value?.length
129
- ? !value.find((v) => v._key === language.id)
130
- : true
131
- )
132
- .map((language) => ({...itemBase, _key: language.id}))
133
-
134
- // Insert new items in the correct order
135
- const languagesInUse = value?.length ? value.map((v) => v) : []
136
-
137
- const insertions = newItems.map((item) => {
138
- // What's the original index of this language?
139
- const languageIndex = languages.findIndex((l) => item._key === l.id)
140
-
141
- // What languages are there beyond that index?
142
- const remainingLanguages = languages.slice(languageIndex + 1)
143
90
 
144
- // So what is the index in the current value array of the next language in the language array?
145
- const nextLanguageIndex = languagesInUse.findIndex((l) =>
146
- // eslint-disable-next-line max-nested-callbacks
147
- remainingLanguages.find((r) => r.id === l._key)
148
- )
149
-
150
- // Keep local state up to date incase multiple insertions are being made
151
- if (nextLanguageIndex < 0) {
152
- languagesInUse.push(item)
153
- } else {
154
- languagesInUse.splice(nextLanguageIndex, 0, item)
155
- }
156
-
157
- return nextLanguageIndex < 0
158
- ? // No next language (-1), add to end of array
159
- insert([item], 'after', [nextLanguageIndex])
160
- : // Next language found, insert before that
161
- insert([item], 'before', [nextLanguageIndex])
91
+ const patches = createAddLanguagePatches({
92
+ addLanguageKeys,
93
+ schemaType,
94
+ languages,
95
+ filteredLanguages,
96
+ value,
162
97
  })
163
98
 
164
- onChange([setIfMissing([]), ...insertions])
99
+ onChange([setIfMissing([]), ...patches])
165
100
  },
166
- [filteredLanguages, onChange, schemaType.name, value]
101
+ [filteredLanguages, languages, onChange, schemaType, value]
167
102
  )
168
103
 
169
104
  // Create default fields if the document is not yet created
@@ -255,100 +190,76 @@ export default function InternationalizedArray(
255
190
  }, [languagesOutOfOrder, allKeysAreLanguages, handleRestoreOrder])
256
191
 
257
192
  // compare value keys with possible languages
258
- const allLanguagesArePresent = useMemo(() => {
259
- const filteredLanguageIds = filteredLanguages.map((l) => l.id)
260
- const languagesInUseIds = value ? value.map((v) => v._key) : []
261
-
262
- return (
263
- languagesInUseIds.length === filteredLanguageIds.length &&
264
- languagesInUseIds.every((l) => filteredLanguageIds.includes(l))
265
- )
266
- }, [filteredLanguages, value])
193
+ const allLanguagesArePresent = useMemo(
194
+ () => checkAllLanguagesArePresent(filteredLanguages, value),
195
+ [filteredLanguages, value]
196
+ )
267
197
 
268
198
  if (!languagesAreValid) {
269
199
  return <Feedback />
270
200
  }
271
201
 
202
+ const addButtonsAreVisible =
203
+ // Plugin was configured to display buttons here (default!)
204
+ buttonLocations.includes('field') &&
205
+ // There's at least one language visible
206
+ filteredLanguages?.length > 0 &&
207
+ // Not every language has a value yet
208
+ !allLanguagesArePresent
209
+ const fieldHasMembers = members?.length > 0
210
+
272
211
  return (
273
- <LanguageProvider value={{languages: filteredLanguages}}>
274
- <Stack space={2}>
275
- {members?.length > 0 ? (
276
- <>
277
- {filteredMembers.map((member) => {
278
- if (member.kind === 'item') {
279
- return (
280
- <ArrayOfObjectsItem
281
- key={member.key}
282
- member={member}
283
- renderItem={props.renderItem}
284
- renderField={props.renderField}
285
- renderInput={props.renderInput}
286
- renderPreview={props.renderPreview}
287
- />
288
- )
289
- }
290
-
291
- return null
292
- })}
293
- </>
294
- ) : null}
295
-
296
- {/* Show buttons if languages are configured */}
297
- {/* Hide them if all languages have values */}
298
- {filteredLanguages?.length > 0 && !allLanguagesArePresent ? (
299
- <Stack space={2}>
300
- {/* Hide language-specific buttons if there's only one */}
301
- {/* No more than 7 columns */}
302
- {filteredLanguages.length > 1 ? (
303
- <Grid
304
- columns={Math.min(filteredLanguages.length, MAX_COLUMNS)}
305
- gap={2}
306
- >
307
- {filteredLanguages.map((language) => (
308
- <Button
309
- key={language.id}
310
- tone="primary"
311
- mode="ghost"
312
- fontSize={1}
313
- disabled={
314
- readOnly ||
315
- Boolean(value?.find((item) => item._key === language.id))
316
- }
317
- text={language.id.toUpperCase()}
318
- // Only show plus icon if there's one row or less
319
- icon={
320
- filteredLanguages.length > MAX_COLUMNS
321
- ? undefined
322
- : AddIcon
323
- }
324
- value={language.id}
325
- onClick={handleAddLanguage}
326
- />
327
- ))}
328
- </Grid>
329
- ) : null}
212
+ <Stack space={2}>
213
+ {fieldHasMembers ? (
214
+ <>
215
+ {filteredMembers.map((member) => {
216
+ if (member.kind === 'item') {
217
+ return (
218
+ <ArrayOfObjectsItem
219
+ key={member.key}
220
+ member={member}
221
+ renderItem={props.renderItem}
222
+ renderField={props.renderField}
223
+ renderInput={props.renderInput}
224
+ renderPreview={props.renderPreview}
225
+ />
226
+ )
227
+ }
228
+
229
+ return null
230
+ })}
231
+ </>
232
+ ) : null}
233
+
234
+ {/* Give some feedback in the UI so the field doesn't look "missing" */}
235
+ {!addButtonsAreVisible && !fieldHasMembers ? (
236
+ <Card border tone="transparent" padding={3} radius={2}>
237
+ <Text size={1}>
238
+ This internationalized field currently has no translations.
239
+ </Text>
240
+ </Card>
241
+ ) : null}
242
+
243
+ {addButtonsAreVisible ? (
244
+ <Stack space={2}>
245
+ <AddButtons
246
+ languages={filteredLanguages}
247
+ value={value}
248
+ readOnly={readOnly}
249
+ onClick={handleAddLanguage}
250
+ />
251
+ {buttonAddAll ? (
330
252
  <Button
331
253
  tone="primary"
332
254
  mode="ghost"
333
255
  disabled={readOnly || allLanguagesArePresent}
334
256
  icon={AddIcon}
335
- text={
336
- // eslint-disable-next-line no-nested-ternary
337
- value?.length
338
- ? `Add missing ${
339
- filteredLanguages.length - value.length === 1
340
- ? `language`
341
- : `languages`
342
- }`
343
- : filteredLanguages.length === 1
344
- ? `Add ${filteredLanguages[0].title} Field`
345
- : `Add all languages`
346
- }
257
+ text={createAddAllTitle(value, filteredLanguages)}
347
258
  onClick={handleAddLanguage}
348
259
  />
349
- </Stack>
350
- ) : null}
351
- </Stack>
352
- </LanguageProvider>
260
+ ) : null}
261
+ </Stack>
262
+ ) : null}
263
+ </Stack>
353
264
  )
354
265
  }
@@ -0,0 +1,106 @@
1
+ import {useLanguageFilterStudioContext} from '@sanity/language-filter'
2
+ import {Stack} from '@sanity/ui'
3
+ import equal from 'fast-deep-equal'
4
+ import {createContext, useContext, useDeferredValue, useMemo} from 'react'
5
+ import {ObjectInputProps, useClient, useFormBuilder} from 'sanity'
6
+ import {suspend} from 'suspend-react'
7
+
8
+ import {namespace, version} from '../cache'
9
+ import {CONFIG_DEFAULT} from '../constants'
10
+ import {Language, PluginConfig} from '../types'
11
+ import DocumentAddButtons from './DocumentAddButtons'
12
+ import {getSelectedValue} from './getSelectedValue'
13
+
14
+ // This provider makes the plugin config available to all components in the document form
15
+ // But with languages resolved and filtered languages updated base on @sanity/language-filter
16
+
17
+ type InternationalizedArrayContextProps = Required<PluginConfig> & {
18
+ languages: Language[]
19
+ filteredLanguages: Language[]
20
+ }
21
+
22
+ export const InternationalizedArrayContext =
23
+ createContext<InternationalizedArrayContextProps>({
24
+ ...CONFIG_DEFAULT,
25
+ languages: [],
26
+ filteredLanguages: [],
27
+ })
28
+
29
+ export function useInternationalizedArrayContext() {
30
+ return useContext(InternationalizedArrayContext)
31
+ }
32
+
33
+ type InternationalizedArrayProviderProps = ObjectInputProps & {
34
+ internationalizedArray: Required<PluginConfig>
35
+ }
36
+
37
+ export function InternationalizedArrayProvider(
38
+ props: InternationalizedArrayProviderProps
39
+ ) {
40
+ const {internationalizedArray} = props
41
+
42
+ const client = useClient({apiVersion: internationalizedArray.apiVersion})
43
+ const {value: document} = useFormBuilder()
44
+ const deferredDocument = useDeferredValue(document)
45
+ const selectedValue = useMemo(
46
+ () => getSelectedValue(internationalizedArray.select, deferredDocument),
47
+ [internationalizedArray.select, deferredDocument]
48
+ )
49
+
50
+ // Fetch or return languages
51
+ const languages = Array.isArray(internationalizedArray.languages)
52
+ ? internationalizedArray.languages
53
+ : suspend(
54
+ // eslint-disable-next-line require-await
55
+ async () => {
56
+ if (typeof internationalizedArray.languages === 'function') {
57
+ return internationalizedArray.languages(client, selectedValue)
58
+ }
59
+ return internationalizedArray.languages
60
+ },
61
+ [version, namespace],
62
+ {equal}
63
+ )
64
+
65
+ // Filter out some languages if language filter is enabled
66
+ const {selectedLanguageIds, options: languageFilterOptions} =
67
+ useLanguageFilterStudioContext()
68
+
69
+ const filteredLanguages = useMemo(() => {
70
+ const documentType = deferredDocument ? deferredDocument._type : undefined
71
+ const languageFilterEnabled =
72
+ typeof documentType === 'string' &&
73
+ languageFilterOptions.documentTypes.includes(documentType)
74
+
75
+ return languageFilterEnabled
76
+ ? languages.filter((language) =>
77
+ selectedLanguageIds.includes(language.id)
78
+ )
79
+ : languages
80
+ }, [deferredDocument, languageFilterOptions, languages, selectedLanguageIds])
81
+
82
+ const showDocumentButtons =
83
+ internationalizedArray.buttonLocations.includes('document')
84
+
85
+ return (
86
+ <InternationalizedArrayContext.Provider
87
+ value={{
88
+ ...internationalizedArray,
89
+ languages,
90
+ filteredLanguages,
91
+ }}
92
+ >
93
+ {showDocumentButtons ? (
94
+ <Stack space={5}>
95
+ <DocumentAddButtons
96
+ schemaType={props.schemaType}
97
+ value={props.value}
98
+ />
99
+ {props.renderDefault(props)}
100
+ </Stack>
101
+ ) : (
102
+ props.renderDefault(props)
103
+ )}
104
+ </InternationalizedArrayContext.Provider>
105
+ )
106
+ }
@@ -10,12 +10,12 @@ import {
10
10
  Spinner,
11
11
  Stack,
12
12
  } from '@sanity/ui'
13
- import React, {useCallback, useContext, useMemo} from 'react'
13
+ import React, {useCallback, useMemo} from 'react'
14
14
  import {ObjectItemProps, useFormValue} from 'sanity'
15
15
  import {set, unset} from 'sanity'
16
16
 
17
17
  import {getToneFromValidation} from './getToneFromValidation'
18
- import {LanguageContext} from './languageContext'
18
+ import {useInternationalizedArrayContext} from './InternationalizedArrayContext'
19
19
 
20
20
  type InternationalizedValue = {
21
21
  _type: string
@@ -44,7 +44,7 @@ export default function InternationalizedInput(
44
44
  const {validation, value, onChange, readOnly} = inlineProps
45
45
 
46
46
  // The parent array contains the languages from the plugin config
47
- const {languages} = useContext(LanguageContext)
47
+ const {languages} = useInternationalizedArrayContext()
48
48
 
49
49
  const languageKeysInUse = useMemo(
50
50
  () => parentValue?.map((v) => v._key) ?? [],
package/src/constants.ts CHANGED
@@ -1 +1,13 @@
1
+ import {PluginConfig} from './types'
2
+
1
3
  export const MAX_COLUMNS = 7
4
+
5
+ export const CONFIG_DEFAULT: Required<PluginConfig> = {
6
+ languages: [],
7
+ select: {},
8
+ defaultLanguages: [],
9
+ fieldTypes: [],
10
+ apiVersion: '2022-11-27',
11
+ buttonLocations: ['field'],
12
+ buttonAddAll: true,
13
+ }