using System; using System.Collections; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text.RegularExpressions; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Core; using Umbraco.Core.Composing; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Models.Editors; using Umbraco.Core.PropertyEditors; using Umbraco.Core.Services; namespace Umbraco.Web.PropertyEditors { /// /// Represents a nested content property editor. /// [DataEditor( Constants.PropertyEditors.Aliases.NestedContent, "Nested Content", "nestedcontent", ValueType = ValueTypes.Json, Group = Constants.PropertyEditors.Groups.Lists, Icon = "icon-thumbnail-list")] public class NestedContentPropertyEditor : DataEditor { private readonly Lazy _propertyEditors; private readonly IDataTypeService _dataTypeService; private readonly IContentTypeService _contentTypeService; internal const string ContentTypeAliasPropertyKey = "ncContentTypeAlias"; public NestedContentPropertyEditor(ILogger logger, Lazy propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base (logger) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _contentTypeService = contentTypeService; } // has to be lazy else circular dep in ctor private PropertyEditorCollection PropertyEditors => _propertyEditors.Value; #region Pre Value Editor protected override IConfigurationEditor CreateConfigurationEditor() => new NestedContentConfigurationEditor(); #endregion #region Value Editor protected override IDataValueEditor CreateValueEditor() => new NestedContentPropertyValueEditor(Attribute, PropertyEditors, _dataTypeService, _contentTypeService); internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference { private readonly PropertyEditorCollection _propertyEditors; private readonly IDataTypeService _dataTypeService; private readonly NestedContentValues _nestedContentValues; public NestedContentPropertyValueEditor(DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, IContentTypeService contentTypeService) : base(attribute) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _nestedContentValues = new NestedContentValues(contentTypeService); Validators.Add(new NestedContentValidator(propertyEditors, dataTypeService, _nestedContentValues)); } /// 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; } } #region DB to String public override string ConvertDbToString(PropertyType propertyType, object propertyValue, IDataTypeService dataTypeService) { var vals = _nestedContentValues.GetPropertyValues(propertyValue, out var deserialized).ToList(); if (vals.Count == 0) return string.Empty; foreach (var row in vals) { if (row.PropType == null) { // type not found, and property is not system: just delete the value if (IsSystemPropertyKey(row.PropKey) == false) row.JsonRowValue[row.PropKey] = null; } else { try { // convert the value, and store the converted value var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; if (propEditor == null) continue; var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; var valEditor = propEditor.GetValueEditor(tempConfig); var convValue = valEditor.ConvertDbToString(row.PropType, row.JsonRowValue[row.PropKey]?.ToString(), dataTypeService); row.JsonRowValue[row.PropKey] = convValue; } catch (InvalidOperationException) { // deal with weird situations by ignoring them (no comment) row.JsonRowValue[row.PropKey] = null; } } } return JsonConvert.SerializeObject(deserialized).ToXmlString(); } #endregion #region Convert database // editor // note: there is NO variant support here public override object ToEditor(Property property, IDataTypeService dataTypeService, string culture = null, string segment = null) { var val = property.GetValue(culture, segment); var vals = _nestedContentValues.GetPropertyValues(val, out var deserialized).ToList(); if (vals.Count == 0) return string.Empty; foreach (var row in vals) { if (row.PropType == null) { // type not found, and property is not system: just delete the value if (IsSystemPropertyKey(row.PropKey) == false) row.JsonRowValue[row.PropKey] = null; } else { try { // create a temp property with the value // - force it to be culture invariant as NC can't handle culture variant element properties row.PropType.Variations = ContentVariation.Nothing; var tempProp = new Property(row.PropType); tempProp.SetValue(row.JsonRowValue[row.PropKey] == null ? null : row.JsonRowValue[row.PropKey].ToString()); // convert that temp property, and store the converted value var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; if (propEditor == null) { row.JsonRowValue[row.PropKey] = tempProp.GetValue()?.ToString(); continue; } var tempConfig = dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; var valEditor = propEditor.GetValueEditor(tempConfig); var convValue = valEditor.ToEditor(tempProp, dataTypeService); row.JsonRowValue[row.PropKey] = convValue == null ? null : JToken.FromObject(convValue); } catch (InvalidOperationException) { // deal with weird situations by ignoring them (no comment) row.JsonRowValue[row.PropKey] = null; } } } // return json return deserialized; } public override object FromEditor(ContentPropertyData editorValue, object currentValue) { if (editorValue.Value == null || string.IsNullOrWhiteSpace(editorValue.Value.ToString())) return null; var vals = _nestedContentValues.GetPropertyValues(editorValue.Value, out var deserialized).ToList(); if (vals.Count == 0) return string.Empty; foreach (var row in vals) { if (row.PropType == null) { // type not found, and property is not system: just delete the value if (IsSystemPropertyKey(row.PropKey) == false) row.JsonRowValue[row.PropKey] = null; } else { // Fetch the property types prevalue var propConfiguration = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; // Lookup the property editor var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; if (propEditor == null) continue; // Create a fake content property data object var contentPropData = new ContentPropertyData(row.JsonRowValue[row.PropKey], propConfiguration); // Get the property editor to do it's conversion var newValue = propEditor.GetValueEditor().FromEditor(contentPropData, row.JsonRowValue[row.PropKey]); // Store the value back row.JsonRowValue[row.PropKey] = (newValue == null) ? null : JToken.FromObject(newValue); } } // return json return JsonConvert.SerializeObject(deserialized); } #endregion public IEnumerable GetReferences(object value) { var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); var result = new List(); foreach (var row in _nestedContentValues.GetPropertyValues(rawJson, out _)) { if (row.PropType == null) continue; var propEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; var valueEditor = propEditor?.GetValueEditor(); if (!(valueEditor is IDataValueReference reference)) continue; var val = row.JsonRowValue[row.PropKey]?.ToString(); var refs = reference.GetReferences(val); result.AddRange(refs); } return result; } } internal class NestedContentValidator : IValueValidator { private readonly PropertyEditorCollection _propertyEditors; private readonly IDataTypeService _dataTypeService; private readonly NestedContentValues _nestedContentValues; public NestedContentValidator(PropertyEditorCollection propertyEditors, IDataTypeService dataTypeService, NestedContentValues nestedContentValues) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _nestedContentValues = nestedContentValues; } public IEnumerable Validate(object rawValue, string valueType, object dataTypeConfiguration) { var validationResults = new List(); foreach(var row in _nestedContentValues.GetPropertyValues(rawValue, out _)) { if (row.PropType == null) continue; var config = _dataTypeService.GetDataType(row.PropType.DataTypeId).Configuration; var propertyEditor = _propertyEditors[row.PropType.PropertyEditorAlias]; if (propertyEditor == null) continue; foreach (var validator in propertyEditor.GetValueEditor().Validators) { foreach (var result in validator.Validate(row.JsonRowValue[row.PropKey], propertyEditor.GetValueEditor().ValueType, config)) { result.ErrorMessage = "Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' " + result.ErrorMessage; validationResults.Add(result); } } // Check mandatory if (row.PropType.Mandatory) { if (row.JsonRowValue[row.PropKey] == null) validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' cannot be null", new[] { row.PropKey })); else if (row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace() || (row.JsonRowValue[row.PropKey].Type == JTokenType.Array && !row.JsonRowValue[row.PropKey].HasValues)) validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' cannot be empty", new[] { row.PropKey })); } // Check regex if (!row.PropType.ValidationRegExp.IsNullOrWhiteSpace() && row.JsonRowValue[row.PropKey] != null && !row.JsonRowValue[row.PropKey].ToString().IsNullOrWhiteSpace()) { var regex = new Regex(row.PropType.ValidationRegExp); if (!regex.IsMatch(row.JsonRowValue[row.PropKey].ToString())) { validationResults.Add(new ValidationResult("Item " + (row.RowIndex + 1) + " '" + row.PropType.Name + "' is invalid, it does not match the correct pattern", new[] { row.PropKey })); } } } return validationResults; } } internal class NestedContentValues { private readonly Lazy> _contentTypes; public NestedContentValues(IContentTypeService contentTypeService) { _contentTypes = new Lazy>(() => contentTypeService.GetAll().ToDictionary(c => c.Alias)); } private IContentType GetElementType(JObject item) { var contentTypeAlias = item[ContentTypeAliasPropertyKey]?.ToObject() ?? string.Empty; _contentTypes.Value.TryGetValue(contentTypeAlias, out var contentType); return contentType; } public IEnumerable GetPropertyValues(object propertyValue, out List deserialized) { var rowValues = new List(); deserialized = null; if (propertyValue == null || string.IsNullOrWhiteSpace(propertyValue.ToString())) return Enumerable.Empty(); deserialized = 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 (deserialized == null || deserialized.Count == 0) return Enumerable.Empty(); var index = 0; foreach (var o in deserialized) { var propValues = o; var contentType = GetElementType(propValues); if (contentType == null) continue; var propertyTypes = contentType.CompositionPropertyTypes.ToDictionary(x => x.Alias, x => x); var propAliases = propValues.Properties().Select(x => x.Name); foreach (var propAlias in propAliases) { propertyTypes.TryGetValue(propAlias, out var propType); rowValues.Add(new RowValue(propAlias, propType, propValues, index)); } index++; } return rowValues; } internal class RowValue { public RowValue(string propKey, PropertyType propType, JObject propValues, int index) { PropKey = propKey ?? throw new ArgumentNullException(nameof(propKey)); PropType = propType; JsonRowValue = propValues ?? throw new ArgumentNullException(nameof(propValues)); RowIndex = index; } /// /// The current property key being iterated for the row value /// public string PropKey { get; } /// /// The of the value (if any), this may be null /// public PropertyType PropType { get; } /// /// The json values for the current row /// public JObject JsonRowValue { get; } /// /// The Nested Content row index /// public int RowIndex { get; } } } #endregion private static bool IsSystemPropertyKey(string propKey) { return propKey == "name" || propKey == "key" || propKey == ContentTypeAliasPropertyKey; } } }