UNPKG

@stackbit/utils

Version:
884 lines (868 loc) 34.7 kB
import type * as StackbitTypes from '@stackbit/types'; /** * Takes object of UpdateOperationField and creates a new object by mapping the * UpdateOperationField to DocumentField. * * @param updateOperationFields * @param modelName * @param getModelByName * @param locale */ export function createDocumentFieldsWithUpdateOperationFields({ updateOperationFields, modelName, getModelByName, locale }: { updateOperationFields: Record<string, StackbitTypes.UpdateOperationField>; modelName: string; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; locale?: string; }): Record<string, StackbitTypes.DocumentField> { try { const model = getModelByName(modelName); if (!model) { throw new Error(`The document's model '${modelName}' was not found.`); } return Object.entries(updateOperationFields).reduce( (documentFields, [fieldName, updateOperationField]): Record<string, StackbitTypes.DocumentField> => { const modelField = (model.fields ?? []).find((field) => field.name === fieldName); if (!modelField) { throw new Error(`Field '${fieldName}' in model '${modelName}' was not found.`); } const documentField = convertOperationFieldToDocumentField({ operationField: updateOperationField, modelField, getModelByName, locale }); if (typeof documentField !== 'undefined') { documentFields[fieldName] = documentField; } return documentFields; }, {} as Record<string, StackbitTypes.DocumentField> ); } catch (error: any) { throw new Error(`Error creating document. ${error.message}`); } } /** * Updates `document` with array of `UpdateOperation`. * * This method doesn't mutate the passed `document`, it returns a new deep copied * document with the updated data. * * @param document * @param operations * @param getModelByName */ export function updateDocumentWithOperations({ document, operations, getModelByName }: { document: StackbitTypes.Document; operations: StackbitTypes.UpdateOperation[]; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; }): StackbitTypes.Document { return operations.reduce((document, updateOperation) => { return updateDocumentWithOperation({ document, updateOperation, getModelByName }); }, document); } /** * Updates `document` with `updateOperation`. * * This method doesn't mutate the passed `document`, it returns a new deep copied * document with the updated data. * * @param document * @param updateOperation * @param getModelByName */ export function updateDocumentWithOperation({ document, updateOperation, getModelByName }: { document: StackbitTypes.Document; updateOperation: StackbitTypes.UpdateOperation; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; }): StackbitTypes.Document { try { if (updateOperation.fieldPath.length === 0) { throw new Error(`The operation's fieldPath cannot be empty.`); } const [fieldName, ...fieldPathTail] = updateOperation.fieldPath; if (typeof fieldName !== 'string') { throw new Error(`The first item in operation's fieldPath must be a string.`); } const model = getModelByName(document.modelName); if (!model) { throw new Error(`The document's model '${document.modelName}' was not found.`); } const modelField = getModelFieldByName(model, fieldName); if (!modelField) { throw new Error(`The field '${fieldName}' in operation's fieldPath doesn't match any field of model '${model.name}'.`); } const { [fieldName]: documentField, ...restFields } = document.fields; const updatedDocField = updateDocumentFieldAtFieldPath({ documentField, modelField, fieldPath: fieldPathTail, updateOperation, getModelByName }); return { ...document, fields: { ...restFields, ...wrapDocumentFieldOrUndefined(fieldName, updatedDocField) } }; } catch (error: any) { throw new Error( `Error updating document '${document.id}' with '${updateOperation.opType}' operation ` + `at field path '${updateOperation.fieldPath.join('.')}'. ${error.message}` ); } } /** * Updates `documentField` at the specified `fieldPath` with the `updateOperation`. * This method doesn't mutate the passed `documentField`, it returns a new deep * copied document field with the updated data. * * The `fieldPath` specifies the path from the current `documentField` to the * document field that needs to be updated with the operation. The `fieldPath` * is always a slice of the `updateOperation.fieldPath`. * * The `updateOperation.fieldPath` is a path from the root of the document * containing the `documentField` to the document field that needs to be updated. */ function updateDocumentFieldAtFieldPath<Type extends StackbitTypes.DocumentField>({ documentField, modelField, fieldPath, updateOperation, getModelByName }: { documentField?: Type; modelField: StackbitTypes.Field | StackbitTypes.FieldListItems; fieldPath: StackbitTypes.FieldPath; updateOperation: StackbitTypes.UpdateOperation; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; }): Type | undefined { // If no more items left in the fieldPath, we found our target documentField, update it. if (fieldPath.length === 0) { return updateDocumentFieldWithOperation({ documentField, updateOperation, getModelByName }); } if (!documentField) { throw new Error(`The field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' points to an undefined document field.`); } const locale = updateOperation.locale; const [fieldName, ...fieldPathTail] = fieldPath; if (documentField.type === 'object') { if (modelField.type !== 'object') { throw new Error( `The 'object' document field at field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' ` + `does not match the model field type '${modelField.type}'.` ); } if (typeof fieldName !== 'string') { throw new Error(`The '${fieldName}' in the field path '${getFieldPathPrefixStr(updateOperation, fieldPathTail)}' must be a string.`); } const childModelField = getModelFieldByName(modelField, fieldName); if (!childModelField) { throw new Error(`The '${fieldName}' in the field path '${getFieldPathPrefixStr(updateOperation, fieldPathTail)}' doesn't match any model field.`); } if (documentField.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentObjectFieldLocalized>({ documentField, fieldPath: getFieldPathPrefix(updateOperation, fieldPath), locale }); const { [fieldName]: childDocField, ...restFields } = localizedValue.fields; const updatedValue = updateDocumentFieldAtFieldPath({ documentField: childDocField, modelField: childModelField, fieldPath: fieldPathTail, updateOperation, getModelByName }); return { ...documentField, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, fields: { ...restFields, ...wrapDocumentFieldOrUndefined(fieldName, updatedValue) } } } }; } const { [fieldName]: childDocField, ...restFields } = documentField.fields; const updatedValue = updateDocumentFieldAtFieldPath({ documentField: childDocField, modelField: childModelField, fieldPath: fieldPathTail, updateOperation, getModelByName }); return { ...documentField, fields: { ...restFields, ...wrapDocumentFieldOrUndefined(fieldName, updatedValue) } }; } else if (documentField.type === 'model') { if (modelField.type !== 'model') { throw new Error( `The 'model' document field at field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' ` + `does not match the model field type '${modelField.type}'.` ); } if (typeof fieldName !== 'string') { throw new Error(`The '${fieldName}' in the field path '${getFieldPathPrefixStr(updateOperation, fieldPathTail)}' must be a string.`); } if (documentField.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentModelFieldLocalized>({ documentField, fieldPath: getFieldPathPrefix(updateOperation, fieldPath), locale }); const model = getModelByName(localizedValue.modelName); if (!model) { throw new Error( `The 'model' document field at field path '${getFieldPathPrefixStr(updateOperation, fieldPathTail)}' ` + `references a non existing model '${localizedValue.modelName}'.` ); } const childModelField = getModelFieldByName(model, fieldName); if (!childModelField) { throw new Error(`The '${fieldName}' in field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' doesn't match any model field.`); } const { [fieldName]: childDocField, ...restFields } = localizedValue.fields; const updatedValue = updateDocumentFieldAtFieldPath({ documentField: childDocField, modelField: childModelField, fieldPath: fieldPathTail, updateOperation, getModelByName }); return { ...documentField, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, fields: { ...restFields, ...wrapDocumentFieldOrUndefined(fieldName, updatedValue) } } } }; } const model = getModelByName(documentField.modelName); if (!model) { throw new Error( `The 'model' document field at field path '${getFieldPathPrefixStr(updateOperation, fieldPathTail)}' ` + `references a non existing model '${documentField.modelName}'.` ); } const childModelField = getModelFieldByName(model, fieldName); if (!childModelField) { throw new Error(`The field '${fieldName}' in field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' doesn't match any model field.`); } const { [fieldName]: childDocField, ...restFields } = documentField.fields; const updatedValue = updateDocumentFieldAtFieldPath({ documentField: childDocField, modelField: childModelField, fieldPath: fieldPathTail, updateOperation, getModelByName }); return { ...documentField, fields: { ...restFields, ...wrapDocumentFieldOrUndefined(fieldName, updatedValue) } }; } else if (documentField.type === 'list') { if (modelField.type !== 'list') { throw new Error( `The 'list' document field at field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' ` + `does not match the model field type '${modelField.type}'.` ); } const itemIndex = fieldName as number; if (documentField.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentListFieldLocalized>({ documentField, fieldPath: getFieldPathPrefix(updateOperation, fieldPath), locale }); const updatedItem = updateDocumentFieldAtFieldPath({ documentField: localizedValue.items[itemIndex], modelField: modelField.items, fieldPath: fieldPathTail, updateOperation, getModelByName }); if (typeof updatedItem === 'undefined') { throw new Error( `Cannot set list item at field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' ` + `to 'undefined', use 'remove' operation to remove list item.` ); } return { ...documentField, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, items: splice(localizedValue.items, itemIndex, 1, updatedItem) } } }; } const updatedItem = updateDocumentFieldAtFieldPath({ documentField: documentField.items[itemIndex], modelField: modelField.items, fieldPath: fieldPathTail, updateOperation, getModelByName }); if (typeof updatedItem === 'undefined') { throw new Error( `Cannot set list item at field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' ` + `to 'undefined', use 'remove' operation to remove list item.` ); } return { ...documentField, items: splice(documentField.items, itemIndex, 1, updatedItem) }; } else { throw new Error(`The partial field path '${getFieldPathPrefixStr(updateOperation, fieldPath)}' points to a non-object or non-list field`); } } function getModelFieldByName(model: StackbitTypes.Model | StackbitTypes.FieldObjectProps, fieldName: string): StackbitTypes.Field | undefined { return model.fields?.find((field) => field.name === fieldName); } function wrapDocumentFieldOrUndefined(fieldName: string, documentField?: StackbitTypes.DocumentField): Record<string, StackbitTypes.DocumentField> | undefined { if (typeof documentField === 'undefined') { return undefined; } return { [fieldName]: documentField }; } function getFieldPathPrefixStr(updateOperation: StackbitTypes.UpdateOperation, fieldPath: StackbitTypes.FieldPath): string { return getFieldPathPrefix(updateOperation, fieldPath).join('.'); } function getFieldPathPrefix(updateOperation: StackbitTypes.UpdateOperation, fieldPathTail: StackbitTypes.FieldPath): StackbitTypes.FieldPath { return updateOperation.fieldPath.slice(0, updateOperation.fieldPath.length - fieldPathTail.length); } function updateDocumentFieldWithOperation<Type extends StackbitTypes.DocumentField>({ documentField, updateOperation, getModelByName }: { documentField?: Type; updateOperation: StackbitTypes.UpdateOperation; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; }): Type | undefined { const locale = updateOperation.locale; switch (updateOperation.opType) { case 'set': { return convertOperationFieldToDocumentField({ operationField: updateOperation.field, modelField: updateOperation.modelField as StackbitTypes.Field, documentField, getModelByName, locale }) as Type; } case 'unset': { if (documentField?.localized) { if (!locale) { throw new Error( `The '${documentField.type}' document field at field path '${updateOperation.fieldPath.join('.')}' ` + `is localized, but no locale was provided in the update operation.` ); } // Remove the locale property to unset the value for the specified locale. const { [locale]: currentLocale, ...restLocales } = documentField.locales; return { ...documentField, locales: { ...restLocales } }; } return undefined; } case 'insert': { // Empty lists can be undefined, in this case, create a dummy // document list field with empty items to insert the first item const documentFieldRes = documentField ?? (updateOperation.modelField.localized ? { type: 'list', localized: true, locales: locale ? { [locale]: { locale, items: [] } } : {} } : { type: 'list', items: [] }); if (documentFieldRes.type !== 'list') { throw new Error(`The 'insert' operation can be performed on 'list' document fields only, got '${documentFieldRes.type}'.`); } const newItem = convertOperationFieldToDocumentField({ operationField: updateOperation.item, modelField: updateOperation.modelField.items, getModelByName, locale }); if (documentFieldRes.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentListFieldLocalized>({ documentField: documentFieldRes, fieldPath: updateOperation.fieldPath, locale }); const insertionIndex = updateOperation.index ?? localizedValue.items.length; return { type: 'list', localized: true, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, items: splice(localizedValue.items, insertionIndex, 0, newItem) } } } as Type; } const insertionIndex = updateOperation.index ?? documentFieldRes.items.length; return { type: 'list', items: splice(documentFieldRes.items, insertionIndex, 0, newItem) } as Type; } case 'remove': { if (documentField?.type !== 'list') { throw new Error(`The 'remove' operation can be performed on 'list' document fields only, got '${documentField?.type}'.`); } if (documentField.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentListFieldLocalized>({ documentField, fieldPath: updateOperation.fieldPath, locale }); return { ...documentField, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, items: splice(localizedValue.items, updateOperation.index, 1) } } }; } return { ...documentField, items: splice(documentField.items, updateOperation.index, 1) }; } case 'reorder': { if (documentField?.type !== 'list') { throw new Error(`The 'reorder' operation can be performed on 'list' document fields only, got '${documentField?.type}'.`); } if (documentField.localized) { const { localizedValue, restLocalizedValues, locale: resolvedLocale } = getLocalizedValueForLocale<StackbitTypes.DocumentListFieldLocalized>({ documentField, fieldPath: updateOperation.fieldPath, locale }); return { ...documentField, locales: { ...restLocalizedValues, [resolvedLocale]: { ...localizedValue, items: updateOperation.order.map((newIndex) => localizedValue.items[newIndex]!) } } }; } return { ...documentField, items: updateOperation.order.map((newIndex) => documentField.items[newIndex]!) }; } default: { const _exhaustiveCheck: never = updateOperation; return _exhaustiveCheck; } } } function splice<Type>(array: Type[], start: number, deleteCount: number, ...items: Type[]): Type[] { const copy = array.slice(); copy.splice(start, deleteCount, ...items); return copy; } function getLocalizedValueForLocale<Type extends StackbitTypes.DocumentFieldLocalized>({ documentField, fieldPath, locale }: { documentField: Type; fieldPath: StackbitTypes.FieldPath; locale?: string; }): { locale: string; localizedValue: Type['locales'][string]; restLocalizedValues: Type['locales']; } { if (!locale) { throw new Error( `The '${documentField.type}' document field at field path '${fieldPath.join('.')}' ` + `is localized, but no locale was provided in the update operation.` ); } const { [locale]: localizedValue, ...restLocalizedValues } = documentField.locales; if (!localizedValue) { throw new Error( `The '${documentField.type}' document field at field path '${fieldPath.join('.')}' ` + `is localized, but it has no value for the specified locale '${locale}'.` ); } return { locale, localizedValue: localizedValue as Type['locales'][string], restLocalizedValues }; } function convertOperationFieldToDocumentField< OpField extends StackbitTypes.UpdateOperationListFieldItem, DocField extends StackbitTypes.ExtractByType<StackbitTypes.DocumentListFieldItems, OpField['type']> >({ operationField, modelField, getModelByName, locale }: { operationField: OpField; modelField: StackbitTypes.FieldListItems; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; locale?: string; }): DocField; function convertOperationFieldToDocumentField< OpField extends StackbitTypes.UpdateOperationField, DocField extends StackbitTypes.DocumentFieldForType<OpField['type']> >({ operationField, modelField, documentField, getModelByName, locale }: { operationField: OpField; modelField: StackbitTypes.Field; documentField?: DocField; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; locale?: string; }): DocField; function convertOperationFieldToDocumentField({ operationField, modelField, documentField, getModelByName, locale }: { operationField: StackbitTypes.UpdateOperationField; modelField: StackbitTypes.Field | StackbitTypes.FieldListItems; documentField?: StackbitTypes.DocumentField; getModelByName: (modelName: string) => StackbitTypes.Model | undefined; locale?: string; }): StackbitTypes.DocumentField { const localized = 'localized' in modelField && modelField.localized; if (localized && !locale) { throw new Error(`The '${modelField.type}' field is localized, but it has no value for the specified locale '${locale}'.`); } if (documentField && documentField.type !== operationField.type) { throw new Error(`The updateOperation.field.type '${operationField.type}' doesn't match the target document field type '${documentField.type}'.`); } const documentFieldLocales = documentField && 'locales' in documentField ? documentField.locales : null; switch (operationField.type) { case 'string': case 'url': case 'slug': case 'text': case 'markdown': case 'html': case 'number': case 'boolean': case 'date': case 'datetime': case 'color': case 'json': case 'richText': case 'file': case 'enum': case 'style': { if (localized) { return { type: operationField.type, localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, value: operationField.value } } } as StackbitTypes.DocumentFieldLocalized; } return { type: operationField.type, value: operationField.value }; } case 'image': { if (modelField.type !== 'image') { throw new Error(`The model's field type '${modelField.type}' doesn't match the operation's field type '${operationField.type}'.`); } if (localized) { return { type: operationField.type, localized: true, source: modelField.source, locales: { ...documentFieldLocales, [locale!]: { locale, sourceData: operationField.value } } } as StackbitTypes.DocumentImageFieldLocalized; } return { type: operationField.type, source: modelField.source, sourceData: operationField.value }; } case 'object': { if (modelField.type !== 'object') { throw new Error(`The model's field type '${modelField.type}' doesn't match the operation's field type '${operationField.type}'.`); } const updatedFields = Object.entries(operationField.fields).reduce( (fields: Record<string, StackbitTypes.DocumentField>, [fieldName, operationField]) => { const childModelField = modelField.fields.find((field) => field.name === fieldName); if (!childModelField) { throw new Error(`Model field with name '${fieldName}' was not found.`); } fields[fieldName] = convertOperationFieldToDocumentField({ operationField, modelField: childModelField, getModelByName, locale }); return fields; }, {} ); if (localized) { return { type: operationField.type, localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, fields: updatedFields } } } as StackbitTypes.DocumentObjectFieldLocalized; } return { type: operationField.type, fields: updatedFields }; } case 'model': { if (modelField.type !== 'model') { throw new Error(`The model's field type '${modelField.type}' doesn't match the operation's field type '${operationField.type}'.`); } const model = getModelByName(operationField.modelName) as StackbitTypes.Model; if (!model) { throw new Error(`Model with name '${operationField.modelName}' not found.`); } const modelFields = model.fields ?? []; const updatedFields = Object.entries(operationField.fields).reduce( (fields: Record<string, StackbitTypes.DocumentField>, [fieldName, operationField]) => { const childModelField = modelFields.find((field) => field.name === fieldName); if (!childModelField) { throw new Error(`Model field with name '${fieldName}' was not found in model '${model.name}'.`); } fields[fieldName] = convertOperationFieldToDocumentField({ operationField, modelField: childModelField, getModelByName, locale }); return fields; }, {} ); if (localized) { return { type: operationField.type, localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, modelName: operationField.modelName, fields: updatedFields } } } as StackbitTypes.DocumentModelFieldLocalized; } return { type: operationField.type, modelName: operationField.modelName, fields: updatedFields }; } case 'reference': { if (localized) { return { type: operationField.type, refType: operationField.refType, localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, refId: operationField.refId } } } as StackbitTypes.DocumentReferenceFieldLocalized; } return { type: operationField.type, refType: operationField.refType, refId: operationField.refId }; } case 'cross-reference': { if (localized) { return { type: operationField.type, refType: 'document', localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, refSrcType: operationField.value.refSrcType, refProjectId: operationField.value.refProjectId, refId: operationField.value.refId } } } as StackbitTypes.DocumentCrossReferenceFieldLocalized; } return { type: operationField.type, refType: 'document', refSrcType: operationField.value.refSrcType, refProjectId: operationField.value.refProjectId, refId: operationField.value.refId }; } case 'list': { if (modelField.type !== 'list') { throw new Error(`The model's field type '${modelField.type}' doesn't match the operation's field type '${operationField.type}'.`); } const items = operationField.items.map((item: StackbitTypes.UpdateOperationListFieldItem) => { return convertOperationFieldToDocumentField({ operationField: item, modelField: modelField.items, getModelByName, locale }); }); if (localized) { return { type: operationField.type, localized: true, locales: { ...documentFieldLocales, [locale!]: { locale, items } } } as StackbitTypes.DocumentListFieldLocalized; } return { type: operationField.type, items }; } default: { const _exhaustiveCheck: never = operationField; return _exhaustiveCheck; } } }