diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs index 1fe1e92d9b..5969e0a788 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/ApiMediaQueryService.cs @@ -113,7 +113,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService if (parts.Length != 2) { // invalid filter - _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not valid"); + _logger.LogInformation("An invalid filter option was encountered. Please ensure that supplied filter options are two-part, separated by ':'."); return null; } @@ -127,7 +127,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService break; default: // unknown filter - _logger.LogInformation($"The \"{nameof(filters)}\" query option \"{filter}\" is not supported"); + _logger.LogInformation("An unsupported filter option was supplied for the query. Please use only valid filter options. See the documentation for details."); return null; } } @@ -143,7 +143,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService if (parts.Length != 2) { // invalid sort - _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not valid"); + _logger.LogInformation("An invalid sort option was encountered. Please ensure that the supplied sort options are two-part, separated by ':'."); return null; } @@ -164,7 +164,7 @@ internal sealed class ApiMediaQueryService : IApiMediaQueryService break; default: // unknown sort - _logger.LogInformation($"The \"{nameof(sorts)}\" query option \"{sort}\" is not supported"); + _logger.LogInformation("An unsupported sort option was supplied for the query. Please use only valid sort options. See the documentation for details."); return null; } diff --git a/src/Umbraco.Core/Composing/BuilderCollectionBase.cs b/src/Umbraco.Core/Composing/BuilderCollectionBase.cs index 722a6efca0..e92790ab4e 100644 --- a/src/Umbraco.Core/Composing/BuilderCollectionBase.cs +++ b/src/Umbraco.Core/Composing/BuilderCollectionBase.cs @@ -3,7 +3,7 @@ using System.Collections; namespace Umbraco.Cms.Core.Composing; /// -/// Provides a base class for builder collections. +/// Provides a base class for builder collections. /// /// The type of the items. public abstract class BuilderCollectionBase : IBuilderCollection @@ -11,21 +11,18 @@ public abstract class BuilderCollectionBase : IBuilderCollection private readonly LazyReadOnlyCollection _items; /// - /// Initializes a new instance of the with items. + /// Initializes a new instance of the with items. /// /// The items. - public BuilderCollectionBase(Func> items) => _items = new LazyReadOnlyCollection(items); + public BuilderCollectionBase(Func> items) + => _items = new LazyReadOnlyCollection(items); /// public int Count => _items.Count; - /// - /// Gets an enumerator. - /// + /// public IEnumerator GetEnumerator() => _items.GetEnumerator(); - /// - /// Gets an enumerator. - /// + /// IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/src/Umbraco.Core/Composing/IBuilderCollection.cs b/src/Umbraco.Core/Composing/IBuilderCollection.cs index 56036997bc..c8920149c5 100644 --- a/src/Umbraco.Core/Composing/IBuilderCollection.cs +++ b/src/Umbraco.Core/Composing/IBuilderCollection.cs @@ -1,13 +1,16 @@ namespace Umbraco.Cms.Core.Composing; /// -/// Represents a builder collection, ie an immutable enumeration of items. +/// Represents a builder collection, ie an immutable enumeration of items. /// /// The type of the items. public interface IBuilderCollection : IEnumerable { /// - /// Gets the number of items in the collection. + /// Gets the number of items in the collection. /// + /// + /// The count. + /// int Count { get; } } diff --git a/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs b/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs index c093962408..3f02be10b1 100644 --- a/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs +++ b/src/Umbraco.Core/Models/Editors/UmbracoEntityReference.cs @@ -1,49 +1,88 @@ namespace Umbraco.Cms.Core.Models.Editors; /// -/// Used to track reference to other entities in a property value +/// Used to track a reference to another entity in a property value. /// public struct UmbracoEntityReference : IEquatable { private static readonly UmbracoEntityReference _empty = new(UnknownTypeUdi.Instance, string.Empty); + /// + /// Initializes a new instance of the struct. + /// + /// The UDI. + /// The relation type alias. public UmbracoEntityReference(Udi udi, string relationTypeAlias) { Udi = udi ?? throw new ArgumentNullException(nameof(udi)); RelationTypeAlias = relationTypeAlias ?? throw new ArgumentNullException(nameof(relationTypeAlias)); } + /// + /// Initializes a new instance of the struct for a document or media item. + /// + /// The UDI. public UmbracoEntityReference(Udi udi) { Udi = udi ?? throw new ArgumentNullException(nameof(udi)); switch (udi.EntityType) { + case Constants.UdiEntityType.Document: + RelationTypeAlias = Constants.Conventions.RelationTypes.RelatedDocumentAlias; + break; case Constants.UdiEntityType.Media: RelationTypeAlias = Constants.Conventions.RelationTypes.RelatedMediaAlias; break; default: - RelationTypeAlias = Constants.Conventions.RelationTypes.RelatedDocumentAlias; + // No relation type alias convention for this entity type, so leave it empty + RelationTypeAlias = string.Empty; break; } } + /// + /// Gets the UDI. + /// + /// + /// The UDI. + /// public Udi Udi { get; } - public static UmbracoEntityReference Empty() => _empty; - - public static bool IsEmpty(UmbracoEntityReference reference) => reference == Empty(); - + /// + /// Gets the relation type alias. + /// + /// + /// The relation type alias. + /// public string RelationTypeAlias { get; } - public static bool operator ==(UmbracoEntityReference left, UmbracoEntityReference right) => left.Equals(right); + /// + /// Gets an empty reference. + /// + /// + /// An empty reference. + /// + public static UmbracoEntityReference Empty() => _empty; + /// + /// Determines whether the specified reference is empty. + /// + /// The reference. + /// + /// true if the specified reference is empty; otherwise, false. + /// + public static bool IsEmpty(UmbracoEntityReference reference) => reference == Empty(); + + /// public override bool Equals(object? obj) => obj is UmbracoEntityReference reference && Equals(reference); + /// public bool Equals(UmbracoEntityReference other) => EqualityComparer.Default.Equals(Udi, other.Udi) && RelationTypeAlias == other.RelationTypeAlias; + /// public override int GetHashCode() { var hashCode = -487348478; @@ -52,5 +91,9 @@ public struct UmbracoEntityReference : IEquatable return hashCode; } + /// + public static bool operator ==(UmbracoEntityReference left, UmbracoEntityReference right) => left.Equals(right); + + /// public static bool operator !=(UmbracoEntityReference left, UmbracoEntityReference right) => !(left == right); } diff --git a/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs b/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs new file mode 100644 index 0000000000..47eda5468d --- /dev/null +++ b/src/Umbraco.Core/Notifications/ContentScaffoldedNotification.cs @@ -0,0 +1,18 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// Notification that is send out when a Content item has been scaffolded from an original item and basic cleaning has been performed +/// +public sealed class ContentScaffoldedNotification : ScaffoldedNotification +{ + public ContentScaffoldedNotification(IContent original, IContent scaffold, int parentId, EventMessages messages) + : base(original, scaffold, parentId, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs b/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs new file mode 100644 index 0000000000..f64bfd3933 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ScaffoldedNotification.cs @@ -0,0 +1,23 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Umbraco.Cms.Core.Events; + +namespace Umbraco.Cms.Core.Notifications; + +public abstract class ScaffoldedNotification : CancelableObjectNotification + where T : class +{ + protected ScaffoldedNotification(T original, T scaffold, int parentId, EventMessages messages) + : base(original, messages) + { + Scaffold = scaffold; + ParentId = parentId; + } + + public T Original => Target; + + public T Scaffold { get; } + + public int ParentId { get; } +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs index c605d45a9a..86c9c48fc0 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollection.cs @@ -4,56 +4,109 @@ using Umbraco.Cms.Core.Models.Editors; namespace Umbraco.Cms.Core.PropertyEditors; +/// +/// Provides a builder collection for items. +/// public class DataValueReferenceFactoryCollection : BuilderCollectionBase { + // TODO: We could further reduce circular dependencies with PropertyEditorCollection by not having IDataValueReference implemented + // by property editors and instead just use the already built in IDataValueReferenceFactory and/or refactor that into a more normal collection + + /// + /// Initializes a new instance of the class. + /// + /// The items. public DataValueReferenceFactoryCollection(Func> items) : base(items) { } - // TODO: We could further reduce circular dependencies with PropertyEditorCollection by not having IDataValueReference implemented - // by property editors and instead just use the already built in IDataValueReferenceFactory and/or refactor that into a more normal collection + /// + /// Gets all unique references from the specified properties. + /// + /// The properties. + /// The property editors. + /// + /// The unique references from the specified properties. + /// public ISet GetAllReferences(IPropertyCollection properties, PropertyEditorCollection propertyEditors) { var references = new HashSet(); - foreach (IProperty property in properties) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var propertyValuesByPropertyEditorAlias in properties.GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Values)) { - if (!propertyEditors.TryGet(property.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor)) + if (!propertyEditors.TryGet(propertyValuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { continue; } - // TODO: We will need to change this once we support tracking via variants/segments - // for now, we are tracking values from ALL variants - foreach (IPropertyValue propertyValue in property.Values) + // Use distinct values to avoid duplicate parsing of the same value + var values = new HashSet(properties.Count); + foreach (IPropertyValue propertyValue in propertyValuesByPropertyEditorAlias.SelectMany(x => x)) { - object? value = propertyValue.EditedValue; - - if (dataEditor.GetValueEditor() is IDataValueReference dataValueReference) - { - references.UnionWith(dataValueReference.GetReferences(value)); - } - - // Loop over collection that may be add to existing property editors - // implementation of GetReferences in IDataValueReference. - // Allows developers to add support for references by a - // package /property editor that did not implement IDataValueReference themselves - foreach (IDataValueReferenceFactory dataValueReferenceFactory in this) - { - // Check if this value reference is for this datatype/editor - // Then call it's GetReferences method - to see if the value stored - // in the dataeditor/property has references to media/content items - if (dataValueReferenceFactory.IsForEditor(dataEditor)) - { - references.UnionWith(dataValueReferenceFactory.GetDataValueReference().GetReferences(value)); - } - } + values.Add(propertyValue.EditedValue); + values.Add(propertyValue.PublishedValue); } + + references.UnionWith(GetReferences(dataEditor, values)); } return references; } + /// + /// Gets the references. + /// + /// The data editor. + /// The values. + /// + /// The references. + /// + public IEnumerable GetReferences(IDataEditor dataEditor, params object?[] values) + => GetReferences(dataEditor, (IEnumerable)values); + + /// + /// Gets the references. + /// + /// The data editor. + /// The values. + /// + /// The references. + /// + public ISet GetReferences(IDataEditor dataEditor, IEnumerable values) => + GetReferencesEnumerable(dataEditor, values).ToHashSet(); + private IEnumerable GetReferencesEnumerable(IDataEditor dataEditor, IEnumerable values) + { + // TODO: We will need to change this once we support tracking via variants/segments + // for now, we are tracking values from ALL variants + if (dataEditor.GetValueEditor() is IDataValueReference dataValueReference) + { + foreach (UmbracoEntityReference reference in values.SelectMany(dataValueReference.GetReferences)) + { + yield return reference; + } + } + + // Loop over collection that may be add to existing property editors + // implementation of GetReferences in IDataValueReference. + // Allows developers to add support for references by a + // package /property editor that did not implement IDataValueReference themselves + foreach (IDataValueReferenceFactory dataValueReferenceFactory in this) + { + // Check if this value reference is for this datatype/editor + // Then call it's GetReferences method - to see if the value stored + // in the dataeditor/property has references to media/content items + if (dataValueReferenceFactory.IsForEditor(dataEditor)) + { + IDataValueReference factoryDataValueReference = dataValueReferenceFactory.GetDataValueReference(); + foreach (UmbracoEntityReference reference in values.SelectMany(factoryDataValueReference.GetReferences)) + { + yield return reference; + } + } + } + } + /// /// Gets all relation type aliases that are automatically tracked. /// @@ -61,7 +114,10 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase /// All relation type aliases that are automatically tracked. /// - public ISet GetAutomaticRelationTypesAliases(PropertyEditorCollection propertyEditors) + [Obsolete("Use GetAllAutomaticRelationTypesAliases. This will be removed in Umbraco 15.")] + public ISet GetAutomaticRelationTypesAliases(PropertyEditorCollection propertyEditors) => + GetAllAutomaticRelationTypesAliases(propertyEditors); + public ISet GetAllAutomaticRelationTypesAliases(PropertyEditorCollection propertyEditors) { // Always add default automatic relation types var automaticRelationTypeAliases = new HashSet(Constants.Conventions.RelationTypes.AutomaticRelationTypes); @@ -74,15 +130,7 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase - /// Gets the relation type aliases that are automatically tracked for all properties. - /// - /// The properties. - /// The property editors. - /// - /// The relation type aliases that are automatically tracked for all properties. - /// + [Obsolete("Use non-obsolete GetAutomaticRelationTypesAliases. This will be removed in Umbraco 15.")] public ISet GetAutomaticRelationTypesAliases(IPropertyCollection properties, PropertyEditorCollection propertyEditors) { // Always add default automatic relation types @@ -93,14 +141,23 @@ public class DataValueReferenceFactoryCollection : BuilderCollectionBase GetAutomaticRelationTypesAliases(IDataEditor dataEditor) + /// + /// Gets the automatic relation types aliases. + /// + /// The data editor. + /// + /// The automatic relation types aliases. + /// + public ISet GetAutomaticRelationTypesAliases(IDataEditor dataEditor) => + GetAutomaticRelationTypesAliasesEnumerable(dataEditor).ToHashSet(); + private IEnumerable GetAutomaticRelationTypesAliasesEnumerable(IDataEditor dataEditor) { if (dataEditor.GetValueEditor() is IDataValueReference dataValueReference) { diff --git a/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs b/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs index c20ebf9284..2d4345c053 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedCacheBase.cs @@ -1,6 +1,8 @@ using System.Xml.XPath; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Xml; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Core.PublishedCache; @@ -9,10 +11,24 @@ public abstract class PublishedCacheBase : IPublishedCache { private readonly IVariationContextAccessor? _variationContextAccessor; - public PublishedCacheBase(IVariationContextAccessor variationContextAccessor) => _variationContextAccessor = - variationContextAccessor ?? throw new ArgumentNullException(nameof(variationContextAccessor)); - protected PublishedCacheBase(bool previewDefault) => PreviewDefault = previewDefault; + [Obsolete("Use ctor with all parameters. This will be removed in V15")] + public PublishedCacheBase(IVariationContextAccessor variationContextAccessor) + : this(variationContextAccessor, false) + { + } + + [Obsolete("Use ctor with all parameters. This will be removed in V15")] + protected PublishedCacheBase(bool previewDefault) + : this(StaticServiceProvider.Instance.GetRequiredService(), previewDefault) + { + } + + public PublishedCacheBase(IVariationContextAccessor variationContextAccessor, bool previewDefault) + { + _variationContextAccessor = variationContextAccessor; + PreviewDefault = previewDefault; + } public bool PreviewDefault { get; } diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a60cef7e7f..345b80a846 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -367,10 +367,13 @@ public static partial class UmbracoBuilderExtensions builder .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index 3ae4248119..ce30372d35 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -1082,20 +1082,24 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement protected void PersistRelations(TEntity entity) { - // Get all references from our core built in DataEditors/Property Editors - // Along with seeing if developers want to collect additional references from the DataValueReferenceFactories collection + // Get all references and automatic relation type aliases ISet references = _dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors); - - // First delete all auto-relations for this entity - ISet automaticRelationTypeAliases = _dataValueReferenceFactories.GetAutomaticRelationTypesAliases(entity.Properties, PropertyEditors); - RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray()); + ISet automaticRelationTypeAliases = _dataValueReferenceFactories.GetAllAutomaticRelationTypesAliases(PropertyEditors); if (references.Count == 0) { + // Delete all relations using the automatic relation type aliases + RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray()); + // No need to add new references/relations return; } + // Lookup all relation type IDs + var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty()) + .Where(x => automaticRelationTypeAliases.Contains(x.Alias)) + .ToDictionary(x => x.Alias, x => x.Id); + // Lookup node IDs for all GUID based UDIs IEnumerable keys = references.Select(x => x.Udi).OfType().Select(x => x.Guid); var keysLookup = Database.FetchByGroups(keys, Constants.Sql.MaxParameterCount, guids => @@ -1106,15 +1110,17 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement .WhereIn(x => x.UniqueId, guids); }).ToDictionary(x => x.UniqueId, x => x.NodeId); - // Lookup all relation type IDs - var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty()).ToDictionary(x => x.Alias, x => x.Id); - // Get all valid relations - var relations = new List(references.Count); + var relations = new List<(int ChildId, int RelationTypeId)>(references.Count); foreach (UmbracoEntityReference reference in references) { - if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias)) + if (string.IsNullOrEmpty(reference.RelationTypeAlias)) { + // Reference does not specify a relation type alias, so skip adding a relation + Logger.LogDebug("The reference to {Udi} does not specify a relation type alias, so it will not be saved as relation.", reference.Udi); + } + else if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias)) + { // Returning a reference that doesn't use an automatic relation type is an issue that should be fixed in code Logger.LogError("The reference to {Udi} uses a relation type {RelationTypeAlias} that is not an automatic relation type.", reference.Udi, reference.RelationTypeAlias); } @@ -1130,12 +1136,24 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement } else { - relations.Add(new ReadOnlyRelation(entity.Id, id, relationTypeId)); + relations.Add((id, relationTypeId)); } } - // Save bulk relations - RelationRepository.SaveBulk(relations); + // Get all existing relations (optimize for adding new and keeping existing relations) + var query = Query().Where(x => x.ParentId == entity.Id).WhereIn(x => x.RelationTypeId, relationTypeLookup.Values); + var existingRelations = RelationRepository.GetPagedRelationsByQuery(query, 0, int.MaxValue, out _, null) + .ToDictionary(x => (x.ChildId, x.RelationTypeId)); // Relations are unique by parent ID, child ID and relation type ID + + // Add relations that don't exist yet + var relationsToAdd = relations.Except(existingRelations.Keys).Select(x => new ReadOnlyRelation(entity.Id, x.ChildId, x.RelationTypeId)); + RelationRepository.SaveBulk(relationsToAdd); + + // Delete relations that don't exist anymore + foreach (IRelation relation in existingRelations.Where(x => !relations.Contains(x.Key)).Select(x => x.Value)) + { + RelationRepository.Delete(relation); + } } /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs index 0d06582739..e23cfde52b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorPropertyValueEditor.cs @@ -18,18 +18,28 @@ internal abstract class BlockEditorPropertyValueEditor : BlockV { private readonly IJsonSerializer _jsonSerializer; private BlockEditorValues? _blockEditorValues; + private readonly IDataTypeService _dataTypeService; + private readonly PropertyEditorCollection _propertyEditors; + private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories; + private readonly ILogger _logger; protected BlockEditorPropertyValueEditor( DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, IDataTypeService dataTypeService, ILocalizedTextService textService, ILogger> logger, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper) - : base(attribute, propertyEditors, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) - => _jsonSerializer = jsonSerializer; + : base(attribute, propertyEditors, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper, dataValueReferenceFactories) + { + _propertyEditors = propertyEditors; + _dataValueReferenceFactories = dataValueReferenceFactories; + _dataTypeService = dataTypeService; + _logger = logger; + } protected BlockEditorValues BlockEditorValues { @@ -40,29 +50,57 @@ internal abstract class BlockEditorPropertyValueEditor : BlockV /// public override IEnumerable GetReferences(object? value) { - var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); - - BlockEditorData? blockEditorData = BlockEditorValues.DeserializeAndClean(rawJson); - if (blockEditorData == null) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var valuesByPropertyEditorAlias in GetAllPropertyValues(value).GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { - return Enumerable.Empty(); - } + if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) + { + continue; + } - return GetBlockValueReferences(blockEditorData.BlockValue); + // Use distinct values to avoid duplicate parsing of the same value + foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, valuesByPropertyEditorAlias.Distinct())) + { + yield return reference; + } + } } /// public override IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) + { + foreach (BlockItemData.BlockPropertyValue propertyValue in GetAllPropertyValues(value)) + { + if (!_propertyEditors.TryGet(propertyValue.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor) || + dataEditor.GetValueEditor() is not IDataValueTags dataValueTags) + { + continue; + } + + object? configuration = _dataTypeService.GetDataType(propertyValue.PropertyType.DataTypeKey)?.Configuration; + foreach (ITag tag in dataValueTags.GetTags(propertyValue.Value, configuration, languageId)) + { + yield return tag; + } + } + } + + private IEnumerable GetAllPropertyValues(object? value) { var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); BlockEditorData? blockEditorData = BlockEditorValues.DeserializeAndClean(rawJson); - if (blockEditorData == null) + if (blockEditorData is null) { - return Enumerable.Empty(); + yield break; } - return GetBlockValueTags(blockEditorData.BlockValue, languageId); + // Return all property values from the content and settings data + IEnumerable data = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData); + foreach (BlockItemData.BlockPropertyValue propertyValue in data.SelectMany(x => x.PropertyValues.Select(x => x.Value))) + { + yield return propertyValue; + } } // note: there is NO variant support here diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs index c7c971047a..24ebbb9d82 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockGridPropertyEditorBase.cs @@ -50,6 +50,7 @@ public abstract class BlockGridPropertyEditorBase : DataEditor public BlockGridEditorPropertyValueEditor( DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, IDataTypeService dataTypeService, ILocalizedTextService textService, ILogger logger, @@ -58,7 +59,7 @@ public abstract class BlockGridPropertyEditorBase : DataEditor IIOHelper ioHelper, IContentTypeService contentTypeService, IPropertyValidationService propertyValidationService) - : base(attribute, propertyEditors, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) + : base(attribute, propertyEditors, dataValueReferenceFactories, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) { BlockEditorValues = new BlockEditorValues(new BlockGridEditorDataConverter(jsonSerializer), contentTypeService, logger); Validators.Add(new BlockEditorValidator(propertyValidationService, BlockEditorValues, contentTypeService)); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs index d7199404fc..e0272fc34b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockListPropertyEditorBase.cs @@ -57,6 +57,7 @@ public abstract class BlockListPropertyEditorBase : DataEditor DataEditorAttribute attribute, BlockEditorDataConverter blockEditorDataConverter, PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, IDataTypeService dataTypeService, IContentTypeService contentTypeService, ILocalizedTextService textService, @@ -65,7 +66,7 @@ public abstract class BlockListPropertyEditorBase : DataEditor IJsonSerializer jsonSerializer, IIOHelper ioHelper, IPropertyValidationService propertyValidationService) : - base(attribute, propertyEditors, dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) + base(attribute, propertyEditors, dataValueReferenceFactories,dataTypeService, textService, logger, shortStringHelper, jsonSerializer, ioHelper) { BlockEditorValues = new BlockEditorValues(blockEditorDataConverter, contentTypeService, logger); Validators.Add(new BlockEditorValidator(propertyValidationService, BlockEditorValues, contentTypeService)); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 01a2cba307..994d7fcbfe 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -16,6 +16,7 @@ internal abstract class BlockValuePropertyValueEditorBase : Dat private readonly IDataTypeService _dataTypeService; private readonly PropertyEditorCollection _propertyEditors; private readonly ILogger _logger; + private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactoryCollection; protected BlockValuePropertyValueEditorBase( DataEditorAttribute attribute, @@ -25,12 +26,14 @@ internal abstract class BlockValuePropertyValueEditorBase : Dat ILogger logger, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, - IIOHelper ioHelper) + IIOHelper ioHelper, + DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection) : base(textService, shortStringHelper, jsonSerializer, ioHelper, attribute) { _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; _logger = logger; + _dataValueReferenceFactoryCollection = dataValueReferenceFactoryCollection; } /// @@ -38,26 +41,31 @@ internal abstract class BlockValuePropertyValueEditorBase : Dat protected IEnumerable GetBlockValueReferences(TValue blockValue) { - var result = new List(); - - // loop through all content and settings data - foreach (BlockItemData row in blockValue.ContentData.Concat(blockValue.SettingsData)) + var result = new HashSet(); + BlockItemData.BlockPropertyValue[] propertyValues = blockValue.ContentData.Concat(blockValue.SettingsData) + .SelectMany(x => x.PropertyValues.Values).ToArray(); + foreach (IGrouping valuesByPropertyEditorAlias in propertyValues.GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { - foreach (KeyValuePair prop in row.PropertyValues) + if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + continue; + } - IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); - if (!(valueEditor is IDataValueReference reference)) + var districtValues = valuesByPropertyEditorAlias.Distinct().ToArray(); + + if (dataEditor.GetValueEditor() is IDataValueReference reference) + { + foreach (UmbracoEntityReference value in districtValues.SelectMany(reference.GetReferences)) { - continue; + result.Add(value); } + } - var val = prop.Value.Value?.ToString(); + IEnumerable references = _dataValueReferenceFactoryCollection.GetReferences(dataEditor, districtValues); - IEnumerable refs = reference.GetReferences(val); - - result.AddRange(refs); + foreach (UmbracoEntityReference value in references) + { + result.Add(value); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs index 2b4ac75042..ce867d29e4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ComplexPropertyEditorContentNotificationHandler.cs @@ -10,9 +10,16 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; +/// +/// Handles nested Udi keys when +/// - saving: Empty keys get generated +/// - copy: keys get replaced by new ones while keeping references intact +/// - scaffolding: keys get replaced by new ones while keeping references intact +/// public abstract class ComplexPropertyEditorContentNotificationHandler : INotificationHandler, - INotificationHandler + INotificationHandler, + INotificationHandler { protected abstract string EditorAlias { get; } @@ -31,6 +38,12 @@ public abstract class ComplexPropertyEditorContentNotificationHandler : } } + public void Handle(ContentScaffoldedNotification notification) + { + IEnumerable props = notification.Scaffold.GetPropertiesByEditor(EditorAlias); + UpdatePropertyValues(props, false); + } + protected abstract string FormatPropertyValue(string rawJson, bool onlyMissingKeys); private void UpdatePropertyValues(IEnumerable props, bool onlyMissingKeys) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 1105b76ac0..0a3f2454f7 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -91,9 +91,10 @@ public class NestedContentPropertyEditor : DataEditor internal class NestedContentPropertyValueEditor : DataValueEditor, IDataValueReference, IDataValueTags { private readonly IDataTypeService _dataTypeService; + private readonly PropertyEditorCollection _propertyEditors; + private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories; private readonly ILogger _logger; private readonly NestedContentValues _nestedContentValues; - private readonly PropertyEditorCollection _propertyEditors; public NestedContentPropertyValueEditor( IDataTypeService dataTypeService, @@ -102,16 +103,19 @@ public class NestedContentPropertyEditor : DataEditor IShortStringHelper shortStringHelper, DataEditorAttribute attribute, PropertyEditorCollection propertyEditors, + DataValueReferenceFactoryCollection dataValueReferenceFactories, ILogger logger, IJsonSerializer jsonSerializer, IIOHelper ioHelper, IPropertyValidationService propertyValidationService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { - _propertyEditors = propertyEditors; _dataTypeService = dataTypeService; + _propertyEditors = propertyEditors; + _dataValueReferenceFactories = dataValueReferenceFactories; _logger = logger; _nestedContentValues = new NestedContentValues(contentTypeService); + Validators.Add(new NestedContentValidator(propertyValidationService, _nestedContentValues, contentTypeService)); } @@ -139,66 +143,47 @@ public class NestedContentPropertyEditor : DataEditor } } + /// public IEnumerable GetReferences(object? value) { - var rawJson = value == null ? string.Empty : value is string str ? str : value.ToString(); - - var result = new List(); - - foreach (NestedContentValues.NestedContentRowValue row in _nestedContentValues.GetPropertyValues(rawJson)) + // Group by property editor alias to avoid duplicate lookups and optimize value parsing + foreach (var valuesByPropertyEditorAlias in GetAllPropertyValues(value).GroupBy(x => x.PropertyType.PropertyEditorAlias, x => x.Value)) { - foreach (KeyValuePair prop in - row.PropertyValues) + if (!_propertyEditors.TryGet(valuesByPropertyEditorAlias.Key, out IDataEditor? dataEditor)) { - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + continue; + } - IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); - if (!(valueEditor is IDataValueReference reference)) - { - continue; - } - - var val = prop.Value.Value?.ToString(); - - IEnumerable refs = reference.GetReferences(val); - - result.AddRange(refs); + // Use distinct values to avoid duplicate parsing of the same value + foreach (UmbracoEntityReference reference in _dataValueReferenceFactories.GetReferences(dataEditor, valuesByPropertyEditorAlias.Distinct())) + { + yield return reference; } } - - return result; } /// public IEnumerable GetTags(object? value, object? dataTypeConfiguration, int? languageId) { - IReadOnlyList rows = - _nestedContentValues.GetPropertyValues(value); - - var result = new List(); - - foreach (NestedContentValues.NestedContentRowValue row in rows.ToList()) + foreach (NestedContentValues.NestedContentPropertyValue propertyValue in GetAllPropertyValues(value)) { - foreach (KeyValuePair prop in row.PropertyValues - .ToList()) + if (!_propertyEditors.TryGet(propertyValue.PropertyType.PropertyEditorAlias, out IDataEditor? dataEditor) || + dataEditor.GetValueEditor() is not IDataValueTags dataValueTags) { - IDataEditor? propEditor = _propertyEditors[prop.Value.PropertyType.PropertyEditorAlias]; + continue; + } - IDataValueEditor? valueEditor = propEditor?.GetValueEditor(); - if (valueEditor is not IDataValueTags tagsProvider) - { - continue; - } - - object? configurationObject = _dataTypeService.GetDataType(prop.Value.PropertyType.DataTypeKey)?.ConfigurationObject; - - result.AddRange(tagsProvider.GetTags(prop.Value.Value, configurationObject, languageId)); + object? configurationObject = _dataTypeService.GetDataType(propertyValue.PropertyType.DataTypeKey)?.ConfigurationObject; + foreach (ITag tag in dataValueTags.GetTags(propertyValue.Value, configurationObject, languageId)) + { + yield return tag; } } - - return result; } + private IEnumerable GetAllPropertyValues(object? value) + => _nestedContentValues.GetPropertyValues(value).SelectMany(x => x.PropertyValues.Values); + #region DB to String public override string ConvertDbToString(IPropertyType propertyType, object? propertyValue) @@ -424,7 +409,8 @@ public class NestedContentPropertyEditor : DataEditor // set values to null row.PropertyValues[elementTypeProp.Alias] = new NestedContentValues.NestedContentPropertyValue { - PropertyType = elementTypeProp, Value = null, + PropertyType = elementTypeProp, + Value = null, }; row.RawPropertyValues[elementTypeProp.Alias] = null; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index d16239ce0e..39722b5314 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -189,8 +189,9 @@ public class RichTextPropertyEditor : DataEditor IHtmlSanitizer htmlSanitizer, IHtmlMacroParameterParser macroParameterParser, IContentTypeService contentTypeService, - IPropertyValidationService propertyValidationService) - : base(attribute, propertyEditors, dataTypeService, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper) + IPropertyValidationService propertyValidationService, + DataValueReferenceFactoryCollection dataValueReferenceFactoryCollection) + : base(attribute, propertyEditors, dataTypeService, localizedTextService, logger, shortStringHelper, jsonSerializer, ioHelper, dataValueReferenceFactoryCollection) { _backOfficeSecurityAccessor = backOfficeSecurityAccessor; _imageSourceParser = imageSourceParser; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs index c7fa4e4592..75851ee856 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RichTextParsingRegexes.cs @@ -4,6 +4,6 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters; internal static partial class RichTextParsingRegexes { - [GeneratedRegex(".[^\"]*)\"><\\/umb-rte-block(?:-inline)?>")] + [GeneratedRegex(".[^\"]*)\"><\\/umb-rte-block(?:-inline)?>")] public static partial Regex BlockRegex(); } diff --git a/src/Umbraco.Infrastructure/Templates/HtmlMacroParameterParser.cs b/src/Umbraco.Infrastructure/Templates/HtmlMacroParameterParser.cs index 5257b2f141..6c7445d2da 100644 --- a/src/Umbraco.Infrastructure/Templates/HtmlMacroParameterParser.cs +++ b/src/Umbraco.Infrastructure/Templates/HtmlMacroParameterParser.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Umbraco.Cms.Core; @@ -6,6 +7,7 @@ using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Macros; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Templates; @@ -15,12 +17,23 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser private readonly ILogger _logger; private readonly IMacroService _macroService; private readonly ParameterEditorCollection _parameterEditors; + private readonly DataValueReferenceFactoryCollection _dataValueReferenceFactories; + [Obsolete("Use the non-obsolete overload instead, scheduled for removal in v14")] public HtmlMacroParameterParser(IMacroService macroService, ILogger logger, ParameterEditorCollection parameterEditors) + : this( + macroService, + logger, + parameterEditors, + StaticServiceProvider.Instance.GetRequiredService()) + { } + + public HtmlMacroParameterParser(IMacroService macroService, ILogger logger, ParameterEditorCollection parameterEditors, DataValueReferenceFactoryCollection dataValueReferenceFactories) { _macroService = macroService; _logger = logger; _parameterEditors = parameterEditors; + _dataValueReferenceFactories = dataValueReferenceFactories; } /// @@ -41,6 +54,7 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser (macroAlias, macroAttributes) => foundMacros.Add(new Tuple>( macroAlias, new Dictionary(macroAttributes, StringComparer.OrdinalIgnoreCase)))); + foreach (UmbracoEntityReference umbracoEntityReference in GetUmbracoEntityReferencesFromMacros(foundMacros)) { yield return umbracoEntityReference; @@ -52,8 +66,7 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser /// /// /// - public IEnumerable FindUmbracoEntityReferencesFromGridControlMacros( - IEnumerable macroGridControls) + public IEnumerable FindUmbracoEntityReferencesFromGridControlMacros(IEnumerable macroGridControls) { var foundMacros = new List>>(); @@ -65,8 +78,7 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser // Collect any macro parameters that contain the media udi format if (gridMacro is not null && gridMacro.MacroParameters is not null && gridMacro.MacroParameters.Any()) { - foundMacros.Add( - new Tuple>(gridMacro.MacroAlias, gridMacro.MacroParameters)); + foundMacros.Add(new Tuple>(gridMacro.MacroAlias, gridMacro.MacroParameters)); } } @@ -101,14 +113,12 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser continue; } - foundMacroUmbracoEntityReferences.Add( - new UmbracoEntityReference(Udi.Create(Constants.UdiEntityType.Macro, macroConfig.Key))); + foundMacroUmbracoEntityReferences.Add(new UmbracoEntityReference(Udi.Create(Constants.UdiEntityType.Macro, macroConfig.Key))); // Only do this if the macros actually have parameters if (macroConfig.Properties.Keys.Any(f => f != "macroAlias")) { - foreach (UmbracoEntityReference umbracoEntityReference in GetUmbracoEntityReferencesFromMacroParameters( - macro.Item2, macroConfig, _parameterEditors)) + foreach (UmbracoEntityReference umbracoEntityReference in GetUmbracoEntityReferencesFromMacroParameters(macro.Item2, macroConfig, _parameterEditors)) { yield return umbracoEntityReference; } @@ -130,41 +140,23 @@ public sealed class HtmlMacroParameterParser : IHtmlMacroParameterParser /// look up the corresponding property editor for a macro parameter /// /// - private IEnumerable GetUmbracoEntityReferencesFromMacroParameters( - Dictionary macroParameters, IMacro macroConfig, ParameterEditorCollection parameterEditors) + private IEnumerable GetUmbracoEntityReferencesFromMacroParameters(Dictionary macroParameters, IMacro macroConfig, ParameterEditorCollection parameterEditors) { - var foundUmbracoEntityReferences = new List(); foreach (IMacroProperty parameter in macroConfig.Properties) { if (macroParameters.TryGetValue(parameter.Alias, out var parameterValue)) { var parameterEditorAlias = parameter.EditorAlias; - - // Lookup propertyEditor from the registered ParameterEditors with the implmementation to avoid looking up for each parameter - IDataEditor? parameterEditor = parameterEditors.FirstOrDefault(f => - string.Equals(f.Alias, parameterEditorAlias, StringComparison.OrdinalIgnoreCase)); + IDataEditor? parameterEditor = parameterEditors.FirstOrDefault(f => string.Equals(f.Alias, parameterEditorAlias, StringComparison.OrdinalIgnoreCase)); if (parameterEditor is not null) { - // Get the ParameterValueEditor for this PropertyEditor (where the GetReferences method is implemented) - cast as IDataValueReference to determine if 'it is' implemented for the editor - if (parameterEditor.GetValueEditor() is IDataValueReference parameterValueEditor) + foreach (UmbracoEntityReference entityReference in _dataValueReferenceFactories.GetReferences(parameterEditor, parameterValue)) { - foreach (UmbracoEntityReference entityReference in parameterValueEditor.GetReferences( - parameterValue)) - { - foundUmbracoEntityReferences.Add(entityReference); - } - } - else - { - _logger.LogInformation( - "{0} doesn't have a ValueEditor that implements IDataValueReference", - parameterEditor.Alias); + yield return entityReference; } } } } - - return foundUmbracoEntityReferences; } // Poco class to deserialise the Json for a Macro Control diff --git a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs index d854de4b95..6897f04d72 100644 --- a/src/Umbraco.PublishedCache.NuCache/ContentCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/ContentCache.cs @@ -36,7 +36,7 @@ public class ContentCache : PublishedCacheBase, IPublishedContentCache, INavigab IDomainCache domainCache, IOptions globalSettings, IVariationContextAccessor variationContextAccessor) - : base(previewDefault) + : base(variationContextAccessor, previewDefault) { _snapshot = snapshot; _snapshotCache = snapshotCache; diff --git a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs index 014140e884..516d2f555e 100644 --- a/src/Umbraco.PublishedCache.NuCache/MediaCache.cs +++ b/src/Umbraco.PublishedCache.NuCache/MediaCache.cs @@ -17,7 +17,7 @@ public class MediaCache : PublishedCacheBase, IPublishedMediaCache, INavigableDa #region Constructors public MediaCache(bool previewDefault, ContentStore.Snapshot snapshot, IVariationContextAccessor variationContextAccessor) - : base(previewDefault) + : base(variationContextAccessor, previewDefault) { _snapshot = snapshot; _variationContextAccessor = variationContextAccessor; diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index 2d9c4bc09a..d6d024325d 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Editors; using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Querying; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Routing; @@ -635,17 +636,33 @@ public class ContentController : ContentControllerBase [OutgoingEditorModelEvent] public ActionResult GetEmptyBlueprint(int blueprintId, int parentId) { - IContent? blueprint = _contentService.GetBlueprintById(blueprintId); - if (blueprint == null) + IContent? scaffold; + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { - return NotFound(); + IContent? blueprint = _contentService.GetBlueprintById(blueprintId); + if (blueprint is null) + { + return NotFound(); + } + scaffold = (IContent)blueprint.DeepClone(); + + scaffold.Id = 0; + scaffold.Name = string.Empty; + scaffold.ParentId = parentId; + + var scaffoldedNotification = new ContentScaffoldedNotification(blueprint, scaffold, parentId, new EventMessages()); + if (scope.Notifications.PublishCancelable(scaffoldedNotification)) + { + scope.Complete(); + return Problem("Scaffolding was cancelled"); + } + + scope.Complete(); } - blueprint.Id = 0; - blueprint.Name = string.Empty; - blueprint.ParentId = parentId; - ContentItemDisplay? mapped = _umbracoMapper.Map(blueprint); + + ContentItemDisplay? mapped = _umbracoMapper.Map(scaffold); if (mapped is not null) { diff --git a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs index 4969a4e5c9..8cbb4adb3e 100644 --- a/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs +++ b/src/Umbraco.Web.BackOffice/Security/BackOfficeSignInManager.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; @@ -39,9 +40,9 @@ public class BackOfficeSignInManager : UmbracoSignInManager confirmation, IEventAggregator eventAggregator, IOptions securitySettings, - IOptions backOfficeAuthenticationTypeSettings - ) - : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings) + IOptions backOfficeAuthenticationTypeSettings, + IRequestCache requestCache) + : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings, requestCache) { _userManager = userManager; _externalLogins = externalLogins; @@ -50,6 +51,36 @@ public class BackOfficeSignInManager : UmbracoSignInManager claimsFactory, + IOptions optionsAccessor, + IOptions globalSettings, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IEventAggregator eventAggregator, + IOptions securitySettings) + : this( + userManager, + contextAccessor, + externalLogins, + claimsFactory, + optionsAccessor, + globalSettings, + logger, + schemes, + confirmation, + eventAggregator, + securitySettings, + StaticServiceProvider.Instance.GetRequiredService>(), + StaticServiceProvider.Instance.GetRequiredService() + ) + { + } protected override string AuthenticationType => _backOfficeAuthenticationTypeSettings.Value.AuthenticationType; diff --git a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs index 9a8aaa72f4..1139c04270 100644 --- a/src/Umbraco.Web.Common/Security/MemberSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/MemberSignInManager.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; @@ -32,13 +33,41 @@ public class MemberSignInManager : UmbracoSignInManager, IMe IUserConfirmation confirmation, IMemberExternalLoginProviders memberExternalLoginProviders, IEventAggregator eventAggregator, - IOptions securitySettings) - : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings) + IOptions securitySettings, + IRequestCache requestCache) + : base(memberManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation, securitySettings, requestCache) { _memberExternalLoginProviders = memberExternalLoginProviders; _eventAggregator = eventAggregator; } + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V15.")] + public MemberSignInManager( + UserManager memberManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IMemberExternalLoginProviders memberExternalLoginProviders, + IEventAggregator eventAggregator, + IOptions securitySettings) + : this( + memberManager, + contextAccessor, + claimsFactory, + optionsAccessor, + logger, + schemes, + confirmation, + memberExternalLoginProviders, + eventAggregator, + securitySettings, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V14.")] public MemberSignInManager( UserManager memberManager, diff --git a/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs index 84cbce6d8d..f52db46241 100644 --- a/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs +++ b/src/Umbraco.Web.Common/Security/UmbracoSignInManager.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Security; @@ -19,6 +20,7 @@ namespace Umbraco.Cms.Web.Common.Security; public abstract class UmbracoSignInManager : SignInManager where TUser : UmbracoIdentityUser { + private readonly IRequestCache _requestCache; private SecuritySettings _securitySettings; // borrowed from https://github.com/dotnet/aspnetcore/blob/master/src/Identity/Core/src/SignInManager.cs @@ -44,7 +46,31 @@ public abstract class UmbracoSignInManager : SignInManager logger, schemes, confirmation, - StaticServiceProvider.Instance.GetRequiredService>()) + StaticServiceProvider.Instance.GetRequiredService>(), + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + [Obsolete("Use non-obsolete constructor. This is scheduled for removal in V15.")] + public UmbracoSignInManager( + UserManager userManager, + IHttpContextAccessor contextAccessor, + IUserClaimsPrincipalFactory claimsFactory, + IOptions optionsAccessor, + ILogger> logger, + IAuthenticationSchemeProvider schemes, + IUserConfirmation confirmation, + IOptions securitySettingsOptions) + : this( + userManager, + contextAccessor, + claimsFactory, + optionsAccessor, + logger, + schemes, + confirmation, + securitySettingsOptions, + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -56,9 +82,11 @@ public abstract class UmbracoSignInManager : SignInManager ILogger> logger, IAuthenticationSchemeProvider schemes, IUserConfirmation confirmation, - IOptions securitySettingsOptions) + IOptions securitySettingsOptions, + IRequestCache requestCache) : base(userManager, contextAccessor, claimsFactory, optionsAccessor, logger, schemes, confirmation) { + _requestCache = requestCache; _securitySettings = securitySettingsOptions.Value; } @@ -370,7 +398,13 @@ public abstract class UmbracoSignInManager : SignInManager if (_securitySettings.AllowConcurrentLogins is false) { - await UserManager.UpdateSecurityStampAsync(user); + + if (_requestCache.Get("SecurityStampUpdated") is null) + { + await UserManager.UpdateSecurityStampAsync(user); + _requestCache.Set("SecurityStampUpdated", true); + } + } Logger.LogInformation("User: {UserName} logged in from IP address {IpAddress}", username, Context.Connection.RemoteIpAddress); diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 9916f2b270..c5ba92ffc2 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -39,7 +39,7 @@ "ng-file-upload": "12.2.13", "nouislider": "15.7.1", "spectrum-colorpicker2": "2.0.10", - "tinymce": "6.8.1", + "tinymce": "6.8.2", "typeahead.js": "0.11.1", "underscore": "1.13.6", "wicg-inert": "3.1.2" @@ -16606,9 +16606,9 @@ "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" }, "node_modules/tinymce": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-6.8.1.tgz", - "integrity": "sha512-WYPvMXIjBrXM/oBiqGCbT2a8ptiO3TWXm/xxPWDCl8SxRKMW7Rfp0Lk190E9fXmX6uh9lJMRCnmKHzvryz0ftA==" + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/tinymce/-/tinymce-6.8.2.tgz", + "integrity": "sha512-Lho79o2Y1Yn+XdlTEkHTEkEmzwYWTXz7IUsvPwxJF3VTtgHUIAAuBab29kik+f2KED3rZvQavr9D7sHVMJ9x4A==" }, "node_modules/to-absolute-glob": { "version": "2.0.2", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index f23a0ab750..ec8ec22307 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -51,7 +51,7 @@ "ng-file-upload": "12.2.13", "nouislider": "15.7.1", "spectrum-colorpicker2": "2.0.10", - "tinymce": "6.8.1", + "tinymce": "6.8.2", "typeahead.js": "0.11.1", "underscore": "1.13.6", "wicg-inert": "3.1.2" diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js index 812fec6e9c..7821c00e69 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/grid/grid.rte.directive.js @@ -12,22 +12,18 @@ angular.module("umbraco.directives") replace: true, link: function (scope, element, attrs) { - // TODO: A lot of the code below should be shared between the grid rte and the normal rte - scope.isLoading = true; - var promises = []; - //To id the html textarea we need to use the datetime ticks because we can have multiple rte's per a single property alias // because now we have to support having 2x (maybe more at some stage) content editors being displayed at once. This is because // we have this mini content editor panel that can be launched with MNTP. scope.textAreaHtmlId = scope.uniqueId + "_" + String.CreateGuid(); - var editorConfig = scope.configuration ? scope.configuration : null; + let editorConfig = scope.configuration ? scope.configuration : null; if (!editorConfig || Utilities.isString(editorConfig)) { editorConfig = tinyMceService.defaultPrevalues(); //for the grid by default, we don't want to include the macro or the block-picker toolbar - editorConfig.toolbar = _.without(editorConfig, "umbmacro", "umbblockpicker"); + editorConfig.toolbar = _.without(editorConfig.toolbar, "umbmacro", "umbblockpicker"); } //ensure the grid's global config is being passed up to the RTE, these 2 properties need to be in this format @@ -39,46 +35,50 @@ angular.module("umbraco.directives") scope.dataTypeKey = scope.datatypeKey; //Yes - this casing is rediculous, but it's because the var starts with `data` so it can't be `data-type-id` :/ //stores a reference to the editor - var tinyMceEditor = null; + let tinyMceEditor = null; + + const assetPromises = []; //queue file loading tinyMceAssets.forEach(function (tinyJsAsset) { - promises.push(assetsService.loadJs(tinyJsAsset, scope)); + assetPromises.push(assetsService.loadJs(tinyJsAsset, scope)); }); - promises.push(tinyMceService.getTinyMceEditorConfig({ - htmlId: scope.textAreaHtmlId, - stylesheets: editorConfig.stylesheets, - toolbar: editorConfig.toolbar, - mode: editorConfig.mode - })); - $q.all(promises).then(function (result) { + //wait for assets to load before proceeding + $q.all(assetPromises) + .then(function () { + return tinyMceService.getTinyMceEditorConfig({ + htmlId: scope.textAreaHtmlId, + stylesheets: editorConfig.stylesheets, + toolbar: editorConfig.toolbar, + mode: editorConfig.mode + }) + }) - var standardConfig = result[promises.length - 1]; + // Handle additional assets loading depending on the configuration before initializing the editor + .then(function (tinyMceConfig) { + // Load the plugins.min.js file from the TinyMCE Cloud if a Cloud Api Key is specified + if (tinyMceConfig.cloudApiKey) { + return assetsService.loadJs(`https://cdn.tiny.cloud/1/${tinyMceConfig.cloudApiKey}/tinymce/${tinymce.majorVersion}.${tinymce.minorVersion}/plugins.min.js`) + .then(() => tinyMceConfig); + } + + return tinyMceConfig; + }) + + //wait for config to be ready after assets have loaded + .then(function (standardConfig) { //create a baseline Config to extend upon - var baseLineConfigObj = { - maxImageSize: editorConfig.maxImageSize, - toolbar_sticky: true + let baseLineConfigObj = { + maxImageSize: editorConfig.maxImageSize }; - Utilities.extend(baseLineConfigObj, standardConfig); - baseLineConfigObj.setup = function (editor) { //set the reference tinyMceEditor = editor; - //initialize the standard editor functionality for Umbraco - tinyMceService.initializeEditor({ - editor: editor, - toolbar: editorConfig.toolbar, - model: scope, - // Form is found in the scope of the grid controller above us, not in our isolated scope - // https://github.com/umbraco/Umbraco-CMS/issues/7461 - currentForm: angularHelper.getCurrentForm(scope.$parent) - }); - //custom initialization for this editor within the grid editor.on('init', function (e) { @@ -96,49 +96,52 @@ angular.module("umbraco.directives") }, 400); }); + + //initialize the standard editor functionality for Umbraco + tinyMceService.initializeEditor({ + editor: editor, + toolbar: editorConfig.toolbar, + model: scope, + // Form is found in the scope of the grid controller above us, not in our isolated scope + // https://github.com/umbraco/Umbraco-CMS/issues/7461 + currentForm: angularHelper.getCurrentForm(scope.$parent) + }); }; - /** Loads in the editor */ - function loadTinyMce() { - - //we need to add a timeout here, to force a redraw so TinyMCE can find - //the elements needed - $timeout(function () { - tinymce.init(baseLineConfigObj); - }, 150, false); - } - - loadTinyMce(); - - // TODO: This should probably be in place for all RTE, not just for the grid, which means - // this code can live in tinyMceService.initializeEditor - var tabShownListener = eventsService.on("app.tabChange", function (e, args) { - - var tabId = args.id; - var myTabId = element.closest(".umb-tab-pane").attr("rel"); - - if (String(tabId) === myTabId) { - //the tab has been shown, trigger the mceAutoResize (as it could have timed out before the tab was shown) - if (tinyMceEditor !== undefined && tinyMceEditor != null) { - tinyMceEditor.execCommand('mceAutoResize', false, null, null); - } - } - - }); - - //when the element is disposed we need to unsubscribe! - // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom - // element might still be there even after the modal has been hidden. - scope.$on('$destroy', function () { - eventsService.unsubscribe(tabShownListener); - //ensure we unbind this in case the blur doesn't fire above - if (tinyMceEditor !== undefined && tinyMceEditor != null) { - tinyMceEditor.destroy() - } - }); + Utilities.extend(baseLineConfigObj, standardConfig); + //we need to add a timeout here, to force a redraw so TinyMCE can find + //the elements needed + $timeout(function () { + tinymce.init(baseLineConfigObj); + }, 150); }); + const tabShownListener = eventsService.on("app.tabChange", function (e, args) { + + const tabId = String(args.id); + const myTabId = element.closest(".umb-tab-pane").attr("rel"); + + if (tabId === myTabId) { + //the tab has been shown, trigger the mceAutoResize (as it could have timed out before the tab was shown) + if (tinyMceEditor !== undefined && tinyMceEditor != null) { + tinyMceEditor.execCommand('mceAutoResize', false, null, null); + } + } + }); + + //when the element is disposed we need to unsubscribe! + // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom + // element might still be there even after the modal has been hidden. + scope.$on('$destroy', function () { + eventsService.unsubscribe(tabShownListener); + + //ensure we unbind this in case the blur doesn't fire above + if (tinyMceEditor) { + tinyMceEditor.destroy(); + tinyMceEditor = null; + } + }); } }; }); diff --git a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js index 8f447e3f23..ebf759c20c 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/tinymce.service.js @@ -543,10 +543,15 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s * @param {Object} editor the TinyMCE editor instance */ createInsertEmbeddedMedia: function (editor, callback) { - editor.ui.registry.addButton('umbembeddialog', { + editor.ui.registry.addToggleButton('umbembeddialog', { icon: 'embed', tooltip: 'Embed', - stateSelector: 'div[data-embed-url]', + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('div[data-embed-url]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + }, onAction: function () { // Get the selected element @@ -555,6 +560,12 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s var nodeName = selectedElm.nodeName; var modify = null; + // If we have an iframe, we need to get the parent element + if (nodeName.toUpperCase() === "IFRAME") { + selectedElm = selectedElm.parentElement; + nodeName = selectedElm.nodeName; + } + if (nodeName.toUpperCase() === "DIV" && selectedElm.classList.contains("embeditem")) { // See if we can go and get the attributes var embedUrl = editor.dom.getAttrib(selectedElm, "data-embed-url"); @@ -630,10 +641,15 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s * @param {Object} editor the TinyMCE editor instance */ createMediaPicker: function (editor, callback) { - editor.ui.registry.addButton('umbmediapicker', { + editor.ui.registry.addToggleButton('umbmediapicker', { icon: 'image', tooltip: 'Image Picker', - stateSelector: 'img[data-udi]', + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('img[data-udi]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + }, onAction: function () { var selectedElm = editor.selection.getNode(), @@ -781,10 +797,20 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s }); }); - editor.ui.registry.addButton('umbblockpicker', { + // Do not add any further controls if the block editor is not available + if (!blockEditorApi) { + return; + } + + editor.ui.registry.addToggleButton('umbblockpicker', { icon: 'visualblocks', tooltip: 'Insert Block', - stateSelector: 'umb-rte-block[data-content-udi], umb-rte-block-inline[data-content-udi]', + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('umb-rte-block[data-content-udi], umb-rte-block-inline[data-content-udi]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + }, onAction: function () { var blockEl = editor.selection.getNode(); @@ -900,9 +926,15 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s } /** Adds the button instance */ - editor.ui.registry.addButton('umbmacro', { + editor.ui.registry.addToggleButton('umbmacro', { icon: 'preferences', tooltip: 'Insert macro', + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('div.umb-macro-holder', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + }, /** The insert macro button click event handler */ onAction: function () { @@ -1207,32 +1239,47 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s }); } - editor.ui.registry.addButton('link', { + editor.ui.registry.addToggleButton('link', { icon: 'link', tooltip: 'Insert/edit link', shortcut: 'Ctrl+K', onAction: createLinkList(showDialog), - stateSelector: 'a[href]' + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('a[href]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + } }); - editor.ui.registry.addButton('unlink', { + editor.ui.registry.addToggleButton('unlink', { icon: 'unlink', tooltip: 'Remove link', onAction: () => { editor.execCommand('unlink'); }, - stateSelector: 'a[href]' + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('a[href]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + } }); editor.addShortcut('Ctrl+K', '', createLinkList(showDialog)); this.showDialog = showDialog; - editor.ui.registry.addMenuItem('link', { + editor.ui.registry.addToggleMenuItem('link', { icon: 'link', text: 'Insert link', shortcut: 'Ctrl+K', onAction: createLinkList(showDialog), - stateSelector: 'a[href]', + onSetup: function (api) { + const changed = editor.selection.selectorChangedWithUnbind('a[href]', (state) => + api.setActive(state) + ); + return () => changed.unbind(); + }, context: 'insert', prependToContext: true }); @@ -1439,6 +1486,10 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s function initBlocks() { + if(!args.blockEditorApi) { + return; + } + const blockEls = args.editor.contentDocument.querySelectorAll('umb-rte-block, umb-rte-block-inline'); for (var blockEl of blockEls) { if(!blockEl._isInitializedUmbBlock) { @@ -1708,7 +1759,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s }); //Create the insert media plugin - self.createMediaPicker(args.editor, function (currentTarget, userData, imgDomElement) { + self.createMediaPicker(args.editor, function (currentTarget, userData) { var startNodeId, startNodeIsVirtual; if (!args.model.config.startNodeId) { @@ -1732,7 +1783,7 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s startNodeIsVirtual: startNodeIsVirtual, dataTypeKey: args.model.dataTypeKey, submit: function (model) { - self.insertMediaInEditor(args.editor, model.selection[0], imgDomElement); + self.insertMediaInEditor(args.editor, model.selection[0]); editorService.close(); }, close: function () { @@ -1743,19 +1794,21 @@ function tinyMceService($rootScope, $q, imageHelper, $locale, $http, $timeout, s }); - //Create the insert block plugin - self.createBlockPicker(args.editor, args.blockEditorApi, function (currentTarget, userData, imgDomElement) { - args.blockEditorApi.showCreateDialog(0, false, (newBlock) => { - // TODO: Handle if its an array: - if(Utilities.isArray(newBlock)) { - newBlock.forEach(block => { - self.insertBlockInEditor(args.editor, block.layout.contentUdi, block.config.displayInline); - }); - } else { - self.insertBlockInEditor(args.editor, newBlock.layout.contentUdi, newBlock.config.displayInline); - } + if(args.blockEditorApi) { + //Create the insert block plugin + self.createBlockPicker(args.editor, args.blockEditorApi, function (currentTarget, userData, imgDomElement) { + args.blockEditorApi.showCreateDialog(0, false, (newBlock) => { + // TODO: Handle if its an array: + if(Utilities.isArray(newBlock)) { + newBlock.forEach(block => { + self.insertBlockInEditor(args.editor, block.layout.contentUdi, block.config.displayInline); + }); + } else { + self.insertBlockInEditor(args.editor, newBlock.layout.contentUdi, newBlock.config.displayInline); + } + }); }); - }); + } //Create the embedded plugin self.createInsertEmbeddedMedia(args.editor, function (activeElement, modify) { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/rte.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/rte.html index fb6f3b2a4b..2fbd0b2fe3 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/rte.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/grid/editors/rte.html @@ -4,8 +4,8 @@ configuration="model.config.rte" value="control.value" unique-id="control.$uniqueId" - datatype-key="{{model.dataTypeKey}}" - ignore-user-start-nodes="{{model.config.ignoreUserStartNodes}}"> + datatype-key="{{model.dataTypeKey}}" + ignore-user-start-nodes="{{model.config.ignoreUserStartNodes}}"> diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js index 2bb5b6c3a0..7f06215148 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js @@ -43,6 +43,7 @@ var vm = this; vm.readonly = false; + vm.noBlocksMode = false; vm.tinyMceEditor = null; $attrs.$observe('readonly', (value) => { @@ -102,58 +103,69 @@ var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "umbVariantContentController"); vm.umbVariantContent = found ? found.vm : null; if (!vm.umbVariantContent) { - throw "Could not find umbVariantContent in the $scope chain"; + //Could not find umbVariantContent in the $scope chain, lets go into no blocks mode: + vm.noBlocksMode = true; + vm.blocksLoading = false; + this.updateLoading(); } } + const config = vm.model.config || {}; + // set the onValueChanged callback, this will tell us if the block list model changed on the server // once the data is submitted. If so we need to re-initialize vm.model.onValueChanged = onServerValueChanged; - liveEditing = vm.model.config.useLiveEditing; + liveEditing = config.useLiveEditing; vm.listWrapperStyles = {}; - if (vm.model.config.maxPropertyWidth) { - vm.listWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth; + if (config.maxPropertyWidth) { + vm.listWrapperStyles['max-width'] = config.maxPropertyWidth; } - // We need to ensure that the property model value is an object, this is needed for modelObject to recive a reference and keep that updated. + // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated. ensurePropertyValue(vm.model.value); - var scopeOfExistence = $scope; - if (vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) { - scopeOfExistence = vm.umbVariantContentEditors.getScope(); - } else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) { - scopeOfExistence = vm.umbElementEditorContent.getScope(); + const assetPromises = []; + + if(vm.noBlocksMode !== true) { + + var scopeOfExistence = $scope; + if (vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) { + scopeOfExistence = vm.umbVariantContentEditors.getScope(); + } else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) { + scopeOfExistence = vm.umbElementEditorContent.getScope(); + } + + /* + copyAllBlocksAction = { + labelKey: "clipboard_labelForCopyAllEntries", + labelTokens: [vm.model.label], + icon: "icon-documents", + method: requestCopyAllBlocks, + isDisabled: true, + useLegacyIcon: false + }; + + deleteAllBlocksAction = { + labelKey: "clipboard_labelForRemoveAllEntries", + labelTokens: [], + icon: "icon-trash", + method: requestDeleteAllBlocks, + isDisabled: true, + useLegacyIcon: false + }; + + var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction]; + */ + + // Create Model Object, to manage our data for this Block Editor. + modelObject = blockEditorService.createModelObject(vm.model.value.blocks, vm.model.editor, config.blocks, scopeOfExistence, $scope); + const blockModelObjectLoading = modelObject.load(); + assetPromises.push(blockModelObjectLoading) + blockModelObjectLoading.then(onLoaded); } - /* - copyAllBlocksAction = { - labelKey: "clipboard_labelForCopyAllEntries", - labelTokens: [vm.model.label], - icon: "icon-documents", - method: requestCopyAllBlocks, - isDisabled: true, - useLegacyIcon: false - }; - - deleteAllBlocksAction = { - labelKey: "clipboard_labelForRemoveAllEntries", - labelTokens: [], - icon: "icon-trash", - method: requestDeleteAllBlocks, - isDisabled: true, - useLegacyIcon: false - }; - - var propertyActions = [copyAllBlocksAction, deleteAllBlocksAction]; - */ - - // Create Model Object, to manage our data for this Block Editor. - modelObject = blockEditorService.createModelObject(vm.model.value.blocks, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope); - const blockModelObjectLoading = modelObject.load() - blockModelObjectLoading.then(onLoaded); - // ******************** // // RTE PART: @@ -165,188 +177,175 @@ // we have this mini content editor panel that can be launched with MNTP. vm.textAreaHtmlId = vm.model.alias + "_" + String.CreateGuid(); - var editorConfig = vm.model.config ? vm.model.config.editor : null; + var editorConfig = config.editor ?? null; if (!editorConfig || Utilities.isString(editorConfig)) { editorConfig = tinyMceService.defaultPrevalues(); } + var width = editorConfig.dimensions ? parseInt(editorConfig.dimensions.width, 10) || null : null; var height = editorConfig.dimensions ? parseInt(editorConfig.dimensions.height, 10) || null : null; vm.containerWidth = "auto"; vm.containerHeight = "auto"; - vm.containerOverflow = "inherit"; - - const assetPromises = [blockModelObjectLoading]; + vm.containerOverflow = "inherit" //queue file loading tinyMceAssets.forEach(function (tinyJsAsset) { assetPromises.push(assetsService.loadJs(tinyJsAsset, $scope)); }); - const tinyMceConfigDeferred = $q.defer(); - //wait for assets to load before proceeding - $q.all(assetPromises).then(function () { - - tinyMceService.getTinyMceEditorConfig({ - htmlId: vm.textAreaHtmlId, - stylesheets: editorConfig.stylesheets, - toolbar: editorConfig.toolbar, - mode: editorConfig.mode + $q.all(assetPromises) + .then(function () { + return tinyMceService.getTinyMceEditorConfig({ + htmlId: vm.textAreaHtmlId, + stylesheets: editorConfig.stylesheets, + toolbar: editorConfig.toolbar, + mode: editorConfig.mode + }) }) - .then(function (tinyMceConfig) { - // Load the plugins.min.js file from the TinyMCE Cloud if a Cloud Api Key is specified - if (tinyMceConfig.cloudApiKey) { - return assetsService.loadJs(`https://cdn.tiny.cloud/1/${tinyMceConfig.cloudApiKey}/tinymce/${tinymce.majorVersion}.${tinymce.minorVersion}/plugins.min.js`) - .then(() => tinyMceConfig); + + // Handle additional assets loading depending on the configuration before initializing the editor + .then(function (tinyMceConfig) { + // Load the plugins.min.js file from the TinyMCE Cloud if a Cloud Api Key is specified + if (tinyMceConfig.cloudApiKey) { + return assetsService.loadJs(`https://cdn.tiny.cloud/1/${tinyMceConfig.cloudApiKey}/tinymce/${tinymce.majorVersion}.${tinymce.minorVersion}/plugins.min.js`) + .then(() => tinyMceConfig); + } + + return tinyMceConfig; + }) + + //wait for config to be ready after assets have loaded + .then(function (standardConfig) { + + if (height !== null) { + standardConfig.plugins.splice(standardConfig.plugins.indexOf("autoresize"), 1); } - return tinyMceConfig; - }) - .then(function (tinyMceConfig) { - tinyMceConfigDeferred.resolve(tinyMceConfig); - }); - }); + //create a baseline Config to extend upon + let baseLineConfigObj = { + maxImageSize: editorConfig.maxImageSize, + width: width, + height: height + }; - //wait for config to be ready after assets have loaded - tinyMceConfigDeferred.promise.then(function (standardConfig) { + baseLineConfigObj.setup = function (editor) { - if (height !== null) { - standardConfig.plugins.splice(standardConfig.plugins.indexOf("autoresize"), 1); - } + //set the reference + vm.tinyMceEditor = editor; - //create a baseline Config to extend upon - var baseLineConfigObj = { - maxImageSize: editorConfig.maxImageSize, - width: width, - height: height - }; - - baseLineConfigObj.setup = function (editor) { - - //set the reference - vm.tinyMceEditor = editor; - - vm.tinyMceEditor.on('init', function (e) { - $timeout(function () { - vm.rteLoading = false; - vm.updateLoading(); - }); - }); - vm.tinyMceEditor.on("focus", function () { - $element[0].dispatchEvent(new CustomEvent('umb-rte-focus', {composed: true, bubbles: true})); - }); - vm.tinyMceEditor.on("blur", function () { - $element[0].dispatchEvent(new CustomEvent('umb-rte-blur', {composed: true, bubbles: true})); - }); - - //initialize the standard editor functionality for Umbraco - tinyMceService.initializeEditor({ - //scope: $scope, - editor: editor, - toolbar: editorConfig.toolbar, - model: vm.model, - getValue: function () { - return vm.model.value.markup; - }, - setValue: function (newVal) { - vm.model.value.markup = newVal; - $scope.$evalAsync(); - }, - culture: vm.umbProperty?.culture ?? null, - segment: vm.umbProperty?.segment ?? null, - blockEditorApi: vm.blockEditorApi, - parentForm: vm.propertyForm, - valFormManager: vm.valFormManager, - currentFormInput: $scope.rteForm.modelValue - }); - - }; - - Utilities.extend(baseLineConfigObj, standardConfig); - - // Readonly mode - baseLineConfigObj.toolbar = vm.readonly ? false : baseLineConfigObj.toolbar; - baseLineConfigObj.readonly = vm.readonly ? 1 : baseLineConfigObj.readonly; - - // We need to wait for DOM to have rendered before we can find the element by ID. - $timeout(function () { - tinymce.init(baseLineConfigObj); - }, 50); - - //listen for formSubmitting event (the result is callback used to remove the event subscription) - unsubscribe.push($scope.$on("formSubmitting", function () { - if (vm.tinyMceEditor != null && !vm.rteLoading) { - - // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. - var blockElements = vm.tinyMceEditor.dom.select(`umb-rte-block, umb-rte-block-inline`); - const usedContentUdis = blockElements.map(blockElement => blockElement.getAttribute('data-content-udi')); - - const unusedBlocks = vm.layout.filter(x => usedContentUdis.indexOf(x.contentUdi) === -1); - unusedBlocks.forEach(blockLayout => { - deleteBlock(blockLayout.$block); - }); - - - // Remove Angular Classes from markup: - var parser = new DOMParser(); - var doc = parser.parseFromString(vm.model.value.markup, 'text/html'); - - // Get all elements in the parsed document - var elements = doc.querySelectorAll('*[class]'); - elements.forEach(element => { - var classAttribute = element.getAttribute("class"); - if (classAttribute) { - // Split the class attribute by spaces and remove "ng-scope" and "ng-isolate-scope" - var classes = classAttribute.split(" "); - var newClasses = classes.filter(function (className) { - return className !== "ng-scope" && className !== "ng-isolate-scope"; + vm.tinyMceEditor.on('init', function (e) { + $timeout(function () { + vm.rteLoading = false; + vm.updateLoading(); }); - - // Update the class attribute with the remaining classes - if (newClasses.length > 0) { - element.setAttribute('class', newClasses.join(' ')); - } else { - // If no remaining classes, remove the class attribute - element.removeAttribute('class'); - } - } + }); + vm.tinyMceEditor.on("focus", function () { + $element[0].dispatchEvent(new CustomEvent('umb-rte-focus', {composed: true, bubbles: true})); + }); + vm.tinyMceEditor.on("blur", function () { + $element[0].dispatchEvent(new CustomEvent('umb-rte-blur', {composed: true, bubbles: true})); }); - vm.model.value.markup = doc.body.innerHTML; + //initialize the standard editor functionality for Umbraco + tinyMceService.initializeEditor({ + //scope: $scope, + editor: editor, + toolbar: editorConfig.toolbar, + model: vm.model, + getValue: function () { + return vm.model.value.markup; + }, + setValue: function (newVal) { + vm.model.value.markup = newVal; + $scope.$evalAsync(); + }, + culture: vm.umbProperty?.culture ?? null, + segment: vm.umbProperty?.segment ?? null, + blockEditorApi: vm.noBlocksMode ? undefined : vm.blockEditorApi, + parentForm: vm.propertyForm, + valFormManager: vm.valFormManager, + currentFormInput: $scope.rteForm.modelValue + }); - } - })); + }; - vm.focusRTE = function () { - vm.tinyMceEditor.focus(); - } + Utilities.extend(baseLineConfigObj, standardConfig); + + // Readonly mode + baseLineConfigObj.toolbar = vm.readonly ? false : baseLineConfigObj.toolbar; + baseLineConfigObj.readonly = vm.readonly ? 1 : baseLineConfigObj.readonly; + + // We need to wait for DOM to have rendered before we can find the element by ID. + $timeout(function () { + tinymce.init(baseLineConfigObj); + }, 50); + + //listen for formSubmitting event (the result is callback used to remove the event subscription) + unsubscribe.push($scope.$on("formSubmitting", function () { + if (vm.tinyMceEditor != null && !vm.rteLoading) { + + // Remove unused Blocks of Blocks Layout. Leaving only the Blocks that are present in Markup. + var blockElements = vm.tinyMceEditor.dom.select(`umb-rte-block, umb-rte-block-inline`); + const usedContentUdis = blockElements.map(blockElement => blockElement.getAttribute('data-content-udi')); + + const unusedBlocks = vm.layout.filter(x => usedContentUdis.indexOf(x.contentUdi) === -1); + unusedBlocks.forEach(blockLayout => { + deleteBlock(blockLayout.$block); + }); + + + // Remove Angular Classes from markup: + var parser = new DOMParser(); + var doc = parser.parseFromString(vm.model.value.markup, 'text/html'); + + // Get all elements in the parsed document + var elements = doc.querySelectorAll('*[class]'); + elements.forEach(element => { + var classAttribute = element.getAttribute("class"); + if (classAttribute) { + // Split the class attribute by spaces and remove "ng-scope" and "ng-isolate-scope" + var classes = classAttribute.split(" "); + var newClasses = classes.filter(function (className) { + return className !== "ng-scope" && className !== "ng-isolate-scope"; + }); + + // Update the class attribute with the remaining classes + if (newClasses.length > 0) { + element.setAttribute('class', newClasses.join(' ')); + } else { + // If no remaining classes, remove the class attribute + element.removeAttribute('class'); + } + } + }); + + vm.model.value.markup = doc.body.innerHTML; - // When the element is disposed we need to unsubscribe! - // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom - // element might still be there even after the modal has been hidden. - $scope.$on('$destroy', function () { - if (vm.tinyMceEditor != null) { - if($element) { - $element[0]?.dispatchEvent(new CustomEvent('blur', {composed: true, bubbles: true})); } - vm.tinyMceEditor.destroy(); - vm.tinyMceEditor = null; - } - }); + })); - }); + }); }; + vm.focusRTE = function () { + if (vm.tinyMceEditor) { + vm.tinyMceEditor.focus(); + } + } + // Called when we save the value, the server may return an updated data and our value is re-synced // we need to deal with that here so that our model values are all in sync so we basically re-initialize. function onServerValueChanged(newVal, oldVal) { ensurePropertyValue(newVal); - modelObject.update(vm.model.value.blocks, $scope); + if(modelObject) { + modelObject.update(vm.model.value.blocks, $scope); + } onLoaded(); } @@ -965,6 +964,17 @@ for (const subscription of unsubscribe) { subscription(); } + + // When the element is disposed we need to unsubscribe! + // NOTE: this is very important otherwise if this is part of a modal, the listener still exists because the dom + // element might still be there even after the modal has been hidden. + if (vm.tinyMceEditor != null) { + if($element) { + $element[0]?.dispatchEvent(new CustomEvent('blur', {composed: true, bubbles: true})); + } + vm.tinyMceEditor.destroy(); + vm.tinyMceEditor = null; + } }); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs index 0e027326a6..f56c974f34 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs @@ -1,5 +1,4 @@ -using System.Linq; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Moq; using NUnit.Framework; using Umbraco.Cms.Core.IO; @@ -16,6 +15,7 @@ public class DataValueEditorReuseTests { private Mock _dataValueEditorFactoryMock; private PropertyEditorCollection _propertyEditorCollection; + private DataValueReferenceFactoryCollection _dataValueReferenceFactories; [SetUp] public void SetUp() @@ -32,6 +32,7 @@ public class DataValueEditorReuseTests Mock.Of())); _propertyEditorCollection = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)); + _dataValueReferenceFactories = new DataValueReferenceFactoryCollection(Enumerable.Empty); _dataValueEditorFactoryMock .Setup(m => @@ -40,6 +41,7 @@ public class DataValueEditorReuseTests new DataEditorAttribute("a", "b", "c"), new BlockListEditorDataConverter(Mock.Of()), _propertyEditorCollection, + _dataValueReferenceFactories, Mock.Of(), Mock.Of(), Mock.Of(), @@ -95,7 +97,7 @@ public class DataValueEditorReuseTests { var blockListPropertyEditor = new BlockListPropertyEditor( _dataValueEditorFactoryMock.Object, - new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)), + _propertyEditorCollection, Mock.Of(), Mock.Of(), Mock.Of(), @@ -117,7 +119,7 @@ public class DataValueEditorReuseTests { var blockListPropertyEditor = new BlockListPropertyEditor( _dataValueEditorFactoryMock.Object, - new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)), + _propertyEditorCollection, Mock.Of(), Mock.Of(), Mock.Of(), diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionTests.cs index cff072873f..38fc5125dc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueReferenceFactoryCollectionTests.cs @@ -176,14 +176,11 @@ public class DataValueReferenceFactoryCollectionTests { var collection = new DataValueReferenceFactoryCollection(Enumerable.Empty); var propertyEditors = new PropertyEditorCollection(new DataEditorCollection(Enumerable.Empty)); - var properties = new PropertyCollection(); - var resultA = collection.GetAutomaticRelationTypesAliases(propertyEditors).ToArray(); - var resultB = collection.GetAutomaticRelationTypesAliases(properties, propertyEditors).ToArray(); + var result = collection.GetAllAutomaticRelationTypesAliases(propertyEditors).ToArray(); var expected = Constants.Conventions.RelationTypes.AutomaticRelationTypes; - CollectionAssert.AreEquivalent(expected, resultA, "Result A does not contain the expected relation type aliases."); - CollectionAssert.AreEquivalent(expected, resultB, "Result B does not contain the expected relation type aliases."); + CollectionAssert.AreEquivalent(expected, result, "Result does not contain the expected relation type aliases."); } [Test] @@ -194,15 +191,11 @@ public class DataValueReferenceFactoryCollectionTests var labelPropertyEditor = new LabelPropertyEditor(DataValueEditorFactory, IOHelper, EditorConfigurationParser); var propertyEditors = new PropertyEditorCollection(new DataEditorCollection(() => labelPropertyEditor.Yield())); var serializer = new ConfigurationEditorJsonSerializer(); - var property = new Property(new PropertyType(ShortStringHelper, new DataType(labelPropertyEditor, serializer))); - var properties = new PropertyCollection { property, property }; // Duplicate on purpose to test distinct aliases - var resultA = collection.GetAutomaticRelationTypesAliases(propertyEditors).ToArray(); - var resultB = collection.GetAutomaticRelationTypesAliases(properties, propertyEditors).ToArray(); + var result = collection.GetAllAutomaticRelationTypesAliases(propertyEditors).ToArray(); var expected = Constants.Conventions.RelationTypes.AutomaticRelationTypes.Append("umbTest"); - CollectionAssert.AreEquivalent(expected, resultA, "Result A does not contain the expected relation type aliases."); - CollectionAssert.AreEquivalent(expected, resultB, "Result B does not contain the expected relation type aliases."); + CollectionAssert.AreEquivalent(expected, result, "Result does not contain the expected relation type aliases."); } private class TestDataValueReferenceFactory : IDataValueReferenceFactory diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs index 5394de5fd7..b80555bd43 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Security/MemberSignInManagerTests.cs @@ -10,6 +10,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Net; @@ -71,7 +72,8 @@ public class MemberSignInManagerTests Mock.Of>(), Mock.Of(), Mock.Of(), - Mock.Of>(x => x.Value == new SecuritySettings())); + Mock.Of>(x => x.Value == new SecuritySettings()), + new DictionaryAppCache()); } private static Mock MockMemberManager()