diff --git a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs index d36baed604..b12cbe58ef 100644 --- a/src/Umbraco.Core/Models/PropertyTagsExtensions.cs +++ b/src/Umbraco.Core/Models/PropertyTagsExtensions.cs @@ -23,19 +23,15 @@ public static class PropertyTagsExtensions IDataEditor? editor = propertyEditors[property.PropertyType?.PropertyEditorAlias]; TagsPropertyEditorAttribute? tagAttribute = editor?.GetTagAttribute(); - if (tagAttribute == null) - { - return null; - } var configurationObject = property.PropertyType is null ? null : dataTypeService.GetDataType(property.PropertyType.DataTypeId)?.Configuration; - TagConfiguration? configuration = ConfigurationEditor.ConfigurationAs(configurationObject); + TagConfiguration? configuration = configurationObject as TagConfiguration; if (configuration is not null && configuration.Delimiter == default) { - configuration.Delimiter = tagAttribute.Delimiter; + configuration.Delimiter = tagAttribute?.Delimiter ?? ','; } return configuration; diff --git a/src/Umbraco.Core/PropertyEditors/IDataValueTags.cs b/src/Umbraco.Core/PropertyEditors/IDataValueTags.cs new file mode 100644 index 0000000000..f809e787bc --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/IDataValueTags.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.PropertyEditors; + +/// +/// Resolve tags from values +/// +public interface IDataValueTags +{ + /// + /// Returns any tags contained in the value + /// + /// + /// + /// + /// + IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId); +} diff --git a/src/Umbraco.Core/PropertyEditors/PropertyEditorTagsExtensions.cs b/src/Umbraco.Core/PropertyEditors/PropertyEditorTagsExtensions.cs index ff92c2012f..8206ab538b 100644 --- a/src/Umbraco.Core/PropertyEditors/PropertyEditorTagsExtensions.cs +++ b/src/Umbraco.Core/PropertyEditors/PropertyEditorTagsExtensions.cs @@ -5,6 +5,7 @@ namespace Umbraco.Extensions; /// /// Provides extension methods for the interface to manage tags. /// +[Obsolete] public static class PropertyEditorTagsExtensions { /// diff --git a/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs b/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs index 849d6446a9..d6f1584e8d 100644 --- a/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs +++ b/src/Umbraco.Core/PropertyEditors/TagsPropertyEditorAttribute.cs @@ -6,6 +6,7 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// Marks property editors that support tags. /// [AttributeUsage(AttributeTargets.Class)] +[Obsolete("Implement a custom IDataValueEditor with the IDataValueTags interface instead")] public class TagsPropertyEditorAttribute : Attribute { /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index ded99bf8bf..2d6584234e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -274,29 +274,59 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { foreach (IProperty property in entity.Properties) { - TagConfiguration? tagConfiguration = property.GetTagConfiguration(PropertyEditors, DataTypeService); - if (tagConfiguration == null) + if (PropertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out var editor) is false) { - continue; // not a tags property + continue; } + if (editor.GetValueEditor() is not IDataValueTags tagsProvider) + { + // support for legacy tag editors, everything from here down to the last continue can be removed when TagsPropertyEditorAttribute is removed + TagConfiguration? tagConfiguration = property.GetTagConfiguration(PropertyEditors, DataTypeService); + if (tagConfiguration == null) + { + continue; + } + + if (property.PropertyType.VariesByCulture()) + { + var tags = new List(); + foreach (IPropertyValue pvalue in property.Values) + { + IEnumerable tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer, pvalue.Culture); + var languageId = LanguageRepository.GetIdByIsoCode(pvalue.Culture); + IEnumerable cultureTags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x, LanguageId = languageId }); + tags.AddRange(cultureTags); + } + + tagRepo.Assign(entity.Id, property.PropertyTypeId, tags); + } + else + { + IEnumerable tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer); // strings + IEnumerable tags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x }); + tagRepo.Assign(entity.Id, property.PropertyTypeId, tags); + } + + continue; // not implementing IDataValueTags, continue + } + + object? configuration = DataTypeService.GetDataType(property.PropertyType.DataTypeId)?.Configuration; + if (property.PropertyType.VariesByCulture()) { var tags = new List(); foreach (IPropertyValue pvalue in property.Values) { - IEnumerable tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer, pvalue.Culture); var languageId = LanguageRepository.GetIdByIsoCode(pvalue.Culture); - IEnumerable cultureTags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x, LanguageId = languageId }); - tags.AddRange(cultureTags); + tags.AddRange(tagsProvider.GetTags(pvalue.EditedValue, configuration, languageId)); } tagRepo.Assign(entity.Id, property.PropertyTypeId, tags); } else { - IEnumerable tagsValue = property.GetTagsValue(PropertyEditors, DataTypeService, serializer); // strings - IEnumerable tags = tagsValue.Select(x => new Tag { Group = tagConfiguration.Group, Text = x }); + IEnumerable tags = tagsProvider.GetTags(property.GetValue(), configuration, null); tagRepo.Assign(entity.Id, property.PropertyTypeId, tags); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index 11b3810640..fbf2239828 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -13,7 +13,7 @@ using Umbraco.Cms.Core.Strings; namespace Umbraco.Cms.Core.PropertyEditors; -internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference +internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataValueReference, IDataValueTags { private BlockEditorValues? _blockEditorValues; private readonly IDataTypeService _dataTypeService; @@ -77,6 +77,40 @@ internal abstract class BlockEditorPropertyValueEditor : DataValueEditor, IDataV return result; } + /// + public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + { + var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); + + BlockEditorData? blockEditorData = BlockEditorValues.DeserializeAndClean(rawJson); + if (blockEditorData == null) + { + return Enumerable.Empty(); + } + + var result = new List(); + // loop through all content and settings data + foreach (BlockItemData row in blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData)) + { + foreach (KeyValuePair prop in row.PropertyValues) + { + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + + IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); + if (valueEditor is not IDataValueTags tagsProvider) + { + continue; + } + + object? configuration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeKey)?.Configuration; + + result.AddRange(tagsProvider.GetTags(prop.Value.Value, configuration, languageId)); + } + } + + return result; + } + #region Convert database // editor // note: there is NO variant support here diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 230c6e2b59..f38c88c4bc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -68,7 +68,7 @@ public class NestedContentPropertyEditor : DataEditor protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); - internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference + internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference, IDataValueTags { private readonly IDataTypeService _dataTypeService; private readonly ILogger _logger; @@ -149,6 +149,36 @@ public class NestedContentPropertyEditor : DataEditor return result; } + /// + public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + { + IReadOnlyList rows = + _nestedContentValues.GetPropertyValues(value); + + var result = new List(); + + foreach (NestedContentValues.NestedContentRowValue row in rows.ToList()) + { + foreach (KeyValuePair prop in row.PropertyValues + .ToList()) + { + IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + + IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); + if (valueEditor is not IDataValueTags tagsProvider) + { + continue; + } + + object? configuration = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeKey)?.Configuration; + + result.AddRange(tagsProvider.GetTags(prop.Value.Value, configuration, languageId)); + } + } + + return result; + } + #region DB to String public override string ConvertDbToString(IPropertyType propertyType, object? propertyValue) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index 88648c47fd..ff646a039d 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations; using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -68,18 +69,67 @@ public class TagsPropertyEditor : DataEditor protected override IConfigurationEditor CreateConfigurationEditor() => new TagConfigurationEditor(_validators, _ioHelper, _localizedTextService, _editorConfigurationParser); - internal class TagPropertyValueEditor : DataValueEditor + internal class TagPropertyValueEditor : DataValueEditor, IDataValueTags { + private readonly IDataTypeService _dataTypeService; + public TagPropertyValueEditor( ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, - DataEditorAttribute attribute) + DataEditorAttribute attribute, + IDataTypeService dataTypeService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { + _dataTypeService = dataTypeService; } + /// + public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + { + var strValue = value?.ToString(); + if (string.IsNullOrWhiteSpace(strValue)) return Enumerable.Empty(); + + var tagConfiguration = ConfigurationEditor.ConfigurationAs(dataTypeConfiguration) ?? new TagConfiguration(); + + if (tagConfiguration.Delimiter == default) + tagConfiguration.Delimiter = ','; + + IEnumerable tags; + + switch (tagConfiguration.StorageType) + { + case TagsStorageType.Csv: + tags = strValue.Split(new[] { tagConfiguration.Delimiter }, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()); + break; + + case TagsStorageType.Json: + try + { + tags = JsonConvert.DeserializeObject(strValue)?.Select(x => x.Trim()) ?? Enumerable.Empty(); + } + catch (JsonException) + { + //cannot parse, malformed + tags = Enumerable.Empty(); + } + + break; + + default: + throw new NotSupportedException($"Value \"{tagConfiguration.StorageType}\" is not a valid TagsStorageType."); + } + + return tags.Select(x => new Tag + { + Group = tagConfiguration.Group, + Text = x, + LanguageId = languageId, + }); + } + + /// public override IValueRequiredValidator RequiredValidator => new RequiredJsonValueValidator(); @@ -93,14 +143,33 @@ public class TagsPropertyEditor : DataEditor return null; } + var tagConfiguration = editorValue.DataTypeConfiguration as TagConfiguration ?? new TagConfiguration(); + if (tagConfiguration.Delimiter == default) + tagConfiguration.Delimiter = ','; + + string[] trimmedTags = Array.Empty(); + if (editorValue.Value is JArray json) { - return json.HasValues ? json.Select(x => x.Value()) : null; + trimmedTags = json.HasValues ? json.Select(x => x.Value()).OfType().ToArray() : Array.Empty(); + } + else if (string.IsNullOrWhiteSpace(value) == false) + { + trimmedTags = value.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); } - if (string.IsNullOrWhiteSpace(value) == false) + if (trimmedTags.Length == 0) { - return value.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries); + return null; + } + + switch (tagConfiguration.StorageType) + { + case TagsStorageType.Csv: + return string.Join(tagConfiguration.Delimiter.ToString(), trimmedTags).NullOrWhiteSpaceAsNull(); + + case TagsStorageType.Json: + return trimmedTags.Length == 0 ? null : JsonConvert.SerializeObject(trimmedTags); } return null; diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs index 795257800a..36a60843fb 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentControllerBase.cs @@ -146,7 +146,9 @@ public abstract class ContentControllerBase : BackOfficeNotificationsController // set the value - tags are special TagsPropertyEditorAttribute? tagAttribute = propertyDto.PropertyEditor.GetTagAttribute(); - if (tagAttribute != null) + // when TagsPropertyEditorAttribute is removed this whole if can also be removed + // since the call to sovePropertyValue is all that's needed now + if (tagAttribute is not null && valueEditor is not IDataValueTags) { TagConfiguration? tagConfiguration = ConfigurationEditor.ConfigurationAs(propertyDto.DataType?.Configuration); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js index 864e07f17c..d461a3b0bc 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/blockeditormodelobject.service.js @@ -206,7 +206,6 @@ * Used to highlight unsupported properties for the user, changes unsupported properties into a unsupported-property. */ var notSupportedProperties = [ - "Umbraco.Tags", "Umbraco.UploadField", "Umbraco.ImageCropper", "Umbraco.NestedContent" @@ -654,7 +653,7 @@ blockObject.__scope.$evalAsync(); }); }); - + observer.observe(labelElement[0], {characterData: true, subtree:true}); blockObject.__watchers.push(() => { @@ -671,9 +670,9 @@ $index: this.index + 1, ... this.data }; - + this.__labelScope = Object.assign(this.__labelScope, labelVars); - + $compile(labelElement.contents())(this.__labelScope); }.bind(blockObject) } else { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js index 7fcf5cc365..1f18c0ed65 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/nestedcontent/nestedcontent.controller.js @@ -68,7 +68,7 @@ function NestedContentController($scope, $interpolate, $filter, serverValidationManager, contentResource, localizationService, iconHelper, clipboardService, eventsService, overlayService, $attrs) { const vm = this; - + var model = $scope.$parent.$parent.model; vm.readonly = false; @@ -167,7 +167,7 @@ isDisabled: true, useLegacyIcon: false }; - + function removeAllEntries() { localizationService.localizeMany(["content_nestedContentDeleteAllItems", "general_delete"]).then(data => { @@ -186,7 +186,7 @@ }); }); } - + // helper to force the current form into the dirty state function setDirty() { if (vm.umbProperty) { @@ -531,7 +531,6 @@ storageUpdate(); }); var notSupported = [ - "Umbraco.Tags", "Umbraco.UploadField", "Umbraco.ImageCropper", "Umbraco.BlockList"