@stackbit/utils
Version:
Stackbit utilities
884 lines (868 loc) • 34.7 kB
text/typescript
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;
}
}
}