sanity-plugin-internationalized-array 3.1.4 → 3.1.6

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.
@@ -7,6 +7,7 @@ import {
7
7
  isSanityDocument,
8
8
  PatchEvent,
9
9
  setIfMissing,
10
+ useSchema,
10
11
  } from 'sanity'
11
12
  import {useDocumentPane} from 'sanity/structure'
12
13
 
@@ -21,15 +22,79 @@ type DocumentAddButtonsProps = {
21
22
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
23
  value: Record<string, any> | undefined
23
24
  }
24
- export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
25
+ export default function DocumentAddButtons(
26
+ props: DocumentAddButtonsProps
27
+ ): React.ReactElement {
25
28
  const {filteredLanguages} = useInternationalizedArrayContext()
26
29
  const value = isSanityDocument(props.value) ? props.value : undefined
27
30
 
28
31
  const toast = useToast()
29
32
  const {onChange} = useDocumentPane()
33
+ const schema = useSchema()
30
34
 
31
35
  const documentsToTranslation = getDocumentsToTranslate(value, [])
32
36
 
37
+ // Helper function to determine if a field should be initialized as an array
38
+ const getInitialValueForType = useCallback(
39
+ (typeName: string): unknown => {
40
+ if (!typeName) return undefined
41
+
42
+ // Extract the base type name from internationalized array type
43
+ // e.g., "internationalizedArrayBodyValue" -> "body"
44
+ const match = typeName.match(/^internationalizedArray(.+)Value$/)
45
+ if (!match) return undefined
46
+
47
+ const baseTypeName = match[1].charAt(0).toLowerCase() + match[1].slice(1)
48
+
49
+ // Check if it's a known array-based type (Portable Text fields)
50
+ const arrayBasedTypes = [
51
+ 'body',
52
+ 'htmlContent',
53
+ 'blockContent',
54
+ 'portableText',
55
+ ]
56
+ if (arrayBasedTypes.includes(baseTypeName)) {
57
+ return []
58
+ }
59
+
60
+ // Try to look up the schema type to determine if it's an array
61
+ try {
62
+ const schemaType = schema.get(typeName)
63
+ if (schemaType) {
64
+ // Check if this is an object type with a 'value' field
65
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
66
+ const valueField = (schemaType as any)?.fields?.find(
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ (f: any) => f.name === 'value'
69
+ )
70
+ if (valueField) {
71
+ const fieldType = valueField.type
72
+ // Check if the value field is an array type
73
+ if (
74
+ fieldType?.jsonType === 'array' ||
75
+ fieldType?.name === 'array' ||
76
+ fieldType?.type === 'array' ||
77
+ fieldType?.of !== undefined ||
78
+ arrayBasedTypes.includes(fieldType?.name)
79
+ ) {
80
+ return []
81
+ }
82
+ }
83
+ }
84
+ } catch (error) {
85
+ // If we can't determine from schema, fall back to undefined
86
+ console.warn(
87
+ 'Could not determine field type from schema:',
88
+ typeName,
89
+ error
90
+ )
91
+ }
92
+
93
+ return undefined
94
+ },
95
+ [schema]
96
+ )
97
+
33
98
  const handleDocumentButtonClick = useCallback(
34
99
  async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
35
100
  const languageId = event.currentTarget.value
@@ -77,13 +142,16 @@ export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
77
142
  for (const toTranslate of removeDuplicates) {
78
143
  const path = toTranslate.path
79
144
 
145
+ // Get the appropriate initial value for this field type
146
+ const initialValue = getInitialValueForType(toTranslate._type)
147
+
80
148
  const ifMissing = setIfMissing([], path)
81
149
  const insertValue = insert(
82
150
  [
83
151
  {
84
152
  _key: languageId,
85
153
  _type: toTranslate._type,
86
- value: undefined,
154
+ value: initialValue, // Use the determined initial value instead of undefined
87
155
  },
88
156
  ],
89
157
  'after',
@@ -95,7 +163,7 @@ export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
95
163
 
96
164
  onChange(PatchEvent.from(patches.flat()))
97
165
  },
98
- [documentsToTranslation, onChange, toast]
166
+ [documentsToTranslation, getInitialValueForType, onChange, toast]
99
167
  )
100
168
  return (
101
169
  <Stack space={3}>
@@ -1,4 +1,5 @@
1
1
  import {Card, Code, Stack, Text} from '@sanity/ui'
2
+ import type React from 'react'
2
3
 
3
4
  const schemaExample = {
4
5
  languages: [
@@ -7,7 +8,7 @@ const schemaExample = {
7
8
  ],
8
9
  }
9
10
 
10
- export default function Feedback() {
11
+ export default function Feedback(): React.ReactElement {
11
12
  return (
12
13
  <Card tone="caution" border radius={2} padding={3}>
13
14
  <Stack space={4}>
@@ -29,7 +29,7 @@ export type InternationalizedArrayProps = ArrayOfObjectsInputProps<
29
29
 
30
30
  export default function InternationalizedArray(
31
31
  props: InternationalizedArrayProps
32
- ) {
32
+ ): React.ReactElement {
33
33
  const {members, value, schemaType, onChange} = props
34
34
 
35
35
  const readOnly =
@@ -131,7 +131,7 @@ export default function InternationalizedArray(
131
131
  languages,
132
132
  ])
133
133
 
134
- // TODO: This is reordering and re-setting the whole array, it could be surgical
134
+ // NOTE: This is reordering and re-setting the whole array, it could be surgical
135
135
  const handleRestoreOrder = useCallback(() => {
136
136
  if (!value?.length || !languages?.length) {
137
137
  return
@@ -1,12 +1,13 @@
1
1
  import {useLanguageFilterStudioContext} from '@sanity/language-filter'
2
2
  import {Stack} from '@sanity/ui'
3
3
  import equal from 'fast-deep-equal'
4
+ import type React from 'react'
4
5
  import {createContext, useContext, useDeferredValue, useMemo} from 'react'
5
6
  import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
6
7
  import {useDocumentPane} from 'sanity/structure'
7
8
  import {suspend} from 'suspend-react'
8
9
 
9
- import {namespace, version} from '../cache'
10
+ import {createCacheKey, setFunctionCache} from '../cache'
10
11
  import {CONFIG_DEFAULT} from '../constants'
11
12
  import type {Language, PluginConfig} from '../types'
12
13
  import DocumentAddButtons from './DocumentAddButtons'
@@ -27,7 +28,7 @@ export const InternationalizedArrayContext =
27
28
  filteredLanguages: [],
28
29
  })
29
30
 
30
- export function useInternationalizedArrayContext() {
31
+ export function useInternationalizedArrayContext(): InternationalizedArrayContextProps {
31
32
  return useContext(InternationalizedArrayContext)
32
33
  }
33
34
 
@@ -37,7 +38,7 @@ type InternationalizedArrayProviderProps = ObjectInputProps & {
37
38
 
38
39
  export function InternationalizedArrayProvider(
39
40
  props: InternationalizedArrayProviderProps
40
- ) {
41
+ ): React.ReactElement {
41
42
  const {internationalizedArray} = props
42
43
 
43
44
  const client = useClient({apiVersion: internationalizedArray.apiVersion})
@@ -49,6 +50,27 @@ export function InternationalizedArrayProvider(
49
50
  [internationalizedArray.select, deferredDocument]
50
51
  )
51
52
 
53
+ // Use a stable workspace identifier to prevent unnecessary re-renders
54
+ const workspaceId = useMemo(() => {
55
+ // Use workspace name if available, otherwise create a stable hash
56
+ if (workspace?.name) {
57
+ return workspace.name
58
+ }
59
+ // Create a stable hash from workspace properties that matter for caching
60
+ const workspaceKey = {
61
+ name: workspace?.name,
62
+ title: workspace?.title,
63
+ // Add other stable properties as needed
64
+ }
65
+ return JSON.stringify(workspaceKey)
66
+ }, [workspace])
67
+
68
+ // Memoize the cache key to prevent expensive JSON.stringify calls
69
+ const cacheKey = useMemo(
70
+ () => createCacheKey(selectedValue, workspaceId),
71
+ [selectedValue, workspaceId]
72
+ )
73
+
52
74
  // Fetch or return languages
53
75
  const languages = Array.isArray(internationalizedArray.languages)
54
76
  ? internationalizedArray.languages
@@ -56,11 +78,22 @@ export function InternationalizedArrayProvider(
56
78
  // eslint-disable-next-line require-await
57
79
  async () => {
58
80
  if (typeof internationalizedArray.languages === 'function') {
59
- return internationalizedArray.languages(client, selectedValue)
81
+ const result = await internationalizedArray.languages(
82
+ client,
83
+ selectedValue
84
+ )
85
+ // Populate function cache for use outside React context
86
+ setFunctionCache(
87
+ internationalizedArray.languages,
88
+ selectedValue,
89
+ result,
90
+ workspaceId
91
+ )
92
+ return result
60
93
  }
61
94
  return internationalizedArray.languages
62
95
  },
63
- [version, namespace, selectedValue, workspace],
96
+ cacheKey,
64
97
  {equal}
65
98
  )
66
99
 
@@ -34,6 +34,96 @@ export default function InternationalizedInput(
34
34
  props.path.slice(0, -1)
35
35
  ) as InternationalizedValue[]
36
36
 
37
+ // Extract the original onChange to avoid dependency issues
38
+ const originalOnChange = props.inputProps.onChange
39
+
40
+ // Create a wrapped onChange handler to intercept patches for paste operations
41
+ const wrappedOnChange = useCallback(
42
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
43
+ (patches: any) => {
44
+ // Ensure patches is an array before proceeding with paste logic
45
+ // For single patch operations (like unset), pass through directly
46
+ if (!Array.isArray(patches)) {
47
+ return originalOnChange(patches)
48
+ }
49
+
50
+ // Check if this is a paste operation into an empty or uninitialized Portable Text field
51
+ const valueField = props.value?.value
52
+ const isEmptyOrUndefined =
53
+ valueField === undefined ||
54
+ valueField === null ||
55
+ (Array.isArray(valueField) && valueField.length === 0)
56
+
57
+ if (isEmptyOrUndefined) {
58
+ // Check for insert patches that are trying to operate on a non-existent structure
59
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
+ const hasProblematicInsert = patches.some((patch: any) => {
61
+ // Ensure patch exists and has required properties
62
+ if (!patch || typeof patch !== 'object') {
63
+ return false
64
+ }
65
+
66
+ // Look for insert patches targeting the value field or direct array index
67
+ if (
68
+ patch.type === 'insert' &&
69
+ patch.path &&
70
+ Array.isArray(patch.path) &&
71
+ patch.path.length > 0
72
+ ) {
73
+ // The path might be ['value', index] or just [index] depending on context
74
+ const isTargetingValue =
75
+ patch.path[0] === 'value' || typeof patch.path[0] === 'number'
76
+ return isTargetingValue
77
+ }
78
+ return false
79
+ })
80
+
81
+ if (hasProblematicInsert) {
82
+ // First, ensure the value field exists as an empty array if it doesn't
83
+ const initPatch =
84
+ valueField === undefined
85
+ ? {type: 'setIfMissing', path: ['value'], value: []}
86
+ : null
87
+
88
+ // Transform the patches to ensure they work with the nested structure
89
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
90
+ const fixedPatches = patches.map((patch: any) => {
91
+ // Ensure patch exists and has required properties
92
+ if (!patch || typeof patch !== 'object') {
93
+ return patch
94
+ }
95
+
96
+ if (
97
+ patch.type === 'insert' &&
98
+ patch.path &&
99
+ Array.isArray(patch.path)
100
+ ) {
101
+ // Ensure the path is correct for the nested structure
102
+ const fixedPath =
103
+ patch.path[0] === 'value'
104
+ ? patch.path
105
+ : ['value', ...patch.path]
106
+ const fixedPatch = {...patch, path: fixedPath}
107
+ return fixedPatch
108
+ }
109
+ return patch
110
+ })
111
+
112
+ // If we need to initialize the field, include that patch first
113
+ const allPatches = initPatch
114
+ ? [initPatch, ...fixedPatches]
115
+ : fixedPatches
116
+
117
+ return originalOnChange(allPatches)
118
+ }
119
+ }
120
+
121
+ // For all other cases, pass through unchanged
122
+ return originalOnChange(patches)
123
+ },
124
+ [props.value, originalOnChange]
125
+ )
126
+
37
127
  const inlineProps = {
38
128
  ...props.inputProps,
39
129
  // This is the magic that makes inline editing work?
@@ -43,6 +133,8 @@ export default function InternationalizedInput(
43
133
  // This just overrides the type
44
134
  // Remove this as it shouldn't be necessary?
45
135
  value: props.value as InternationalizedValue,
136
+ // Use our wrapped onChange handler
137
+ onChange: wrappedOnChange,
46
138
  }
47
139
 
48
140
  const {validation, value, onChange, readOnly} = inlineProps
@@ -137,7 +229,7 @@ export default function InternationalizedInput(
137
229
  </Card>
138
230
  <Flex align="center" gap={2}>
139
231
  <Card flex={1} tone="inherit">
140
- {props.inputProps.renderInput(props.inputProps)}
232
+ {props.inputProps.renderInput(inlineProps)}
141
233
  </Card>
142
234
 
143
235
  <Card tone="inherit">
@@ -1,20 +1,30 @@
1
1
  import {memo} from 'react'
2
2
  import {useClient} from 'sanity'
3
3
 
4
- import {peek, preload} from '../cache'
4
+ import {createCacheKey, peek, preloadWithKey, setFunctionCache} from '../cache'
5
5
  import type {PluginConfig} from '../types'
6
6
 
7
7
  export default memo(function Preload(
8
8
  props: Required<Pick<PluginConfig, 'apiVersion' | 'languages'>>
9
9
  ) {
10
10
  const client = useClient({apiVersion: props.apiVersion})
11
+
12
+ // Use the same cache key structure as the main component
13
+ // This should match the main component when selectedValue is empty
14
+ const cacheKey = createCacheKey({})
15
+
11
16
  if (!Array.isArray(peek({}))) {
12
17
  // eslint-disable-next-line require-await
13
- preload(async () =>
14
- Array.isArray(props.languages)
15
- ? props.languages
16
- : props.languages(client, {})
17
- )
18
+ preloadWithKey(async () => {
19
+ if (Array.isArray(props.languages)) {
20
+ return props.languages
21
+ }
22
+ const result = await props.languages(client, {})
23
+ // Populate function cache for sharing with other components
24
+ // Use the same key structure as the main component
25
+ setFunctionCache(props.languages, {}, result)
26
+ return result
27
+ }, cacheKey)
18
28
  }
19
29
 
20
30
  return null
@@ -1,7 +1,7 @@
1
1
  /* eslint-disable no-nested-ternary */
2
2
  import {defineField, type FieldDefinition, type Rule} from 'sanity'
3
3
 
4
- import {peek} from '../cache'
4
+ import {getFunctionCache, peek, setFunctionCache} from '../cache'
5
5
  import {createFieldName} from '../components/createFieldName'
6
6
  import {getSelectedValue} from '../components/getSelectedValue'
7
7
  import InternationalizedArray from '../components/InternationalizedArray'
@@ -50,7 +50,12 @@ export default (config: ArrayFactoryConfig): FieldDefinition<'array'> => {
50
50
  // @ts-expect-error - fix typings
51
51
  validation: (rule: Rule) =>
52
52
  rule.custom<Value[]>(async (value, context) => {
53
- if (!value) {
53
+ if (!value || value.length === 0) {
54
+ return true
55
+ }
56
+
57
+ // Early return for simple cases to avoid expensive operations
58
+ if (value.length === 1 && !value[0]?._key) {
54
59
  return true
55
60
  }
56
61
 
@@ -65,7 +70,33 @@ export default (config: ArrayFactoryConfig): FieldDefinition<'array'> => {
65
70
  } else if (Array.isArray(peek(selectedValue))) {
66
71
  contextLanguages = peek(selectedValue) || []
67
72
  } else if (typeof languagesFieldOption === 'function') {
68
- contextLanguages = await languagesFieldOption(client, selectedValue)
73
+ // Try to get from function cache first (if it's the same function as the component)
74
+ const cachedLanguages = getFunctionCache(
75
+ languagesFieldOption,
76
+ selectedValue
77
+ )
78
+
79
+ if (Array.isArray(cachedLanguages)) {
80
+ contextLanguages = cachedLanguages
81
+ } else {
82
+ // Try suspend cache as fallback
83
+ const suspendCachedLanguages = peek(selectedValue)
84
+ if (Array.isArray(suspendCachedLanguages)) {
85
+ contextLanguages = suspendCachedLanguages
86
+ } else {
87
+ // Only make the async call if we don't have cached data
88
+ contextLanguages = await languagesFieldOption(
89
+ client,
90
+ selectedValue
91
+ )
92
+ // Cache the result for future validation calls
93
+ setFunctionCache(
94
+ languagesFieldOption,
95
+ selectedValue,
96
+ contextLanguages
97
+ )
98
+ }
99
+ }
69
100
  }
70
101
 
71
102
  if (value && value.length > contextLanguages.length) {
@@ -76,12 +107,13 @@ export default (config: ArrayFactoryConfig): FieldDefinition<'array'> => {
76
107
  }`
77
108
  }
78
109
 
79
- const nonLanguageKeys = value?.length
80
- ? value.filter(
81
- (item) =>
82
- !contextLanguages.find((language) => item._key === language.id)
83
- )
84
- : []
110
+ // Create a Set for faster language ID lookups
111
+ const languageIds = new Set(contextLanguages.map((lang) => lang.id))
112
+
113
+ // Check for invalid language keys
114
+ const nonLanguageKeys = value.filter(
115
+ (item) => item?._key && !languageIds.has(item._key)
116
+ )
85
117
  if (nonLanguageKeys.length) {
86
118
  return {
87
119
  message: `Array item keys must be valid languages registered to the field type`,
@@ -89,27 +121,20 @@ export default (config: ArrayFactoryConfig): FieldDefinition<'array'> => {
89
121
  }
90
122
  }
91
123
 
92
- // Ensure there's no duplicate `language` fields
93
- type KeyedValues = {
94
- [key: string]: Value[]
124
+ // Check for duplicate language keys (more efficient)
125
+ const seenKeys = new Set<string>()
126
+ const duplicateValues: Value[] = []
127
+
128
+ for (const item of value) {
129
+ if (item?._key) {
130
+ if (seenKeys.has(item._key)) {
131
+ duplicateValues.push(item)
132
+ } else {
133
+ seenKeys.add(item._key)
134
+ }
135
+ }
95
136
  }
96
137
 
97
- const valuesByLanguage = value?.length
98
- ? value
99
- .filter((item) => Boolean(item?._key))
100
- .reduce((acc, cur) => {
101
- if (acc[cur._key]) {
102
- return {...acc, [cur._key]: [...acc[cur._key], cur]}
103
- }
104
- return {
105
- ...acc,
106
- [cur._key]: [cur],
107
- }
108
- }, {} as KeyedValues)
109
- : {}
110
- const duplicateValues = Object.values(valuesByLanguage)
111
- .filter((item) => item?.length > 1)
112
- .flat()
113
138
  if (duplicateValues.length) {
114
139
  return {
115
140
  message: 'There can only be one field per language',