@stackbit/sdk
Version:
570 lines (535 loc) • 20.1 kB
text/typescript
import _ from 'lodash';
import { mapPromise, mapValuesPromise } from '@stackbit/utils';
import { Field, FieldList, FieldListItems, FieldModelProps, FieldObjectProps } from '@stackbit/types';
import { getListFieldItems, isListDataModel, isListField, isObjectListItems, isModelField, isObjectField, isModelListItems } from './model-utils';
import { Model, YamlModel, DataModel } from '../config/config-types';
/**
* This function invokes the `iteratee` function for every field of the `model`.
* It recursively traverses through fields of type `object` and `list` with
* items of type `object` and invokes the `iteratee` on their child fields,
* and so on. The traversal is a depth-first and the `iteratee` is invoked
* before traversing the field's child fields.
*
* The iteratee is invoked with two parameters, `field` and `fieldPath`. The
* `field` is the currently iterated field, and `fieldPath` is an array of
* strings indicating the path of the `field` relative to the model.
*
* @example
* model = {
* fields: [
* { name: "title", type: "string" },
* { name: "tags", type: "list" },
* { name: "banner", type: "object", fields: [{ name: "logo", type: "image" }] }
* {
* name: "actions",
* type: "list",
* items: { type: "object", fields: [{ name: "label", type: "string" }] }
* }
* ]
* }
* iterateModelFieldsRecursively(model, iteratee);
* // will call the iteratee 6 times with the following "field" and "fieldPath" arguments
* field = { name: "title", ... }, fieldPath = ['fields', 'title']
* field = { name: "tags", ... }, fieldPath = ['fields', 'tags']
* field = { name: "banner", ... }, fieldPath = ['fields', 'banner']
* field = { name: "logo", ... }, fieldPath = ['fields', 'banner', 'fields', 'logo']
* field = { name: "actions", ... }, fieldPath = ['fields', 'actions']
* field = { name: "label", ... }, fieldPath = ['fields', 'actions', 'items', 'fields', 'label']
*
* @param model The model to iterate fields
* @param iteratee The callback function
*/
export function iterateModelFieldsRecursively(model: Model | YamlModel, iteratee: (field: Field, modelKeyPath: string[]) => void) {
function _iterateDeep({ fields, modelKeyPath }: { fields: Field[]; modelKeyPath: string[] }) {
modelKeyPath = modelKeyPath.concat('fields');
_.forEach(fields, (field) => {
if (!field) {
return;
}
const childModelKeyPath = modelKeyPath.concat(field.name);
iteratee(field, childModelKeyPath);
if (isObjectField(field)) {
_iterateDeep({
fields: field.fields,
modelKeyPath: childModelKeyPath
});
} else if (isListField(field) && field.items && isObjectListItems(field.items)) {
_iterateDeep({
fields: field.items?.fields,
modelKeyPath: childModelKeyPath.concat('items')
});
}
});
}
if (model && isListDataModel(model) && model.items && isObjectListItems(model.items)) {
_iterateDeep({
fields: model.items?.fields,
modelKeyPath: ['items']
});
} else {
_iterateDeep({
fields: model?.fields || [],
modelKeyPath: []
});
}
}
export function mapModelFieldsRecursively<T extends Model>(model: T, iteratee: (field: Field, modelKeyPath: string[]) => Field): T {
function _mapField(field: Field, modelKeyPath: string[]): Field {
if (!field) {
return field;
}
modelKeyPath = modelKeyPath.concat(field.name);
field = iteratee(field, modelKeyPath);
if (isObjectField(field)) {
return _mapObjectField(field, modelKeyPath);
} else if (isListField(field)) {
return _mapListField(field, modelKeyPath);
} else {
return field;
}
}
function _mapObjectField<Y extends FieldObjectProps | T>(field: Y, modelKeyPath: string[]): Y {
const fields = field.fields;
if (!fields) {
return field;
}
modelKeyPath = modelKeyPath.concat('fields');
return {
...field,
fields: _.map(fields, (field) => _mapField(field, modelKeyPath))
};
}
function _mapListField<Y extends FieldList | (T & { type: 'data'; isList: true })>(field: Y, modelKeyPath: string[]): Y {
const items = field.items;
if (!items || !isObjectListItems(items)) {
return field;
}
return {
...field,
items: _mapObjectField(items, modelKeyPath.concat('items'))
};
}
if (!model) {
return model;
} else if (isListDataModel(model)) {
return _mapListField(model, []);
} else {
return _mapObjectField(model, []);
}
}
export function iterateObjectFieldsWithModelRecursively(
value: any,
model: Model,
modelsByName: Record<string, Model>,
iteratee: (options: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
error: string | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
}) => void,
{ pageLayoutKey = 'layout', objectTypeKey = 'type', valueId }: { pageLayoutKey?: string; objectTypeKey?: string; valueId?: string } = {}
) {
function _iterateDeep({
value,
model,
field,
fieldListItem,
valueKeyPath,
modelKeyPath,
objectStack
}: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
}) {
let error: string | null = null;
let modelField: FieldModelProps | null = null;
if (!model && !field && !fieldListItem) {
error = `could not match model/field ${modelKeyPath.join('.')} for content at ${valueKeyPath.join('.')}`;
}
if (field && isModelField(field)) {
modelField = field;
} else if (fieldListItem && isModelListItems(fieldListItem)) {
modelField = fieldListItem;
}
if (modelField) {
const modelResult = getModelOfObject({
object: value,
field: modelField,
modelsByName,
pageLayoutKey,
objectTypeKey,
valueKeyPath,
modelKeyPath
});
if ('error' in modelResult) {
error = modelResult.error;
} else {
model = modelResult.model;
}
field = null;
fieldListItem = null;
modelKeyPath = model ? [model.name] : [];
}
iteratee({ value, model, field, fieldListItem, error, valueKeyPath, modelKeyPath, objectStack });
if (_.isPlainObject(value)) {
// if fields will not be resolved or the object will have a key that
// doesn't exist among fields, the nested calls to _iterateDeep will
// include an error.
const fields = getFieldsOfModelOrField(model, field, fieldListItem);
const fieldsByName = _.keyBy(fields, 'name');
modelKeyPath = _.concat(modelKeyPath, 'fields');
_.forEach(value, (val, key) => {
const field = _.get(fieldsByName, key, null);
_iterateDeep({
value: val,
model: null,
field: field,
fieldListItem: null,
valueKeyPath: _.concat(valueKeyPath, key),
modelKeyPath: _.concat(modelKeyPath, key),
objectStack: _.concat(objectStack, value)
});
});
} else if (_.isArray(value)) {
let fieldListItems: FieldListItems | null = null;
if (field && isListField(field)) {
fieldListItems = getListFieldItems(field);
} else if (model && isListDataModel(model)) {
fieldListItems = model.items;
}
_.forEach(value, (val, idx) => {
_iterateDeep({
value: val,
model: null,
field: null,
fieldListItem: fieldListItems,
valueKeyPath: _.concat(valueKeyPath, idx),
modelKeyPath: _.concat(modelKeyPath, 'items'),
objectStack: _.concat(objectStack, value)
});
});
}
}
_iterateDeep({
value: value,
model: model,
field: null,
fieldListItem: null,
valueKeyPath: valueId ? [valueId] : [],
modelKeyPath: [model.name],
objectStack: []
});
}
export function mapObjectFieldsWithModelRecursively(
value: any,
model: Model,
modelsByName: Record<string, Model>,
iteratee: (options: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
error: string | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
}) => any,
{ pageLayoutKey = 'layout', objectTypeKey = 'type', valueId }: { pageLayoutKey?: string; objectTypeKey?: string; valueId?: string } = {}
) {
function _mapDeep({
value,
model,
field,
fieldListItem,
valueKeyPath,
modelKeyPath,
objectStack
}: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
}) {
let error: string | null = null;
let modelField: FieldModelProps | null = null;
if (!model && !field && !fieldListItem) {
error = `could not match model/field ${modelKeyPath.join('.')} to content at ${valueKeyPath.join('.')}`;
}
if (field && isModelField(field)) {
modelField = field;
} else if (fieldListItem && isModelListItems(fieldListItem)) {
modelField = fieldListItem;
}
if (modelField) {
const modelResult = getModelOfObject({
object: value,
field: modelField,
modelsByName,
pageLayoutKey,
objectTypeKey,
valueKeyPath,
modelKeyPath
});
if ('error' in modelResult) {
error = modelResult.error;
} else {
model = modelResult.model;
}
field = null;
fieldListItem = null;
modelKeyPath = model ? [model.name] : [];
}
const res = iteratee({ value, model, field, fieldListItem, error, valueKeyPath, modelKeyPath, objectStack });
if (!_.isUndefined(res)) {
value = res;
}
if (_.isPlainObject(value)) {
// if fields will not be resolved or the object will have a key that
// doesn't exist among fields, the nested calls to _iterateDeep will
// include an error.
const fields = getFieldsOfModelOrField(model, field, fieldListItem);
const fieldsByName = _.keyBy(fields, 'name');
modelKeyPath = _.concat(modelKeyPath, 'fields');
value = _.mapValues(value, (val, key) => {
const field = _.get(fieldsByName, key, null);
return _mapDeep({
value: val,
model: null,
field: field,
fieldListItem: null,
valueKeyPath: _.concat(valueKeyPath, key),
modelKeyPath: _.concat(modelKeyPath, key),
objectStack: _.concat(objectStack, value)
});
});
} else if (_.isArray(value)) {
let fieldListItems: FieldListItems | null = null;
if (field && isListField(field)) {
fieldListItems = getListFieldItems(field);
} else if (model && isListDataModel(model)) {
fieldListItems = model.items;
}
value = _.map(value, (val, idx) => {
return _mapDeep({
value: val,
model: null,
field: null,
fieldListItem: fieldListItems,
valueKeyPath: _.concat(valueKeyPath, idx),
modelKeyPath: _.concat(modelKeyPath, 'items'),
objectStack: _.concat(objectStack, value)
});
});
}
return value;
}
return _mapDeep({
value: value,
model: model,
field: null,
fieldListItem: null,
valueKeyPath: valueId ? [valueId] : [],
modelKeyPath: [model.name],
objectStack: []
});
}
export async function asyncMapObjectFieldsWithModelRecursively({
value,
model,
modelsByName,
iteratee,
pageLayoutKey = 'layout',
objectTypeKey = 'type',
valueId
}: {
value: any;
model: Model;
modelsByName: Record<string, Model>;
iteratee: (options: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
error: string | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
skipNested: () => void;
}) => Promise<any>;
pageLayoutKey?: string;
objectTypeKey?: string;
valueId?: string;
}): Promise<any> {
async function _mapDeep({
value,
model,
field,
fieldListItem,
valueKeyPath,
modelKeyPath,
objectStack
}: {
value: any;
model: Model | null;
field: Field | null;
fieldListItem: FieldListItems | null;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
objectStack: any[];
}) {
let error: string | null = null;
let modelField: FieldModelProps | null = null;
if (!model && !field && !fieldListItem) {
error = `could not match model/field ${modelKeyPath.join('.')} to content at ${valueKeyPath.join('.')}`;
}
if (field && isModelField(field)) {
modelField = field;
} else if (fieldListItem && isModelListItems(fieldListItem)) {
modelField = fieldListItem;
}
if (modelField) {
const modelResult = getModelOfObject({
object: value,
field: modelField,
modelsByName,
pageLayoutKey,
objectTypeKey,
valueKeyPath,
modelKeyPath
});
if ('error' in modelResult) {
error = modelResult.error;
} else {
model = modelResult.model;
}
field = null;
fieldListItem = null;
modelKeyPath = model ? [model.name] : [];
}
let shouldSkipNested = false;
const skipNested = () => {
shouldSkipNested = true;
};
const res = await iteratee({ value, model, field, fieldListItem, error, valueKeyPath, modelKeyPath, objectStack, skipNested });
if (!_.isUndefined(res)) {
value = res;
}
if (shouldSkipNested) {
return value;
}
if (_.isPlainObject(value)) {
// if fields will not be resolved or the object will have a key that
// doesn't exist among fields, the nested calls to _iterateDeep will
// include an error.
const fields = getFieldsOfModelOrField(model, field, fieldListItem);
const fieldsByName = _.keyBy(fields, 'name');
modelKeyPath = _.concat(modelKeyPath, 'fields');
value = await mapValuesPromise(value, (val, key) => {
const field = _.get(fieldsByName, key, null);
return _mapDeep({
value: val,
model: null,
field: field,
fieldListItem: null,
valueKeyPath: _.concat(valueKeyPath, key),
modelKeyPath: _.concat(modelKeyPath, key),
objectStack: _.concat(objectStack, value)
});
});
} else if (_.isArray(value)) {
let fieldListItems: FieldListItems | null = null;
if (field && isListField(field)) {
fieldListItems = getListFieldItems(field);
} else if (model && isListDataModel(model)) {
fieldListItems = model.items;
}
value = await mapPromise(value, (val, idx) => {
return _mapDeep({
value: val,
model: null,
field: null,
fieldListItem: fieldListItems,
valueKeyPath: _.concat(valueKeyPath, idx),
modelKeyPath: _.concat(modelKeyPath, 'items'),
objectStack: _.concat(objectStack, value)
});
});
}
return value;
}
return _mapDeep({
value: value,
model: model,
field: null,
fieldListItem: null,
valueKeyPath: valueId ? [valueId] : [],
modelKeyPath: [model.name],
objectStack: []
});
}
export function getModelOfObject({
object,
field,
modelsByName,
pageLayoutKey,
objectTypeKey,
valueKeyPath,
modelKeyPath
}: {
object: any;
field: FieldModelProps;
modelsByName: Record<string, Model>;
pageLayoutKey: string;
objectTypeKey: string;
valueKeyPath: (string | number)[];
modelKeyPath: string[];
}): { modelName: string; model: Model } | { error: string } {
const modelNames = field.models ?? [];
let modelName: string;
if (modelNames.length === 0) {
return { error: `invalid field, no 'models' property specified at '${modelKeyPath.join('.')}'` };
}
if (modelNames.length === 1) {
modelName = modelNames[0]!;
if (!_.has(modelsByName, modelName)) {
return { error: `invalid field, model name '${modelName}' specified at '${modelKeyPath.join('.')}' doesn't match any model` };
}
} else {
// we don't know if the object at hand belongs to a model of type "page" or type "data"
// so we try to get the model using both pageLayoutKey and objectTypeKey keys
if (!_.has(object, pageLayoutKey) && !_.has(object, objectTypeKey)) {
return { error: `cannot identify the model of an object, no '${pageLayoutKey}' or '${objectTypeKey}' field exist at ${valueKeyPath.join('.')}` };
}
modelName = object[pageLayoutKey] || object[objectTypeKey];
if (!_.has(modelsByName, modelName)) {
const typeKey = object[pageLayoutKey] ? pageLayoutKey : objectTypeKey;
return { error: `invalid content, '${typeKey}=${modelName}' specified at ${valueKeyPath.join('.')} doesn't match any model` };
}
}
return {
modelName,
model: modelsByName[modelName]!
};
}
function getFieldsOfModelOrField(model: Model | null, field: Field | null, fieldListItems: FieldListItems | null): Field[] {
if (model && model.fields) {
return model.fields;
} else if (field && isObjectField(field)) {
return field.fields;
} else if (fieldListItems && isObjectListItems(fieldListItems)) {
return fieldListItems.fields;
}
return [];
}