sanity-plugin-internationalized-array 1.6.2 → 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.6.2",
3
+ "version": "1.8.0",
4
4
  "description": "Store localized fields in an array to save on attributes",
5
5
  "keywords": [
6
6
  "sanity",
@@ -51,6 +51,7 @@
51
51
  "dependencies": {
52
52
  "@sanity/icons": "^2.2.2",
53
53
  "@sanity/incompatible-plugin": "^1.0.4",
54
+ "@sanity/language-filter": "^3.1.2",
54
55
  "@sanity/ui": "^1.2.2",
55
56
  "fast-deep-equal": "^3.1.3",
56
57
  "lodash.get": "^4.4.2",
@@ -82,7 +83,7 @@
82
83
  "react-dom": "^18",
83
84
  "react-is": "^18",
84
85
  "rimraf": "^4.1.2",
85
- "sanity": "^3.3.1",
86
+ "sanity": "^3.14.1",
86
87
  "semantic-release": "^20.1.0",
87
88
  "typescript": "^4.9.5"
88
89
  },
@@ -97,7 +98,8 @@
97
98
  },
98
99
  "sanityPlugin": {
99
100
  "verifyPackage": {
100
- "eslintImports": false
101
+ "eslintImports": false,
102
+ "dependencies": false
101
103
  }
102
104
  }
103
105
  }
@@ -1,117 +1,121 @@
1
1
  import {AddIcon} from '@sanity/icons'
2
+ import {useLanguageFilterStudioContext} from '@sanity/language-filter'
2
3
  import {Button, Grid, Stack, useToast} from '@sanity/ui'
3
- import equal from 'fast-deep-equal'
4
- import {useCallback, useDeferredValue, useEffect, useMemo} from 'react'
4
+ import React, {useCallback, useEffect, useMemo} from 'react'
5
5
  import {
6
6
  ArrayOfObjectsInputProps,
7
7
  ArrayOfObjectsItem,
8
- ArrayOfObjectsItemMember,
9
- insert,
8
+ ArraySchemaType,
10
9
  set,
11
10
  setIfMissing,
12
- useClient,
13
- useFormBuilder,
11
+ useFormValue,
14
12
  } from 'sanity'
15
- import {suspend} from 'suspend-react'
16
13
 
17
- import {namespace, version} from '../cache'
18
- import type {ArraySchemaWithLanguageOptions, Value} from '../types'
14
+ import {MAX_COLUMNS} from '../constants'
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
19
  import Feedback from './Feedback'
20
- import {getSelectedValue} from './getSelectedValue'
21
- // TODO: Move this provider to the root component
22
- import {LanguageProvider} from './languageContext'
20
+ import {useInternationalizedArrayContext} from './InternationalizedArrayContext'
23
21
 
24
22
  export type InternationalizedArrayProps = ArrayOfObjectsInputProps<
25
23
  Value,
26
- ArraySchemaWithLanguageOptions
24
+ ArraySchemaType
27
25
  >
28
26
 
29
27
  export default function InternationalizedArray(
30
28
  props: InternationalizedArrayProps
31
29
  ) {
32
30
  const {members, value, schemaType, onChange} = props
31
+
33
32
  const readOnly =
34
33
  typeof schemaType.readOnly === 'boolean' ? schemaType.readOnly : false
35
- const {options} = schemaType
36
34
  const toast = useToast()
37
- const {value: document} = useFormBuilder()
38
- const deferredDocument = useDeferredValue(document)
39
- const selectedValue = useMemo(
40
- () => getSelectedValue(options.select, deferredDocument),
41
- [options.select, deferredDocument]
42
- )
43
35
 
44
- const {apiVersion} = options
45
- const client = useClient({apiVersion})
46
- const languages = Array.isArray(options.languages)
47
- ? options.languages
48
- : suspend(
49
- // eslint-disable-next-line require-await
50
- async () => {
51
- if (typeof options.languages === 'function') {
52
- return options.languages(client, selectedValue)
53
- }
54
- return options.languages
55
- },
56
- [version, namespace, selectedValue],
57
- {equal}
58
- )
36
+ const {
37
+ languages,
38
+ filteredLanguages,
39
+ defaultLanguages,
40
+ buttonAddAll,
41
+ buttonLocations,
42
+ } = useInternationalizedArrayContext()
43
+
44
+ // Support updating the UI if languageFilter is installed
45
+ const {selectedLanguageIds, options: languageFilterOptions} =
46
+ useLanguageFilterStudioContext()
47
+ const documentType = useFormValue(['_type'])
48
+ const languageFilterEnabled =
49
+ typeof documentType === 'string' &&
50
+ languageFilterOptions.documentTypes.includes(documentType)
51
+
52
+ const filteredMembers = useMemo(
53
+ () =>
54
+ languageFilterEnabled
55
+ ? members.filter((member) => {
56
+ // This member is the outer object created by the plugin
57
+ // Satisfy TS
58
+ if (member.kind !== 'item') {
59
+ return false
60
+ }
61
+
62
+ // This is the inner "value" field member created by this plugin
63
+ const valueMember = member.item.members[0]
64
+
65
+ // Satisfy TS
66
+ if (valueMember.kind !== 'field') {
67
+ return false
68
+ }
69
+
70
+ return languageFilterOptions.filterField(
71
+ member.item.schemaType,
72
+ valueMember,
73
+ selectedLanguageIds
74
+ )
75
+ })
76
+ : members,
77
+ [languageFilterEnabled, members, languageFilterOptions, selectedLanguageIds]
78
+ )
59
79
 
60
80
  const handleAddLanguage = useCallback(
61
- (languageId?: string) => {
62
- if (!languages?.length) {
81
+ (param?: React.MouseEvent<HTMLButtonElement, MouseEvent> | string[]) => {
82
+ if (!filteredLanguages?.length) {
63
83
  return
64
84
  }
65
85
 
66
- const itemBase = {_type: `${schemaType.name}Value`}
67
-
68
- // Create new items
69
- const newItems = languageId
70
- ? // Just one for this language
71
- [{...itemBase, _key: languageId}]
72
- : // Or one for every missing language
73
- languages
74
- .filter((language) =>
75
- value?.length ? !value.find((v) => v._key === language.id) : true
76
- )
77
- .map((language) => ({...itemBase, _key: language.id}))
78
-
79
- // Insert new items in the correct order
80
- const languagesInUse = value?.length ? value.map((v) => v) : []
81
-
82
- const insertions = newItems.map((item) => {
83
- // What's the original index of this language?
84
- const languageIndex = languages.findIndex((l) => item._key === l.id)
86
+ const addLanguageKeys: string[] = Array.isArray(param)
87
+ ? param
88
+ : ([param?.currentTarget?.value].filter(Boolean) as string[])
85
89
 
86
- // What languages are there beyond that index?
87
- const remainingLanguages = languages.slice(languageIndex + 1)
88
-
89
- // So what is the index in the current value array of the next language in the language array?
90
- const nextLanguageIndex = languagesInUse.findIndex((l) =>
91
- // eslint-disable-next-line max-nested-callbacks
92
- remainingLanguages.find((r) => r.id === l._key)
93
- )
94
-
95
- // Keep local state up to date incase multiple insertions are being made
96
- if (nextLanguageIndex < 0) {
97
- languagesInUse.push(item)
98
- } else {
99
- languagesInUse.splice(nextLanguageIndex, 0, item)
100
- }
101
-
102
- return nextLanguageIndex < 0
103
- ? // No next language (-1), add to end of array
104
- insert([item], 'after', [nextLanguageIndex])
105
- : // Next language found, insert before that
106
- insert([item], 'before', [nextLanguageIndex])
90
+ const patches = createAddLanguagePatches({
91
+ addLanguageKeys,
92
+ schemaType,
93
+ languages,
94
+ filteredLanguages,
95
+ value,
107
96
  })
108
97
 
109
- onChange([setIfMissing([]), ...insertions])
98
+ onChange([setIfMissing([]), ...patches])
110
99
  },
111
- [languages, onChange, schemaType.name, value]
100
+ [filteredLanguages, languages, onChange, schemaType, value]
112
101
  )
113
102
 
114
- // TODO: This is lazy, reordering and re-setting the whole array it could be surgical
103
+ // Create default fields if the document is not yet created
104
+ const documentCreatedAt = useFormValue(['_createdAt'])
105
+
106
+ if (
107
+ // Array field is empty
108
+ !value &&
109
+ // Document form is in "not yet created" state
110
+ !documentCreatedAt &&
111
+ // Plugin config included default languages
112
+ defaultLanguages &&
113
+ defaultLanguages?.length > 0
114
+ ) {
115
+ handleAddLanguage(defaultLanguages)
116
+ }
117
+
118
+ // TODO: This is reordering and re-setting the whole array, it could be surgical
115
119
  const handleRestoreOrder = useCallback(() => {
116
120
  if (!value?.length || !languages?.length) {
117
121
  return
@@ -177,101 +181,96 @@ export default function InternationalizedArray(
177
181
  [languages]
178
182
  )
179
183
 
184
+ // Automatically restore order of fields
180
185
  useEffect(() => {
181
186
  if (languagesOutOfOrder.length > 0 && allKeysAreLanguages) {
182
187
  handleRestoreOrder()
183
188
  }
184
189
  }, [languagesOutOfOrder, allKeysAreLanguages, handleRestoreOrder])
185
190
 
191
+ // compare value keys with possible languages
192
+ const allLanguagesArePresent = useMemo(
193
+ () => checkAllLanguagesArePresent(filteredLanguages, value),
194
+ [filteredLanguages, value]
195
+ )
196
+
186
197
  if (!languagesAreValid) {
187
198
  return <Feedback />
188
199
  }
189
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
+
190
209
  return (
191
- <LanguageProvider value={{languages}}>
192
- <Stack space={2}>
193
- {members?.length > 0 ? (
194
- <>
195
- {/* TODO: Resolve type for ArrayOfObjectsItemMember */}
196
- {/* @ts-ignore */}
197
- {members.map((member: ArrayOfObjectsItemMember) => {
198
- if (member.kind === 'item') {
199
- return (
200
- <ArrayOfObjectsItem
201
- key={member.key}
202
- member={member}
203
- renderItem={props.renderItem}
204
- renderField={props.renderField}
205
- renderInput={props.renderInput}
206
- renderPreview={props.renderPreview}
207
- />
208
- )
209
- }
210
-
211
- return null
212
- })}
213
- </>
214
- ) : null}
215
-
216
- {/* This now happens automatically */}
217
- {/* {languagesOutOfOrder.length > 0 && allKeysAreLanguages ? (
218
- <Button
219
- tone="caution"
220
- icon={RestoreIcon}
221
- onClick={() => handleRestoreOrder()}
222
- text="Restore order of languages"
223
- />
224
- ) : null} */}
225
-
226
- {/* Show buttons if languages are configured */}
227
- {/* Hide them if all languages have values */}
228
- {languages?.length > 0 && languagesInUse.length < languages.length ? (
229
- <Stack space={2}>
230
- {/* Hide language-specific buttons if there's only one */}
231
- {/* No more than 5 columns */}
232
- {languages.length > 1 ? (
233
- <Grid columns={Math.min(languages.length, 5)} gap={2}>
234
- {languages.map((language) => (
235
- <Button
236
- key={language.id}
237
- tone="primary"
238
- mode="ghost"
239
- fontSize={1}
240
- disabled={
241
- readOnly ||
242
- Boolean(value?.find((item) => item._key === language.id))
243
- }
244
- text={language.id.toUpperCase()}
245
- icon={AddIcon}
246
- onClick={() => handleAddLanguage(language.id)}
247
- />
248
- ))}
249
- </Grid>
250
- ) : 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 ? (
251
263
  <Button
252
264
  tone="primary"
253
265
  mode="ghost"
254
- disabled={
255
- readOnly || (value && value?.length >= languages?.length)
256
- }
266
+ disabled={readOnly || allLanguagesArePresent}
257
267
  icon={AddIcon}
258
- text={
259
- // eslint-disable-next-line no-nested-ternary
260
- value?.length
261
- ? `Add missing ${
262
- languages.length - value.length === 1
263
- ? `language`
264
- : `languages`
265
- }`
266
- : languages.length === 1
267
- ? `Add ${languages[0].title} Field`
268
- : `Add all languages`
269
- }
270
- onClick={() => handleAddLanguage()}
268
+ text={createAddAllTitle(value, filteredLanguages)}
269
+ onClick={handleAddLanguage}
271
270
  />
272
- </Stack>
273
- ) : null}
274
- </Stack>
275
- </LanguageProvider>
271
+ ) : null}
272
+ </Stack>
273
+ ) : null}
274
+ </Stack>
276
275
  )
277
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
+ }
@@ -15,7 +15,7 @@ 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} = React.useContext(LanguageContext)
47
+ const {languages} = useInternationalizedArrayContext()
48
48
 
49
49
  const languageKeysInUse = useMemo(
50
50
  () => parentValue?.map((v) => v._key) ?? [],
@@ -56,7 +56,9 @@ export default function InternationalizedInput(
56
56
 
57
57
  // Changes the key of this item, ideally to a valid language
58
58
  const handleKeyChange = useCallback(
59
- (languageId: string) => {
59
+ (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
60
+ const languageId = event?.currentTarget?.value
61
+
60
62
  if (
61
63
  !value ||
62
64
  !languages?.length ||
@@ -99,12 +101,13 @@ export default function InternationalizedInput(
99
101
  fontSize={1}
100
102
  key={language.id}
101
103
  text={language.id.toLocaleUpperCase()}
102
- onClick={() => handleKeyChange(language.id)}
104
+ value={language.id}
105
+ // @ts-expect-error
106
+ onClick={handleKeyChange}
103
107
  />
104
108
  ))}
105
109
  </Menu>
106
110
  }
107
- placement="right"
108
111
  popover={{portal: true}}
109
112
  />
110
113
  )}
@@ -0,0 +1,13 @@
1
+ import {PluginConfig} from './types'
2
+
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
+ }