import _ from 'lodash'; import { v4 as uuid } from 'uuid'; import tinycolor from 'tinycolor2'; import type { PatchOperations, SanityDocument } from '@sanity/client'; import type * as StackbitTypes from '@stackbit/types'; import type { ModelContext, ModelWithContext } from './sanity-schema-converter'; import { getItemTypeForListItem, isLocalizedModelField, getSanityAliasFieldType, resolvedFieldType } from './utils'; import type { GetModelByName } from './sanity-document-converter'; export function convertUpdateOperation({ operation, ...rest }: { operation: StackbitTypes.UpdateOperation; sanityDocument: SanityDocument; getModelByName: GetModelByName; model: ModelWithContext; }): PatchOperations { switch (operation.opType) { case 'set': return Operations.set({ operation, ...rest }); case 'unset': return Operations.unset({ operation, ...rest }); case 'insert': return Operations.insert({ operation, ...rest }); case 'remove': return Operations.remove({ operation, ...rest }); case 'reorder': return Operations.reorder({ operation, ...rest }); } } export const Operations: { [Type in StackbitTypes.UpdateOperation as Type['opType']]: ({ sanityDocument, operation, getModelByName }: { sanityDocument: SanityDocument; operation: Type; getModelByName: GetModelByName; model: StackbitTypes.Model; }) => PatchOperations; } = { set: ({ operation, sanityDocument, getModelByName, model }) => { const { field, fieldPath, modelField, locale } = operation; const { patchFieldPath, modelFieldPath, localizedLeaf, isInList } = getPatchPathAndModelFieldPaths({ fieldPath, sanityDocument, getModelByName, addValueToI18NLeafs: true, allowUndefinedI18NLeafArrays: true, locale }); let value = mapUpdateOperationFieldToSanityValue({ updateOperationField: field, getModelByName, modelField, rootModel: model, modelFieldPath, locale, isInList }); if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') { value = localizedValue({ value, model, modelFieldPath, locale }); if (localizedLeaf === 'newItem') { return { insert: { after: `${patchFieldPath}[-1]`, items: [value] } }; } else if (localizedLeaf === 'undefinedArray') { return { set: { [patchFieldPath]: [value] } }; } } return { set: { [patchFieldPath]: value } }; }, unset: ({ operation, sanityDocument, getModelByName }) => { const { fieldPath, locale } = operation; const { patchFieldPath } = getPatchPathAndModelFieldPaths({ fieldPath, sanityDocument, getModelByName, locale }); return { unset: [patchFieldPath] }; }, insert: ({ operation, sanityDocument, getModelByName, model }) => { const { item, fieldPath, modelField, index, locale } = operation; const listItemModelField = (modelField as StackbitTypes.FieldList).items ?? { type: 'string' }; const { patchFieldPath, modelFieldPath, currentValue, localizedLeaf } = getPatchPathAndModelFieldPaths({ fieldPath, sanityDocument, getModelByName, addValueToI18NLeafs: true, allowUndefinedI18NLeafArrays: true, locale }); let value = mapUpdateOperationFieldToSanityValue({ updateOperationField: item, getModelByName, modelField: listItemModelField, rootModel: model, modelFieldPath: modelFieldPath.concat('items'), locale, isInList: true }); // In the case of a localized array field, the field will contain an // array of objects with localized arrays: // [ // { // _key: 'en', // _type: 'internationalizedArrayLocalizedArray', // value: ['en value 1', 'en value 2', 'en value 3'] // }, // { // _key: 'es', // _type: 'internationalizedArrayLocalizedArray', // value: ['es value 1', 'es value 2', 'es value 3'] // } // ] if (isLocalizedModelField(modelField) && localizedLeaf !== 'value') { // When there is no array for a given locale, create a new localized // array with a single value and insert it into the localized array: // { // _key: 'es', // _type: 'internationalizedArrayLocalizedArray', // value: [value] // } value = localizedValue({ value: [value], model, modelFieldPath, locale }); if (localizedLeaf === 'newItem') { return { insert: { after: `${patchFieldPath}[-1]`, items: [value] } }; } else if (localizedLeaf === 'undefinedArray') { return { set: { [patchFieldPath]: [value] } }; } } if (!currentValue) { return { set: { [patchFieldPath]: [value] } }; } else if (_.isNil(index) || index >= currentValue.length) { return { insert: { after: `${patchFieldPath}[-1]`, items: [value] } }; } return { insert: { before: `${patchFieldPath}[${index}]`, items: [value] } }; }, remove: ({ sanityDocument, operation, getModelByName }) => { const { fieldPath, index, locale } = operation; const { patchFieldPath } = getPatchPathAndModelFieldPaths({ fieldPath: fieldPath.concat(index), sanityDocument, getModelByName, locale }); return { unset: [patchFieldPath] }; }, reorder: ({ sanityDocument, operation, getModelByName }) => { const { fieldPath, order, locale } = operation; const { patchFieldPath, currentValue } = getPatchPathAndModelFieldPaths({ fieldPath, sanityDocument, getModelByName, addValueToI18NLeafs: true, locale }); const newEntryArr = order.map((newIndex) => currentValue[newIndex]); return { set: { [patchFieldPath]: newEntryArr } }; } }; export function mapUpdateOperationFieldToSanityValue({ updateOperationField, getModelByName, modelField, rootModel, modelFieldPath, locale, isInList }: { updateOperationField: StackbitTypes.UpdateOperationField; getModelByName: GetModelByName; modelField: StackbitTypes.FieldSpecificProps; rootModel: StackbitTypes.Model; modelFieldPath: string[]; locale: string | undefined; isInList?: boolean; }): any { switch (updateOperationField.type) { case 'string': case 'url': case 'text': case 'markdown': case 'html': case 'boolean': case 'date': case 'datetime': case 'enum': case 'style': case 'json': case 'richText': case 'file': { return updateOperationField.value; } case 'number': { return Number(updateOperationField.value); } case 'slug': { return { _type: getSanityAliasFieldType({ resolvedType: 'slug', model: rootModel, modelFieldPath }), current: updateOperationField.value }; } case 'color': { const color = tinycolor(updateOperationField.value); return { _type: getSanityAliasFieldType({ resolvedType: 'color', model: rootModel, modelFieldPath }), hex: color.toHexString(), alpha: color.getAlpha(), hsl: { _type: 'hslaColor', ...color.toHsl() }, hsv: { _type: 'hsvaColor', ...color.toHsv() }, rgb: { _type: 'rgbaColor', ...color.toRgb() } }; } case 'image': { const value = updateOperationField?.value; if (modelField.type === 'image') { if (modelField.source === 'cloudinary' || modelField.source === 'aprimo') { const type = modelField.source === 'cloudinary' ? 'cloudinary.asset' : 'aprimo.cdnasset'; return addKeyIfInList( { _type: type, ...value }, isInList ); } else if (modelField.source === 'bynder') { let imageValue = value; if (imageValue?.__typename) { imageValue = _.omitBy( { id: value.id, name: value.name, databaseId: value.databaseId, type: value.type, previewUrl: value.type === 'VIDEO' ? value.previewUrls[0] : value.files.webImage.url, previewImg: value.files.webImage.url, datUrl: value.files.transformBaseUrl?.url, videoUrl: value.type === 'VIDEO' ? value.files.original?.url : null, description: value.description, aspectRatio: value.height / value.width }, _.isUndefined ); } return addKeyIfInList( { _type: 'bynder.asset', ...imageValue }, isInList ); } } // TODO: there is a bug right now because documentField is inferred from the model which is an "image", not reference return addKeyIfInList(linkForAssetId(value), isInList); } case 'object': { if (modelField.type !== 'object') { throw new Error(`Operation field type 'object' does not match model field type '${modelField.type}'.`); } // Sanity array fields may consist of anonymous 'object' with names. // When creating such objects, the '_type' should be set to their // name to identify them among other 'object' types. const fieldAlias = rootModel.context?.fieldAliasMap?.[modelFieldPath.join('.')] ?? []; const typeName = fieldAlias?.find((alias) => alias.resolvedTypeName === 'object')?.origTypeName; const object = addKeyIfInList( { ...(typeName ? { _type: typeName } : null) }, isInList ); return _.reduce( updateOperationField.fields, (result, childUpdateOperationField, fieldName) => { const childModelField = _.find(modelField.fields, (field) => field.name === fieldName); if (!childModelField) { throw new Error( `No model field found for field '${fieldName}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.` ); } const childModelFieldPath = modelFieldPath.concat(fieldName); const value = mapUpdateOperationFieldToSanityValue({ updateOperationField: childUpdateOperationField, getModelByName, modelField: childModelField, rootModel, modelFieldPath: childModelFieldPath, locale }); if (isLocalizedModelField(childModelField)) { _.set(result, fieldName, [ localizedValue({ value, model: rootModel, modelFieldPath: childModelFieldPath, locale }) ]); } else { _.set(result, fieldName, value); } return result; }, object ); } case 'model': { if (modelField.type !== 'model') { throw new Error(`Operation field type 'model' does not match model field type '${modelField.type}'.`); } const modelName = updateOperationField.modelName; const childModel = getModelByName(modelName); if (!childModel) { throw new Error(`No model '${modelName}' was found for field at '${modelFieldPath.join('.')}' in model '${rootModel.name}'.`); } const object = addKeyIfInList( { _type: getSanityAliasFieldType({ resolvedType: modelName, model: rootModel, modelFieldPath }) }, isInList ); return _.reduce( updateOperationField.fields, (result, childUpdateOperationField, fieldName) => { const childModelField = _.find(childModel?.fields, (field) => field.name === fieldName); if (!childModelField) { throw new Error(`No model field found for field '${fieldName}' in model '${childModel.name}'.`); } const childModelFieldPath = [fieldName]; const value = mapUpdateOperationFieldToSanityValue({ updateOperationField: childUpdateOperationField, getModelByName, modelField: childModelField, rootModel: childModel, modelFieldPath: childModelFieldPath, locale }); if (isLocalizedModelField(childModelField)) { _.set(result, fieldName, [ localizedValue({ value, model: rootModel, modelFieldPath: childModelFieldPath, locale }) ]); } else { _.set(result, fieldName, value); } return result; }, object ); } case 'reference': { const value = updateOperationField.refType === 'document' ? { _ref: updateOperationField.refId, _type: getSanityAliasFieldType({ resolvedType: 'reference', model: rootModel, modelFieldPath }), // TODO: this is a bug! // The _weak is not always `true`. Lookup the referenced document // by id, and the original model field's `weak` value. // If the referenced document was published (status === 'published' // or status === 'modified'), then the _weak value should be set to // the `weak` value defined in Sanity model field's. // Otherwise, if the document was not published (status === 'added'), // then the _weak value should be set to `true` and the // _strengthenOnPublish.weak should be set to the `weak` value // defined in the model field's. _weak: true, // TODO: Lookup the referenced document by id, and the original model // field's `weak` value. If the referenced document was never published // (status === 'added'), then add the _strengthenOnPublish object and // set its `weak` property to the model field's `weak` value. // When publishing objects with reference fields having the // _strengthenOnPublish object, update the field's _weak with that of // _strengthenOnPublish.weak. // For more info: https://www.sanity.io/blog/obvious-features-aren-t-obviously-made#2c38c9f38060 _strengthenOnPublish: { // type: , // weak: } } : linkForAssetId(updateOperationField.refId); return addKeyIfInList(value, isInList); } case 'cross-reference': { throw new Error('Sanity crossDatasetReference fields not supported.'); } case 'list': { if (modelField.type !== 'list') { throw new Error(`Operation field type 'list' does not match model field type '${modelField.type}'.`); } return updateOperationField.items.map((item, index) => { let listItemModelField = modelField.items; if (_.isArray(modelField.items)) { const itemModel = (modelField.items as StackbitTypes.FieldListItems[]).find((listItemsModel) => listItemsModel.type === item.type); if (!itemModel) { throw new Error( `No list item model found for item type '${item.type}' in model '${rootModel.name}' at field path ${modelFieldPath.join('.')}.` ); } listItemModelField = itemModel; } return mapUpdateOperationFieldToSanityValue({ updateOperationField: item, getModelByName, modelField: listItemModelField, rootModel, modelFieldPath: modelFieldPath.concat('items'), locale, isInList: true }); }); } default: { const _exhaustiveCheck: never = updateOperationField; return _exhaustiveCheck; } } } function addKeyIfInList(object: Record, isInList?: boolean) { if (isInList) { _.set(object, '_key', uuid()); } return object; } /** * In Sanity, a localized field is represented by an array of objects containing the localized field values. * Each object has three properties: * - `_key` holds the field's locale * - `_type` holds the type of the localized value * - `value` holds the localized value. * * Note: the `_type` is not the regular type such as `string` or `object`, * but a special localized type generated by the Sanity Internationalized Array plugin. * The map between the localized fields and these types is stored in the model's context. * title: [ * { * _key: 'us', * _type: 'internationalizedArrayStringValue', * value: 'hello', * }, { * _key: 'es', * _type: 'internationalizedArrayStringValue', * value: 'hola', * } * ] */ export function localizedValue({ value, model, modelFieldPath, locale }: { value: any; model: ModelWithContext; modelFieldPath: string[]; locale?: string }) { const localizedFieldModelNameMap = model.context?.localizedFieldsModelMap?.[modelFieldPath.join('.')]; if (!localizedFieldModelNameMap) { throw Error(`Internationalized array model for localized field at path ${modelFieldPath.join('.')} of model ${model.name} not found.`); } if (!locale) { throw Error(`No locale provided for localized field at path ${modelFieldPath.join('.')} of model ${model.name}.`); } return { _key: locale, _type: localizedFieldModelNameMap.arrayValueModelName, value: value }; } function linkForAssetId(assetId?: string): any { return { _type: 'image', asset: { _ref: assetId, _type: 'reference' } }; } /** * Receives a `fieldPath` of a target field in a `sanityDocument` and returns * an object with following properties: * * - `model`: The closest ancestor model of the target field. * - `modelFieldPath`: The field path of the target field from the `model`. * The model's field path doesn't identify a specific document field, * therefore it does not include list indexes. * - `patchFieldPath`: A Sanity specific field path for patching fields. * For array items, the field path will include [_key="..."] when possible. * For localized fields, which represented by arrays in Sanity, the path * will point to the localized array item using the `_key`: * `sections[_key=="es"].value[3].title` * - `localizedLeaf`: A string specifying the leaf value in the `patchFieldPath` * when the target field is localized: * - `value`: The `patchFieldPath` points to the localized value: * `sections[_key=="es"].value * - `newItem`: The `patchFieldPath` points to the existing localized array, * which does not include an item for the provided locale. * Returned only when `allowUndefinedI18NLeafArrays` set to `true`. * - `undefinedArray`: The `patchFieldPath` points to undefined localized array. * Returned only when `allowUndefinedI18NLeafArrays` set to `true`. * - `currentValue`: The value of the target field. If the target field is * localized but doesn't have a value for the provided locale, * the `currentValue` will be undefined. */ function getPatchPathAndModelFieldPaths({ fieldPath, sanityDocument, getModelByName, addValueToI18NLeafs, allowUndefinedI18NLeafArrays, locale }: { fieldPath: StackbitTypes.FieldPath; sanityDocument: SanityDocument; getModelByName: (modelName: string) => ModelWithContext | undefined; addValueToI18NLeafs?: boolean; allowUndefinedI18NLeafArrays?: boolean; locale?: string; }): { model: ModelWithContext; modelFieldPath: string[]; patchFieldPath: string; localizedLeaf?: 'value' | 'newItem' | 'undefinedArray'; currentValue: any; isInList: boolean; } { function iterateObject({ object, model, rootModel, modelFieldPath, fieldPath, first = false }: { object: Record; model: StackbitTypes.Model | StackbitTypes.FieldObjectProps; rootModel: ModelWithContext; modelFieldPath: string[]; fieldPath: StackbitTypes.FieldPath; first?: boolean; }): { model: ModelWithContext; modelFieldPath: string[]; patchFieldPath: string; localizedLeaf?: 'value' | 'newItem' | 'undefinedArray'; currentValue: any; isInList: boolean; } { const [fieldName, ...fieldPathTail] = fieldPath as [string, ...StackbitTypes.FieldPath]; if (typeof fieldName === 'undefined') { throw new Error('The fieldPath cannot be empty.'); } const modelField = (model.fields ?? []).find((field) => field.name === fieldName); if (!modelField) { throw new Error(`Model field for field '${fieldName}' not found.`); } let patchFieldPath = ''; if (/\W/.test(fieldName)) { // field name is a string with non-alphanumeric characters patchFieldPath += `['${fieldName}']`; } else { if (!first) { patchFieldPath += '.'; } patchFieldPath += fieldName; } let value = object[fieldName]; let localizedLeaf: 'value' | 'newItem' | 'undefinedArray' | undefined; if (modelField?.localized) { if (Array.isArray(value)) { const localizedItem = value.find((item) => item._key === locale); if (localizedItem) { patchFieldPath += `[_key=="${locale}"]`; if (fieldPathTail.length === 0) { localizedLeaf = 'value'; if (addValueToI18NLeafs) { patchFieldPath += '.value'; } } else { patchFieldPath += '.value'; } value = localizedItem.value; } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) { localizedLeaf = 'newItem'; value = undefined; } else { throw new Error(`The localized field '${fieldName}' has no value for locale '${locale}'.`); } } else if (fieldPathTail.length === 0 && allowUndefinedI18NLeafArrays) { localizedLeaf = 'undefinedArray'; value = undefined; } else { throw new Error(`The localized field '${fieldName}' has no localization array defined for locale '${locale}'.`); } } const result = iterateField({ value, modelField, fieldPath: fieldPathTail, rootModel, modelFieldPath: modelFieldPath.concat(fieldName), isInList: false }); return { model: result.model, modelFieldPath: result.modelFieldPath, patchFieldPath: patchFieldPath + result.patchFieldPath, localizedLeaf: localizedLeaf ?? result.localizedLeaf, currentValue: result.currentValue, isInList: result.isInList }; } function iterateField({ value, modelField, fieldPath, rootModel, modelFieldPath, isInList }: { value: any; modelField: StackbitTypes.FieldSpecificProps; fieldPath: StackbitTypes.FieldPath; rootModel: ModelWithContext; modelFieldPath: string[]; isInList: boolean; }): { model: ModelWithContext; modelFieldPath: string[]; patchFieldPath: string; localizedLeaf?: 'value' | 'newItem' | 'undefinedArray'; currentValue: any; isInList: boolean; } { if (fieldPath.length === 0) { return { patchFieldPath: '', currentValue: value, model: rootModel, modelFieldPath, isInList: isInList }; } if (typeof value === 'undefined') { throw new Error(`Field path has more items [${fieldPath.join('.')}], but value is undefined.`); } if (modelField?.type === 'object') { return iterateObject({ object: value, model: modelField, rootModel, modelFieldPath, fieldPath }); } else if (modelField?.type === 'model') { const modelName = resolvedFieldType({ sanityFieldType: value._type, model: rootModel, modelFieldPath }); const model = getModelByName(modelName); if (!model) { throw new Error(`Model '${modelName}' not found.`); } return iterateObject({ object: value, model, rootModel: model, modelFieldPath: [], fieldPath }); } else if (modelField?.type === 'list') { const [itemIndex, ...fieldPathTail] = fieldPath as [number, ...StackbitTypes.FieldPath]; const listItem = value[itemIndex]; const itemModel = getItemTypeForListItem(listItem, modelField); if (!itemModel) { throw new Error('Could not resolve type of a list item.'); } const result = iterateField({ value: listItem, modelField: itemModel, rootModel, modelFieldPath: modelFieldPath.concat('items'), fieldPath: fieldPathTail, isInList: true }); // try to use Sanity _key as explicit accessor const key = listItem?._key; const patchFieldPath = key ? `[_key=="${key}"]` : `[${Number(itemIndex)}]`; return { // model fieldPath doesn't include array indexes. // return fieldPath with field names only, no list indexes model: result.model, modelFieldPath: result.modelFieldPath, // fieldPath for Sanity patches should always include array indexes. patchFieldPath: patchFieldPath + result.patchFieldPath, currentValue: result.currentValue, isInList: result.isInList }; } else { throw new Error(`Field path has more items [${fieldPath.join('.')}], but no objects/arrays left to iterate.`); } } const modelName = sanityDocument._type; const model = getModelByName(modelName); if (!model) { throw new Error(`Model '${modelName}' not found.`); } return iterateObject({ object: sanityDocument, model, rootModel: model, modelFieldPath: [], fieldPath, first: true }); }