import _ from 'lodash'; import * as consts from './consts'; import { omitByNil } from './utils'; export type FieldData = Record; export interface FieldDataItem { type: 'object'; srcObjectLabel: string; srcModelName: string; srcModelLabel: string; fields: Record; } export interface FieldDataRootItem extends Omit { type: 'object' | 'image' | 'file'; srcType: string; srcProjectId: string; srcProjectUrl: string; srcEnvironment: string; srcObjectId: string; srcObjectUrl: string; isChanged: boolean; status: 'modified' | 'added' | 'deleted' | 'published'; createdAt: string; createdBy?: string; updatedAt: string; updatedBy?: string[]; } function mapObjectFields({ data, model, fieldDataPath, fieldDataPathsInverted, fieldModelPath, encodeValue, delegate }: any): any { if (!fieldModelPath) { fieldModelPath = [model.name]; } const isRoot = fieldDataPath.length === 1; const metadata = delegate.getItemMetadata(data, model, isRoot); const fields = delegate.getItemFields(data, model); return _.reduce( fields, (accum, field) => { const fieldModel = _.find(model.fields, { name: field.name }); // if field model wasn't found ignore this field if (!fieldModel) { return accum; } // don't include const fields if ('const' in fieldModel) { return accum; } let childFieldModelPath: any; if (_.get(model, 'isList') && field.name === 'items') { childFieldModelPath = fieldModelPath; } else { childFieldModelPath = _.concat(fieldModelPath, field.name); } const fieldPath = ['fields', field.name]; const mapLocalizedField = (accum: any, localizedField: any): any => { const localePath = fieldModel.localized ? ['locales', localizedField.locale] : []; const fullFieldDataPath = _.concat(fieldPath, localePath); const mappedData = mapField({ fieldValue: localizedField.value, fieldModel: fieldModel, fieldDataPath: _.concat(fieldDataPath, fullFieldDataPath), fieldDataPaths: accum.fieldDataPaths, fieldDataPathsInverted: fieldDataPathsInverted, fieldModelPath: childFieldModelPath, delegate: delegate }); if (localizedField.locale && localizedField.locale !== '_unset') { mappedData.fieldData.locale = localizedField.locale; } _.set(accum.fieldData, fullFieldDataPath, { ..._.get(accum.fieldData, fullFieldDataPath), ...omitByNil(mappedData.fieldData) }); return { fieldData: accum.fieldData }; }; // always create 'fields' as object, even for objects that have field names as numbers _.setWith(accum.fieldData, fieldPath, fieldModelToFieldData(fieldModel), Object); if (fieldModel.localized) { accum = mapLocalizedField(accum, { locale: '_unset' }); } if (_.has(field, 'locales')) { return _.reduce( field.locales, (accum, localizedField) => { return mapLocalizedField(accum, localizedField); }, accum ); } return mapLocalizedField(accum, field); }, { fieldData: metadata } ); } function mapField({ fieldValue, fieldModel, fieldDataPath, fieldModelPath, delegate }: any): any { if (_.includes(consts.SIMPLE_VALUE_FIELDS, fieldModel.type)) { return { fieldData: { value: fieldValue } }; } else if (['enum', 'style'].includes(fieldModel.type)) { return { fieldData: { value: fieldValue } }; } else if (fieldModel.type === 'list') { const itemsModel = _.get(fieldModel, 'items'); let getListItemModel: any; if (_.isArray(itemsModel)) { // in Sanity, list items may have multiple types, in this case, 'items' will be an array getListItemModel = (listItem: any, fieldModel: any) => delegate.getItemTypeForListItem(listItem, fieldModel); } else { // get the type of list items, if type is not defined, set string as it is the default const listItemsType = _.get(itemsModel, 'type', 'string'); getListItemModel = _.constant(_.defaults({}, itemsModel, { type: listItemsType })); } return _.reduce( fieldValue, (accum, listItem, listIdx) => { const itemModel = getListItemModel(listItem, fieldModel); let mappedData; if (!itemModel) { mappedData = unresolvedModel(); accum.fieldData.items = _.concat(accum.fieldData.items, mappedData.fieldData); } else { mappedData = mapField({ fieldValue: listItem, fieldModel: itemModel, fieldDataPath: _.concat(fieldDataPath, ['items', listIdx]), fieldModelPath: fieldModelPath, delegate }); accum.fieldData.items = _.concat(accum.fieldData.items, fieldModelToFieldData(itemModel, mappedData.fieldData)); } return { fieldData: accum.fieldData }; }, { fieldData: { items: [] as any[] } } ); } else if (fieldModel.type === 'object') { // inline object if (!fieldValue) { return unsetObject(); } return mapObjectFields({ data: fieldValue, model: fieldModel, fieldDataPath, fieldModelPath, delegate }); } else if (fieldModel.type === 'reference') { if (!fieldValue) { return unsetObject(); } const isLink = delegate.isLinkItem(fieldValue); if (isLink) { return unresolvedReference(fieldValue, delegate); } const model = delegate.getModelForItemOfReferenceType(fieldValue); if (!model) { return unresolvedModel(); } return mapObjectFields({ data: fieldValue, model, fieldDataPath, delegate }); } else if (fieldModel.type === 'model') { if (!fieldValue) { return unsetObject(); } const fieldModels = _.get(fieldModel, 'models', []); const modelsByName = delegate.getModelsByName(); const model = fieldModels.length === 1 ? _.get(modelsByName, fieldModels[0]) : delegate.getModelForItemOfModelsType(fieldValue); if (!model) { return unresolvedModel(); } return mapObjectFields({ data: fieldValue, model, fieldDataPath, delegate }); } else { // for everything else, delegate encoding to the delegate const fieldData = {}; const encodedField = delegate.encodeField(fieldValue, fieldModel, fieldDataPath); if (encodedField) { if (_.has(encodedField, 'fieldData')) { _.assign(fieldData, encodedField.fieldData); } } return { fieldData: omitByNil(fieldData) }; } } function fieldModelToFieldData(fieldModel: any, overrides?: any): any { const type = ['reference', 'model'].includes(fieldModel.type) ? 'object' : fieldModel.type; return omitByNil( _.assign( { type: type, label: fieldModel.label, description: fieldModel.description, // fieldType: fieldModel.type, // fieldLabel: fieldModel.label, // "localized" field will be excluded from final fieldData by decoder localized: fieldModel.localized, // "models" field will be excluded from final fieldData by decoder models: getFieldModelNames(fieldModel) }, fieldModel.type === 'markdown' ? { multiElement: true } : null, fieldModel.type === 'enum' ? { options: _.get(fieldModel, 'options', []), source: _.get(fieldModel, 'source') } : null, overrides ) ); } function getFieldModelNames(fieldModel: any): any { const fieldType = fieldModel.type; if (fieldType === 'reference' || fieldType === 'model') { return _.clone(_.get(fieldModel, 'models', [])); } else if (fieldModel.type === 'list') { const itemsModel = _.get(fieldModel, 'items'); // in Sanity, array can have items of multiple types if (_.isArray(itemsModel)) { return Array.from( _.reduce( itemsModel, (accum, itemModel) => { return new Set([...accum, ...(getFieldModelNames(itemModel) || [])]); }, new Set() ) ); } else { return getFieldModelNames(itemsModel); } } return null; } function unsetObject() { return { fieldData: { isUnset: true } }; } function unresolvedReference(fieldValue: any, delegate: any) { return { fieldData: { type: 'unresolved_reference', refId: delegate.getReferenceId(fieldValue), refType: 'object' } }; } function unresolvedModel() { return { fieldData: { type: 'unresolved_model' } }; } function mapData(data: any, prevEncodingResult: any, delegate: any): any { // scan model instances and replace their 'data' with an identity-mapped data return _.reduce( data, (accum, item) => { const model = delegate.getModelForRootItem(item); if (!model) { return { fieldData: accum.fieldData }; } const itemId = delegate.getItemId(item); // todo: pass the accumulated hashedData to all children, because we need to remove duplicate fieldPaths from it per strategy when a field has changed (because we might get partial field data) // todo: create inverse map to find duplications const mappedData = mapObjectFields({ data: item, model: model, fieldDataPath: [itemId], delegate: delegate }); return { fieldData: _.assign(accum.fieldData, { [itemId]: mappedData.fieldData }) }; }, { fieldData: _.get(prevEncodingResult, 'fieldData', {}) } ); } export interface EncodingResult { fieldData: FieldData; encodedData?: any[]; hashedData?: Record; fieldDataPaths?: string[]; } export interface EncodeDataOptions { data: any[]; prevEncodingResult?: EncodingResult | null; delegate: any; encodeDelimiter: string; } export default function encodeData({ data, prevEncodingResult, delegate }: EncodeDataOptions): EncodingResult { data = _.cloneDeep(data); const mappedData = mapData(data, prevEncodingResult, delegate); return { fieldData: mappedData.fieldData }; }