// Copyright (c) Umbraco. // See LICENSE for more details. using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; 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; /// /// Represents a nested content property editor. /// [DataEditor( Constants.PropertyEditors.Aliases.NestedContent, "Nested Content (legacy)", "nestedcontent", ValueType = ValueTypes.Json, Group = Constants.PropertyEditors.Groups.Lists, Icon = "icon-thumbnail-list", ValueEditorIsReusable = false, IsDeprecated = true)] [Obsolete("Nested content is obsolete, will be removed in V13")] public class NestedContentPropertyEditor : DataEditor { public const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; private readonly IEditorConfigurationParser _editorConfigurationParser; private readonly IIOHelper _ioHelper; private readonly INestedContentPropertyIndexValueFactory _nestedContentPropertyIndexValueFactory; [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 12.")] public NestedContentPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper) : this(dataValueEditorFactory, ioHelper, StaticServiceProvider.Instance.GetRequiredService()) { } [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] public NestedContentPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, IEditorConfigurationParser editorConfigurationParser) : this( dataValueEditorFactory, ioHelper, editorConfigurationParser, StaticServiceProvider.Instance.GetRequiredService()) { } public NestedContentPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, IEditorConfigurationParser editorConfigurationParser, INestedContentPropertyIndexValueFactory nestedContentPropertyIndexValueFactory) : base(dataValueEditorFactory) { _ioHelper = ioHelper; _editorConfigurationParser = editorConfigurationParser; _nestedContentPropertyIndexValueFactory = nestedContentPropertyIndexValueFactory; SupportsReadOnly = true; } public override IPropertyIndexValueFactory PropertyIndexValueFactory => _nestedContentPropertyIndexValueFactory; #region Pre Value Editor protected override IConfigurationEditor CreateConfigurationEditor() => new NestedContentConfigurationEditor(_ioHelper, _editorConfigurationParser); #endregion private static bool IsSystemPropertyKey(string propKey) => propKey == "name" || propKey == "key" || propKey == ContentTypeAliasPropertyKey; #region Value Editor protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference, IDataValueTags { private readonly IDataTypeService _dataTypeService; private readonly PropertyEditorCollection _propertyEditors; private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories; private readonly ILogger _logger; private readonly NestedContentValues _nestedContentValues; public NestedContentPropertyValueEditor( IDataTypeService dataTypeService, ILocalizedTextService localizedTextService, IContentTypeService contentTypeService, IShortStringHelper shortStringHelper, DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories, ILogger logger, IJsonSerializer jsonSerializer, IIOHelper ioHelper, IPropertyValidationService propertyValidationService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _dataTypeService = dataTypeService; _propertyEditors = propertyEditors; _dataValueReferenceFactories = dataValueReferenceFactories; _logger = logger; _nestedContentValues = new NestedContentValues(contentTypeService); Validators.Add(new NestedContentValidator(propertyValidationService, _nestedContentValues, contentTypeService)); } /// public override object? Configuration { get => base.Configuration; set { if (value == null) { throw new ArgumentNullException(nameof(value)); } if (!(value is NestedContentConfiguration configuration)) { throw new ArgumentException( $"Expected a {typeof(NestedContentConfiguration).Name} instance, but got {value.GetType().Name}.", nameof(value)); } base.Configuration = value; HideLabel = configuration.HideLabel.TryConvertTo().Result; } } /// public IEnumerable GetReferences(object? value) { // Group by property editor alias to avoid duplicate lookups and optimize value parsing foreach (var valuesByPropertyEditorAlias in GetAllPropertyValues(value).GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } // Use distinct values to avoid duplicate parsing of the same value foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, valuesByPropertyEditorAlias.Distinct())) { yield return reference; } } } /// public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) { foreach (NestedContentValues.NestedContentPropertyValue propertyValue in GetAllPropertyValues(value)) { if (!_propertyEditors.TryGet(propertyValue.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor) || dataEditor.GetValueEditor() is not IDataValueTags dataValueTags) { continue; } object? configuration = _dataTypeService.GetDataType(propertyValue.PropertyType.DataTypeKey)?.Configuration; foreach (ITag tag in dataValueTags.GetTags(propertyValue.Value, configuration, languageId)) { yield return tag; } } } private IEnumerable GetAllPropertyValues(object? value) => _nestedContentValues.GetPropertyValues(value).SelectMany(x => x.PropertyValues.Values); #region DB to String public override string ConvertDbToString(IPropertyType propertyType, object? propertyValue) { IReadOnlyList rows = _nestedContentValues.GetPropertyValues(propertyValue); if (rows.Count == 0) { return string.Empty; } foreach (NestedContentValues.NestedContentRowValue row in rows.ToList()) { foreach (KeyValuePair prop in row.PropertyValues .ToList()) { try { // convert the value, and store the converted value IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; if (propEditor == null) { continue; } var tempConfig = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId) ?.Configuration; IDataValueEditor valEditor = propEditor.GetValueEditor(tempConfig); var convValue = valEditor.ConvertDbToString(prop.Value.PropertyType, prop.Value.Value); // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = convValue; } catch (InvalidOperationException ex) { // deal with weird situations by ignoring them (no comment) row.RawPropertyValues.Remove(prop.Key); _logger.LogWarning(ex, "ConvertDbToString removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}", prop.Key, row.Id, propertyType.Alias); } } } return JsonConvert.SerializeObject(rows, Formatting.None).ToXmlString(); } #endregion #region Convert database // editor // note: there is NO variant support here /// /// Ensure that sub-editor values are translated through their ToEditor methods /// /// /// /// /// public override object ToEditor(IProperty property, string? culture = null, string? segment = null) { var val = property.GetValue(culture, segment); var valEditors = new Dictionary(); IReadOnlyList rows = _nestedContentValues.GetPropertyValues(val); if (rows.Count == 0) { return string.Empty; } foreach (NestedContentValues.NestedContentRowValue row in rows.ToList()) { foreach (KeyValuePair prop in row.PropertyValues .ToList()) { try { // create a temp property with the value // - force it to be culture invariant as NC 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 IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; if (propEditor == null) { // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = tempProp.GetValue()?.ToString(); continue; } var dataTypeId = prop.Value.PropertyType.DataTypeId; if (!valEditors.TryGetValue(dataTypeId, out IDataValueEditor? valEditor)) { var tempConfig = _dataTypeService.GetDataType(dataTypeId)?.Configuration; valEditor = propEditor.GetValueEditor(tempConfig); valEditors.Add(dataTypeId, valEditor); } var convValue = valEditor.ToEditor(tempProp); // update the raw value since this is what will get serialized out row.RawPropertyValues[prop.Key] = convValue == null ? null : JToken.FromObject(convValue); } catch (InvalidOperationException ex) { // deal with weird situations by ignoring them (no comment) row.RawPropertyValues.Remove(prop.Key); _logger.LogWarning(ex, "ToEditor removed property value {PropertyKey} in row {RowId} for property type {PropertyTypeAlias}", prop.Key, row.Id, property.PropertyType.Alias); } } } // return the object, there's a native json converter for this so it will serialize correctly return rows; } /// /// Ensure that sub-editor values are translated through their FromEditor methods /// /// /// /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString())) { return null; } IReadOnlyList rows = _nestedContentValues.GetPropertyValues(editorValue.Value); if (rows.Count == 0) { return null; } foreach (NestedContentValues.NestedContentRowValue row in rows.ToList()) { foreach (KeyValuePair prop in row.PropertyValues .ToList()) { // Fetch the property types prevalue var propConfiguration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeId)?.Configuration; // Lookup the property editor IDataEditor? 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 == null ? null : JToken.FromObject(newValue); } } // return json return JsonConvert.SerializeObject(rows, Formatting.None); } #endregion } /// /// Validator for nested content to ensure that all nesting of editors is validated /// internal class NestedContentValidator : ComplexEditorValidator { private readonly IContentTypeService _contentTypeService; private readonly NestedContentValues _nestedContentValues; public NestedContentValidator(IPropertyValidationService propertyValidationService, NestedContentValues nestedContentValues, IContentTypeService contentTypeService) : base(propertyValidationService) { _nestedContentValues = nestedContentValues; _contentTypeService = contentTypeService; } protected override IEnumerable GetElementTypeValidation(object? value) { IReadOnlyList rows = _nestedContentValues.GetPropertyValues(value); if (rows.Count == 0) { yield break; } // 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 allElementAliases = rows.Select(x => x.ContentTypeAlias).ToList(); // unfortunately we need to get all content types and post filter - but they are cached so its ok, there's // no overload to lookup by many aliases. var allElementTypes = _contentTypeService.GetAll().Where(x => allElementAliases.Contains(x.Alias)) .ToDictionary(x => x.Alias); foreach (NestedContentValues.NestedContentRowValue row in rows) { if (!allElementTypes.TryGetValue(row.ContentTypeAlias, out IContentType? elementType)) { throw new InvalidOperationException($"No element type found with alias {row.ContentTypeAlias}"); } // now ensure missing properties foreach (IPropertyType elementTypeProp in elementType.CompositionPropertyTypes) { if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias)) { // set values to null row.PropertyValues[elementTypeProp.Alias] = new NestedContentValues.NestedContentPropertyValue { PropertyType = elementTypeProp, Value = null, }; row.RawPropertyValues[elementTypeProp.Alias] = null; } } var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Id); foreach (KeyValuePair prop in row.PropertyValues) { elementValidation.AddPropertyTypeValidation( new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); } yield return elementValidation; } } } /// /// Used to deserialize the nested content serialized value /// internal class NestedContentValues { private readonly Lazy> _contentTypes; public NestedContentValues(IContentTypeService contentTypeService) => _contentTypes = new Lazy>(() => contentTypeService.GetAll().ToDictionary(c => c.Alias)); private IContentType? GetElementType(NestedContentRowValue item) { _contentTypes.Value.TryGetValue(item.ContentTypeAlias, out IContentType? contentType); return contentType; } /// /// Deserialize the raw json property value /// /// /// public IReadOnlyList GetPropertyValues(object? propertyValue) { if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())) { return new List(); } if (!propertyValue.ToString()!.DetectIsJson()) { return new List(); } List? rowValues = JsonConvert.DeserializeObject>(propertyValue.ToString()!); // There was a note here about checking if the result had zero items and if so it would return null, so we'll continue to do that // The original note was: "Issue #38 - Keep recursive property lookups working" // Which is from the original NC tracker: https://github.com/umco/umbraco-nested-content/issues/38 // This check should be used everywhere when iterating NC prop values, instead of just the one previous place so that // empty values don't get persisted when there is nothing, it should actually be null. if (rowValues == null || rowValues.Count == 0) { return new List(); } var contentTypePropertyTypes = new Dictionary>(); foreach (NestedContentRowValue row in rowValues) { IContentType? contentType = GetElementType(row); if (contentType == null) { continue; } // 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 Dictionary? propertyTypes)) { propertyTypes = contentTypePropertyTypes[contentType.Alias] = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x); } // find any keys that are not real property types and remove them if (row.RawPropertyValues != null) { foreach (KeyValuePair prop in row.RawPropertyValues.ToList()) { if (IsSystemPropertyKey(prop.Key)) { continue; } // doesn't exist so remove it if (!propertyTypes.TryGetValue(prop.Key, out IPropertyType? propType)) { row.RawPropertyValues.Remove(prop.Key); } else { // set the value to include the resolved property type row.PropertyValues[prop.Key] = new NestedContentPropertyValue { PropertyType = propType, Value = prop.Value, }; } } } } return rowValues; } /// /// Used during deserialization to populate the property value/property type of a nested content row property /// internal class NestedContentPropertyValue { public object? Value { get; set; } public IPropertyType PropertyType { get; set; } = null!; } /// /// Used to deserialize a nested content row /// internal class NestedContentRowValue { [JsonProperty("key")] public Guid Id { get; set; } [JsonProperty("name")] public string? Name { get; set; } [JsonProperty("ncContentTypeAlias")] public string ContentTypeAlias { get; set; } = null!; public IPropertyType? PropType { get; } /// /// The remaining properties will be serialized to a dictionary /// /// /// The JsonExtensionDataAttribute is used to put the non-typed properties into a bucket /// http://www.newtonsoft.com/json/help/html/DeserializeExtensionData.htm /// NestedContent serializes to string, int, whatever eg /// "stringValue":"Some String","numericValue":125,"otherNumeric":null /// [JsonExtensionData] public IDictionary RawPropertyValues { get; set; } = null!; /// /// Used during deserialization to convert the raw property data into data with a property type context /// [JsonIgnore] public IDictionary PropertyValues { get; set; } = new Dictionary(); } } #endregion }