@stackbit/cms-sanity
Version:
Stackbit Sanity CMS Interface
296 lines (281 loc) • 10.9 kB
JavaScript
const _ = require('lodash');
const { omitByNil } = require('@stackbit/cms-core/dist/utils');
const { IMAGE_MODEL } = require('@stackbit/cms-core');
const { resolveLabelFieldForModel } = require('./utils');
// sanity cloudinary plugin adds these models, we are replacing them to our internal image model, so we removing them from schema response
const skipModels = ['cloudinary.asset', 'cloudinary.assetDerived'];
export function convertSchema(schema, { stackbitModels, presets }) {
// sanity schema allow arrays to be at the root level, we don't.
// map all root arrays, then pass them to rest of the model and reference them as if they were inline
// hope there is no cyclic references from array objects from within array objects
const filteredModels = schema.models.filter((model) => !skipModels.includes(model.name));
const [arrayModels, nonArrayModels] = _.partition(filteredModels, { type: 'array' });
const arrayModelsByName = _.keyBy(arrayModels, 'name');
const models = nonArrayModels.map((model) => {
const modelName = _.get(model, 'name');
const stackbitModel = _.find(stackbitModels, { name: modelName });
return mapObjectModel(model, arrayModelsByName, stackbitModel, presets);
});
models.push(IMAGE_MODEL);
return { models, presets };
}
function mapObjectModel(model, arrayModelsByName, stackbitModel, presets) {
// model name can be null for nested objects
const modelName = _.get(model, 'name', null);
const modelLabel = _.get(model, 'title', modelName ? _.startCase(modelName) : null);
const urlPath = getUrlPath(stackbitModel);
const stackbitFieldGroups = _.get(stackbitModel, 'fieldGroups');
const sanityFieldGroups = _.get(model, 'groups', []).map((group) => ({ name: group.name, label: group.title }));
const fields = _.get(model, 'fields', []);
const mappedFields = mapObjectFields(fields, arrayModelsByName, stackbitModel);
const presetsForModel = Object.keys(_.pickBy(presets, (preset) => preset.modelName === modelName));
return omitByNil({
type: getNormalizedModelType(stackbitModel, model),
name: modelName,
label: modelLabel,
urlPath,
description: null,
labelField: resolveLabelFieldForModel(model, 'preview.select.title', mappedFields),
fieldGroups: stackbitFieldGroups ?? sanityFieldGroups,
fields: mappedFields,
presets: _.isEmpty(presetsForModel) ? _.get(stackbitModel, 'presets') : presetsForModel
});
}
function mapObjectFields(fields, arrayModelsByName, stackbitModel) {
return _.map(fields, (field) => {
const fieldName = _.get(field, 'name');
const stackbitField = _.find(_.get(stackbitModel, 'fields'), { name: fieldName });
return mapField(field, arrayModelsByName, stackbitField);
});
}
function mapField(field, arrayModelsByName, stackbitField) {
const type = _.get(field, 'type');
const name = _.get(field, 'name');
const label = _.get(field, 'title', name ? _.startCase(name) : undefined);
const description = _.get(field, 'description');
const readOnly = _.get(field, 'readOnly');
const isRequired = _.get(field, 'validation.isRequired');
const options = _.get(field, 'options');
const sanityFieldGroup = _.get(field, 'group');
const stackbitFieldGroup = _.get(stackbitField, 'group');
const group = stackbitFieldGroup ?? (Array.isArray(sanityFieldGroup) ? sanityFieldGroup[0] : sanityFieldGroup);
const controlType = _.get(stackbitField, 'controlType');
let hidden = _.get(field, 'hidden');
// compute default and const values
let defaultValue = _.get(field, 'initialValue');
let constValue;
if (isRequired) {
if (!_.isUndefined(defaultValue) && hidden) {
constValue = defaultValue;
defaultValue = undefined;
} else if (type === 'string') {
const optionsList = _.get(options, 'list');
if (_.isArray(optionsList) && optionsList.length === 1) {
hidden = true;
// constValue = _.head(optionsList);
// constValue = _.get(constValue, 'value', constValue);
// defaultValue = undefined;
}
}
}
const extra = convertField(field, arrayModelsByName, stackbitField);
return _.omitBy(
_.assign(
{
type: null,
name: name,
label: label,
description: description,
group: group,
controlType: controlType,
required: isRequired || undefined,
default: defaultValue,
const: constValue,
readOnly: readOnly,
hidden: hidden
},
extra
),
_.isUndefined
);
}
function convertField(field, arrayModelsByName, stackbitField) {
const type = _.get(field, 'type');
if (!_.has(fieldConverterMap, type)) {
return fieldConverterMap.model(field, arrayModelsByName, stackbitField);
}
return fieldConverterMap[type](field, arrayModelsByName, stackbitField);
}
function getEnumOptions(field, stackbitField) {
// A list of predefined values that the user can choose from.
// The array can either include string values ['sci-fi', 'western']
// or objects [{title: 'Sci-Fi', value: 'sci-fi'}, ...]
const list = _.get(field, 'options.list', []);
if (!_.isEmpty(list)) {
return _.map(list, (item, i) => {
if (_.has(item, 'title')) {
const sbOptionData = stackbitField?.options?.find((option) => option.value === item.value);
return { label: item.title, value: item.value, ...sbOptionData };
} else {
const sbOptionData = stackbitField?.options?.find((option) => option.value === item);
return sbOptionData ? sbOptionData : item;
}
});
} else {
return null;
}
}
const fieldConverterMap = {
string: (field, arrayModelsByName, stackbitField) => {
const options = getEnumOptions(field, stackbitField);
if (options) {
return { type: 'enum', options: options };
} else {
return { type: 'string' };
}
},
slug: () => {
return { type: 'slug' };
},
url: () => {
return { type: 'url' };
},
text: () => {
return { type: 'text' };
},
markdown: () => {
return { type: 'markdown' };
},
block: () => {
return { type: 'richText' };
},
span: () => {
// TODO: implement when we will handle parsing of rich text
},
number: (field) => {
const validation = _.get(field, 'validation');
const isInteger = _.get(validation, 'isInteger');
return _.omitBy(
{
type: 'number',
subtype: isInteger ? 'int' : 'float',
min: _.get(validation, 'min'),
max: _.get(validation, 'max')
},
_.isNil
);
},
boolean: () => {
return { type: 'boolean' };
},
date: () => {
return { type: 'date' };
},
datetime: () => {
return { type: 'datetime' };
},
file: () => {
return { type: 'file' };
},
image: () => {
return { type: 'image' };
},
'cloudinary.asset': () => {
return { type: 'image', source: 'cloudinary' };
},
geopoint: () => {
return { type: 'geopoint' };
},
reference: (field) => {
const toItems = _.castArray(_.get(field, 'to', []));
return {
type: 'reference',
models: _.map(toItems, (item) => item.type)
};
},
object: (field, arrayModelsByName, stackbitField) => {
return mapObjectModel(field, arrayModelsByName, stackbitField);
},
model: (field, arrayModelsByName, stackbitField) => {
const type = _.get(field, 'type');
if (_.has(arrayModelsByName, type)) {
return fieldConverterMap.array(arrayModelsByName[type], arrayModelsByName, stackbitField);
}
return {
type: 'model',
models: [type]
};
},
array: (field, arrayModelsByName, stackbitField) => {
const options = getEnumOptions(field);
if (options) {
return {
type: 'list',
controlType: 'checkbox',
items: {
type: 'enum',
options: options
}
};
}
const ofItems = _.get(field, 'of', []);
if (_.some(ofItems, { type: 'block' })) {
return { type: 'richText' };
}
const stackbitListItems = _.get(stackbitField, 'items');
const items = _.map(ofItems, (item, index) => {
const stackbitListItem = Array.isArray(stackbitListItems) ? stackbitListItems[index] || _.last(stackbitListItems) : stackbitListItems;
return mapField(item, arrayModelsByName, stackbitListItem);
});
let modelNames = [];
let referenceModelName = [];
const consolidatedItems = [];
_.forEach(items, (item) => {
const type = _.get(item, 'type');
if (type === 'model' && _.has(item, 'models') && _.size(item) === 2) {
modelNames = modelNames.concat(item.models);
} else if (type === 'reference' && _.has(item, 'models') && _.size(item) === 2) {
referenceModelName = referenceModelName.concat(item.models);
} else {
consolidatedItems.push(item);
}
});
if (!_.isEmpty(modelNames)) {
consolidatedItems.push({
type: 'model',
models: modelNames
});
}
if (!_.isEmpty(referenceModelName)) {
consolidatedItems.push({
type: 'reference',
models: referenceModelName
});
}
if (consolidatedItems.length === 1) {
return {
type: 'list',
items: _.head(consolidatedItems)
};
}
return {
type: 'list',
items: consolidatedItems
};
}
};
function getNormalizedModelType(stackbitModel, sanityModel) {
if (stackbitModel) {
return stackbitModel.type === 'config' ? 'data' : stackbitModel.type;
}
// sanity model type can be 'document', 'object'
const modelType = _.get(sanityModel, 'type');
return modelType === 'document' ? 'data' : 'object';
}
function getUrlPath(stackbitModel) {
const modelType = getNormalizedModelType(stackbitModel);
if (modelType === 'page') {
return _.get(stackbitModel, 'urlPath', '/{slug}');
}
return null;
}