* https://github.com/umbraco/Umbraco-CMS/issues/10265 Resolve virtual view paths from DataEditorAttribute in DataValueEditor + Introduced an IDataValueEditorFactory, so we don't need to inject nested dependencies every time we introduce a new dependency in DataValueEditor.. * Cleanup + xml doc
431 lines
20 KiB
C#
431 lines
20 KiB
C#
// Copyright (c) Umbraco.
|
|
// See LICENSE for more details.
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.ComponentModel.DataAnnotations;
|
|
using System.Linq;
|
|
using Microsoft.Extensions.Logging;
|
|
using Newtonsoft.Json;
|
|
using Umbraco.Cms.Core.Hosting;
|
|
using Umbraco.Cms.Core.IO;
|
|
using Umbraco.Cms.Core.Models;
|
|
using Umbraco.Cms.Core.Models.Blocks;
|
|
using Umbraco.Cms.Core.Models.Editors;
|
|
using Umbraco.Cms.Core.Serialization;
|
|
using Umbraco.Cms.Core.Services;
|
|
using Umbraco.Cms.Core.Strings;
|
|
using Umbraco.Extensions;
|
|
|
|
namespace Umbraco.Cms.Core.PropertyEditors
|
|
{
|
|
/// <summary>
|
|
/// Abstract class for block editor based editors
|
|
/// </summary>
|
|
public abstract class BlockEditorPropertyEditor : DataEditor
|
|
{
|
|
public const string ContentTypeKeyPropertyKey = "contentTypeKey";
|
|
public const string UdiPropertyKey = "udi";
|
|
private readonly Lazy<PropertyEditorCollection> _propertyEditors;
|
|
|
|
public BlockEditorPropertyEditor(
|
|
IDataValueEditorFactory dataValueEditorFactory,
|
|
Lazy<PropertyEditorCollection> propertyEditors)
|
|
: base(dataValueEditorFactory)
|
|
{
|
|
_propertyEditors = propertyEditors;
|
|
}
|
|
|
|
// has to be lazy else circular dep in ctor
|
|
private PropertyEditorCollection PropertyEditors => _propertyEditors.Value;
|
|
|
|
#region Value Editor
|
|
|
|
protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create<BlockEditorPropertyValueEditor>(Attribute);
|
|
|
|
internal class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference
|
|
{
|
|
private readonly PropertyEditorCollection _propertyEditors;
|
|
private readonly IDataTypeService _dataTypeService;
|
|
private readonly ILogger<BlockEditorPropertyValueEditor> _logger;
|
|
private readonly BlockEditorValues _blockEditorValues;
|
|
|
|
public BlockEditorPropertyValueEditor(
|
|
DataEditorAttribute attribute,
|
|
PropertyEditorCollection propertyEditors,
|
|
IDataTypeService dataTypeService,
|
|
IContentTypeService contentTypeService,
|
|
ILocalizedTextService textService,
|
|
ILogger<BlockEditorPropertyValueEditor> logger,
|
|
IShortStringHelper shortStringHelper,
|
|
IJsonSerializer jsonSerializer,
|
|
IIOHelper ioHelper,
|
|
IPropertyValidationService propertyValidationService)
|
|
: base(textService, shortStringHelper, jsonSerializer, ioHelper, attribute)
|
|
{
|
|
_propertyEditors = propertyEditors;
|
|
_dataTypeService = dataTypeService;
|
|
_logger = logger;
|
|
_blockEditorValues = new BlockEditorValues(new BlockListEditorDataConverter(), contentTypeService, _logger);
|
|
Validators.Add(new BlockEditorValidator(propertyValidationService, _blockEditorValues,contentTypeService));
|
|
Validators.Add(new MinMaxValidator(_blockEditorValues, textService));
|
|
}
|
|
|
|
public IEnumerable<UmbracoEntityReference> GetReferences(object value)
|
|
{
|
|
var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString();
|
|
|
|
var result = new List<UmbracoEntityReference>();
|
|
var blockEditorData = _blockEditorValues.DeserializeAndClean(rawJson);
|
|
if (blockEditorData == null)
|
|
return Enumerable.Empty<UmbracoEntityReference>();
|
|
|
|
// loop through all content and settings data
|
|
foreach (var row in blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData))
|
|
{
|
|
foreach (var prop in row.PropertyValues)
|
|
{
|
|
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
|
|
|
var valueEditor = propEditor?.GetValueEditor();
|
|
if (!(valueEditor is IDataValueReference reference)) continue;
|
|
|
|
var val = prop.Value.Value?.ToString();
|
|
|
|
var refs = reference.GetReferences(val);
|
|
|
|
result.AddRange(refs);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
#region Convert database // editor
|
|
|
|
// note: there is NO variant support here
|
|
|
|
/// <summary>
|
|
/// Ensure that sub-editor values are translated through their ToEditor methods
|
|
/// </summary>
|
|
/// <param name="property"></param>
|
|
/// <param name="dataTypeService"></param>
|
|
/// <param name="culture"></param>
|
|
/// <param name="segment"></param>
|
|
/// <returns></returns>
|
|
public override object ToEditor(IProperty property, string culture = null, string segment = null)
|
|
{
|
|
var val = property.GetValue(culture, segment);
|
|
|
|
BlockEditorData blockEditorData;
|
|
try
|
|
{
|
|
blockEditorData = _blockEditorValues.DeserializeAndClean(val);
|
|
}
|
|
catch (JsonSerializationException)
|
|
{
|
|
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
|
|
return string.Empty;
|
|
}
|
|
|
|
if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0)
|
|
return string.Empty;
|
|
|
|
foreach (var row in blockEditorData.BlockValue.ContentData)
|
|
{
|
|
foreach (var prop in row.PropertyValues)
|
|
{
|
|
// create a temp property with the value
|
|
// - force it to be culture invariant as the block editor can't handle culture variant element properties
|
|
prop.Value.PropertyType.Variations = ContentVariation.Nothing;
|
|
var tempProp = new Property(prop.Value.PropertyType);
|
|
|
|
tempProp.SetValue(prop.Value.Value);
|
|
|
|
// convert that temp property, and store the converted value
|
|
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
|
if (propEditor == null)
|
|
{
|
|
// NOTE: This logic was borrowed from Nested Content and I'm unsure why it exists.
|
|
// if the property editor doesn't exist I think everything will break anyways?
|
|
// update the raw value since this is what will get serialized out
|
|
row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString();
|
|
continue;
|
|
}
|
|
|
|
var dataType = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId);
|
|
if (dataType == null)
|
|
{
|
|
// deal with weird situations by ignoring them (no comment)
|
|
row.PropertyValues.Remove(prop.Key);
|
|
_logger.LogWarning(
|
|
"ToEditor removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}",
|
|
prop.Key, row.Key, property.PropertyType.Alias);
|
|
continue;
|
|
}
|
|
|
|
var tempConfig = dataType.Configuration;
|
|
var valEditor = propEditor.GetValueEditor(tempConfig);
|
|
var convValue = valEditor.ToEditor(tempProp);
|
|
|
|
// update the raw value since this is what will get serialized out
|
|
row.RawPropertyValues[prop.Key] = convValue;
|
|
}
|
|
}
|
|
|
|
// return json convertable object
|
|
return blockEditorData.BlockValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensure that sub-editor values are translated through their FromEditor methods
|
|
/// </summary>
|
|
/// <param name="editorValue"></param>
|
|
/// <param name="currentValue"></param>
|
|
/// <returns></returns>
|
|
public override object FromEditor(ContentPropertyData editorValue, object currentValue)
|
|
{
|
|
if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString()))
|
|
return null;
|
|
|
|
BlockEditorData blockEditorData;
|
|
try
|
|
{
|
|
blockEditorData = _blockEditorValues.DeserializeAndClean(editorValue.Value);
|
|
}
|
|
catch (JsonSerializationException)
|
|
{
|
|
// if this occurs it means the data is invalid, shouldn't happen but has happened if we change the data format.
|
|
return string.Empty;
|
|
}
|
|
|
|
if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0)
|
|
return string.Empty;
|
|
|
|
foreach (var row in blockEditorData.BlockValue.ContentData)
|
|
{
|
|
foreach (var prop in row.PropertyValues)
|
|
{
|
|
// Fetch the property types prevalue
|
|
var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId).Configuration;
|
|
|
|
// Lookup the property editor
|
|
var propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias];
|
|
if (propEditor == null) continue;
|
|
|
|
// Create a fake content property data object
|
|
var contentPropData = new ContentPropertyData(prop.Value.Value, propConfiguration);
|
|
|
|
// Get the property editor to do it's conversion
|
|
var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, prop.Value.Value);
|
|
|
|
// update the raw value since this is what will get serialized out
|
|
row.RawPropertyValues[prop.Key] = newValue;
|
|
}
|
|
}
|
|
|
|
// return json
|
|
return JsonConvert.SerializeObject(blockEditorData.BlockValue);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates the min/max of the block editor
|
|
/// </summary>
|
|
private class MinMaxValidator : IValueValidator
|
|
{
|
|
private readonly BlockEditorValues _blockEditorValues;
|
|
private readonly ILocalizedTextService _textService;
|
|
|
|
public MinMaxValidator(BlockEditorValues blockEditorValues, ILocalizedTextService textService)
|
|
{
|
|
_blockEditorValues = blockEditorValues;
|
|
_textService = textService;
|
|
}
|
|
|
|
public IEnumerable<ValidationResult> Validate(object value, string valueType, object dataTypeConfiguration)
|
|
{
|
|
var blockConfig = (BlockListConfiguration)dataTypeConfiguration;
|
|
if (blockConfig == null) yield break;
|
|
|
|
var validationLimit = blockConfig.ValidationLimit;
|
|
if (validationLimit == null) yield break;
|
|
|
|
var blockEditorData = _blockEditorValues.DeserializeAndClean(value);
|
|
|
|
if ((blockEditorData == null && validationLimit.Min.HasValue && validationLimit.Min > 0)
|
|
|| (blockEditorData != null && validationLimit.Min.HasValue && blockEditorData.Layout.Count() < validationLimit.Min))
|
|
{
|
|
yield return new ValidationResult(
|
|
_textService.Localize("validation/entriesShort", new[]
|
|
{
|
|
validationLimit.Min.ToString(),
|
|
(validationLimit.Min - blockEditorData.Layout.Count()).ToString()
|
|
}),
|
|
new[] { "minCount" });
|
|
}
|
|
|
|
if (blockEditorData != null && validationLimit.Max.HasValue && blockEditorData.Layout.Count() > validationLimit.Max)
|
|
{
|
|
yield return new ValidationResult(
|
|
_textService.Localize("validation/entriesExceed", new[]
|
|
{
|
|
validationLimit.Max.ToString(),
|
|
(blockEditorData.Layout.Count() - validationLimit.Max).ToString()
|
|
}),
|
|
new[] { "maxCount" });
|
|
}
|
|
}
|
|
}
|
|
|
|
internal class BlockEditorValidator : ComplexEditorValidator
|
|
{
|
|
private readonly BlockEditorValues _blockEditorValues;
|
|
private readonly IContentTypeService _contentTypeService;
|
|
|
|
public BlockEditorValidator(IPropertyValidationService propertyValidationService, BlockEditorValues blockEditorValues, IContentTypeService contentTypeService)
|
|
: base(propertyValidationService)
|
|
{
|
|
_blockEditorValues = blockEditorValues;
|
|
_contentTypeService = contentTypeService;
|
|
}
|
|
|
|
protected override IEnumerable<ElementTypeValidationModel> GetElementTypeValidation(object value)
|
|
{
|
|
var blockEditorData = _blockEditorValues.DeserializeAndClean(value);
|
|
if (blockEditorData != null)
|
|
{
|
|
// There is no guarantee that the client will post data for every property defined in the Element Type but we still
|
|
// need to validate that data for each property especially for things like 'required' data to work.
|
|
// Lookup all element types for all content/settings and then we can populate any empty properties.
|
|
var allElements = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData).ToList();
|
|
var allElementTypes = _contentTypeService.GetAll(allElements.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key);
|
|
|
|
foreach (var row in allElements)
|
|
{
|
|
if (!allElementTypes.TryGetValue(row.ContentTypeKey, out var elementType))
|
|
throw new InvalidOperationException($"No element type found with key {row.ContentTypeKey}");
|
|
|
|
// now ensure missing properties
|
|
foreach (var elementTypeProp in elementType.CompositionPropertyTypes)
|
|
{
|
|
if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias))
|
|
{
|
|
// set values to null
|
|
row.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp);
|
|
row.RawPropertyValues[elementTypeProp.Alias] = null;
|
|
}
|
|
}
|
|
|
|
var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Key);
|
|
foreach (var prop in row.PropertyValues)
|
|
{
|
|
elementValidation.AddPropertyTypeValidation(
|
|
new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value));
|
|
}
|
|
yield return elementValidation;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Used to deserialize json values and clean up any values based on the existence of element types and layout structure
|
|
/// </summary>
|
|
internal class BlockEditorValues
|
|
{
|
|
private readonly Lazy<Dictionary<Guid, IContentType>> _contentTypes;
|
|
private readonly BlockEditorDataConverter _dataConverter;
|
|
private readonly ILogger _logger;
|
|
|
|
public BlockEditorValues(BlockEditorDataConverter dataConverter, IContentTypeService contentTypeService, ILogger logger)
|
|
{
|
|
_contentTypes = new Lazy<Dictionary<Guid, IContentType>>(() => contentTypeService.GetAll().ToDictionary(c => c.Key));
|
|
_dataConverter = dataConverter;
|
|
_logger = logger;
|
|
}
|
|
|
|
private IContentType GetElementType(BlockItemData item)
|
|
{
|
|
_contentTypes.Value.TryGetValue(item.ContentTypeKey, out var contentType);
|
|
return contentType;
|
|
}
|
|
|
|
public BlockEditorData DeserializeAndClean(object propertyValue)
|
|
{
|
|
if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString()))
|
|
return null;
|
|
|
|
var blockEditorData = _dataConverter.Deserialize(propertyValue.ToString());
|
|
|
|
if (blockEditorData.BlockValue.ContentData.Count == 0)
|
|
{
|
|
// if there's no content ensure there's no settings too
|
|
blockEditorData.BlockValue.SettingsData.Clear();
|
|
return null;
|
|
}
|
|
|
|
var contentTypePropertyTypes = new Dictionary<string, Dictionary<string, IPropertyType>>();
|
|
|
|
// filter out any content that isn't referenced in the layout references
|
|
foreach (var block in blockEditorData.BlockValue.ContentData.Where(x => blockEditorData.References.Any(r => r.ContentUdi == x.Udi)))
|
|
{
|
|
ResolveBlockItemData(block, contentTypePropertyTypes);
|
|
}
|
|
// filter out any settings that isn't referenced in the layout references
|
|
foreach (var block in blockEditorData.BlockValue.SettingsData.Where(x => blockEditorData.References.Any(r => r.SettingsUdi == x.Udi)))
|
|
{
|
|
ResolveBlockItemData(block, contentTypePropertyTypes);
|
|
}
|
|
|
|
// remove blocks that couldn't be resolved
|
|
blockEditorData.BlockValue.ContentData.RemoveAll(x => x.ContentTypeAlias.IsNullOrWhiteSpace());
|
|
blockEditorData.BlockValue.SettingsData.RemoveAll(x => x.ContentTypeAlias.IsNullOrWhiteSpace());
|
|
|
|
return blockEditorData;
|
|
}
|
|
|
|
private bool ResolveBlockItemData(BlockItemData block, Dictionary<string, Dictionary<string, IPropertyType>> contentTypePropertyTypes)
|
|
{
|
|
var contentType = GetElementType(block);
|
|
if (contentType == null)
|
|
return false;
|
|
|
|
// get the prop types for this content type but keep a dictionary of found ones so we don't have to keep re-looking and re-creating
|
|
// objects on each iteration.
|
|
if (!contentTypePropertyTypes.TryGetValue(contentType.Alias, out var propertyTypes))
|
|
propertyTypes = contentTypePropertyTypes[contentType.Alias] = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x);
|
|
|
|
var propValues = new Dictionary<string, BlockItemData.BlockPropertyValue>();
|
|
|
|
// find any keys that are not real property types and remove them
|
|
foreach (var prop in block.RawPropertyValues.ToList())
|
|
{
|
|
// doesn't exist so remove it
|
|
if (!propertyTypes.TryGetValue(prop.Key, out var propType))
|
|
{
|
|
block.RawPropertyValues.Remove(prop.Key);
|
|
_logger.LogWarning("The property {PropertyKey} for block {BlockKey} was removed because the property type {PropertyTypeAlias} was not found on {ContentTypeAlias}",
|
|
prop.Key, block.Key, prop.Key, contentType.Alias);
|
|
}
|
|
else
|
|
{
|
|
// set the value to include the resolved property type
|
|
propValues[prop.Key] = new BlockItemData.BlockPropertyValue(prop.Value, propType);
|
|
}
|
|
}
|
|
|
|
block.ContentTypeAlias = contentType.Alias;
|
|
block.PropertyValues = propValues;
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
}
|
|
}
|