Add support for file upload property editor within the block list and grid (#18976)

* Fix for https://github.com/umbraco/Umbraco-CMS/issues/18872

* Parsing added for current value

* Build fix.

* Cyclomatic complexity fix

* Resolved breaking change.

* Pass content key.

* Simplified collections.

* Added unit tests to verify behaviour.

* Allow file upload on block list.

* Added unit test verifying added property.

* Added unit test verifying removed property.

* Restored null return for null value fixing failing integration tests.

* Logic has been updated according edge cases

* Logic to copy files from block list items has been added.

* Logic to delete files from block list items on content deletion has been added

* Test fix.

* Refactoring.

* WIP: Resolved breaking changes, minor refactoring.

* Consistently return null over empty, resolving failure in integration test.

* Removed unnecessary code nesting.

* Handle distinct paths.

* Handles clean up of files added via file upload in rich text blocks on delete of the content.

* Update src/Umbraco.Infrastructure/PropertyEditors/FileUploadPropertyEditor.cs

Co-authored-by: Sven Geusens <geusens@gmail.com>

* Fixed build of integration tests project.

* Handled delete of file uploads when deleting a block from an RTE using a file upload property.

* Refactored ensure of property type property populated on rich text values to a common helper extension method.

* Fixed integration tests build.

* Handle create of new file from file upload block in an RTE when the document is copied.

* Fixed failing integration tests.

* Refactored notification handlers relating to file uploads into separate classes.

* Handle nested rich text editor block with file upload when copying content.

* Handle nested rich text editor block with file upload when deleting content.

* Minor refactor.

* Integration test compatibility supressions.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Sven Geusens <geusens@gmail.com>
This commit is contained in:
Peter
2025-06-30 14:21:10 +03:00
committed by GitHub
parent 2cb114ffaf
commit 14063a0b89
19 changed files with 1397 additions and 270 deletions

View File

@@ -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<ContentSavingNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopyingNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentScaffoldedNotification, RichTextPropertyNotificationHandler>()
.AddNotificationHandler<ContentCopiedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<ContentDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MediaDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MediaSavingNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<MemberDeletedNotification, FileUploadPropertyEditor>()
.AddNotificationHandler<ContentCopiedNotification, FileUploadContentCopiedNotificationHandler>()
.AddNotificationHandler<ContentDeletedNotification, FileUploadContentDeletedNotificationHandler>()
.AddNotificationHandler<MediaDeletedNotification, FileUploadMediaDeletedNotificationHandler>()
.AddNotificationHandler<MediaSavingNotification, FileUploadMediaSavingNotificationHandler>()
.AddNotificationHandler<MemberDeletedNotification, FileUploadMemberDeletedNotificationHandler>()
.AddNotificationHandler<ContentCopiedNotification, ImageCropperPropertyEditor>()
.AddNotificationHandler<ContentDeletedNotification, ImageCropperPropertyEditor>()
.AddNotificationHandler<MediaDeletedNotification, ImageCropperPropertyEditor>()

View File

@@ -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;
/// <summary>
/// Defines extensions on <see cref="RichTextEditorValue"/>.
/// </summary>
internal static class RichTextEditorValueExtensions
{
/// <summary>
/// Ensures that the property type property is populated on all blocks.
/// </summary>
/// <param name="richTextEditorValue">The <see cref="RichTextEditorValue"/> providing the blocks.</param>
/// <param name="elementTypeCache">Cache for element types.</param>
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<IContentType> 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);
}
}
}
}

View File

@@ -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;
/// <summary>
/// Provides an abstract base class for property value editors based on block editors.
/// </summary>
public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockValuePropertyValueEditorBase<TValue, TLayout>
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
/// <summary>
/// Initializes a new instance of the <see cref="BlockEditorPropertyValueEditor{TValue, TLayout}"/> class.
/// </summary>
protected BlockEditorPropertyValueEditor(
PropertyEditorCollection propertyEditors,
DataValueReferenceFactoryCollection dataValueReferenceFactories,
@@ -62,13 +66,7 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
return BlockEditorValues.DeserializeAndClean(rawJson)?.BlockValue;
}
/// <summary>
/// Ensure that sub-editor values are translated through their ToEditor methods
/// </summary>
/// <param name="property"></param>
/// <param name="culture"></param>
/// <param name="segment"></param>
/// <returns></returns>
/// <inheritdoc />
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<TValue, TLayout> : BlockVal
return blockEditorData.BlockValue;
}
/// <summary>
/// Ensure that sub-editor values are translated through their FromEditor methods
/// </summary>
/// <param name="editorValue"></param>
/// <param name="currentValue"></param>
/// <returns></returns>
/// <inheritdoc />
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<TValue, TLayout>? currentBlockEditorData = GetBlockEditorData(currentValue);
BlockEditorData<TValue, TLayout>? blockEditorData = GetBlockEditorData(editorValue.Value);
// We can skip MapBlockValueFromEditor if both editorValue and currentValue values are empty.
if (IsBlockEditorDataEmpty(currentBlockEditorData) && IsBlockEditorDataEmpty(blockEditorData))
{
return null;
}
BlockEditorData<TValue, TLayout>? blockEditorData;
MapBlockValueFromEditor(blockEditorData?.BlockValue, currentBlockEditorData?.BlockValue, editorValue.ContentKey);
if (IsBlockEditorDataEmpty(blockEditorData))
{
return null;
}
return JsonSerializer.Serialize(blockEditorData.BlockValue);
}
private BlockEditorData<TValue, TLayout>? 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<TValue, TLayout>? editorData)
=> editorData is null || editorData.BlockValue.ContentData.Count == 0;
}

View File

@@ -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<TValue, TLayout> : 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++)
{

View File

@@ -129,10 +129,115 @@ public abstract class BlockValuePropertyValueEditorBase<TValue, TLayout> : 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<BlockItemData> editedItems, List<BlockItemData> currentItems, Guid contentKey)
{
// Create mapping between edited and current block items.
IEnumerable<BlockStateMapping<BlockItemData>> itemsMapping = GetBlockStatesMapping(editedItems, currentItems, (mapping, current) => mapping.Edited?.Key == current.Key);
foreach (BlockStateMapping<BlockItemData> itemMapping in itemsMapping)
{
// Create mapping between edited and current block item values.
IEnumerable<BlockStateMapping<BlockPropertyValue>> valuesMapping = GetBlockStatesMapping(itemMapping.Edited?.Values, itemMapping.Current?.Values, (mapping, current) => mapping.Edited?.Alias == current.Alias);
foreach (BlockStateMapping<BlockPropertyValue> 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<T>
{
public T? Edited { get; set; }
public T? Current { get; set; }
}
private static IEnumerable<BlockStateMapping<T>> GetBlockStatesMapping<T>(IList<T>? editedItems, IList<T>? currentItems, Func<BlockStateMapping<T>, T, bool> condition)
{
// filling with edited items first
List<BlockStateMapping<T>> mapping = editedItems?
.Select(editedItem => new BlockStateMapping<T>
{
Current = default,
Edited = editedItem,
})
.ToList()
?? [];
if (currentItems is null)
{
return mapping;
}
// then adding current items
foreach (T currentItem in currentItems)
{
BlockStateMapping<T>? mappingItem = mapping.FirstOrDefault(x => condition(x, currentItem));
if (mappingItem == null) // if there is no edited item, then adding just current
{
mapping.Add(new BlockStateMapping<T>
{
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<TValue, TLayout> : DataV
}
}
private void MapBlockItemDataFromEditor(List<BlockItemData> 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;
}
}
}
/// <summary>
/// Updates the invariant data in the source with the invariant data in the value if allowed
/// </summary>

View File

@@ -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.
/// <summary>
/// Defines the file upload property editor.
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.UploadField,
ValueEditorIsReusable = true)]
@@ -22,12 +28,11 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
INotificationHandler<MediaDeletedNotification>, INotificationHandler<MediaSavingNotification>,
INotificationHandler<MemberDeletedNotification>
{
private readonly IContentService _contentService;
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
private readonly IIOHelper _ioHelper;
private readonly MediaFileManager _mediaFileManager;
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadPropertyEditor"/> class.
/// </summary>
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;
}
/// <inheritdoc/>
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<IProperty> 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);
/// <inheritdoc />
protected override IConfigurationEditor CreateConfigurationEditor() =>
new FileUploadConfigurationEditor(_ioHelper);
@@ -117,86 +72,43 @@ public class FileUploadPropertyEditor : DataEditor, IMediaUrlGenerator,
protected override IDataValueEditor CreateValueEditor()
=> DataValueEditorFactory.Create<FileUploadPropertyValueEditor>(Attribute!);
/// <summary>
/// Gets a value indicating whether a property is an upload field.
/// </summary>
/// <param name="property">The property.</param>
/// <returns>
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
/// </returns>
private static bool IsUploadField(IProperty property) => property.PropertyType.PropertyEditorAlias ==
Constants.PropertyEditors.Aliases.UploadField;
#region Obsolete notification handler notifications
/// <summary>
/// The paths to all file upload property files contained within a collection of content entities
/// </summary>
/// <param name="entities"></param>
private IEnumerable<string> ContainedFilePaths(IEnumerable<IContentBase> entities) => entities
.SelectMany(x => x.Properties)
.Where(IsUploadField)
.SelectMany(GetFilePathsFromPropertyValues)
.Distinct();
/// <summary>
/// Look through all property values stored against the property and resolve any file paths stored
/// </summary>
/// <param name="prop"></param>
/// <returns></returns>
private IEnumerable<string> GetFilePathsFromPropertyValues(IProperty prop)
/// <inheritdoc/>
[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<IPropertyValue> 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<IContentBase> deletedEntities)
/// <inheritdoc/>
[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<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
_mediaFileManager.DeleteMediaFiles(filePathsToDelete);
// This handler is no longer registered. Logic has been migrated to FileUploadMediaSavingNotificationHandler.
}
/// <summary>
/// Auto-fill properties (or clear).
/// </summary>
private void AutoFillProperties(IContentBase model)
/// <inheritdoc/>
[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<IProperty> 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.
}
/// <inheritdoc/>
[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.
}
/// <inheritdoc/>
[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
}

View File

@@ -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;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadPropertyValueEditor"/> class.
/// </summary>
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));
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
/// <summary>
/// Converts the client model (FileUploadValue) into the value can be stored in the database (the file path).
/// </summary>
@@ -83,12 +90,15 @@ internal class FileUploadPropertyValueEditor : DataValueEditor
/// </remarks>
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();

View File

@@ -0,0 +1,45 @@
using Umbraco.Cms.Core.PropertyEditors.ValueConverters;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.PropertyEditors;
/// <summary>
/// Handles the parsing of raw values to <see cref="FileUploadValue"/> objects.
/// </summary>
internal sealed class FileUploadValueParser
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadValueParser"/> class.
/// </summary>
/// <param name="jsonSerializer"></param>
public FileUploadValueParser(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer;
/// <summary>
/// Parses raw value to a <see cref="FileUploadValue"/>.
/// </summary>
/// <param name="editorValue">The editor value.</param>
/// <returns><a cref="FileUploadValue"></a> value</returns>
/// <exception cref="ArgumentException"></exception>
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.");
}
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed class FileUploadContentCopiedNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler<ContentCopiedNotification>
{
private readonly IContentService _contentService;
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadContentCopiedNotificationHandler"/> class.
/// </summary>
public FileUploadContentCopiedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentCopiedNotificationHandler> logger,
IContentService contentService)
: base(jsonSerializer, mediaFileManager, elementTypeCache)
{
_blockListEditorValues = new(new BlockListEditorDataConverter(jsonSerializer), elementTypeCache, logger);
_blockGridEditorValues = new(new BlockGridEditorDataConverter(jsonSerializer), elementTypeCache, logger);
_contentService = contentService;
}
/// <inheritdoc/>
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<TValue, TLayout>(ContentCopiedNotification notification, IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var isUpdated = false;
foreach (IPropertyValue blockPropertyValue in property.Values)
{
var rawBlockPropertyValue = property.GetValue(blockPropertyValue.Culture, blockPropertyValue.Segment);
BlockEditorData<TValue, TLayout>? 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<TValue, TLayout>(ContentCopiedNotification notification, BlockEditorData<TValue, TLayout>? blockEditorData)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var isUpdated = false;
if (blockEditorData is null)
{
return (isUpdated, null);
}
IEnumerable<BlockPropertyValue> 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<RichTextBlockValue>(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<BlockPropertyValue> 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<BlockPropertyValue> 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<RichTextBlockValue>(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<TValue, TLayout>(BlockPropertyValue blockItemDataValue, ContentCopiedNotification notification, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
BlockEditorData<TValue, TLayout>? 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);
}
}

View File

@@ -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;
/// <summary>
/// Implements a notification handler that processes file uploads when content is deleted, removing associated files.
/// </summary>
internal sealed class FileUploadContentDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler<ContentDeletedNotification>
{
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadContentDeletedNotificationHandler"/> class.
/// </summary>
public FileUploadContentDeletedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentDeletedNotificationHandler> logger)
: base(jsonSerializer, mediaFileManager, elementTypeCache, logger)
{
}
/// <inheritdoc/>
public void Handle(ContentDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
}

View File

@@ -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;
/// <summary>
/// Provides base class for notification handler that processes file uploads when a content entity is deleted, removing associated files.
/// </summary>
internal abstract class FileUploadEntityDeletedNotificationHandlerBase : FileUploadNotificationHandlerBase
{
private readonly BlockEditorValues<BlockListValue, BlockListLayoutItem> _blockListEditorValues;
private readonly BlockEditorValues<BlockGridValue, BlockGridLayoutItem> _blockGridEditorValues;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadEntityDeletedNotificationHandlerBase"/> class.
/// </summary>
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);
}
/// <summary>
/// Deletes all file upload property files contained within a collection of content entities.
/// </summary>
/// <param name="deletedEntities"></param>
protected void DeleteContainedFiles(IEnumerable<IContentBase> deletedEntities)
{
IReadOnlyList<string> filePathsToDelete = ContainedFilePaths(deletedEntities);
MediaFileManager.DeleteMediaFiles(filePathsToDelete);
}
/// <summary>
/// Gets the paths to all file upload property files contained within a collection of content entities.
/// </summary>
private IReadOnlyList<string> ContainedFilePaths(IEnumerable<IContentBase> entities)
{
var paths = new List<string>();
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<string> 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<string> GetPathsFromBlockProperty<TValue, TLayout>(IProperty property, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
var paths = new List<string>();
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<string> GetPathsFromBlockValue(BlockValue? blockValue)
{
var paths = new List<string>();
if (blockValue is null)
{
return paths;
}
IEnumerable<BlockPropertyValue> 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<string> GetPathsFromBlockPropertyValue<TValue, TLayout>(BlockPropertyValue blockItemDataValue, BlockEditorValues<TValue, TLayout> blockEditorValues)
where TValue : BlockValue<TLayout>, new()
where TLayout : class, IBlockLayoutItem, new()
{
BlockEditorData<TValue, TLayout>? blockItemEditorDataValue = GetBlockEditorData(blockItemDataValue.Value, blockEditorValues);
return GetPathsFromBlockValue(blockItemEditorDataValue?.BlockValue);
}
private IReadOnlyCollection<string> GetPathsFromRichTextProperty(IProperty property)
{
var paths = new List<string>();
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<string> GetPathsFromRichTextPropertyValue(BlockPropertyValue blockItemDataValue)
{
RichTextEditorValue? richTextEditorValue = GetRichTextEditorValue(blockItemDataValue.Value);
// Ensure the property type is populated on all blocks.
richTextEditorValue?.EnsurePropertyTypePopulatedOnBlocks(ElementTypeCache);
return GetPathsFromBlockValue(richTextEditorValue?.Blocks);
}
}

View File

@@ -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;
/// <summary>
/// Implements a notification handler that processes file uploads when media is deleted, removing associated files.
/// </summary>
internal sealed class FileUploadMediaDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler<MediaDeletedNotification>
{
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadMediaDeletedNotificationHandler"/> class.
/// </summary>
public FileUploadMediaDeletedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentDeletedNotificationHandler> logger)
: base(jsonSerializer, mediaFileManager, elementTypeCache, logger)
{
}
/// <inheritdoc/>
public void Handle(MediaDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
}

View File

@@ -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;
/// <summary>
/// Implements a notification handler that processes file uploads media is saved, completing properties on the media item.
/// </summary>
internal sealed class FileUploadMediaSavingNotificationHandler : FileUploadNotificationHandlerBase, INotificationHandler<MediaSavingNotification>
{
private readonly IOptionsMonitor<ContentSettings> _contentSettings;
private readonly UploadAutoFillProperties _uploadAutoFillProperties;
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadMediaSavingNotificationHandler"/> class.
/// </summary>
public FileUploadMediaSavingNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
IOptionsMonitor<ContentSettings> contentSettings,
UploadAutoFillProperties uploadAutoFillProperties)
: base(jsonSerializer, mediaFileManager, elementTypeCache)
{
_contentSettings = contentSettings;
_uploadAutoFillProperties = uploadAutoFillProperties;
}
/// <inheritdoc/>
public void Handle(MediaSavingNotification notification)
{
foreach (IMedia entity in notification.SavedEntities)
{
AutoFillProperties(entity);
}
}
private void AutoFillProperties(IContentBase model)
{
IEnumerable<IProperty> 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);
}
}
}
}
}

View File

@@ -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;
/// <summary>
/// Implements a notification handler that processes file uploads when a member is deleted, removing associated files.
/// </summary>
internal sealed class FileUploadMemberDeletedNotificationHandler : FileUploadEntityDeletedNotificationHandlerBase, INotificationHandler<MemberDeletedNotification>
{
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadMemberDeletedNotificationHandler"/> class.
/// </summary>
public FileUploadMemberDeletedNotificationHandler(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache,
ILogger<FileUploadContentDeletedNotificationHandler> logger)
: base(jsonSerializer, mediaFileManager, elementTypeCache, logger)
{
}
/// <inheritdoc/>
public void Handle(MemberDeletedNotification notification) => DeleteContainedFiles(notification.DeletedEntities);
}

View File

@@ -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;
/// <summary>
/// Provides a base class for all notification handlers relating to file uploads in property editors.
/// </summary>
internal abstract class FileUploadNotificationHandlerBase
{
/// <summary>
/// Initializes a new instance of the <see cref="FileUploadNotificationHandlerBase"/> class.
/// </summary>
protected FileUploadNotificationHandlerBase(
IJsonSerializer jsonSerializer,
MediaFileManager mediaFileManager,
IBlockEditorElementTypeCache elementTypeCache)
{
JsonSerializer = jsonSerializer;
MediaFileManager = mediaFileManager;
ElementTypeCache = elementTypeCache;
FileUploadValueParser = new FileUploadValueParser(jsonSerializer);
}
/// <summary>
/// Gets the <see cref="IJsonSerializer" /> used for serializing and deserializing values.
/// </summary>
protected IJsonSerializer JsonSerializer { get; }
/// <summary>
/// Gets the <see cref="MediaFileManager" /> used for managing media files.
/// </summary>
protected MediaFileManager MediaFileManager { get; }
/// <summary>
/// Gets the <IBlockEditorElementTypeCache> used for caching block editor element types.
/// </summary>
protected IBlockEditorElementTypeCache ElementTypeCache { get; }
/// <summary>
/// Gets the <see cref="FileUploadValueParser" /> used for parsing file upload values.
/// </summary>
protected FileUploadValueParser FileUploadValueParser { get; }
/// <summary>
/// Gets a value indicating whether a property is an upload field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an upload field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsUploadFieldPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.UploadField;
/// <summary>
/// Gets a value indicating whether a property is an block list field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an block list field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsBlockListPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockList;
/// <summary>
/// Gets a value indicating whether a property is an block grid field.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an block grid field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsBlockGridPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.BlockGrid;
/// <summary>
/// Gets a value indicating whether a property is an rich text field (supporting blocks).
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>
/// <c>true</c> if the specified property is an rich text field; otherwise, <c>false</c>.
/// </returns>
protected static bool IsRichTextPropertyType(IPropertyType propertyType)
=> propertyType.PropertyEditorAlias == Constants.PropertyEditors.Aliases.RichText ||
propertyType.PropertyEditorAlias == "Umbraco.TinyMCE";
/// <summary>
/// Deserializes the block editor data value.
/// </summary>
protected static BlockEditorData<TValue, TLayout>? GetBlockEditorData<TValue, TLayout>(object? value, BlockEditorValues<TValue, TLayout> blockListEditorValues)
where TValue : BlockValue<TLayout>, 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;
}
}
/// <summary>
/// Deserializes the rich text editor value.
/// </summary>
protected RichTextEditorValue? GetRichTextEditorValue(object? value)
{
if (value is null)
{
return null;
}
JsonSerializer.TryDeserialize(value, out RichTextEditorValue? richTextEditorValue);
return richTextEditorValue;
}
/// <summary>
/// Deserializes the rich text block value.
/// </summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// The constructor will setup the property editor based on the attribute if one is found.
/// Initializes a new instance of the <see cref="RichTextPropertyEditor"/> class.
/// </summary>
/// <remarks>
/// The constructor will setup the property editor based on the attribute if one is found.
/// </remarks>
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<RichTextPropertyValueEditor> _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
/// <returns></returns>
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<RichTextBlockValue> handleMapping)
private RichTextEditorValue CleanAndMapBlocks(RichTextEditorValue? richTextEditorValue, Action<RichTextBlockValue> 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<RichTextBlockValue, RichTextBlockLayoutItem>? 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<RichTextBlockValue, RichTextBlockLayoutItem>? 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(),
};
}

View File

@@ -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'],
};

View File

@@ -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;

View File

@@ -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<ILocalizedTextService>();
localizedTextServiceMock.Setup(x => x.Localize(
localizedTextServiceMock
.Setup(x => x.Localize(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<CultureInfo>(),
It.IsAny<IDictionary<string, string>>()))
It.IsAny<IDictionary<string, string>>()))
.Returns((string key, string alias, CultureInfo culture, IDictionary<string, string> args) => $"{key}_{alias}");
var jsonSerializer = new SystemTextJsonSerializer();
var languageService = Mock.Of<ILanguageService>();
var dataValueEditorFactoryMock = new Mock<IDataValueEditorFactory>();
DataEditor textBoxEditor;
switch (valueEditorSetup)
{
case ValueEditorSetup.ConcatenatingTextValueEditor:
dataValueEditorFactoryMock
.Setup(x => x.Create<ConcatenatingTextValueEditor>(It.IsAny<object[]>()))
.Returns(new ConcatenatingTextValueEditor(
Mock.Of<IShortStringHelper>(),
new SystemTextJsonSerializer()));
textBoxEditor = new ConcatenatingTextboxPropertyEditor(
dataValueEditorFactoryMock.Object);
break;
default:
dataValueEditorFactoryMock
.Setup(x => x.Create<TextOnlyValueEditor>(It.IsAny<object[]>()))
.Returns(new TextOnlyValueEditor(
new DataEditorAttribute("a"),
Mock.Of<ILocalizedTextService>(),
Mock.Of<IShortStringHelper>(),
new SystemTextJsonSerializer(),
Mock.Of<IIOHelper>()));
textBoxEditor = new TextboxPropertyEditor(
dataValueEditorFactoryMock.Object,
Mock.Of<IIOHelper>());
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<IBlockEditorElementTypeCache>();
elementTypeCacheMock
.Setup(x => x.GetMany(It.Is<IEnumerable<Guid>>(y => y.First() == _contentTypeKey)))
.Returns([elementType]);
return new BlockListEditorPropertyValueEditor(
new DataEditorAttribute("alias"),
new BlockListEditorDataConverter(jsonSerializer),
new(new DataEditorCollection(() => [])),
propertyEditors,
new DataValueReferenceFactoryCollection(Enumerable.Empty<IDataValueReferenceFactory>, Mock.Of<ILogger<DataValueReferenceFactoryCollection>>()),
Mock.Of<IDataTypeConfigurationCache>(),
Mock.Of<IBlockEditorElementTypeCache>(),
elementTypeCacheMock.Object,
localizedTextServiceMock.Object,
new NullLogger<BlockListEditorPropertyValueEditor>(),
Mock.Of<IShortStringHelper>(),
@@ -171,4 +310,62 @@ public class BlockListEditorPropertyValueEditorTests
},
};
}
/// <summary>
/// An illustrative property editor that uses the edited and current value when returning a result from the FromEditor calls.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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<ConcatenatingTextValueEditor>(Attribute!);
}
/// <summary>
/// An illustrative value editor that uses the edited and current value when returning a result from the FromEditor calls.
/// </summary>
/// <remarks>
/// See notes on <see cref="ConcatenatingTextboxPropertyEditor"/>.
/// </remarks>
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<string>();
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());
}
}