import _ from 'lodash'; import { Config, Model as SDKModel } from '@stackbit/sdk'; import { Model as CSIModel, Field, FieldSpecificProps, UpdateOperationListFieldItem, isOneOfFieldTypes, isModelFieldSpecificPropsOneOfFieldTypes } from '@stackbit/types'; import { fieldPathToString, mapPromise } from '@stackbit/utils'; import * as CSITypes from '@stackbit/types'; import * as ContentStoreTypes from '../types'; import { getContentSourceId, getUserContextForSrcType, updateOperationValueFieldWithCrossReference } from '../content-store-utils'; import { createDocumentHooked } from './document-hooks'; import { getAssetSourceBySourceName, transformAssetSourceDataForAssetSource } from './asset-sources-utils'; import { validateUpdateOperationFields } from './document-validations'; import { createConfigDelegate } from './config-delegate'; export type CreateDocumentThunk = ({ updateOperationFields, contentSourceData, modelName, locale }: { updateOperationFields: Record; contentSourceData: ContentStoreTypes.ContentSourceData; modelName: string; locale: string | undefined; }) => Promise<{ documentId: string }>; export function getCreateDocumentThunk({ stackbitConfig, getContentSourceDataById, logger, defaultLocaleDocumentId, user }: { stackbitConfig: Config | null; getContentSourceDataById: () => Record; logger: CSITypes.Logger; defaultLocaleDocumentId?: string; user?: ContentStoreTypes.User; }): CreateDocumentThunk { return async ({ updateOperationFields, modelName, contentSourceData, locale }) => { // When passing model and modelMap to contentSourceInstance, we have to pass // the original models (i.e., csiModel and csiModelMap) that we've received // from that contentSourceInstance. We can't pass internal models as they // might const csiModel = contentSourceData.csiModelMap[modelName]; if (!csiModel) { throw new Error(`Model not found: '${modelName}'. Source: '${contentSourceData.id}'.`); } await validateUpdateOperationFields({ updateOperationFields, modelName, locale, contentSourceData, configDelegate: createConfigDelegate({ contentSourceDataById: getContentSourceDataById(), logger: logger }) }); const userContext = getUserContextForSrcType(contentSourceData.srcType, user); const actionOptions = { updateOperationFields: updateOperationFields, model: csiModel, modelMap: contentSourceData.csiModelMap, locale, defaultLocaleDocumentId, userContext }; return await createDocumentHooked({ actionOptions, stackbitConfig, contentSourceData, getContentSourceDataById, user, logger }); }; } /** * Receives a plain `object`, and creates a map of `CSITypes.UpdateOperationField` * by recursively iterating the `object` fields. Then invokes the `createDocument` * callback to delegate the creation of the document to a CSI module. * * If the `object` has fields of type `reference`, `cross-reference` or `model` * with the special `$$type` property holding the model name of the nested object, * this function will recursively create new documents and nested objects for * these fields. Other fields within the object will be used to populate the * fields of the new document or nested object. * * @example * { * title: 'hello world', * button: { * $$type: 'Button', // a new nested object of type Button will be * label: 'Click me' // created with 'label' field set to 'Click me' * } * } * * If the `object` has fields of type `reference` or `image` with the special * `$$ref` property holding an ID of an existing document, this function will * link an existing documents or assets with that ID. * * @example * { * title: 'hello world', * author: { * $$ref: 'xyz' // the 'author' field will be linked to a document * // with ID 'xyz' * } * } * * Returns an object with two fields: * 1. `document` holding the created CSITypes.Document for the passed object. * 2. `newRefDocumentIds` containing the list of the created document IDs that * were created recursively for `reference` fields having the `$$type` property. */ export async function createDocumentRecursively({ object, locale, modelName, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }: { object?: Record; locale: string | undefined; modelName: string; contentSourceId: string; contentSourceDataById: Record; assetSources: CSITypes.AssetSource[]; createDocument: CreateDocumentThunk; userLogger: CSITypes.Logger; }): Promise<{ documentId: string; newRefDocumentIds: string[] }> { const contentSourceData = contentSourceDataById[contentSourceId]; if (!contentSourceData) { throw new Error(`Content source data not found. Source: '${contentSourceId}'.`); } const modelMap = contentSourceData.modelMap; const csiModelMap = contentSourceData.csiModelMap; const model = modelMap[modelName]; const csiModel = contentSourceData.csiModelMap[modelName]; if (!model || !csiModel) { throw new Error(`Model not found: '${modelName}'. Source: '${contentSourceData.id}'.`); } const modelFields = model.fields ?? []; const csiModelFields = csiModel.fields ?? []; const nestedResult = await createObjectRecursively({ object, modelFields, csiModelFields, fieldPath: [modelName], locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); const result = await createDocument({ updateOperationFields: nestedResult.fields, contentSourceData: contentSourceData, modelName: modelName, locale: locale }); return { documentId: result.documentId, newRefDocumentIds: nestedResult.newRefDocumentIds }; } async function createObjectRecursively({ object, modelFields, csiModelFields, fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }: { object?: Record; modelFields: Field[]; csiModelFields: Field[]; fieldPath: (string | number)[]; locale: string | undefined; modelMap: Record; csiModelMap: Record; contentSourceId: string; contentSourceDataById: Record; assetSources: CSITypes.AssetSource[]; createDocument: CreateDocumentThunk; userLogger: CSITypes.Logger; }): Promise<{ fields: Record; newRefDocumentIds: string[]; }> { object = object ?? {}; const result: { fields: Record; newRefDocumentIds: string[]; } = { fields: {}, newRefDocumentIds: [] }; // When creating new documents, we are iterating over the model's fields to // construct the update operations. In case object has a field that does not // exist on the model, it will be ignored. On the other hand, if a model has // a field with "const" or "default" property that does not exist on the // object, the new document will be created with that field set to the value // of the "const" or the "default" value. const objectFieldNames = Object.keys(object); for (const modelField of modelFields) { const fieldName = modelField.name; const csiModelField = csiModelFields.find((field) => field.name === fieldName); if (!csiModelField) { throw new Error(`no model field found for field at path ${fieldPathToString(fieldPath.concat(fieldName))}`); } let value; if (modelField.const) { if (fieldName in object && typeof object[fieldName] !== 'undefined') { userLogger.warn( `got a value for a constant field '${fieldName}' while creating a document, ignoring the provided value, using the 'const' value instead.` ); _.pull(objectFieldNames, fieldName); } // if the model field has the "const" property, use its value if (typeof modelField.const === 'function') { value = await modelField.const({ data: object, locale }); } else { value = modelField.const; } } else if (fieldName in object) { // if the object has a field name matching a model field, use it value = object[fieldName]; _.pull(objectFieldNames, fieldName); } else if (!_.isNil(modelField.default)) { // if the model field has the "default" property, use its value if (typeof modelField.default === 'function') { value = await modelField.default({ data: object, locale }); } else { value = modelField.default; } } if (!_.isNil(value)) { const fieldResult = await createUpdateOperationFieldRecursively({ value, modelField, csiModelField, fieldPath: fieldPath.concat(fieldName), locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); result.fields[fieldName] = fieldResult.field; result.newRefDocumentIds = result.newRefDocumentIds.concat(fieldResult.newRefDocumentIds); } } if (objectFieldNames.length > 0) { userLogger.warn( `unknown fields were provided while creating a document at field path '${fieldPathToString( fieldPath )}', ignoring unknown fields: '${objectFieldNames.join(', ')}'` ); } return result; } /** * This method receives a value of type any and transforms it into a recursive * tree of {@link UpdateOperationField}. * * Important note: * The `type` of the UpdateOperationField MUST match the type of the original * `csiModelField` as provided by the content-source, not the type of the * `modelField` after it was extended via modelExtensions, mapModels, or other * means. The content-source expects to receive the field and operation types it * originally returned from its `getSchema` implementation. * * For example, a particular content source may not have a notion of a 'date' * field. But a developer who uses that content source may remap a 'string' field * into a 'date' field. Internally, Stackbit will treat that field as 'date' * field. But when sending update and create operations to the content-source, * the field must be converted back to the original content-source 'string' type. * * When reading model field properties other than 'type', this method should * use the extended modelField, as it may have data not available in the original * csiModelField. * * @example * // The content-source has an 'object' model field with two fields, 'title' * // and 'content' of type 'text', which were remapped to 'string' and 'markdown' * // in stackbit.config.ts. * createUpdateOperationFieldRecursively({ * value: { * title: 'Hero', * content: 'Multiline\ncontent' * }, * modelField: { * type: 'object', * fields: [ * { type: 'string', name: 'title' }, * { type: 'markdown', name: 'content' } * ] * }, * csiModelField: { * type: 'object', * fields: [ * { type: 'text', name: 'title' }, * { type: 'text', name: 'content' } * ] * } * }) * // The generated UpdateOperationField will use the original csiModelField types: * { * field: { * type: 'object', * fields: { * title: { * type: 'text', * value: 'title' * }, * content: { * type: 'text', * value: 'Multiline\ncontent' * }, * } * } * } * * @param value The value to transform into the {@link UpdateOperationField} * @param modelField The model Field of the value, after any model extensions * and/or model mapping have been applied to the original csiModelField. * @param csiModelField The original model Field of the value, as provided by * the content-source. * @param fieldPath Field path of the currently transformed value relative to the * root of the document. The first item in the field path is the model name of * the root document. This parameter is used to provide exact location of the * erroneous value when throwing exceptions. * @param locale * @param modelMap Map of extended content source models. * @param csiModelMap Map of original content source models. * @param contentSourceId * @param contentSourceDataById * @param assetSources Assets sources from stackbit.config.ts. * @param createDocument A method used to created nested documents. * @param userLogger */ async function createUpdateOperationFieldRecursively({ value, modelField, csiModelField, fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }: { value: any; modelField: FieldSpecificProps; csiModelField: FieldSpecificProps; fieldPath: (string | number)[]; locale: string | undefined; modelMap: Record; csiModelMap: Record; contentSourceId: string; contentSourceDataById: Record; assetSources: CSITypes.AssetSource[]; createDocument: CreateDocumentThunk; userLogger: CSITypes.Logger; }): Promise<{ field: CSITypes.UpdateOperationField; newRefDocumentIds: string[] }> { if (csiModelField.type === 'object') { if (modelField.type !== 'object') { throw new Error(`Field type mismatch between content-source and mapped models at field path '${fieldPathToString(fieldPath)}'.`); } const result = await createObjectRecursively({ object: value, modelFields: modelField.fields, csiModelFields: csiModelField.fields, fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return { field: { type: 'object', fields: result.fields }, newRefDocumentIds: result.newRefDocumentIds }; } else if (csiModelField.type === 'model') { if (modelField.type !== 'model') { throw new Error(`Field type mismatch between content-source and mapped models at field path '${fieldPathToString(fieldPath)}'.`); } let { $$type, ...rest } = value; const modelNames = modelField.models; // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use // the 'type' property in default values if (!$$type && 'type' in rest) { $$type = rest.type; rest = _.omit(rest, 'type'); } const modelName = $$type ?? (modelNames.length === 1 ? modelNames[0] : null); if (!modelName) { throw new Error('Invalid configuration. $$type was not found.'); } // asset model can't be part model field const model = modelMap[modelName]; const csiModel = csiModelMap[modelName]; if (!model || !csiModel) { throw new Error(`Model not found: '${modelName}'. Source: '${contentSourceId}'.`); } const result = await createObjectRecursively({ object: rest, modelFields: model.fields ?? [], csiModelFields: csiModel.fields ?? [], fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return { field: { type: 'model', modelName: modelName, fields: result.fields }, newRefDocumentIds: result.newRefDocumentIds }; } else if (csiModelField.type === 'image') { let refId: string | undefined; if (modelField.type !== 'image') { throw new Error(`Field type mismatch between content-source and mapped models at field path '${fieldPathToString(fieldPath)}'.`); } const assetSource = getAssetSourceBySourceName(assetSources, modelField.source); if (assetSource) { // omit $$type for backwards compatibility with legacy presets const sourceData = _.isPlainObject(value) ? _.omit(value, ['$$type']) : value; return { field: { type: 'image', value: transformAssetSourceDataForAssetSource({ sourceData, assetSource, logger: userLogger }) }, newRefDocumentIds: [] }; } // - when setting images in git, the UpdateOperationField is: // { type: 'image', value: 'stackbit_asset_id:static:images/elephants.jpg' } // so the 'value' will be a string 'stackbit_asset_id:static:images/elephants.jpg' // - when setting images in Contentful, the UpdateOperationField is: // { type: 'image', value: '6rEF3N6lFlEscOq8U63gYg' } // so the 'value' will be a string representing the Asset ID - '6rEF3N6lFlEscOq8U63gYg' // - when creating images from presets or default values the asset ID can // can be specified as $$ref or as plain value // - when duplicating documents with images, the duplicated image field will be: // { $$ref: '...' } // TODO: A bug! // - when the image is specified as an absolute URL - https://... or //... // the absolute URL value will will be set as 'refId', with field // type: 'reference'. There is currently no way to solve it because we // use 'image' type both for referenced images and for literal images. if (_.isPlainObject(value)) { refId = value.$$ref ?? value.url; } else { refId = value; } if (!refId) { throw new Error('Reference field must specify a value.'); } return { field: { type: 'reference', refType: 'asset', refId: refId }, newRefDocumentIds: [] }; } else if (csiModelField.type === 'reference') { if (modelField.type !== 'reference') { throw new Error(`Field type mismatch between content-source and mapped models at field path '${fieldPathToString(fieldPath)}'.`); } let { $$ref: refId = null, $$type: modelName = null, ...rest } = _.isPlainObject(value) ? value : { $$ref: value }; if (refId) { return { field: { type: 'reference', refType: 'document', refId: refId }, newRefDocumentIds: [] }; } else { const modelNames = modelField.models; if (!modelName) { // for backward compatibility check if the object has 'type' instead of '$$type' because older projects use // the 'type' property in default values if ('type' in rest) { modelName = rest.type; rest = _.omit(rest, 'type'); } else if (modelNames.length === 1) { modelName = modelNames[0]; } } if (!modelName) { throw new Error('Invalid preset configuration. Reference field type must have $$type or $$ref properties.'); } const { documentId, newRefDocumentIds } = await createDocumentRecursively({ object: rest, locale, modelName, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return { field: { type: 'reference', refType: 'document', refId: documentId }, newRefDocumentIds: [documentId, ...newRefDocumentIds] }; } } else if ( csiModelField.type === 'cross-reference' || (isOneOfFieldTypes(csiModelField.type, ['string', 'text', 'json']) && modelField.type === 'cross-reference') ) { if (modelField.type !== 'cross-reference') { throw new Error(`Field type mismatch between content-source and mapped models at field path '${fieldPathToString(fieldPath)}'.`); } let { $$ref: refId = null, $$type: modelName = null, $$refSrcType: refSrcType = null, $$refProjectId: refProjectId = null, ...rest } = value; let refObject; const _newRefDocumentIds: string[] = []; if (refId && refSrcType && refProjectId) { refObject = { refId, refSrcType, refProjectId }; } else { if (!modelName || !refSrcType || !refProjectId) { const models = modelField.models; if (models && models.length === 1) { modelName = models[0]!.modelName; refSrcType = models[0]!.srcType; refProjectId = models[0]!.srcProjectId; } } if (!modelName || !refSrcType || !refProjectId) { throw new Error('Invalid preset configuration. Reference field type must have $$type or $$ref properties.'); } const { documentId, newRefDocumentIds } = await createDocumentRecursively({ object: rest, locale, modelName, contentSourceId: getContentSourceId(refSrcType, refProjectId), contentSourceDataById, assetSources, createDocument, userLogger }); _newRefDocumentIds.push(documentId, ...newRefDocumentIds); refObject = { refId: documentId, refSrcType, refProjectId }; } return { field: updateOperationValueFieldWithCrossReference(csiModelField.type, refObject), newRefDocumentIds: _newRefDocumentIds }; } else if (csiModelField.type === 'list') { if (modelField.type !== 'list') { throw new Error(`field type mismatch between external and internal models at field path ${fieldPathToString(fieldPath)}`); } if (!Array.isArray(value)) { throw new Error('Value for list field must be array.'); } const itemsField = modelField.items; const csiItemsField = csiModelField.items; if (!itemsField || !csiItemsField) { throw new Error('List field is missing list items.'); } const arrayResult = await mapPromise(value, async (item, index): Promise<{ field: UpdateOperationListFieldItem; newRefDocumentIds: string[] }> => { const result = await createUpdateOperationFieldRecursively({ value: item, modelField: itemsField, csiModelField: csiItemsField, fieldPath: fieldPath.concat(index), locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); if (result.field.type === 'list') { throw new Error('Fields of type list cannot contain children of type: list.'); } return { field: result.field, newRefDocumentIds: result.newRefDocumentIds }; }); return { field: { type: 'list', items: arrayResult.map((result) => result.field) }, newRefDocumentIds: arrayResult.reduce((result: string[], { newRefDocumentIds }) => result.concat(newRefDocumentIds), []) }; } else if ( (csiModelField.type === 'string' || csiModelField.type === 'text') && (modelField.type === 'json' || modelField.type === 'style') && _.isPlainObject(value) ) { return { field: { type: csiModelField.type, value: JSON.stringify(value) }, newRefDocumentIds: [] }; } else if (isModelFieldSpecificPropsOneOfFieldTypes(csiModelField, ['string', 'text', 'json']) && modelField.type === 'image') { if (modelField.source) { const assetSource = getAssetSourceBySourceName(assetSources, modelField.source); if (assetSource) { value = transformAssetSourceDataForAssetSource({ sourceData: value, assetSource, logger: userLogger }); } } return { field: { type: csiModelField.type, value: csiModelField.type !== 'json' && _.isPlainObject(value) ? JSON.stringify(value) : value }, newRefDocumentIds: [] }; } return { field: { type: csiModelField.type, value: value }, newRefDocumentIds: [] }; } export async function convertOperationField({ operationField, fieldPath, modelField, csiModelField, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }: { operationField: ContentStoreTypes.UpdateOperationField; fieldPath: (string | number)[]; modelField: FieldSpecificProps; csiModelField: FieldSpecificProps; locale: string | undefined; modelMap: Record; csiModelMap: Record; contentSourceId: string; contentSourceDataById: Record; assetSources: CSITypes.AssetSource[]; createDocument: CreateDocumentThunk; userLogger: CSITypes.Logger; }): Promise { switch (operationField.type) { case 'object': { if (modelField.type !== 'object' || csiModelField.type !== 'object') { throw new Error(`The operation field type 'object' does not match the model field type '${modelField.type}'.`); } const result = await createObjectRecursively({ object: operationField.object, modelFields: modelField.fields, csiModelFields: csiModelField.fields, fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return { type: operationField.type, fields: result.fields }; } case 'model': { const model = modelMap[operationField.modelName]; const csiModel = csiModelMap[operationField.modelName]; if (!model || !csiModel) { throw new Error(`Could not find document model: '${operationField.modelName}'. Source: '${contentSourceId}'.`); } const result = await createObjectRecursively({ object: operationField.object, modelFields: model.fields ?? [], csiModelFields: csiModel.fields ?? [], fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return { type: operationField.type, modelName: operationField.modelName, fields: result.fields }; } case 'reference': // ContentStore and CSI 'reference' operation field have the same format return operationField; case 'cross-reference': { if (!isOneOfFieldTypes(csiModelField.type, ['string', 'text', 'json', 'cross-reference'])) { throw new Error( `Update operation with with 'cross-reference' field can be performed on 'string', 'text' and 'json' content-source field types only, got '${csiModelField.type}'.` ); } const refObject = { refId: operationField.refId, refSrcType: operationField.refSrcType, refProjectId: operationField.refProjectId }; if (isOneOfFieldTypes(csiModelField.type, ['json', 'cross-reference'])) { return { type: csiModelField.type, value: refObject }; } return { type: csiModelField.type, value: JSON.stringify(refObject) }; } case 'list': { if (modelField.type !== 'list' || csiModelField.type !== 'list') { throw new Error(`Operation '${operationField.type}' is not compatible with model field of type '${modelField.type}'.`); } const result: UpdateOperationListFieldItem[] = await mapPromise( operationField.items, async (item, index): Promise => { const result = await createUpdateOperationFieldRecursively({ value: item, modelField: modelField.items, csiModelField: csiModelField.items, fieldPath: fieldPath.concat(index), locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); if (result.field.type === 'list') { throw new Error('Fields of type list cannot contain children of type: list.'); } return result.field; } ); return { type: operationField.type, items: result }; } case 'enum': if (csiModelField.type !== 'enum' && csiModelField.type !== 'string') { throw new Error(`Invalid field type ${csiModelField.type}. Values for fields of type enum must be one of: enum, string.`); } // When inserting new enum value into a list, the client does not // send value. Set first option as the value. if (typeof operationField.value !== 'string') { if (modelField.type !== 'enum') { throw new Error(`Operation '${operationField.type}' is not compatible with model field of type '${modelField.type}'.`); } const option = modelField.options[0]!; const optionValue = typeof option === 'object' ? option.value : option; return { type: csiModelField.type, value: optionValue }; } return { type: csiModelField.type, value: operationField.value }; case 'string': // When inserting a new item into a list of strings, // the client does not send the value, so set to an empty string. if (typeof operationField.value !== 'string') { return { type: operationField.type, value: '' }; } return operationField; case 'boolean': // When inserting a new item into a list of booleans, // the client does not send the value, so set it to false. if (typeof operationField.value !== 'boolean') { return { type: operationField.type, value: false }; } return operationField; case 'number': // When inserting a new item into a list of numbers, // the client does not send the value, so set it to 0. if (typeof operationField.value !== 'number') { return { type: operationField.type, value: 0 }; } return operationField; default: { const result = await createUpdateOperationFieldRecursively({ value: operationField.value, modelField, csiModelField, fieldPath, locale, modelMap, csiModelMap, contentSourceId, contentSourceDataById, assetSources, createDocument, userLogger }); return result.field; } } }