diff --git a/packages/sanity/src/core/studio/copyPaste/CopyPasteProvider.tsx b/packages/sanity/src/core/studio/copyPaste/CopyPasteProvider.tsx index feff8592190..2953bb2742a 100644 --- a/packages/sanity/src/core/studio/copyPaste/CopyPasteProvider.tsx +++ b/packages/sanity/src/core/studio/copyPaste/CopyPasteProvider.tsx @@ -14,14 +14,6 @@ export const CopyPasteProvider: React.FC<{ const documentMetaRef = useRef(null) const [copyResult, setCopyResult] = useState(null) - const isValidTargetType = useCallback( - (target: string) => { - const source = copyResult?.schemaTypeName - return source === target - }, - [copyResult], - ) - const setDocumentMeta = useCallback( ({documentId, documentType, schemaType, onChange}: Required) => { documentMetaRef.current = { @@ -43,9 +35,8 @@ export const CopyPasteProvider: React.FC<{ getDocumentMeta, setCopyResult, setDocumentMeta, - isValidTargetType, }), - [copyResult, getDocumentMeta, setDocumentMeta, isValidTargetType], + [copyResult, getDocumentMeta, setDocumentMeta], ) return {children} diff --git a/packages/sanity/src/core/studio/copyPaste/__test__/schema.tsx b/packages/sanity/src/core/studio/copyPaste/__test__/schema.tsx index e17a50c275d..8883637255a 100644 --- a/packages/sanity/src/core/studio/copyPaste/__test__/schema.tsx +++ b/packages/sanity/src/core/studio/copyPaste/__test__/schema.tsx @@ -1,9 +1,7 @@ -import {defineField, defineType, type Schema} from '@sanity/types' +import {defineType, type Schema} from '@sanity/types' import {createSchema} from '../../../schema' -const Icon = () => null - const linkType = defineType({ type: 'object', name: 'link', @@ -17,242 +15,172 @@ const linkType = defineType({ validation: (Rule) => Rule.required(), }) -const myStringType = defineType({ +const myStringObjectType = defineType({ + type: 'object', + name: 'myStringObject', + fields: [{type: 'string', name: 'myString', validation: (Rule) => Rule.required()}], +}) + +const nestedObjectType = defineType({ type: 'object', - name: 'test', - fields: [{type: 'string', name: 'mystring', validation: (Rule) => Rule.required()}], + name: 'nestedObject', + fields: [ + { + name: 'title', + type: 'string', + }, + { + type: 'array', + name: 'objectList', + of: [{type: 'nestedObject'}], + }, + ], }) export const schema = createSchema({ name: 'default', types: [ linkType, - myStringType, - // { - // name: 'customNamedBlock', - // type: 'block', - // title: 'A named custom block', - // marks: { - // annotations: [linkType, myStringType], - // }, - // of: [ - // {type: 'image'}, - // { - // type: 'object', - // name: 'test', - // fields: [myStringType], - // }, - // { - // type: 'reference', - // name: 'strongAuthorRef', - // title: 'A strong author ref', - // to: {type: 'author'}, - // }, - // ], - // }, - defineType({ - name: 'author', - title: 'Author', - type: 'document', - //icon: Icon, - fields: [ + myStringObjectType, + nestedObjectType, + { + name: 'customNamedBlock', + type: 'block', + title: 'A named custom block', + marks: { + annotations: [linkType, myStringObjectType], + }, + of: [ { - name: 'name', - type: 'string', + type: 'object', + name: 'test', + fields: [myStringObjectType], }, { - name: 'role', - type: 'string', - }, - // { - // name: 'bio', - // type: 'array', - // of: [{type: 'customNamedBlock'}], - // }, - defineField({ - name: 'bestFriend', type: 'reference', - to: [{type: 'author'}], - }), - ], - initialValue: () => ({ - role: 'Developer', - }), - }), - defineType({ - name: 'address', - title: 'Address', - type: 'object', - fields: [ - { - name: 'street', - type: 'string', - initialValue: 'one old street', - }, - { - name: 'streetNo', - type: 'string', - initialValue: '123', + name: 'strongAuthorRef', + title: 'A strong author ref', + to: {type: 'author'}, }, ], - }), + }, { - name: 'contact', - title: 'Contact', - type: 'object', + name: 'author', + title: 'Author', + type: 'document', fields: [ { - name: 'email', + name: 'name', type: 'string', }, { - name: 'phone', - type: 'string', - }, - ], - }, - { - name: 'person', - title: 'Person', - type: 'document', - icon: Icon, - fields: [ - { - name: 'address', - type: 'address', + name: 'born', + type: 'number', }, { - name: 'contact', - type: 'contact', + name: 'favoriteNumbers', + type: 'array', + of: [{type: 'number'}], }, - ], - }, - - { - name: 'post', - title: 'Post', - type: 'document', - icon: Icon, - fields: [ + {type: 'image', name: 'profileImage'}, { - name: 'title', - type: 'string', + type: 'object', + name: 'socialLinks', + fields: [ + {type: 'string', name: 'twitter'}, + {type: 'string', name: 'linkedin'}, + ], }, - // { - // name: 'body', - // type: 'array', - // of: [{type: 'customNamedBlock'}], - // }, { - name: 'author', - type: 'reference', - to: [{type: 'author'}], + name: 'nestedTest', + type: 'nestedObject', }, - ], - }, - - { - name: 'captionedImage', - type: 'object', - fields: [ - // { - // // This doesn't have a default value, so shouldn't be present, - // // not even with a `_type` stub - // name: 'asset', - // type: 'reference', - // to: [{type: 'sanity.imageAsset'}], - // }, { - name: 'caption', - type: 'string', + name: 'bio', + type: 'array', + of: [{type: 'customNamedBlock'}, {type: 'myStringObject'}], }, - ], - initialValue: {caption: 'Default caption!'}, - }, - - { - name: 'recursiveObject', - type: 'object', - fields: [ { - name: 'name', - type: 'string', + name: 'friends', + type: 'array', + of: [{type: 'reference', to: [{type: 'author'}]}], }, { - name: 'child', - type: 'recursiveObject', + name: 'bestFriend', + type: 'reference', + to: [{type: 'author'}], }, ], - initialValue: { - name: '∞ recursion is ∞', - }, }, - { - name: 'developer', + name: 'editor', + title: 'Editor', type: 'document', - initialValue: () => ({ - name: 'A default name!', - - // Should clear the default value below (but ideally not actually be part - // of the value, eg no `undefined` in the resolved value) - numberOfCats: undefined, - }), fields: [ { name: 'name', type: 'string', }, { - name: 'hasPet', - type: 'boolean', - initialValue: false, - }, - { - name: 'age', + name: 'born', type: 'number', - initialValue: 30, }, + {type: 'image', name: 'profileImage'}, { - name: 'numberOfCats', - type: 'number', - initialValue: 3, + name: 'bio', + type: 'array', + of: [{type: 'customNamedBlock'}], }, { - name: 'heroImage', - type: 'captionedImage', + name: 'favoriteNumbers', + type: 'array', + of: [{type: 'number'}], }, { - name: 'awards', - type: 'array', - of: [{type: 'string'}], - initialValue: () => ['TypeScript Wizard of the Year'], + name: 'nestedTest', + type: 'nestedObject', }, { - name: 'tasks', - type: 'array', - of: [ + name: 'profile', + type: 'object', + fields: [ + {type: 'string', name: 'email'}, + {type: 'image', name: 'avatar'}, { - name: 'task', type: 'object', + name: 'social', fields: [ - {name: 'description', type: 'string'}, - {name: 'isDone', type: 'boolean', initialValue: false}, + {type: 'string', name: 'twitter'}, + {type: 'string', name: 'linkedin'}, ], }, ], - initialValue: () => [ - { - _type: 'task', - description: 'Mark as done', - isDone: false, - }, - ], }, { - name: 'recursive', - type: 'recursiveObject', - // Initial value set on it's actual type + name: 'friends', + type: 'array', + of: [{type: 'reference', to: [{type: 'editor'}, {type: 'author'}]}], + }, + ], + }, + { + name: 'post', + title: 'Post', + type: 'document', + fields: [ + { + name: 'title', + type: 'string', + }, + { + name: 'body', + type: 'array', + of: [{type: 'customNamedBlock'}], + }, + { + name: 'author', + type: 'reference', + to: [{type: 'author'}], }, ], }, diff --git a/packages/sanity/src/core/studio/copyPaste/__test__/valueTransfer.test.ts b/packages/sanity/src/core/studio/copyPaste/__test__/valueTransfer.test.ts index bd7dae6fefc..ec2249a0b76 100644 --- a/packages/sanity/src/core/studio/copyPaste/__test__/valueTransfer.test.ts +++ b/packages/sanity/src/core/studio/copyPaste/__test__/valueTransfer.test.ts @@ -1,6 +1,7 @@ import {beforeEach, describe, expect, jest, test} from '@jest/globals' -import {type CopyActionResult} from 'sanity' +import {omit} from 'lodash' +import {resolveSchemaTypeForPath} from '../resolveSchemaTypeForPath' import {transferValue} from '../valueTransfer' import {schema} from './schema' @@ -10,9 +11,11 @@ beforeEach(() => { }) describe('transferValue', () => { - test('can copy portable text field', () => { - const docValue = { - body: [ + test('cannot copy from one type to another if the schema json type is different', () => { + const sourceValue = { + _type: 'author', + _id: 'xxx', + bio: [ { _key: 'someKey', _type: 'customNamedBlock', @@ -20,21 +23,112 @@ describe('transferValue', () => { }, ], } - const copyActionResult: CopyActionResult = { - _type: 'copyResult', - documentId: '123', - documentType: 'author', - schemaTypeName: 'author', - path: ['bio'], - docValue, - isDocument: true, - isArray: false, - isObject: false, - } - const targetValue = transferValue(copyActionResult, { - targetDocumentType: 'author', - targetSchemaType: schema.get('author')!, + const transferValueResult = transferValue({ + sourceRootSchemaType: schema.get('author')!, + sourcePath: [], + sourceValue, + targetRootSchemaType: schema.get('editor')!, + targetPath: ['bio'], + }) + expect(transferValueResult.errors.length).toEqual(1) + expect(transferValueResult.errors[0].message).toEqual( + 'Source and target schema types are not compatible', + ) + }) + + describe('objects', () => { + test('can copy object', () => { + const sourceValue = { + _type: 'author', + _id: 'xxx', + bio: [ + { + _key: 'someKey', + _type: 'customNamedBlock', + children: [{_key: 'someOtherKey', _type: 'span', text: 'Hello'}], + }, + ], + } + const transferValueResult = transferValue({ + sourceRootSchemaType: schema.get('author')!, + sourcePath: [], + sourceValue, + targetRootSchemaType: schema.get('editor')!, + targetPath: [], + }) + expect(transferValueResult?.targetValue).toEqual({ + ...omit(sourceValue, ['_id']), + _type: 'editor', + }) + }) + + test('can copy array of numbers', () => { + const sourceValue = { + _type: 'author', + _id: 'xxx', + favoriteNumbers: [1, 2, 3, 4, 'foo'], + } + const transferValueResult = transferValue({ + sourceRootSchemaType: schema.get('author')!, + sourcePath: ['favoriteNumbers'], + sourceValue, + targetRootSchemaType: schema.get('editor')!, + targetPath: ['favoriteNumbers'], + }) + expect(transferValueResult?.targetValue).toEqual([1, 2, 3, 4]) + }) + + test('can copy nested objects', () => { + const sourceValue = {_type: 'nestedObject', _key: 'yyy', title: 'item', items: []} + const schemaTypeAtPath = resolveSchemaTypeForPath(schema.get('author')!, ['nestedTest']) + const transferValueResult = transferValue({ + sourceRootSchemaType: schemaTypeAtPath!, + sourcePath: [], + sourceValue, + targetRootSchemaType: schema.get('editor')!, + targetPath: ['nestedTest'], + }) + expect(transferValueResult?.targetValue).toEqual({_type: 'nestedObject', title: 'item'}) + }) + + test('can copy image objects', () => { + const sourceValue = { + _type: 'image', + asset: { + _ref: 'image-e4be7fa20bb20c271060a46bca82b9e84907a13a-320x320-jpg', + _type: 'reference', + }, + } + const schemaTypeAtPath = resolveSchemaTypeForPath(schema.get('author')!, ['profileImage']) + const transferValueResult = transferValue({ + sourceRootSchemaType: schemaTypeAtPath!, + sourcePath: [], + sourceValue, + targetRootSchemaType: schema.get('editor')!, + targetPath: ['profileImage'], + }) + expect(transferValueResult?.targetValue).toEqual(sourceValue) }) - expect(targetValue).toEqual(docValue) }) + // test.only('can copy nested objects', () => { + // const sourceValue = { + // _type: 'nestedObject', + // title: 'root', + // objectList: [{_type: 'nestedObject', _key: 'yyy', title: 'item', items: []}], + // } + // const transferValueResult = transferValue({ + // sourceRootSchemaType: schema.get('author')!, + // sourcePath: ['nestedTest', 'objectList', {_key: 'yyy'}], + // sourceValue, + // targetRootSchemaType: schema.get('editor')!, + // targetPath: ['nestedTest'], + // }) + // expect(transferValueResult.errors.length).toEqual(0) + // expect(transferValueResult?.targetValue).toEqual({ + // _type: 'nestedObject', + // _key: 'yyy', + // title: 'item', + // items: [], + // }) + // }) }) diff --git a/packages/sanity/src/core/studio/copyPaste/types.ts b/packages/sanity/src/core/studio/copyPaste/types.ts index f98a6a4a496..7e828532722 100644 --- a/packages/sanity/src/core/studio/copyPaste/types.ts +++ b/packages/sanity/src/core/studio/copyPaste/types.ts @@ -1,4 +1,4 @@ -import {type ObjectSchemaType, type PatchEvent} from 'sanity' +import {type ObjectSchemaType, type PatchEvent, type Path} from 'sanity' /** * @beta @@ -19,13 +19,13 @@ export interface CopyActionResult { _type: 'copyResult' documentId?: string documentType?: string - schemaTypeName: string - schemaTypeTitle?: string - path: any[] - docValue: any // Adjust the type based on your actual data structure - isDocument: boolean + documentPath: Path + value: unknown isArray: boolean + isDocument: boolean isObject: boolean + schemaTypeName: string + schemaTypeTitle?: string } /** @@ -37,5 +37,4 @@ export interface CopyPasteContextType { copyResult: CopyActionResult | null setCopyResult: (result: CopyActionResult) => void setDocumentMeta: (meta: Required) => void - isValidTargetType: (targetType: string) => boolean } diff --git a/packages/sanity/src/core/studio/copyPaste/useCopyPasteAction.ts b/packages/sanity/src/core/studio/copyPaste/useCopyPasteAction.ts index 6db38223163..a7edd4c3731 100644 --- a/packages/sanity/src/core/studio/copyPaste/useCopyPasteAction.ts +++ b/packages/sanity/src/core/studio/copyPaste/useCopyPasteAction.ts @@ -1,12 +1,12 @@ import {type Path} from '@sanity/types' import {useToast} from '@sanity/ui' import {useCallback} from 'react' -import {type FormDocumentValue, getValueAtPath, PatchEvent, set} from 'sanity' +import {type FormDocumentValue, getValueAtPath, PatchEvent, set, useSchema} from 'sanity' import {useCopyPaste} from './CopyPasteProvider' import {resolveSchemaTypeForPath} from './resolveSchemaTypeForPath' import {type CopyActionResult} from './types' -import {getClipboardItem, isCopyPasteResult, parseCopyResult, writeClipboardItem} from './utils' +import {getClipboardItem, isEmptyValue, writeClipboardItem} from './utils' import {transferValue} from './valueTransfer' /** @@ -14,26 +14,28 @@ import {transferValue} from './valueTransfer' * @hidden */ export function useCopyPasteAction() { - const {getDocumentMeta, setCopyResult, isValidTargetType: _isValidTargetType} = useCopyPaste() + const {getDocumentMeta, setCopyResult} = useCopyPaste() const toast = useToast() const {onChange} = getDocumentMeta()! || {} + const schema = useSchema() const onCopy = useCallback( async (path: Path, value: FormDocumentValue | undefined) => { - const {documentId, documentType, schemaType: sourceSchemaType} = getDocumentMeta()! - const schemaType = resolveSchemaTypeForPath(sourceSchemaType!, path) - const existingValue = getValueAtPath(value, path) - const isDocument = schemaType?.type?.name === 'document' - const isArray = schemaType?.type?.name === 'array' - const isObject = schemaType?.type?.name === 'document' - const parsedDocValue = parseCopyResult(existingValue) - const normalizedValue = isCopyPasteResult(parsedDocValue) - ? parsedDocValue.docValue - : existingValue + const documentMeta = getDocumentMeta() + // Test that we got document meta first + if (!documentMeta) { + console.warn(`Failed to resolve document meta data for path ${path.join('.')}.`) + toast.push({ + status: 'error', + title: `Can't lookup the document meta data due to unknown error`, + }) + return + } + const {documentId, documentType, schemaType} = documentMeta if (!schemaType) { console.warn(`Failed to resolve schema type for path ${path.join('.')}.`, { - sourceSchemaType, + schemaType, }) toast.push({ status: 'error', @@ -42,14 +44,39 @@ export function useCopyPasteAction() { return } + const schemaTypeAtPath = resolveSchemaTypeForPath(schemaType, path) + + if (!schemaTypeAtPath) { + toast.push({ + status: 'error', + title: `Could not resolve schema type for path ${path.join('.')}`, + }) + return + } + + const isDocument = schemaTypeAtPath.type?.name === 'document' + const isArray = schemaTypeAtPath.jsonType === 'array' + const isObject = schemaTypeAtPath.jsonType === 'object' + const valueAtPath = getValueAtPath(value, path) + + // Test if the value is empty (undefined, empty object or empty array) + + if (isEmptyValue(valueAtPath)) { + toast.push({ + status: 'warning', + title: 'Empty value, nothing to copy', + }) + return + } + const payloadValue: CopyActionResult = { _type: 'copyResult', documentId, documentType, - schemaTypeName: schemaType?.name || 'unknown', - schemaTypeTitle: schemaType?.title || schemaType?.name || 'unknown', - path, - docValue: normalizedValue, + schemaTypeName: schemaTypeAtPath?.name || 'unknown', + schemaTypeTitle: schemaTypeAtPath?.title || schemaType?.name || 'unknown', + documentPath: path, + value: valueAtPath, isDocument, isArray, isObject, @@ -67,12 +94,14 @@ export function useCopyPasteAction() { ) const onPaste = useCallback( - async (path: Path) => { - const {documentType, schemaType: sourceSchemaType} = getDocumentMeta()! - const schemaType = resolveSchemaTypeForPath(sourceSchemaType!, path)! - const value = await getClipboardItem() + async (targetPath: Path) => { + const {schemaType: targetDocumentSchemaType} = getDocumentMeta()! + const targetSchemaType = resolveSchemaTypeForPath(targetDocumentSchemaType!, targetPath)! + + const clipboardItem = await getClipboardItem() - if (!value) { + // Return early if no clipboard item or if clipboard item is invalid + if (!clipboardItem) { toast.push({ status: 'info', title: 'Nothing to paste', @@ -80,37 +109,93 @@ export function useCopyPasteAction() { return } - if (!schemaType) { + if (!clipboardItem.documentType) { toast.push({ status: 'error', - title: `Failed to resolve schema type for path ${path.join('.')}`, + title: 'Invalid clipboard item', }) return } - const targetSchemaTypeTitle = schemaType?.title || schemaType?.name - let targetValue = null + const sourceDocumentSchemaType = schema.get(clipboardItem.documentType) - if (value) { - try { - targetValue = transferValue(value, { - targetSchemaType: schemaType, - targetDocumentType: documentType, - }) - onChange?.(PatchEvent.from(set(targetValue, path))) + if (!sourceDocumentSchemaType) { + toast.push({ + status: 'error', + title: 'Invalid clipboard item', + }) + return + } + + const sourceSchemaType = resolveSchemaTypeForPath( + sourceDocumentSchemaType, + clipboardItem.documentPath, + ) + + if (!sourceSchemaType) { + toast.push({ + status: 'error', + title: `Failed to resolve schema type for path ${clipboardItem.documentPath.join('.')}`, + }) + return + } + + if (!targetDocumentSchemaType) { + toast.push({ + status: 'error', + title: `Failed to resolve schema type for path ${targetPath.join('.')}`, + }) + return + } + + const targetSchemaTypeTitle = targetSchemaType?.title || targetSchemaType?.name + + const transferValueOptions = { + sourceRootSchemaType: sourceSchemaType, + sourcePath: [], + sourceValue: clipboardItem.value, + targetRootSchemaType: targetSchemaType, + targetPath: [], + } + + try { + const {targetValue, errors} = transferValue(transferValueOptions) + + if (isEmptyValue(targetValue)) { toast.push({ - status: 'success', - title: `${value.isDocument ? 'Document' : 'Field'} ${targetSchemaTypeTitle} updated`, + status: 'warning', + title: 'Nothing from the clipboard could be pasted here', }) - } catch (error) { + return + } + const nonWarningErrors = errors.filter((error) => error.level !== 'warning') + if (nonWarningErrors.length > 0) { toast.push({ status: 'error', - title: error.message, + title: 'Could not paste', + description: nonWarningErrors[0].message, + }) + return + } else if (errors.length > 0) { + toast.push({ + status: 'warning', + title: 'Could not paste all values', + description: errors.map((error) => error.message).join(', '), }) } + onChange?.(PatchEvent.from(set(targetValue, targetPath))) + toast.push({ + status: 'success', + title: `${clipboardItem.isDocument ? 'Document' : 'Field'} ${targetSchemaTypeTitle} updated`, + }) + } catch (error) { + toast.push({ + status: 'error', + title: error.message, + }) } }, - [toast, getDocumentMeta, onChange], + [getDocumentMeta, schema, toast, onChange], ) return {onCopy, onPaste, onChange} diff --git a/packages/sanity/src/core/studio/copyPaste/utils.ts b/packages/sanity/src/core/studio/copyPaste/utils.ts index fa758cee500..2269c033a6a 100644 --- a/packages/sanity/src/core/studio/copyPaste/utils.ts +++ b/packages/sanity/src/core/studio/copyPaste/utils.ts @@ -1,5 +1,6 @@ +import {isIndexSegment, isKeySegment, isReferenceSchemaType} from '@sanity/types' import {isFinite} from 'lodash' -import {isString} from 'sanity' +import {isString, type ObjectField, type ObjectFieldType, type Path, type SchemaType} from 'sanity' import {type CopyActionResult} from './types' @@ -25,22 +26,27 @@ export function isCopyPasteResult(value: any): value is CopyActionResult { return typeof normalized === 'object' && normalized?._type === 'copyResult' } -export function transformValueToPrimitive(value: CopyActionResult | null): string | number { - const {docValue} = value || {} +export function transformValueToPrimitive( + copyActionResult: CopyActionResult | null, +): string | number { + if (!copyActionResult) { + return '' + } + const {value} = copyActionResult - if (isString(docValue)) { - return docValue + if (isString(value)) { + return value } - if (isFinite(docValue)) { - return Number(docValue) + if (isFinite(value)) { + return Number(value) } - if (isString(docValue) && isFinite(parseFloat(docValue))) { - return parseFloat(docValue) + if (isString(value) && isFinite(parseFloat(value))) { + return parseFloat(value) } - return docValue || '' + return value?.toString() || '' } export function parseCopyResult(value: any): CopyActionResult | null { @@ -102,3 +108,87 @@ function getNativeInputValueSetter() { return Object.getOwnPropertyDescriptor(window?.HTMLInputElement?.prototype, 'value')?.set } + +export function tryResolveSchemaTypeForPath( + baseType: SchemaType, + pathSegments: Path, +): SchemaType | undefined { + let current: SchemaType | undefined = baseType + for (const segment of pathSegments) { + if (!current) { + return undefined + } + + if (typeof segment === 'string') { + current = getFieldTypeByName(current, segment) + continue + } + + const isArrayAccessor = isKeySegment(segment) || isIndexSegment(segment) + if (!isArrayAccessor || current.jsonType !== 'array') { + return undefined + } + + const [memberType, otherType] = current.of || [] + if (otherType || !memberType) { + // Can't figure out the type without knowing the value + return undefined + } + + if (!isReferenceSchemaType(memberType)) { + current = memberType + continue + } + + const [refType, otherRefType] = memberType.to || [] + if (otherRefType || !refType) { + // Can't figure out the type without knowing the value + return undefined + } + + current = refType + } + + return current +} + +function getFieldTypeByName(type: SchemaType, fieldName: string): SchemaType | undefined { + if (!('fields' in type)) { + return undefined + } + + const fieldType = type.fields.find((field) => field.name === fieldName) + return fieldType ? fieldType.type : undefined +} + +export function fieldExtendsType(field: ObjectField | ObjectFieldType, ofType: string): boolean { + let current: SchemaType | undefined = field.type + while (current) { + if (current.name === ofType) { + return true + } + + if (!current.type && current.jsonType === ofType) { + return true + } + + current = current.type + } + + return false +} + +export function isEmptyValue(value: unknown): boolean { + if (value === null || value === undefined) return true + if (typeof value === 'string' && value.trim() === '') return true + if (typeof value === 'number' && isNaN(value)) return true + if (typeof value === 'object' && Object.keys(value).length === 0) return true + if ( + typeof value === 'object' && + Object.keys(value).length === 1 && + Object.keys(value)[0] === '_type' + ) + return true + if (Array.isArray(value) && value.length === 0) return true + return false +} diff --git a/packages/sanity/src/core/studio/copyPaste/valueTransfer.ts b/packages/sanity/src/core/studio/copyPaste/valueTransfer.ts index 454e107a291..e616d5401cc 100644 --- a/packages/sanity/src/core/studio/copyPaste/valueTransfer.ts +++ b/packages/sanity/src/core/studio/copyPaste/valueTransfer.ts @@ -1,46 +1,329 @@ -// const {schemaTypeName: sourceSchemaTypeName, isDocument, isArray} = value +import { + type ArraySchemaType, + type BooleanSchemaType, + isArrayOfObjectsSchemaType, + isArrayOfPrimitivesSchemaType, + isArraySchemaType, + isObjectSchemaType, + isPrimitiveSchemaType, + isReferenceSchemaType, + type NumberSchemaType, + type ObjectSchemaType, + type StringSchemaType, + type TypedObject, +} from '@sanity/types' +import {type Path, type SchemaType} from 'sanity' -import {normalizeBlock} from '@sanity/block-tools' -import {pickBy} from 'lodash' -import {type SchemaType} from 'sanity' +import {getValueAtPath} from '../../field/paths/helpers' +import {isEmptyValue, tryResolveSchemaTypeForPath} from './utils' -import {type CopyActionResult} from './types' - -export interface TransferValueOptions { - targetSchemaType: SchemaType - targetDocumentType?: string +export interface TransferValueError { + level: 'warning' | 'error' + message: string + sourceValue: unknown } -export function transferValue( - value: CopyActionResult, - {targetDocumentType, targetSchemaType}: TransferValueOptions, -): CopyActionResult { - const {schemaTypeName: sourceSchemaTypeName, isDocument, isArray} = value - const keysToDelete = ['_id', '_type', '_createdAt', '_updatedAt', '_rev'] - const isArrayValue = Array.isArray(value.docValue) - const isPrimitiveValue = typeof value.docValue !== 'object' - - let targetValue = - isArrayValue || isPrimitiveValue - ? value.docValue - : pickBy( - value.docValue as object, - (_value, key) => !keysToDelete.includes(key) && !key.startsWith('_'), - ) - const isTargetDocument = targetSchemaType.type?.name === 'document' - const targetName = targetSchemaType.name - const targetTypeLabel = isTargetDocument ? 'document' : 'field' - - if (isArrayValue || isArray) { - // Reset/normalize array object keys - targetValue = [...targetValue].map((item) => { - return normalizeBlock(item) +export function transferValue({ + sourceRootSchemaType, + sourcePath, + sourceValue, + targetRootSchemaType, + targetPath, +}: { + sourceRootSchemaType: SchemaType + sourcePath: Path + sourceValue: unknown + targetRootSchemaType: SchemaType + targetPath: Path +}): { + targetValue: unknown + errors: TransferValueError[] +} { + const errors: TransferValueError[] = [] + + const sourceSchemaTypeAtPath = tryResolveSchemaTypeForPath(sourceRootSchemaType, sourcePath) + const targetSchemaTypeAtPath = tryResolveSchemaTypeForPath(targetRootSchemaType, targetPath) + + if (!sourceSchemaTypeAtPath) { + throw new Error('Could not find source schema type at path') + } + if (!targetSchemaTypeAtPath) { + throw new Error('Could not find target schema type at path') + } + + // Test that the target schematypes are compatible + if ( + sourceSchemaTypeAtPath && + targetSchemaTypeAtPath && + sourceSchemaTypeAtPath.jsonType !== targetSchemaTypeAtPath.jsonType + ) { + return { + targetValue: undefined, + errors: [ + { + level: 'error', + message: 'Source and target schema types are not compatible', + sourceValue, + }, + ], + } + } + + const sourceValueAtPath = getValueAtPath(sourceValue as TypedObject, sourcePath) + + // Objects + if ( + sourceSchemaTypeAtPath.jsonType === 'object' && + targetSchemaTypeAtPath.jsonType === 'object' + ) { + // Special handling for reference objects to ensure that (some) common references exists + // I think this is the best effort we can do without fetching and inspecting the actual referenced data, + if ( + isReferenceSchemaType(sourceSchemaTypeAtPath) && + isReferenceSchemaType(targetSchemaTypeAtPath) + ) { + const sourceReferenceTypes = sourceSchemaTypeAtPath.to.map((type) => type.name) + const targetReferenceTypes = targetSchemaTypeAtPath.to.map((type) => type.name) + if (!targetReferenceTypes.some((type) => sourceReferenceTypes.includes(type))) { + errors.push({ + level: 'error', + message: `References of type ${sourceReferenceTypes.join(', ')} is not allowed in reference field to types ${sourceReferenceTypes.join(', ')}`, + sourceValue, + }) + return { + targetValue: undefined, + errors, + } + } + } + + return collateObjectValue({ + sourceValue: sourceValueAtPath as TypedObject, + targetSchemaType: targetSchemaTypeAtPath as ObjectSchemaType, + targetPath: [], + errors, }) } - if (isDocument && (!isTargetDocument || targetDocumentType !== sourceSchemaTypeName)) { - throw new Error( - `Cannot paste document of type ${sourceSchemaTypeName} into ${targetTypeLabel} of type ${targetName}`, + + // Arrays + if (sourceSchemaTypeAtPath.jsonType === 'array' && targetSchemaTypeAtPath.jsonType === 'array') { + return collateArrayValue({ + sourceValue: sourceValueAtPath as unknown[], + targetSchemaType: targetSchemaTypeAtPath as ArraySchemaType, + errors, + }) + } + + // Primitives + const primitiveSchemaType = targetSchemaTypeAtPath as + | NumberSchemaType + | StringSchemaType + | BooleanSchemaType + + return collatePrimitiveValue({ + sourceValue: sourceValueAtPath as unknown, + targetSchemaType: primitiveSchemaType, + errors, + }) +} + +function collateObjectValue({ + sourceValue, + targetSchemaType, + targetPath, + errors, +}: { + sourceValue: unknown + targetSchemaType: ObjectSchemaType + targetPath: Path + errors: TransferValueError[] +}) { + const targetValue = {_type: targetSchemaType.name} as TypedObject + const objectMembers = targetSchemaType.fields + + objectMembers.forEach((member) => { + const memberSchemaType = member.type + const memberIsArray = isArraySchemaType(memberSchemaType) + const memberIsObject = isObjectSchemaType(memberSchemaType) + const memberIsPrimitive = isPrimitiveSchemaType(memberSchemaType) + // Primitive field + if (memberIsPrimitive) { + const genericValue = sourceValue + ? ((sourceValue as TypedObject)[member.name] as unknown) + : undefined + const collated = collatePrimitiveValue({ + sourceValue: genericValue, + targetSchemaType: memberSchemaType, + errors, + }) + if (!isEmptyValue(collated.targetValue)) { + targetValue[member.name] = collated.targetValue + } + // Object field + } else if (memberIsObject) { + const collated = collateObjectValue({ + sourceValue: getValueAtPath( + sourceValue as TypedObject, + targetPath.concat(member.name), + ) as TypedObject, + targetPath: [], + targetSchemaType: memberSchemaType, + errors, + }) + if (!isEmptyValue(collated.targetValue)) { + targetValue[member.name] = collated.targetValue + } + // Array field + } else if (memberIsArray) { + const genericValue = sourceValue + ? ((sourceValue as TypedObject)[member.name] as unknown) + : undefined + const collated = collateArrayValue({ + sourceValue: genericValue, + targetSchemaType: memberSchemaType as ArraySchemaType, + errors, + }) + if (!isEmptyValue(collated.targetValue)) { + targetValue[member.name] = collated.targetValue + } + } + }) + const valueAtTargetPath = getValueAtPath(targetValue, targetPath) + return { + targetValue: valueAtTargetPath, + errors, + } +} + +function collateArrayValue({ + sourceValue, + targetSchemaType, + errors, +}: { + sourceValue: unknown + targetSchemaType: ArraySchemaType + errors: TransferValueError[] +}): { + targetValue: unknown + errors: TransferValueError[] +} { + let targetValue: unknown[] | undefined = undefined + + const genericValue = sourceValue as unknown[] + + if (!genericValue || !Array.isArray(genericValue)) { + return { + targetValue: undefined, + errors: [ + { + level: 'error', + message: `Value of type ${typeof genericValue}, is not allowed in this array field`, + sourceValue, + }, + ], + } + } + const isArrayOfPrimitivesMember = isArrayOfPrimitivesSchemaType(targetSchemaType) + const isArrayOfObjectsMember = isArrayOfObjectsSchemaType(targetSchemaType) + + // Primitive array + if (isArrayOfPrimitivesMember) { + const transferredItems = genericValue.filter( + (item) => + (typeof item === 'number' && + targetSchemaType.of.map((type) => type.jsonType).includes('number')) || + (typeof item === 'string' && + targetSchemaType.of.map((type) => type.jsonType).includes('string')) || + (typeof item === 'boolean' && + targetSchemaType.of.map((type) => type.jsonType).includes('boolean')), ) + const nonTransferredItems = genericValue.filter((item) => !transferredItems.includes(item)) + if (nonTransferredItems.length > 0) { + nonTransferredItems.forEach((item) => { + errors.push({ + level: transferredItems.length > 0 ? 'warning' : 'error', + message: `Value of type ${typeof item}, is not allowed in this array field`, + sourceValue: item, + }) + }) + } + if (transferredItems.length > 0) { + targetValue = transferredItems + } } - return targetValue + + // Object array + if (isArrayOfObjectsMember) { + const value = sourceValue as TypedObject[] + const transferredItems = value.filter((item) => + targetSchemaType.of.some((type) => type.name === item._type), + ) + const nonTransferredItems = value.filter( + (item) => !targetSchemaType.of.some((type) => type.name === item._type), + ) + targetValue = + transferredItems.length > 0 + ? transferredItems.map((item) => cleanObjectKeys(item)) + : undefined + if (nonTransferredItems.length > 0) { + nonTransferredItems.forEach((item) => { + errors.push({ + level: transferredItems.length > 0 ? 'warning' : 'error', + message: `Value of type '${item._type || typeof item}' is not allowed in this array field`, + sourceValue: item, + }) + }) + } + } + + return { + targetValue, + errors, + } +} + +function collatePrimitiveValue({ + sourceValue, + targetSchemaType, + errors, +}: { + sourceValue: unknown + targetSchemaType: NumberSchemaType | StringSchemaType | BooleanSchemaType + errors: TransferValueError[] +}): { + targetValue: unknown + errors: TransferValueError[] +} { + let targetValue: unknown + const primitiveValue = sourceValue as unknown + if (typeof primitiveValue === 'undefined') { + return { + targetValue: undefined, + errors, + } + } + const isSamePrimitiveType = targetSchemaType.jsonType === typeof primitiveValue + if (isSamePrimitiveType) { + targetValue = primitiveValue + } else { + errors.push({ + level: 'error', + message: `Value of type ${typeof primitiveValue}, is not allowed in this field`, + sourceValue: primitiveValue, + }) + } + return { + targetValue, + errors, + } +} + +function cleanObjectKeys(obj: TypedObject) { + const disallowedKeys = ['_id', '_createdAt', '_updatedAt', '_rev'] + return Object.keys(obj).reduce((acc, key) => { + if (disallowedKeys.includes(key)) { + return acc + } + return {...acc, [key]: obj[key]} + }, {}) }