sanity-plugin-internationalized-array 1.7.0 → 1.8.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.8.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
  },
@@ -1,31 +1,27 @@
1
1
  import {AddIcon} from '@sanity/icons'
2
2
  import {useLanguageFilterStudioContext} from '@sanity/language-filter'
3
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'
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'
21
19
  import Feedback from './Feedback'
22
- import {getSelectedValue} from './getSelectedValue'
23
- // TODO: Move this provider to the root component
24
- import {LanguageProvider} from './languageContext'
20
+ import {useInternationalizedArrayContext} from './InternationalizedArrayContext'
25
21
 
26
22
  export type InternationalizedArrayProps = ArrayOfObjectsInputProps<
27
23
  Value,
28
- ArraySchemaWithLanguageOptions
24
+ ArraySchemaType
29
25
  >
30
26
 
31
27
  export default function InternationalizedArray(
@@ -35,30 +31,15 @@ export default function InternationalizedArray(
35
31
 
36
32
  const readOnly =
37
33
  typeof schemaType.readOnly === 'boolean' ? schemaType.readOnly : false
38
- const {options} = schemaType
39
34
  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
35
 
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
- )
36
+ const {
37
+ languages,
38
+ filteredLanguages,
39
+ defaultLanguages,
40
+ buttonAddAll,
41
+ buttonLocations,
42
+ } = useInternationalizedArrayContext()
62
43
 
63
44
  // Support updating the UI if languageFilter is installed
64
45
  const {selectedLanguageIds, options: languageFilterOptions} =
@@ -96,74 +77,27 @@ export default function InternationalizedArray(
96
77
  [languageFilterEnabled, members, languageFilterOptions, selectedLanguageIds]
97
78
  )
98
79
 
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
80
  const handleAddLanguage = useCallback(
110
81
  (param?: React.MouseEvent<HTMLButtonElement, MouseEvent> | string[]) => {
111
82
  if (!filteredLanguages?.length) {
112
83
  return
113
84
  }
114
85
 
115
- const languageIds: string[] = Array.isArray(param)
86
+ const addLanguageKeys: string[] = Array.isArray(param)
116
87
  ? param
117
88
  : ([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
89
 
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])
90
+ const patches = createAddLanguagePatches({
91
+ addLanguageKeys,
92
+ schemaType,
93
+ languages,
94
+ filteredLanguages,
95
+ value,
162
96
  })
163
97
 
164
- onChange([setIfMissing([]), ...insertions])
98
+ onChange([setIfMissing([]), ...patches])
165
99
  },
166
- [filteredLanguages, onChange, schemaType.name, value]
100
+ [filteredLanguages, languages, onChange, schemaType, value]
167
101
  )
168
102
 
169
103
  // Create default fields if the document is not yet created
@@ -255,100 +189,88 @@ export default function InternationalizedArray(
255
189
  }, [languagesOutOfOrder, allKeysAreLanguages, handleRestoreOrder])
256
190
 
257
191
  // 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])
192
+ const allLanguagesArePresent = useMemo(
193
+ () => checkAllLanguagesArePresent(filteredLanguages, value),
194
+ [filteredLanguages, value]
195
+ )
267
196
 
268
197
  if (!languagesAreValid) {
269
198
  return <Feedback />
270
199
  }
271
200
 
201
+ const addButtonsAreVisible =
202
+ // Plugin was configured to display buttons here (default!)
203
+ buttonLocations.includes('field') &&
204
+ // There's at least one language visible
205
+ filteredLanguages?.length > 0 &&
206
+ // Not every language has a value yet
207
+ !allLanguagesArePresent
208
+
272
209
  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}
210
+ <Stack space={2}>
211
+ {members?.length > 0 ? (
212
+ <>
213
+ {filteredMembers.map((member) => {
214
+ if (member.kind === 'item') {
215
+ return (
216
+ <ArrayOfObjectsItem
217
+ key={member.key}
218
+ member={member}
219
+ renderItem={props.renderItem}
220
+ renderField={props.renderField}
221
+ renderInput={props.renderInput}
222
+ renderPreview={props.renderPreview}
223
+ />
224
+ )
225
+ }
226
+
227
+ return null
228
+ })}
229
+ </>
230
+ ) : null}
231
+
232
+ {addButtonsAreVisible ? (
233
+ <Stack space={2}>
234
+ {/* Hide language-specific buttons if there's only one */}
235
+ {/* No more than 7 columns */}
236
+ {filteredLanguages.length > 1 ? (
237
+ <Grid
238
+ columns={Math.min(filteredLanguages.length, MAX_COLUMNS)}
239
+ gap={2}
240
+ >
241
+ {filteredLanguages.map((language) => (
242
+ <Button
243
+ key={language.id}
244
+ tone="primary"
245
+ mode="ghost"
246
+ fontSize={1}
247
+ disabled={
248
+ readOnly ||
249
+ Boolean(value?.find((item) => item._key === language.id))
250
+ }
251
+ text={language.id.toUpperCase()}
252
+ // Only show plus icon if there's one row or less
253
+ icon={
254
+ filteredLanguages.length > MAX_COLUMNS ? undefined : AddIcon
255
+ }
256
+ value={language.id}
257
+ onClick={handleAddLanguage}
258
+ />
259
+ ))}
260
+ </Grid>
261
+ ) : null}
262
+ {buttonAddAll ? (
330
263
  <Button
331
264
  tone="primary"
332
265
  mode="ghost"
333
266
  disabled={readOnly || allLanguagesArePresent}
334
267
  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
- }
268
+ text={createAddAllTitle(value, filteredLanguages)}
347
269
  onClick={handleAddLanguage}
348
270
  />
349
- </Stack>
350
- ) : null}
351
- </Stack>
352
- </LanguageProvider>
271
+ ) : null}
272
+ </Stack>
273
+ ) : null}
274
+ </Stack>
353
275
  )
354
276
  }
@@ -0,0 +1,91 @@
1
+ import {useLanguageFilterStudioContext} from '@sanity/language-filter'
2
+ import equal from 'fast-deep-equal'
3
+ import {createContext, useContext, useDeferredValue, useMemo} from 'react'
4
+ import {ObjectInputProps, useClient, useFormBuilder} from 'sanity'
5
+ import {suspend} from 'suspend-react'
6
+
7
+ import {namespace, version} from '../cache'
8
+ import {CONFIG_DEFAULT} from '../constants'
9
+ import {Language, PluginConfig} from '../types'
10
+ import {getSelectedValue} from './getSelectedValue'
11
+
12
+ // This provider makes the plugin config available to all components in the document form
13
+ // But with languages resolved and filtered languages updated base on @sanity/language-filter
14
+
15
+ type InternationalizedArrayContextProps = Required<PluginConfig> & {
16
+ languages: Language[]
17
+ filteredLanguages: Language[]
18
+ }
19
+
20
+ export const InternationalizedArrayContext =
21
+ createContext<InternationalizedArrayContextProps>({
22
+ ...CONFIG_DEFAULT,
23
+ languages: [],
24
+ filteredLanguages: [],
25
+ })
26
+
27
+ export function useInternationalizedArrayContext() {
28
+ return useContext(InternationalizedArrayContext)
29
+ }
30
+
31
+ type InternationalizedArrayProviderProps = ObjectInputProps & {
32
+ internationalizedArray: Required<PluginConfig>
33
+ }
34
+
35
+ export function InternationalizedArrayProvider(
36
+ props: InternationalizedArrayProviderProps
37
+ ) {
38
+ const {internationalizedArray} = props
39
+
40
+ const client = useClient({apiVersion: internationalizedArray.apiVersion})
41
+ const {value: document} = useFormBuilder()
42
+ const deferredDocument = useDeferredValue(document)
43
+ const selectedValue = useMemo(
44
+ () => getSelectedValue(internationalizedArray.select, deferredDocument),
45
+ [internationalizedArray.select, deferredDocument]
46
+ )
47
+
48
+ // Fetch or return languages
49
+ const languages = Array.isArray(internationalizedArray.languages)
50
+ ? internationalizedArray.languages
51
+ : suspend(
52
+ // eslint-disable-next-line require-await
53
+ async () => {
54
+ if (typeof internationalizedArray.languages === 'function') {
55
+ return internationalizedArray.languages(client, selectedValue)
56
+ }
57
+ return internationalizedArray.languages
58
+ },
59
+ [version, namespace],
60
+ {equal}
61
+ )
62
+
63
+ // Filter out some languages if language filter is enabled
64
+ const {selectedLanguageIds, options: languageFilterOptions} =
65
+ useLanguageFilterStudioContext()
66
+
67
+ const filteredLanguages = useMemo(() => {
68
+ const documentType = deferredDocument ? deferredDocument._type : undefined
69
+ const languageFilterEnabled =
70
+ typeof documentType === 'string' &&
71
+ languageFilterOptions.documentTypes.includes(documentType)
72
+
73
+ return languageFilterEnabled
74
+ ? languages.filter((language) =>
75
+ selectedLanguageIds.includes(language.id)
76
+ )
77
+ : languages
78
+ }, [deferredDocument, languageFilterOptions, languages, selectedLanguageIds])
79
+
80
+ return (
81
+ <InternationalizedArrayContext.Provider
82
+ value={{
83
+ ...internationalizedArray,
84
+ languages,
85
+ filteredLanguages,
86
+ }}
87
+ >
88
+ {props.renderDefault(props)}
89
+ </InternationalizedArrayContext.Provider>
90
+ )
91
+ }
@@ -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
+ }
@@ -0,0 +1,132 @@
1
+ import {AddIcon, TranslateIcon} from '@sanity/icons'
2
+ import {useCallback} from 'react'
3
+ import {
4
+ defineDocumentFieldAction,
5
+ DocumentFieldActionItem,
6
+ DocumentFieldActionProps,
7
+ PatchEvent,
8
+ setIfMissing,
9
+ useFormValue,
10
+ } from 'sanity'
11
+ import {useDocumentPane} from 'sanity/desk'
12
+
13
+ import {useInternationalizedArrayContext} from '../components/InternationalizedArrayContext'
14
+ import {Language, Value} from '../types'
15
+ import {checkAllLanguagesArePresent} from '../utils/checkAllLanguagesArePresent'
16
+ import {createAddAllTitle} from '../utils/createAddAllTitle'
17
+ import {createAddLanguagePatches} from '../utils/createAddLanguagePatches'
18
+
19
+ const createTranslateFieldActions: (
20
+ fieldActionProps: DocumentFieldActionProps,
21
+ context: {languages: Language[]; filteredLanguages: Language[]}
22
+ ) => DocumentFieldActionItem[] = (
23
+ fieldActionProps,
24
+ {languages, filteredLanguages}
25
+ ) =>
26
+ languages.map((language) => {
27
+ const value = useFormValue(fieldActionProps.path) as Value[]
28
+ const disabled =
29
+ value && Array.isArray(value)
30
+ ? Boolean(value?.find((item) => item._key === language.id))
31
+ : true
32
+ const hidden = !filteredLanguages.some((f) => f.id === language.id)
33
+
34
+ const {onChange} = useDocumentPane()
35
+
36
+ const onAction = useCallback(() => {
37
+ const {schemaType, path} = fieldActionProps
38
+
39
+ const addLanguageKeys = [language.id]
40
+ const patches = createAddLanguagePatches({
41
+ addLanguageKeys,
42
+ schemaType,
43
+ languages,
44
+ filteredLanguages,
45
+ value,
46
+ path,
47
+ })
48
+
49
+ onChange(PatchEvent.from([setIfMissing([], path), ...patches]))
50
+ }, [language.id, value, onChange])
51
+
52
+ return {
53
+ type: 'action',
54
+ icon: AddIcon,
55
+ onAction,
56
+ title: language.id.toLocaleUpperCase(),
57
+ hidden,
58
+ disabled,
59
+ }
60
+ })
61
+
62
+ const AddMissingTranslationsFieldAction: (
63
+ fieldActionProps: DocumentFieldActionProps,
64
+ context: {languages: Language[]; filteredLanguages: Language[]}
65
+ ) => DocumentFieldActionItem = (
66
+ fieldActionProps,
67
+ {languages, filteredLanguages}
68
+ ) => {
69
+ const value = useFormValue(fieldActionProps.path) as Value[]
70
+ const disabled = value.length === filteredLanguages.length
71
+ const hidden = checkAllLanguagesArePresent(filteredLanguages, value)
72
+
73
+ const {onChange} = useDocumentPane()
74
+
75
+ const onAction = useCallback(() => {
76
+ const {schemaType, path} = fieldActionProps
77
+
78
+ const addLanguageKeys: string[] = []
79
+ const patches = createAddLanguagePatches({
80
+ addLanguageKeys,
81
+ schemaType,
82
+ languages,
83
+ filteredLanguages,
84
+ value,
85
+ path,
86
+ })
87
+
88
+ onChange(PatchEvent.from([setIfMissing([], path), ...patches]))
89
+ }, [fieldActionProps, filteredLanguages, languages, onChange, value])
90
+
91
+ return {
92
+ type: 'action',
93
+ icon: AddIcon,
94
+ onAction,
95
+ title: createAddAllTitle(value, filteredLanguages),
96
+ disabled,
97
+ hidden,
98
+ }
99
+ }
100
+
101
+ export const internationalizedArrayFieldAction = defineDocumentFieldAction({
102
+ name: 'internationalizedArray',
103
+ useAction(fieldActionProps) {
104
+ const isInternationalizedArrayField =
105
+ fieldActionProps?.schemaType?.type?.name.startsWith(
106
+ 'internationalizedArray'
107
+ )
108
+ const {languages, filteredLanguages} = useInternationalizedArrayContext()
109
+
110
+ const translateFieldActions = createTranslateFieldActions(
111
+ fieldActionProps,
112
+ {languages, filteredLanguages}
113
+ )
114
+
115
+ return {
116
+ type: 'group',
117
+ icon: TranslateIcon,
118
+ title: 'Add Translation',
119
+ renderAsButton: true,
120
+ children: isInternationalizedArrayField
121
+ ? [
122
+ ...translateFieldActions,
123
+ AddMissingTranslationsFieldAction(fieldActionProps, {
124
+ languages,
125
+ filteredLanguages,
126
+ }),
127
+ ]
128
+ : [],
129
+ hidden: !isInternationalizedArrayField,
130
+ }
131
+ },
132
+ })