diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 56fdface93..d0c68d5aa5 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -52,6 +52,7 @@ using Umbraco.Cms.Infrastructure.Migrations; using Umbraco.Cms.Infrastructure.Migrations.Install; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Mappers; +using Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; using Umbraco.Cms.Infrastructure.Routing; using Umbraco.Cms.Infrastructure.Runtime; using Umbraco.Cms.Infrastructure.Runtime.RuntimeModeValidators; @@ -355,11 +356,11 @@ public static partial class UmbracoBuilderExtensions .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() - .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() diff --git a/src/Umbraco.Infrastructure/Extensions/RichTextEditorValueExtensions.cs b/src/Umbraco.Infrastructure/Extensions/RichTextEditorValueExtensions.cs new file mode 100644 index 0000000000..3cc16b6c21 --- /dev/null +++ b/src/Umbraco.Infrastructure/Extensions/RichTextEditorValueExtensions.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; + +namespace Umbraco.Cms.Infrastructure.Extensions; + +/// +/// Defines extensions on . +/// +internal static class RichTextEditorValueExtensions +{ + /// + /// Ensures that the property type property is populated on all blocks. + /// + /// The providing the blocks. + /// Cache for element types. + public static void EnsurePropertyTypePopulatedOnBlocks(this RichTextEditorValue richTextEditorValue, IBlockEditorElementTypeCache elementTypeCache) + { + Guid[] elementTypeKeys = (richTextEditorValue.Blocks?.ContentData ?? []) + .Select(x => x.ContentTypeKey) + .Union((richTextEditorValue.Blocks?.SettingsData ?? []) + .Select(x => x.ContentTypeKey)) + .Distinct() + .ToArray(); + + IEnumerable elementTypes = elementTypeCache.GetMany(elementTypeKeys); + + foreach (BlockItemData dataItem in (richTextEditorValue.Blocks?.ContentData ?? []) + .Union(richTextEditorValue.Blocks?.SettingsData ?? [])) + { + foreach (BlockPropertyValue item in dataItem.Values) + { + item.PropertyType = elementTypes.FirstOrDefault(x => x.Key == dataItem.ContentTypeKey)?.PropertyTypes.FirstOrDefault(pt => pt.Alias == item.Alias); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index a129dce83f..21359ec1c0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -1,10 +1,8 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using System.Diagnostics.CodeAnalysis; using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; @@ -16,10 +14,16 @@ using Umbraco.Cms.Core.Strings; namespace Umbraco.Cms.Core.PropertyEditors; +/// +/// Provides an abstract base class for property value editors based on block editors. +/// public abstract class BlockEditorPropertyValueEditor : BlockValuePropertyValueEditorBase where TValue : BlockValue, new() where TLayout : class, IBlockLayoutItem, new() { + /// + /// Initializes a new instance of the class. + /// protected BlockEditorPropertyValueEditor( PropertyEditorCollection propertyEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories, @@ -62,13 +66,7 @@ public abstract class BlockEditorPropertyValueEditor : BlockVal return BlockEditorValues.DeserializeAndClean(rawJson)?.BlockValue; } - /// - /// 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); @@ -95,38 +93,48 @@ public abstract class BlockEditorPropertyValueEditor : BlockVal return blockEditorData.BlockValue; } - /// - /// 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())) + // Note: we can't early return here if editorValue is null or empty, because these is the following case: + // - current value not null (which means doc has at least one element in block list) + // - editor value (new value) is null (which means doc has no elements in block list) + // If we check editor value for null value and return before MapBlockValueFromEditor, then we will not trigger updates for properties. + // For most of the properties this is fine, but for properties which contain other state it might be critical (e.g. file upload field). + // So, we must run MapBlockValueFromEditor even if editorValue is null or string.IsNullOrWhiteSpace(editorValue.Value.ToString()) is true. + + BlockEditorData? currentBlockEditorData = GetBlockEditorData(currentValue); + BlockEditorData? blockEditorData = GetBlockEditorData(editorValue.Value); + + // We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty. + if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData)) { return null; } - BlockEditorData? blockEditorData; + MapBlockValueFromEditor(blockEditorData?.BlockValue, currentBlockEditorData?.BlockValue, editorValue.ContentKey); + + if (IsBlockEditorDataEmpty(blockEditorData)) + { + return null; + } + + return JsonSerializer.Serialize(blockEditorData.BlockValue); + } + + private BlockEditorData? GetBlockEditorData(object? value) + { try { - blockEditorData = BlockEditorValues.DeserializeAndClean(editorValue.Value); + return BlockEditorValues.DeserializeAndClean(value); } catch { - // 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 this occurs it means the data is invalid. It shouldn't happen could if we change the data format. + return null; } - - if (blockEditorData == null || blockEditorData.BlockValue.ContentData.Count == 0) - { - return string.Empty; - } - - MapBlockValueFromEditor(blockEditorData.BlockValue); - - // return json - return JsonSerializer.Serialize(blockEditorData.BlockValue); } + + private static bool IsBlockEditorDataEmpty([NotNullWhen(false)] BlockEditorData? editorData) + => editorData is null || editorData.BlockValue.ContentData.Count == 0; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs index 419b84845a..252834209f 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Cache.PropertyEditors; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.Validation; @@ -88,7 +88,13 @@ public abstract class BlockEditorValidatorBase : ComplexEditorV foreach (var group in itemDataGroups) { - var allElementTypes = _elementTypeCache.GetMany(group.Items.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); + Guid[] elementTypeKeys = group.Items.Select(x => x.ContentTypeKey).ToArray(); + if (elementTypeKeys.Length == 0) + { + continue; + } + + var allElementTypes = _elementTypeCache.GetMany(elementTypeKeys).ToDictionary(x => x.Key); for (var i = 0; i < group.Items.Length; i++) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 3777d84eb4..52c02c46bc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -129,10 +129,115 @@ public abstract class BlockValuePropertyValueEditorBase : DataV return result; } - protected void MapBlockValueFromEditor(TValue blockValue) + [Obsolete("This method is no longer used within Umbraco. Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")] + protected void MapBlockValueFromEditor(TValue blockValue) => MapBlockValueFromEditor(blockValue, null, Guid.Empty); + + protected void MapBlockValueFromEditor(TValue? editedBlockValue, TValue? currentBlockValue, Guid contentKey) { - MapBlockItemDataFromEditor(blockValue.ContentData); - MapBlockItemDataFromEditor(blockValue.SettingsData); + MapBlockItemDataFromEditor( + editedBlockValue?.ContentData ?? [], + currentBlockValue?.ContentData ?? [], + contentKey); + + MapBlockItemDataFromEditor( + editedBlockValue?.SettingsData ?? [], + currentBlockValue?.SettingsData ?? [], + contentKey); + } + + private void MapBlockItemDataFromEditor(List editedItems, List currentItems, Guid contentKey) + { + // Create mapping between edited and current block items. + IEnumerable> itemsMapping = GetBlockStatesMapping(editedItems, currentItems, (mapping, current) => mapping.Edited?.Key == current.Key); + + foreach (BlockStateMapping itemMapping in itemsMapping) + { + // Create mapping between edited and current block item values. + IEnumerable> valuesMapping = GetBlockStatesMapping(itemMapping.Edited?.Values, itemMapping.Current?.Values, (mapping, current) => mapping.Edited?.Alias == current.Alias); + + foreach (BlockStateMapping valueMapping in valuesMapping) + { + BlockPropertyValue? editedValue = valueMapping.Edited; + BlockPropertyValue? currentValue = valueMapping.Current; + + IPropertyType propertyType = editedValue?.PropertyType + ?? currentValue?.PropertyType + ?? throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(editedItems)); + + // Lookup the property editor. + IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias]; + if (propertyEditor is null) + { + continue; + } + + // Fetch the property types prevalue. + var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey); + + // Create a real content property data object. + var propertyData = new ContentPropertyData(editedValue?.Value, configuration) + { + ContentKey = contentKey, + PropertyTypeKey = propertyType.Key, + }; + + // Get the property editor to do it's conversion. + IDataValueEditor valueEditor = propertyEditor.GetValueEditor(); + var newValue = valueEditor.FromEditor(propertyData, currentValue?.Value); + + // Update the raw value since this is what will get serialized out. + if (editedValue != null) + { + editedValue.Value = newValue; + } + } + } + } + + private sealed class BlockStateMapping + { + public T? Edited { get; set; } + + public T? Current { get; set; } + } + + private static IEnumerable> GetBlockStatesMapping(IList? editedItems, IList? currentItems, Func, T, bool> condition) + { + // filling with edited items first + List> mapping = editedItems? + .Select(editedItem => new BlockStateMapping + { + Current = default, + Edited = editedItem, + }) + .ToList() + ?? []; + + if (currentItems is null) + { + return mapping; + } + + // then adding current items + foreach (T currentItem in currentItems) + { + BlockStateMapping? mappingItem = mapping.FirstOrDefault(x => condition(x, currentItem)); + + if (mappingItem == null) // if there is no edited item, then adding just current + { + mapping.Add(new BlockStateMapping + { + Current = currentItem, + Edited = default, + }); + } + else + { + mappingItem.Current = currentItem; + } + } + + return mapping; } protected void MapBlockValueToEditor(IProperty property, TValue blockValue, string? culture, string? segment) @@ -197,40 +302,6 @@ public abstract class BlockValuePropertyValueEditorBase : DataV } } - private void MapBlockItemDataFromEditor(List items) - { - foreach (BlockItemData item in items) - { - foreach (BlockPropertyValue blockPropertyValue in item.Values) - { - IPropertyType? propertyType = blockPropertyValue.PropertyType; - if (propertyType is null) - { - throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them from editor.", nameof(items)); - } - - // Lookup the property editor - IDataEditor? propertyEditor = _propertyEditors[propertyType.PropertyEditorAlias]; - if (propertyEditor is null) - { - continue; - } - - // Fetch the property types prevalue - var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey); - - // Create a fake content property data object - var propertyData = new ContentPropertyData(blockPropertyValue.Value, configuration); - - // Get the property editor to do it's conversion - var newValue = propertyEditor.GetValueEditor().FromEditor(propertyData, blockPropertyValue.Value); - - // update the raw value since this is what will get serialized out - blockPropertyValue.Value = newValue; - } - } - } - /// /// Updates the invariant data in the source with the invariant data in the value if allowed /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs index 1d1b5a633f..32df946ed5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs @@ -10,10 +10,16 @@ using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; +// TODO (V17): +// - Remove the implementation of INotificationHandler as these have all been refactored out into sepate notification handler classes. +// - Remove the unused parameters from the constructor. + +/// +/// Defines the file upload property editor. +/// [DataEditor( Constants.PropertyEditors.Aliases.UploadField, ValueEditorIsReusable = true)] @@ -22,12 +28,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, INotificationHandler, INotificationHandler, INotificationHandler { - private readonly IContentService _contentService; - private readonly IOptionsMonitor _contentSettings; private readonly IIOHelper _ioHelper; - private readonly MediaFileManager _mediaFileManager; - private readonly UploadAutoFillProperties _uploadAutoFillProperties; + /// + /// Initializes a new instance of the class. + /// public FileUploadPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, MediaFileManager mediaFileManager, @@ -37,14 +42,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, IIOHelper ioHelper) : base(dataValueEditorFactory) { - _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); - _contentSettings = contentSettings; - _uploadAutoFillProperties = uploadAutoFillProperties; - _contentService = contentService; _ioHelper = ioHelper; SupportsReadOnly = true; } + /// public bool TryGetMediaPath(string? propertyEditorAlias, object? value, [MaybeNullWhen(false)] out string mediaPath) { if (propertyEditorAlias == Alias && @@ -59,53 +61,6 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, return false; } - public void Handle(ContentCopiedNotification notification) - { - // get the upload field properties with a value - IEnumerable properties = notification.Original.Properties.Where(IsUploadField); - - // copy files - var isUpdated = false; - foreach (IProperty property in properties) - { - // copy each of the property values (variants, segments) to the destination - foreach (IPropertyValue propertyValue in property.Values) - { - var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment); - if (propVal == null || !(propVal is string str) || str.IsNullOrWhiteSpace()) - { - continue; - } - - var sourcePath = _mediaFileManager.FileSystem.GetRelativePath(str); - var copyPath = _mediaFileManager.CopyFile(notification.Copy, property.PropertyType, sourcePath); - notification.Copy.SetValue(property.Alias, _mediaFileManager.FileSystem.GetUrl(copyPath), - propertyValue.Culture, propertyValue.Segment); - isUpdated = true; - } - } - - // if updated, re-save the copy with the updated value - if (isUpdated) - { - _contentService.Save(notification.Copy); - } - } - - public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); - - public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); - - public void Handle(MediaSavingNotification notification) - { - foreach (IMedia entity in notification.SavedEntities) - { - AutoFillProperties(entity); - } - } - - public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); - /// protected override IConfigurationEditor CreateConfigurationEditor() => new FileUploadConfigurationEditor(_ioHelper); @@ -117,86 +72,43 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator, protected override IDataValueEditor CreateValueEditor() => DataValueEditorFactory.Create(Attribute!); - /// - /// Gets a value indicating whether a property is an upload field. - /// - /// The property. - /// - /// true if the specified property is an upload field; otherwise, false. - /// - private static bool IsUploadField(IProperty property) => property.PropertyType.PropertyEditorAlias == - Constants.PropertyEditors.Aliases.UploadField; + #region Obsolete notification handler notifications - /// - /// The paths to all file upload property files contained within a collection of content entities - /// - /// - private IEnumerable ContainedFilePaths(IEnumerable entities) => entities - .SelectMany(x => x.Properties) - .Where(IsUploadField) - .SelectMany(GetFilePathsFromPropertyValues) - .Distinct(); - - /// - /// Look through all property values stored against the property and resolve any file paths stored - /// - /// - /// - private IEnumerable GetFilePathsFromPropertyValues(IProperty prop) + /// + [Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler. Scheduled for removal in Umbraco 17.")] + public void Handle(ContentCopiedNotification notification) { - IReadOnlyCollection propVals = prop.Values; - foreach (IPropertyValue propertyValue in propVals) - { - // check if the published value contains data and return it - var propVal = propertyValue.PublishedValue; - if (propVal != null && propVal is string str1 && !str1.IsNullOrWhiteSpace()) - { - yield return _mediaFileManager.FileSystem.GetRelativePath(str1); - } - - // check if the edited value contains data and return it - propVal = propertyValue.EditedValue; - if (propVal != null && propVal is string str2 && !str2.IsNullOrWhiteSpace()) - { - yield return _mediaFileManager.FileSystem.GetRelativePath(str2); - } - } + // This handler is no longer registered. Logic has been migrated to FileUploadContentCopiedNotificationHandler. } - private void DeleteContainedFiles(IEnumerable deletedEntities) + /// + [Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler. Scheduled for removal in Umbraco 17.")] + public void Handle(MediaSavingNotification notification) { - IEnumerable filePathsToDelete = ContainedFilePaths(deletedEntities); - _mediaFileManager.DeleteMediaFiles(filePathsToDelete); + // This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler. } - /// - /// Auto-fill properties (or clear). - /// - private void AutoFillProperties(IContentBase model) + /// + [Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")] + public void Handle(ContentDeletedNotification notification) { - IEnumerable properties = model.Properties.Where(IsUploadField); - - foreach (IProperty property in properties) - { - ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias); - if (autoFillConfig == null) - { - continue; - } - - foreach (IPropertyValue pvalue in property.Values) - { - var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string; - if (string.IsNullOrWhiteSpace(svalue)) - { - _uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment); - } - else - { - _uploadAutoFillProperties.Populate(model, autoFillConfig, - _mediaFileManager.FileSystem.GetRelativePath(svalue), pvalue.Culture, pvalue.Segment); - } - } - } + // This handler is no longer registered. Logic has been migrated to FileUploadContentDeletedNotificationHandler. } + + + /// + [Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")] + public void Handle(MediaDeletedNotification notification) + { + // This handler is no longer registered. Logic has been migrated to FileUploadMediaDeletedNotificationHandler. + } + + /// + [Obsolete("This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler. Scheduled for removal in Umbraco 17.")] + public void Handle(MemberDeletedNotification notification) + { + // This handler is no longer registered. Logic has been migrated to FileUploadMemberDeletedNotificationHandler. + } + + #endregion } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs index 1150fa09a2..1d0a0a0702 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyValueEditor.cs @@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Infrastructure.PropertyEditors; using Umbraco.Cms.Infrastructure.Scoping; using Umbraco.Extensions; @@ -23,12 +24,15 @@ namespace Umbraco.Cms.Core.PropertyEditors; internal class FileUploadPropertyValueEditor : DataValueEditor { private readonly MediaFileManager _mediaFileManager; - private readonly IJsonSerializer _jsonSerializer; private readonly ITemporaryFileService _temporaryFileService; private readonly IScopeProvider _scopeProvider; private readonly IFileStreamSecurityValidator _fileStreamSecurityValidator; + private readonly FileUploadValueParser _valueParser; private ContentSettings _contentSettings; + /// + /// Initializes a new instance of the class. + /// public FileUploadPropertyValueEditor( DataEditorAttribute attribute, MediaFileManager mediaFileManager, @@ -42,10 +46,11 @@ internal class FileUploadPropertyValueEditor : DataValueEditor : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { _mediaFileManager = mediaFileManager ?? throw new ArgumentNullException(nameof(mediaFileManager)); - _jsonSerializer = jsonSerializer; _temporaryFileService = temporaryFileService; _scopeProvider = scopeProvider; _fileStreamSecurityValidator = fileStreamSecurityValidator; + _valueParser = new FileUploadValueParser(jsonSerializer); + _contentSettings = contentSettings.CurrentValue ?? throw new ArgumentNullException(nameof(contentSettings)); contentSettings.OnChange(x => _contentSettings = x); @@ -56,6 +61,7 @@ internal class FileUploadPropertyValueEditor : DataValueEditor IsAllowedInDataTypeConfiguration)); } + /// public override object? ToEditor(IProperty property, string? culture = null, string? segment = null) { // the stored property value (if any) is the path to the file; convert it to the client model (FileUploadValue) @@ -63,11 +69,12 @@ internal class FileUploadPropertyValueEditor : DataValueEditor return propertyValue is string stringValue ? new FileUploadValue { - Src = stringValue + Src = stringValue, } : null; } + /// /// /// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path). /// @@ -83,12 +90,15 @@ internal class FileUploadPropertyValueEditor : DataValueEditor /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { - FileUploadValue? editorModelValue = ParseFileUploadValue(editorValue.Value); + FileUploadValue? editorModelValue = _valueParser.Parse(editorValue.Value); // no change? if (editorModelValue?.TemporaryFileId.HasValue is not true && string.IsNullOrEmpty(editorModelValue?.Src) is false) { - return currentValue; + // since current value can be json string, we have to parse value + FileUploadValue? currentModelValue = _valueParser.Parse(currentValue); + + return currentModelValue?.Src; } // the current editor value (if any) is the path to the file @@ -146,28 +156,8 @@ internal class FileUploadPropertyValueEditor : DataValueEditor return filepath is null ? null : _mediaFileManager.FileSystem.GetUrl(filepath); } - private FileUploadValue? ParseFileUploadValue(object? editorValue) - { - if (editorValue is null) - { - return null; - } - - if (editorValue is string sourceString && sourceString.DetectIsJson() is false) - { - return new FileUploadValue() - { - Src = sourceString - }; - } - - return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue) - ? modelValue - : throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object."); - } - private Guid? TryParseTemporaryFileKey(object? editorValue) - => ParseFileUploadValue(editorValue)?.TemporaryFileId; + => _valueParser.Parse(editorValue)?.TemporaryFileId; private TemporaryFileModel? TryGetTemporaryFile(Guid temporaryFileKey) => _temporaryFileService.GetAsync(temporaryFileKey).GetAwaiter().GetResult(); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/FileUploadValueParser.cs b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadValueParser.cs new file mode 100644 index 0000000000..3fee3d6744 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/FileUploadValueParser.cs @@ -0,0 +1,45 @@ +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors; + +/// +/// Handles the parsing of raw values to objects. +/// +internal sealed class FileUploadValueParser +{ + private readonly IJsonSerializer _jsonSerializer; + + /// + /// Initializes a new instance of the class. + /// + /// + public FileUploadValueParser(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer; + + /// + /// Parses raw value to a . + /// + /// The editor value. + /// value + /// + public FileUploadValue? Parse(object? editorValue) + { + if (editorValue is null) + { + return null; + } + + if (editorValue is string sourceString && sourceString.DetectIsJson() is false) + { + return new FileUploadValue() + { + Src = sourceString, + }; + } + + return _jsonSerializer.TryDeserialize(editorValue, out FileUploadValue? modelValue) + ? modelValue + : throw new ArgumentException($"Could not parse editor value to a {nameof(FileUploadValue)} object."); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentCopiedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentCopiedNotificationHandler.cs new file mode 100644 index 0000000000..b056d31c79 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentCopiedNotificationHandler.cs @@ -0,0 +1,312 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Implements a notification handler that processes file uploads when content is copied, making sure the copied contetnt relates to a new instance +/// of the file. +/// +internal sealed class FileUploadContentCopiedNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler +{ + private readonly IContentService _contentService; + + private readonly BlockEditorValues _blockListEditorValues; + private readonly BlockEditorValues _blockGridEditorValues; + + /// + /// Initializes a new instance of the class. + /// + public FileUploadContentCopiedNotificationHandler( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + ILogger logger, + IContentService contentService) + : base(jsonSerializer, mediaFileManager, elementTypeCache) + { + _blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger); + _blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger); + _contentService = contentService; + } + + /// + public void Handle(ContentCopiedNotification notification) + { + ArgumentNullException.ThrowIfNull(notification); + + var isUpdated = false; + + foreach (IProperty property in notification.Original.Properties) + { + if (IsUploadFieldPropertyType(property.PropertyType)) + { + isUpdated |= UpdateUploadFieldProperty(notification, property); + + continue; + } + + if (IsBlockListPropertyType(property.PropertyType)) + { + isUpdated |= UpdateBlockProperty(notification, property, _blockListEditorValues); + + continue; + } + + if (IsBlockGridPropertyType(property.PropertyType)) + { + isUpdated |= UpdateBlockProperty(notification, property, _blockGridEditorValues); + + continue; + } + + if (IsRichTextPropertyType(property.PropertyType)) + { + isUpdated |= UpdateRichTextProperty(notification, property); + + continue; + } + } + + // if updated, re-save the copy with the updated value + if (isUpdated) + { + _contentService.Save(notification.Copy); + } + } + + private bool UpdateUploadFieldProperty(ContentCopiedNotification notification, IProperty property) + { + var isUpdated = false; + + // Copy each of the property values (variants, segments) to the destination. + foreach (IPropertyValue propertyValue in property.Values) + { + var propVal = property.GetValue(propertyValue.Culture, propertyValue.Segment); + if (propVal == null || propVal is not string sourceUrl || string.IsNullOrWhiteSpace(sourceUrl)) + { + continue; + } + + var copyUrl = CopyFile(sourceUrl, notification.Copy, property.PropertyType); + + notification.Copy.SetValue(property.Alias, copyUrl, propertyValue.Culture, propertyValue.Segment); + + isUpdated = true; + } + + return isUpdated; + } + + private bool UpdateBlockProperty(ContentCopiedNotification notification, IProperty property, BlockEditorValues blockEditorValues) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + var isUpdated = false; + + foreach (IPropertyValue blockPropertyValue in property.Values) + { + var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment); + + BlockEditorData? blockEditorData = GetBlockEditorData(rawBlockPropertyValue, blockEditorValues); + + (bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(notification, blockEditorData); + + if (hasUpdates) + { + notification.Copy.SetValue(property.Alias, updatedValue, blockPropertyValue.Culture, blockPropertyValue.Segment); + } + + isUpdated |= hasUpdates; + } + + return isUpdated; + } + + private (bool, string?) UpdateBlockEditorData(ContentCopiedNotification notification, BlockEditorData? blockEditorData) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + var isUpdated = false; + + if (blockEditorData is null) + { + return (isUpdated, null); + } + + IEnumerable blockPropertyValues = blockEditorData.BlockValue.ContentData + .Concat(blockEditorData.BlockValue.SettingsData) + .SelectMany(x => x.Values); + + isUpdated = UpdateBlockPropertyValues(notification, isUpdated, blockPropertyValues); + + var updatedValue = JsonSerializer.Serialize(blockEditorData.BlockValue); + + return (isUpdated, updatedValue); + } + + private bool UpdateRichTextProperty(ContentCopiedNotification notification, IProperty property) + { + var isUpdated = false; + + foreach (IPropertyValue blockPropertyValue in property.Values) + { + var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment); + + RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(rawBlockPropertyValue); + + (bool hasUpdates, string? updatedValue) = UpdateBlockEditorData(notification, richTextBlockValue); + + if (hasUpdates && string.IsNullOrEmpty(updatedValue) is false) + { + RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(rawBlockPropertyValue); + if (richTextEditorValue is not null) + { + richTextEditorValue.Blocks = JsonSerializer.Deserialize(updatedValue); + notification.Copy.SetValue(property.Alias, JsonSerializer.Serialize(richTextEditorValue), blockPropertyValue.Culture, blockPropertyValue.Segment); + } + } + + isUpdated |= hasUpdates; + } + + return isUpdated; + } + + private (bool, string?) UpdateBlockEditorData(ContentCopiedNotification notification, RichTextBlockValue? richTextBlockValue) + { + var isUpdated = false; + + if (richTextBlockValue is null) + { + return (isUpdated, null); + } + + IEnumerable blockPropertyValues = richTextBlockValue.ContentData + .Concat(richTextBlockValue.SettingsData) + .SelectMany(x => x.Values); + + isUpdated = UpdateBlockPropertyValues(notification, isUpdated, blockPropertyValues); + + var updatedValue = JsonSerializer.Serialize(richTextBlockValue); + + return (isUpdated, updatedValue); + } + + private bool UpdateBlockPropertyValues(ContentCopiedNotification notification, bool isUpdated, IEnumerable blockPropertyValues) + { + foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues) + { + if (blockPropertyValue.Value is null) + { + continue; + } + + IPropertyType? propertyType = blockPropertyValue.PropertyType; + + if (propertyType is null) + { + continue; + } + + if (IsUploadFieldPropertyType(propertyType)) + { + isUpdated |= UpdateUploadFieldBlockPropertyValue(blockPropertyValue, notification, propertyType); + + continue; + } + + if (IsBlockListPropertyType(propertyType)) + { + (bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, notification, _blockListEditorValues); + + isUpdated |= hasUpdates; + + blockPropertyValue.Value = newValue; + + continue; + } + + if (IsBlockGridPropertyType(propertyType)) + { + (bool hasUpdates, string? newValue) = UpdateBlockPropertyValue(blockPropertyValue, notification, _blockGridEditorValues); + + isUpdated |= hasUpdates; + + blockPropertyValue.Value = newValue; + + continue; + } + + if (IsRichTextPropertyType(propertyType)) + { + (bool hasUpdates, string? newValue) = UpdateRichTextPropertyValue(blockPropertyValue, notification); + + if (hasUpdates && string.IsNullOrEmpty(newValue) is false) + { + RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockPropertyValue.Value); + if (richTextEditorValue is not null) + { + isUpdated |= hasUpdates; + + richTextEditorValue.Blocks = JsonSerializer.Deserialize(newValue); + blockPropertyValue.Value = richTextEditorValue; + } + } + + continue; + } + } + + return isUpdated; + } + + private bool UpdateUploadFieldBlockPropertyValue(BlockPropertyValue blockItemDataValue, ContentCopiedNotification notification, IPropertyType propertyType) + { + FileUploadValue? fileUploadValue = FileUploadValueParser.Parse(blockItemDataValue.Value); + + // if original value is empty, we do not need to copy file + if (string.IsNullOrWhiteSpace(fileUploadValue?.Src)) + { + return false; + } + + var copyFileUrl = CopyFile(fileUploadValue.Src, notification.Copy, propertyType); + + blockItemDataValue.Value = copyFileUrl; + + return true; + } + + private (bool, string?) UpdateBlockPropertyValue(BlockPropertyValue blockItemDataValue, ContentCopiedNotification notification, BlockEditorValues blockEditorValues) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + BlockEditorData? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues); + + return UpdateBlockEditorData(notification, blockItemEditorDataValue); + } + + private (bool, string?) UpdateRichTextPropertyValue(BlockPropertyValue blockItemDataValue, ContentCopiedNotification notification) + { + RichTextBlockValue? richTextBlockValue = GetRichTextBlockValue(blockItemDataValue.Value); + return UpdateBlockEditorData(notification, richTextBlockValue); + } + + private string CopyFile(string sourceUrl, IContent destinationContent, IPropertyType propertyType) + { + var sourcePath = MediaFileManager.FileSystem.GetRelativePath(sourceUrl); + var copyPath = MediaFileManager.CopyFile(destinationContent, propertyType, sourcePath); + return MediaFileManager.FileSystem.GetUrl(copyPath); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs new file mode 100644 index 0000000000..681c31cc58 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadContentDeletedNotificationHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Implements a notification handler that processes file uploads when content is deleted, removing associated files. +/// +internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler +{ + /// + /// Initializes a new instance of the class. + /// + public FileUploadContentDeletedNotificationHandler( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + ILogger logger) + : base(jsonSerializer, mediaFileManager, elementTypeCache, logger) + { + } + + /// + public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadEntityDeletedNotificationHandlerBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadEntityDeletedNotificationHandlerBase.cs new file mode 100644 index 0000000000..40877d2fef --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadEntityDeletedNotificationHandlerBase.cs @@ -0,0 +1,218 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Extensions; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files. +/// +internal abstract class FileUploadEntityDeletedNotificationHandlerBase : FileUploadNotificationHandlerBase +{ + private readonly BlockEditorValues _blockListEditorValues; + private readonly BlockEditorValues _blockGridEditorValues; + + /// + /// Initializes a new instance of the class. + /// + protected FileUploadEntityDeletedNotificationHandlerBase( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + ILogger logger) + : base(jsonSerializer, mediaFileManager, elementTypeCache) + { + _blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger); + _blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger); + } + + /// + /// Deletes all file upload property files contained within a collection of content entities. + /// + /// + protected void DeleteContainedFiles(IEnumerable deletedEntities) + { + IReadOnlyList filePathsToDelete = ContainedFilePaths(deletedEntities); + MediaFileManager.DeleteMediaFiles(filePathsToDelete); + } + + /// + /// Gets the paths to all file upload property files contained within a collection of content entities. + /// + private IReadOnlyList ContainedFilePaths(IEnumerable entities) + { + var paths = new List(); + + foreach (IProperty? property in entities.SelectMany(x => x.Properties)) + { + if (IsUploadFieldPropertyType(property.PropertyType)) + { + paths.AddRange(GetPathsFromUploadFieldProperty(property)); + + continue; + } + + if (IsBlockListPropertyType(property.PropertyType)) + { + paths.AddRange(GetPathsFromBlockProperty(property, _blockListEditorValues)); + + continue; + } + + if (IsBlockGridPropertyType(property.PropertyType)) + { + paths.AddRange(GetPathsFromBlockProperty(property, _blockGridEditorValues)); + + continue; + } + + if (IsRichTextPropertyType(property.PropertyType)) + { + paths.AddRange(GetPathsFromRichTextProperty(property)); + + continue; + } + } + + return paths.Distinct().ToList().AsReadOnly(); + } + + private IEnumerable GetPathsFromUploadFieldProperty(IProperty property) + { + foreach (IPropertyValue propertyValue in property.Values) + { + if (propertyValue.PublishedValue != null && propertyValue.PublishedValue is string publishedUrl && !string.IsNullOrWhiteSpace(publishedUrl)) + { + yield return MediaFileManager.FileSystem.GetRelativePath(publishedUrl); + } + + if (propertyValue.EditedValue != null && propertyValue.EditedValue is string editedUrl && !string.IsNullOrWhiteSpace(editedUrl)) + { + yield return MediaFileManager.FileSystem.GetRelativePath(editedUrl); + } + } + } + + private IReadOnlyCollection GetPathsFromBlockProperty(IProperty property, BlockEditorValues blockEditorValues) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + var paths = new List(); + + foreach (IPropertyValue blockPropertyValue in property.Values) + { + paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.PublishedValue, blockEditorValues)?.BlockValue)); + paths.AddRange(GetPathsFromBlockValue(GetBlockEditorData(blockPropertyValue.EditedValue, blockEditorValues)?.BlockValue)); + } + + return paths; + } + + private IReadOnlyCollection GetPathsFromBlockValue(BlockValue? blockValue) + { + var paths = new List(); + + if (blockValue is null) + { + return paths; + } + + IEnumerable blockPropertyValues = blockValue.ContentData + .Concat(blockValue.SettingsData) + .SelectMany(x => x.Values); + + foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues) + { + if (blockPropertyValue.Value == null) + { + continue; + } + + IPropertyType? propertyType = blockPropertyValue.PropertyType; + + if (propertyType == null) + { + continue; + } + + if (IsUploadFieldPropertyType(propertyType)) + { + FileUploadValue? originalValue = FileUploadValueParser.Parse(blockPropertyValue.Value); + + if (string.IsNullOrWhiteSpace(originalValue?.Src)) + { + continue; + } + + paths.Add(MediaFileManager.FileSystem.GetRelativePath(originalValue.Src)); + + continue; + } + + if (IsBlockListPropertyType(propertyType)) + { + paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockListEditorValues)); + + continue; + } + + if (IsBlockGridPropertyType(propertyType)) + { + paths.AddRange(GetPathsFromBlockPropertyValue(blockPropertyValue, _blockGridEditorValues)); + + continue; + } + + if (IsRichTextPropertyType(propertyType)) + { + paths.AddRange(GetPathsFromRichTextPropertyValue(blockPropertyValue)); + + continue; + } + } + + return paths; + } + + private IReadOnlyCollection GetPathsFromBlockPropertyValue(BlockPropertyValue blockItemDataValue, BlockEditorValues blockEditorValues) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + BlockEditorData? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues); + + return GetPathsFromBlockValue(blockItemEditorDataValue?.BlockValue); + } + + private IReadOnlyCollection GetPathsFromRichTextProperty(IProperty property) + { + var paths = new List(); + + IPropertyValue? propertyValue = property.Values.FirstOrDefault(); + if (propertyValue is null) + { + return paths; + } + + paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.PublishedValue))); + paths.AddRange(GetPathsFromBlockValue(GetRichTextBlockValue(propertyValue.EditedValue))); + + return paths; + } + + private IReadOnlyCollection GetPathsFromRichTextPropertyValue(BlockPropertyValue blockItemDataValue) + { + RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockItemDataValue.Value); + + // Ensure the property type is populated on all blocks. + richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache); + + return GetPathsFromBlockValue(richTextEditorValue?.Blocks); + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaDeletedNotificationHandler.cs new file mode 100644 index 0000000000..3a07193ec8 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaDeletedNotificationHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Implements a notification handler that processes file uploads when media is deleted, removing associated files. +/// +internal sealed class FileUploadMediaDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler +{ + /// + /// Initializes a new instance of the class. + /// + public FileUploadMediaDeletedNotificationHandler( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + ILogger logger) + : base(jsonSerializer, mediaFileManager, elementTypeCache, logger) + { + } + + /// + public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaSavingNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaSavingNotificationHandler.cs new file mode 100644 index 0000000000..ffc67391dd --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMediaSavingNotificationHandler.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Media; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Implements a notification handler that processes file uploads media is saved, completing properties on the media item. +/// +internal sealed class FileUploadMediaSavingNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler +{ + private readonly IOptionsMonitor _contentSettings; + private readonly UploadAutoFillProperties _uploadAutoFillProperties; + + /// + /// Initializes a new instance of the class. + /// + public FileUploadMediaSavingNotificationHandler( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + IOptionsMonitor contentSettings, + UploadAutoFillProperties uploadAutoFillProperties) + : base(jsonSerializer, mediaFileManager, elementTypeCache) + { + _contentSettings = contentSettings; + _uploadAutoFillProperties = uploadAutoFillProperties; + } + + /// + public void Handle(MediaSavingNotification notification) + { + foreach (IMedia entity in notification.SavedEntities) + { + AutoFillProperties(entity); + } + } + + private void AutoFillProperties(IContentBase model) + { + IEnumerable properties = model.Properties.Where(x => IsUploadFieldPropertyType(x.PropertyType)); + + foreach (IProperty property in properties) + { + ImagingAutoFillUploadField? autoFillConfig = _contentSettings.CurrentValue.GetConfig(property.Alias); + if (autoFillConfig == null) + { + continue; + } + + foreach (IPropertyValue pvalue in property.Values) + { + var svalue = property.GetValue(pvalue.Culture, pvalue.Segment) as string; + if (string.IsNullOrWhiteSpace(svalue)) + { + _uploadAutoFillProperties.Reset(model, autoFillConfig, pvalue.Culture, pvalue.Segment); + } + else + { + _uploadAutoFillProperties.Populate( + model, + autoFillConfig, + MediaFileManager.FileSystem.GetRelativePath(svalue), + pvalue.Culture, + pvalue.Segment); + } + } + } + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMemberDeletedNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMemberDeletedNotificationHandler.cs new file mode 100644 index 0000000000..91433b88b9 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadMemberDeletedNotificationHandler.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Implements a notification handler that processes file uploads when a member is deleted, removing associated files. +/// +internal sealed class FileUploadMemberDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler +{ + /// + /// Initializes a new instance of the class. + /// + public FileUploadMemberDeletedNotificationHandler( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache, + ILogger logger) + : base(jsonSerializer, mediaFileManager, elementTypeCache, logger) + { + } + + /// + public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities); +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadNotificationHandlerBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadNotificationHandlerBase.cs new file mode 100644 index 0000000000..4e0f6afcc2 --- /dev/null +++ b/src/Umbraco.Infrastructure/PropertyEditors/NotificationHandlers/FileUploadNotificationHandlerBase.cs @@ -0,0 +1,140 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache.PropertyEditors; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Infrastructure.Extensions; + +namespace Umbraco.Cms.Infrastructure.PropertyEditors.NotificationHandlers; + +/// +/// Provides a base class for all notification handlers relating to file uploads in property editors. +/// +internal abstract class FileUploadNotificationHandlerBase +{ + /// + /// Initializes a new instance of the class. + /// + protected FileUploadNotificationHandlerBase( + IJsonSerializer jsonSerializer, + MediaFileManager mediaFileManager, + IBlockEditorElementTypeCache elementTypeCache) + { + JsonSerializer = jsonSerializer; + MediaFileManager = mediaFileManager; + ElementTypeCache = elementTypeCache; + FileUploadValueParser = new FileUploadValueParser(jsonSerializer); + } + + /// + /// Gets the used for serializing and deserializing values. + /// + protected IJsonSerializer JsonSerializer { get; } + + /// + /// Gets the used for managing media files. + /// + protected MediaFileManager MediaFileManager { get; } + + /// + /// Gets the used for caching block editor element types. + /// + protected IBlockEditorElementTypeCache ElementTypeCache { get; } + + /// + /// Gets the used for parsing file upload values. + /// + protected FileUploadValueParser FileUploadValueParser { get; } + + /// + /// Gets a value indicating whether a property is an upload field. + /// + /// The property type. + /// + /// true if the specified property is an upload field; otherwise, false. + /// + protected static bool IsUploadFieldPropertyType(IPropertyType propertyType) + => propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField; + + /// + /// Gets a value indicating whether a property is an block list field. + /// + /// The property type. + /// + /// true if the specified property is an block list field; otherwise, false. + /// + protected static bool IsBlockListPropertyType(IPropertyType propertyType) + => propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockList; + + /// + /// Gets a value indicating whether a property is an block grid field. + /// + /// The property type. + /// + /// true if the specified property is an block grid field; otherwise, false. + /// + protected static bool IsBlockGridPropertyType(IPropertyType propertyType) + => propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockGrid; + + /// + /// Gets a value indicating whether a property is an rich text field (supporting blocks). + /// + /// The property type. + /// + /// true if the specified property is an rich text field; otherwise, false. + /// + protected static bool IsRichTextPropertyType(IPropertyType propertyType) + => propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.RichText || + propertyType.PropertyEditorAlias == "Umbraco.TinyMCE"; + + /// + /// Deserializes the block editor data value. + /// + protected static BlockEditorData? GetBlockEditorData(object? value, BlockEditorValues blockListEditorValues) + where TValue : BlockValue, new() + where TLayout : class, IBlockLayoutItem, new() + { + try + { + return blockListEditorValues.DeserializeAndClean(value); + } + catch + { + // If this occurs it means the data is invalid. Shouldn't happen but could if we change the data format. + return null; + } + } + + /// + /// Deserializes the rich text editor value. + /// + protected RichTextEditorValue? GetRichTextEditorValue(object? value) + { + if (value is null) + { + return null; + } + + JsonSerializer.TryDeserialize(value, out RichTextEditorValue? richTextEditorValue); + return richTextEditorValue; + } + + /// + /// Deserializes the rich text block value. + /// + protected RichTextBlockValue? GetRichTextBlockValue(object? value) + { + RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(value); + if (richTextEditorValue?.Blocks is null) + { + return null; + } + + // Ensure the property type is populated on all blocks. + richTextEditorValue.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache); + + return richTextEditorValue.Blocks; + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 7d75725ef6..7c9b26f343 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -16,6 +16,7 @@ using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Templates; +using Umbraco.Cms.Infrastructure.Extensions; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -33,8 +34,11 @@ public class RichTextPropertyEditor : DataEditor private readonly IRichTextPropertyIndexValueFactory _richTextPropertyIndexValueFactory; /// - /// The constructor will setup the property editor based on the attribute if one is found. + /// Initializes a new instance of the class. /// + /// + /// The constructor will setup the property editor based on the attribute if one is found. + /// public RichTextPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, @@ -43,6 +47,7 @@ public class RichTextPropertyEditor : DataEditor { _ioHelper = ioHelper; _richTextPropertyIndexValueFactory = richTextPropertyIndexValueFactory; + SupportsReadOnly = true; } @@ -95,6 +100,7 @@ public class RichTextPropertyEditor : DataEditor private readonly IRichTextRequiredValidator _richTextRequiredValidator; private readonly IRichTextRegexValidator _richTextRegexValidator; private readonly ILogger _logger; + private readonly IBlockEditorElementTypeCache _elementTypeCache; public RichTextPropertyValueEditor( DataEditorAttribute attribute, @@ -123,6 +129,7 @@ public class RichTextPropertyEditor : DataEditor _localLinkParser = localLinkParser; _pastedImages = pastedImages; _htmlSanitizer = htmlSanitizer; + _elementTypeCache = elementTypeCache; _richTextRequiredValidator = richTextRequiredValidator; _richTextRegexValidator = richTextRegexValidator; _jsonSerializer = jsonSerializer; @@ -242,7 +249,23 @@ public class RichTextPropertyEditor : DataEditor /// public override object? FromEditor(ContentPropertyData editorValue, object? currentValue) { - if (TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue) is false) + // See note on BlockEditorPropertyValueEditor.FromEditor for why we can't return early with only a null or empty editorValue. + TryParseEditorValue(editorValue.Value, out RichTextEditorValue? richTextEditorValue); + TryParseEditorValue(currentValue, out RichTextEditorValue? currentRichTextEditorValue); + + // We can early return if we have a null value and the current value doesn't have any blocks. + if (richTextEditorValue is null && currentRichTextEditorValue?.Blocks is null) + { + return null; + } + + // Ensure the property type is populated on all blocks. + richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache); + currentRichTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(_elementTypeCache); + + RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, blockValue => MapBlockValueFromEditor(blockValue, currentRichTextEditorValue?.Blocks, editorValue.ContentKey)); + + if (string.IsNullOrWhiteSpace(richTextEditorValue?.Markup)) { return null; } @@ -253,11 +276,6 @@ public class RichTextPropertyEditor : DataEditor var config = editorValue.DataTypeConfiguration as RichTextConfiguration; Guid mediaParentId = config?.MediaParentId ?? Guid.Empty; - if (string.IsNullOrWhiteSpace(richTextEditorValue.Markup)) - { - return null; - } - var parseAndSavedTempImages = _pastedImages .FindAndPersistPastedTempImagesAsync(richTextEditorValue.Markup, mediaParentId, userKey) .GetAwaiter() @@ -267,8 +285,6 @@ public class RichTextPropertyEditor : DataEditor richTextEditorValue.Markup = sanitized.NullOrWhiteSpaceAsNull() ?? string.Empty; - RichTextEditorValue cleanedUpRichTextEditorValue = CleanAndMapBlocks(richTextEditorValue, MapBlockValueFromEditor); - // return json return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(cleanedUpRichTextEditorValue, _jsonSerializer); } @@ -377,19 +393,26 @@ public class RichTextPropertyEditor : DataEditor private bool TryParseEditorValue(object? value, [NotNullWhen(true)] out RichTextEditorValue? richTextEditorValue) => RichTextPropertyEditorHelper.TryParseRichTextEditorValue(value, _jsonSerializer, _logger, out richTextEditorValue); - private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue richTextEditorValue, Action handleMapping) + private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue? richTextEditorValue, Action handleMapping) { - if (richTextEditorValue.Blocks is null) + // We handle mapping of blocks even if the edited value is empty, so property editors can clean up any resources + // relating to removed blocks, e.g. files uploaded to the media library from the file upload property editor. + BlockEditorData? blockEditorData = null; + if (richTextEditorValue?.Blocks is not null) + { + blockEditorData = ConvertAndClean(richTextEditorValue.Blocks); + } + + handleMapping(blockEditorData?.BlockValue ?? new RichTextBlockValue()); + + if (richTextEditorValue?.Blocks is null) { // no blocks defined, store empty block value return MarkupWithEmptyBlocks(); } - BlockEditorData? blockEditorData = ConvertAndClean(richTextEditorValue.Blocks); - if (blockEditorData is not null) { - handleMapping(blockEditorData.BlockValue); return new RichTextEditorValue { Markup = richTextEditorValue.Markup, @@ -402,7 +425,7 @@ public class RichTextPropertyEditor : DataEditor RichTextEditorValue MarkupWithEmptyBlocks() => new() { - Markup = richTextEditorValue.Markup, + Markup = richTextEditorValue?.Markup ?? string.Empty, Blocks = new RichTextBlockValue(), }; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/unsupported-property/utils.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/unsupported-property/utils.ts index 15bf237e9c..f313aa61d4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/unsupported-property/utils.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/unsupported-property/utils.ts @@ -1,5 +1,5 @@ import type { UmbUnsupportedEditorSchemaAliases } from '../../types/index.js'; export const UMB_UNSUPPORTED_EDITOR_SCHEMA_ALIASES: UmbUnsupportedEditorSchemaAliases = { - block: ['Umbraco.ImageCropper', 'Umbraco.UploadField'], + block: ['Umbraco.ImageCropper'], }; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityXmlSerializerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityXmlSerializerTests.cs index d404268c3b..3661f8abaa 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityXmlSerializerTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityXmlSerializerTests.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache.PropertyEditors; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; @@ -18,6 +19,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs index fc56535200..32655e88b2 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockListEditorPropertyValueEditorTests.cs @@ -7,7 +7,9 @@ using NUnit.Framework; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Cache.PropertyEditors; 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.Models.Validation; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.ValueConverters; @@ -15,6 +17,9 @@ using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Infrastructure.Serialization; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Extensions; using static Umbraco.Cms.Core.PropertyEditors.BlockListPropertyEditorBase; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; @@ -22,6 +27,9 @@ namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; [TestFixture] public class BlockListEditorPropertyValueEditorTests { + private static readonly Guid _contentTypeKey = Guid.NewGuid(); + private static readonly Guid _contentKey = Guid.NewGuid(); + [Test] public void Validates_Null_As_Below_Configured_Min() { @@ -84,14 +92,80 @@ public class BlockListEditorPropertyValueEditorTests Assert.IsNull(result); } - private static JsonObject CreateBlocksJson(int numberOfBlocks) + [Test] + public void FromEditor_With_Null_Current_Value_Returns_Expected_Json_Value() + { + var editedValue = CreateBlocksJson(1); + var editor = CreateValueEditor(); + + var contentPropertyData = new ContentPropertyData(editedValue, null); + + var result = editor.FromEditor(contentPropertyData, null); + AssertResultValue(result, 0, "A"); + } + + [Test] + public void FromEditor_With_Current_Value_Returns_Expected_Json_Value() + { + var editedValue = CreateBlocksJson(1, "B"); + var currentValue = CreateBlocksJson(1); + var editor = CreateValueEditor(); + + var contentPropertyData = new ContentPropertyData(editedValue, null); + + var result = editor.FromEditor(contentPropertyData, currentValue); + AssertResultValue(result, 0, "B"); + } + + [Test] + public void FromEditor_With_Block_Item_Editor_That_Uses_Current_Value_With_Edited_Property_Returns_Expected_Json_Value() + { + var editedValue = CreateBlocksJson(1, "B"); + var currentValue = CreateBlocksJson(1); + var editor = CreateValueEditor(ValueEditorSetup.ConcatenatingTextValueEditor); + + var contentPropertyData = new ContentPropertyData(editedValue, null); + + var result = editor.FromEditor(contentPropertyData, currentValue); + AssertResultValue(result, 0, "A, B"); + } + + [Test] + public void FromEditor_With_Block_Item_Editor_That_Uses_Current_Value_With_Edited_And_Added_Property_Returns_Expected_Json_Value() + { + var editedValue = CreateBlocksJson(1, "B", "C"); + var currentValue = CreateBlocksJson(1); + var editor = CreateValueEditor(ValueEditorSetup.ConcatenatingTextValueEditor); + + var contentPropertyData = new ContentPropertyData(editedValue, null); + + var result = editor.FromEditor(contentPropertyData, currentValue); + AssertResultValue(result, 0, "A, B"); + AssertResultValue(result, 1, "C"); + } + + [Test] + public void FromEditor_With_Block_Item_Editor_That_Uses_Current_Value_With_Edited_And_Removed_Property_Returns_Expected_Json_Value() + { + var editedValue = CreateBlocksJson(1, "B", "C"); + var currentValue = CreateBlocksJson(1, null); + var editor = CreateValueEditor(ValueEditorSetup.ConcatenatingTextValueEditor); + + var contentPropertyData = new ContentPropertyData(editedValue, null); + + var result = editor.FromEditor(contentPropertyData, currentValue); + AssertResultValue(result, 0, "B"); + AssertResultValue(result, 1, "C"); + } + + private static JsonObject CreateBlocksJson(int numberOfBlocks, string? blockMessagePropertyValue = "A", string? blockMessage2PropertyValue = null) { var layoutItems = new JsonArray(); var contentData = new JsonArray(); for (int i = 0; i < numberOfBlocks; i++) { layoutItems.Add(CreateLayoutBlockJson()); - contentData.Add(CreateContentDataBlockJson()); + contentData.Add(CreateContentDataBlockJson(blockMessagePropertyValue, blockMessage2PropertyValue)); } return new JsonObject @@ -110,48 +184,113 @@ public class BlockListEditorPropertyValueEditorTests new() { { "$type", "BlockListLayoutItem" }, - { "contentKey", Guid.NewGuid() }, + { "contentKey", _contentKey }, }; - private static JsonObject CreateContentDataBlockJson() => - new() + private static JsonObject CreateContentDataBlockJson(string? blockMessagePropertyValue, string? blockMessage2PropertyValue) + { + var values = new JsonArray(); + if (!string.IsNullOrEmpty(blockMessagePropertyValue)) { - { "contentTypeKey", Guid.Parse("01935a73-c86b-4521-9dcb-ad7cea402215") }, - { "key", Guid.NewGuid() }, + values.Add(new JsonObject { - "values", - new JsonArray - { - new JsonObject - { - { "editorAlias", "Umbraco.TextBox" }, - { "alias", "message" }, - { "value", "Hello" }, - }, - } - } - }; + { "editorAlias", "Umbraco.TextBox" }, + { "alias", "message" }, + { "value", blockMessagePropertyValue }, + }); + } - private static BlockListEditorPropertyValueEditor CreateValueEditor() + if (!string.IsNullOrEmpty(blockMessage2PropertyValue)) + { + values.Add(new JsonObject + { + { "editorAlias", "Umbraco.TextBox" }, + { "alias", "message2" }, + { "value", blockMessage2PropertyValue }, + }); + } + + return new() + { + { "contentTypeKey", _contentTypeKey }, + { "key", _contentKey }, + { "values", values } + }; + } + + private enum ValueEditorSetup + { + TextOnlyValueEditor, + ConcatenatingTextValueEditor, + } + + private static BlockListEditorPropertyValueEditor CreateValueEditor(ValueEditorSetup valueEditorSetup = ValueEditorSetup.TextOnlyValueEditor) { var localizedTextServiceMock = new Mock(); - localizedTextServiceMock.Setup(x => x.Localize( + localizedTextServiceMock + .Setup(x => x.Localize( It.IsAny(), It.IsAny(), It.IsAny(), - It.IsAny>())) + It.IsAny>())) .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); var jsonSerializer = new SystemTextJsonSerializer(); var languageService = Mock.Of(); + var dataValueEditorFactoryMock = new Mock(); + + DataEditor textBoxEditor; + switch (valueEditorSetup) + { + case ValueEditorSetup.ConcatenatingTextValueEditor: + dataValueEditorFactoryMock + .Setup(x => x.Create(It.IsAny())) + .Returns(new ConcatenatingTextValueEditor( + Mock.Of(), + new SystemTextJsonSerializer())); + textBoxEditor = new ConcatenatingTextboxPropertyEditor( + dataValueEditorFactoryMock.Object); + + break; + default: + dataValueEditorFactoryMock + .Setup(x => x.Create(It.IsAny())) + .Returns(new TextOnlyValueEditor( + new DataEditorAttribute("a"), + Mock.Of(), + Mock.Of(), + new SystemTextJsonSerializer(), + Mock.Of())); + textBoxEditor = new TextboxPropertyEditor( + dataValueEditorFactoryMock.Object, + Mock.Of()); + break; + } + + var propertyEditors = new PropertyEditorCollection(new DataEditorCollection(() => textBoxEditor.Yield())); + + var elementType = new ContentTypeBuilder() + .WithKey(_contentTypeKey) + .AddPropertyType() + .WithAlias("message") + .Done() + .AddPropertyType() + .WithAlias("message2") + .Done() + .Build(); + var elementTypeCacheMock = new Mock(); + elementTypeCacheMock + .Setup(x => x.GetMany(It.Is>(y => y.First() == _contentTypeKey))) + .Returns([elementType]); + return new BlockListEditorPropertyValueEditor( new DataEditorAttribute("alias"), new BlockListEditorDataConverter(jsonSerializer), - new(new DataEditorCollection(() => [])), + propertyEditors, new DataValueReferenceFactoryCollection(Enumerable.Empty, Mock.Of>()), Mock.Of(), - Mock.Of(), + elementTypeCacheMock.Object, localizedTextServiceMock.Object, new NullLogger(), Mock.Of(), @@ -171,4 +310,62 @@ public class BlockListEditorPropertyValueEditorTests }, }; } + + /// + /// An illustrative property editor that uses the edited and current value when returning a result from the FromEditor calls. + /// + /// + /// This is used to simulate a real-world editor that needs to use this value from within the block editor and verify + /// that it receives and processes the value. + /// + [DataEditor( + global::Umbraco.Cms.Core.Constants.PropertyEditors.Aliases.TextBox)] + private class ConcatenatingTextboxPropertyEditor : DataEditor + { + public ConcatenatingTextboxPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) + : base(dataValueEditorFactory) + { + } + + protected override IDataValueEditor CreateValueEditor() => + DataValueEditorFactory.Create(Attribute!); + } + + /// + /// An illustrative value editor that uses the edited and current value when returning a result from the FromEditor calls. + /// + /// + /// See notes on . + /// + private class ConcatenatingTextValueEditor : DataValueEditor + { + public ConcatenatingTextValueEditor(IShortStringHelper shortStringHelper, IJsonSerializer? jsonSerializer) + : base(shortStringHelper, jsonSerializer) + { + } + + public override object FromEditor(ContentPropertyData propertyData, object? currentValue) + { + var values = new List(); + if (currentValue is not null) + { + values.Add(currentValue.ToString()); + } + + var editedValue = propertyData.Value; + if (editedValue is not null) + { + values.Add(editedValue.ToString()); + } + + return string.Join(", ", values); + } + } + + private static void AssertResultValue(object? result, int valueIndex, string expectedValue) + { + Assert.IsNotNull(result); + var resultAsJson = (JsonObject)JsonNode.Parse(result.ToString()); + Assert.AreEqual(expectedValue, resultAsJson["contentData"][0]["values"][valueIndex]["value"].ToString()); + } }