Skip to content
This repository was archived by the owner on Feb 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions src/components/DocumentAddButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isSanityDocument,
PatchEvent,
setIfMissing,
useSchema,
} from 'sanity'
import {useDocumentPane} from 'sanity/structure'

Expand All @@ -21,15 +22,79 @@ type DocumentAddButtonsProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: Record<string, any> | undefined
}
export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
export default function DocumentAddButtons(
props: DocumentAddButtonsProps
): React.ReactElement {
const {filteredLanguages} = useInternationalizedArrayContext()
const value = isSanityDocument(props.value) ? props.value : undefined

const toast = useToast()
const {onChange} = useDocumentPane()
const schema = useSchema()

const documentsToTranslation = getDocumentsToTranslate(value, [])

// Helper function to determine if a field should be initialized as an array
const getInitialValueForType = useCallback(
(typeName: string): unknown => {
if (!typeName) return undefined

// Extract the base type name from internationalized array type
// e.g., "internationalizedArrayBodyValue" -> "body"
const match = typeName.match(/^internationalizedArray(.+)Value$/)
if (!match) return undefined

const baseTypeName = match[1].charAt(0).toLowerCase() + match[1].slice(1)

// Check if it's a known array-based type (Portable Text fields)
const arrayBasedTypes = [
'body',
'htmlContent',
'blockContent',
'portableText',
]
if (arrayBasedTypes.includes(baseTypeName)) {
return []
}

// Try to look up the schema type to determine if it's an array
try {
const schemaType = schema.get(typeName)
if (schemaType) {
// Check if this is an object type with a 'value' field
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const valueField = (schemaType as any)?.fields?.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(f: any) => f.name === 'value'
)
if (valueField) {
const fieldType = valueField.type
// Check if the value field is an array type
if (
fieldType?.jsonType === 'array' ||
fieldType?.name === 'array' ||
fieldType?.type === 'array' ||
fieldType?.of !== undefined ||
arrayBasedTypes.includes(fieldType?.name)
) {
return []
}
}
}
} catch (error) {
// If we can't determine from schema, fall back to undefined
console.warn(
'Could not determine field type from schema:',
typeName,
error
)
}

return undefined
},
[schema]
)

const handleDocumentButtonClick = useCallback(
async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
const languageId = event.currentTarget.value
Expand Down Expand Up @@ -77,13 +142,16 @@ export default function DocumentAddButtons(props: DocumentAddButtonsProps) {
for (const toTranslate of removeDuplicates) {
const path = toTranslate.path

// Get the appropriate initial value for this field type
const initialValue = getInitialValueForType(toTranslate._type)

const ifMissing = setIfMissing([], path)
const insertValue = insert(
[
{
_key: languageId,
_type: toTranslate._type,
value: undefined,
value: initialValue, // Use the determined initial value instead of undefined
},
],
'after',
Expand All @@ -95,7 +163,7 @@ export default function DocumentAddButtons(props: DocumentAddButtonsProps) {

onChange(PatchEvent.from(patches.flat()))
},
[documentsToTranslation, onChange, toast]
[documentsToTranslation, getInitialValueForType, onChange, toast]
)
return (
<Stack space={3}>
Expand Down
3 changes: 2 additions & 1 deletion src/components/Feedback.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Card, Code, Stack, Text} from '@sanity/ui'
import type React from 'react'

const schemaExample = {
languages: [
Expand All @@ -7,7 +8,7 @@ const schemaExample = {
],
}

export default function Feedback() {
export default function Feedback(): React.ReactElement {
return (
<Card tone="caution" border radius={2} padding={3}>
<Stack space={4}>
Expand Down
4 changes: 2 additions & 2 deletions src/components/InternationalizedArray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type InternationalizedArrayProps = ArrayOfObjectsInputProps<

export default function InternationalizedArray(
props: InternationalizedArrayProps
) {
): React.ReactElement {
const {members, value, schemaType, onChange} = props

const readOnly =
Expand Down Expand Up @@ -131,7 +131,7 @@ export default function InternationalizedArray(
languages,
])

// TODO: This is reordering and re-setting the whole array, it could be surgical
// NOTE: This is reordering and re-setting the whole array, it could be surgical
const handleRestoreOrder = useCallback(() => {
if (!value?.length || !languages?.length) {
return
Expand Down
5 changes: 3 additions & 2 deletions src/components/InternationalizedArrayContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {useLanguageFilterStudioContext} from '@sanity/language-filter'
import {Stack} from '@sanity/ui'
import equal from 'fast-deep-equal'
import type React from 'react'
import {createContext, useContext, useDeferredValue, useMemo} from 'react'
import {type ObjectInputProps, useClient, useWorkspace} from 'sanity'
import {useDocumentPane} from 'sanity/structure'
Expand All @@ -27,7 +28,7 @@ export const InternationalizedArrayContext =
filteredLanguages: [],
})

export function useInternationalizedArrayContext() {
export function useInternationalizedArrayContext(): InternationalizedArrayContextProps {
return useContext(InternationalizedArrayContext)
}

Expand All @@ -37,7 +38,7 @@ type InternationalizedArrayProviderProps = ObjectInputProps & {

export function InternationalizedArrayProvider(
props: InternationalizedArrayProviderProps
) {
): React.ReactElement {
const {internationalizedArray} = props

const client = useClient({apiVersion: internationalizedArray.apiVersion})
Expand Down
94 changes: 93 additions & 1 deletion src/components/InternationalizedInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,96 @@ export default function InternationalizedInput(
props.path.slice(0, -1)
) as InternationalizedValue[]

// Extract the original onChange to avoid dependency issues
const originalOnChange = props.inputProps.onChange

// Create a wrapped onChange handler to intercept patches for paste operations
const wrappedOnChange = useCallback(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(patches: any) => {
// Ensure patches is an array before proceeding with paste logic
// For single patch operations (like unset), pass through directly
if (!Array.isArray(patches)) {
return originalOnChange(patches)
}

// Check if this is a paste operation into an empty or uninitialized Portable Text field
const valueField = props.value?.value
const isEmptyOrUndefined =
valueField === undefined ||
valueField === null ||
(Array.isArray(valueField) && valueField.length === 0)

if (isEmptyOrUndefined) {
// Check for insert patches that are trying to operate on a non-existent structure
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasProblematicInsert = patches.some((patch: any) => {
// Ensure patch exists and has required properties
if (!patch || typeof patch !== 'object') {
return false
}

// Look for insert patches targeting the value field or direct array index
if (
patch.type === 'insert' &&
patch.path &&
Array.isArray(patch.path) &&
patch.path.length > 0
) {
// The path might be ['value', index] or just [index] depending on context
const isTargetingValue =
patch.path[0] === 'value' || typeof patch.path[0] === 'number'
return isTargetingValue
}
return false
})

if (hasProblematicInsert) {
// First, ensure the value field exists as an empty array if it doesn't
const initPatch =
valueField === undefined
? {type: 'setIfMissing', path: ['value'], value: []}
: null

// Transform the patches to ensure they work with the nested structure
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fixedPatches = patches.map((patch: any) => {
// Ensure patch exists and has required properties
if (!patch || typeof patch !== 'object') {
return patch
}

if (
patch.type === 'insert' &&
patch.path &&
Array.isArray(patch.path)
) {
// Ensure the path is correct for the nested structure
const fixedPath =
patch.path[0] === 'value'
? patch.path
: ['value', ...patch.path]
const fixedPatch = {...patch, path: fixedPath}
return fixedPatch
}
return patch
})

// If we need to initialize the field, include that patch first
const allPatches = initPatch
? [initPatch, ...fixedPatches]
: fixedPatches

return originalOnChange(allPatches)
}
}

// For all other cases, pass through unchanged
return originalOnChange(patches)
},
[props.value, originalOnChange]
)

const inlineProps = {
...props.inputProps,
// This is the magic that makes inline editing work?
Expand All @@ -43,6 +133,8 @@ export default function InternationalizedInput(
// This just overrides the type
// Remove this as it shouldn't be necessary?
value: props.value as InternationalizedValue,
// Use our wrapped onChange handler
onChange: wrappedOnChange,
}

const {validation, value, onChange, readOnly} = inlineProps
Expand Down Expand Up @@ -137,7 +229,7 @@ export default function InternationalizedInput(
</Card>
<Flex align="center" gap={2}>
<Card flex={1} tone="inherit">
{props.inputProps.renderInput(props.inputProps)}
{props.inputProps.renderInput(inlineProps)}
</Card>

<Card tone="inherit">
Expand Down