diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 57d486d467..77d814579c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -392,6 +392,7 @@ namespace Umbraco.Cms.Core.DependencyInjection // Two factor providers Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); // Add Query services Services.AddUnique(); diff --git a/src/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeys.cs b/src/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeys.cs index a6b6c16aa5..aca68e9762 100644 --- a/src/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeys.cs +++ b/src/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeys.cs @@ -1,3 +1,5 @@ +using System.Runtime.InteropServices; + namespace Umbraco.Cms.Core.Persistence.Repositories; /// @@ -5,15 +7,34 @@ namespace Umbraco.Cms.Core.Persistence.Repositories; /// public static class RepositoryCacheKeys { - // used to cache keys so we don't keep allocating strings + /// + /// A cache for the keys we don't keep allocating strings. + /// private static readonly Dictionary Keys = new(); + /// + /// Gets the repository cache key for the provided type. + /// public static string GetKey() { Type type = typeof(T); - return Keys.TryGetValue(type, out var key) ? key : Keys[type] = "uRepo_" + type.Name + "_"; + + // The following code is a micro-optimization to avoid an unnecessary lookup in the Keys dictionary, when writing the newly created key. + // Previously, the code was: + // return Keys.TryGetValue(type, out var key) + // ? key + // : Keys[type] = "uRepo_" + type.Name + "_"; + + // Look up the existing value or get a reference to the newly created default value. + ref string? key = ref CollectionsMarshal.GetValueRefOrAddDefault(Keys, type, out _); + + // As we have the reference, we can just assign it if null, without the expensive write back to the dictionary. + return key ??= "uRepo_" + type.Name + "_"; } + /// + /// Gets the repository cache key for the provided type and Id. + /// public static string GetKey(TId? id) { if (EqualityComparer.Default.Equals(id, default)) diff --git a/src/Umbraco.Core/Scoping/LockingMechanism.cs b/src/Umbraco.Core/Scoping/LockingMechanism.cs index 0cee4293f6..e078b047e6 100644 --- a/src/Umbraco.Core/Scoping/LockingMechanism.cs +++ b/src/Umbraco.Core/Scoping/LockingMechanism.cs @@ -1,3 +1,4 @@ +using System.Runtime.InteropServices; using System.Text; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Collections; @@ -7,7 +8,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Core.Scoping; /// -/// Mechanism for handling read and write locks +/// Mechanism for handling read and write locks. /// public class LockingMechanism : ILockingMechanism { @@ -189,24 +190,43 @@ public class LockingMechanism : ILockingMechanism /// Lock ID to increment. /// Instance ID of the scope requesting the lock. /// Reference to the dictionary to increment on - private void IncrementLock(int lockId, Guid instanceId, ref Dictionary>? locks) + /// Internal for tests. + internal static void IncrementLock(int lockId, Guid instanceId, ref Dictionary>? locks) { // Since we've already checked that we're the parent in the WriteLockInner method, we don't need to check again. - // If it's the very first time a lock has been requested the WriteLocks dict hasn't been instantiated yet. - locks ??= new Dictionary>(); + // If it's the very first time a lock has been requested the WriteLocks dictionary hasn't been instantiated yet. + locks ??= []; - // Try and get the dict associated with the scope id. - var locksDictFound = locks.TryGetValue(instanceId, out Dictionary? locksDict); + // Try and get the dictionary associated with the scope id. + + // The following code is a micro-optimization. + // GetValueRefOrAddDefault does lookup or creation with only one hash key generation, internal bucket lookup and value lookup in the bucket. + // This compares to doing it twice when initializing, one for the lookup and one for the insertion of the initial value, we had with the + // previous code: + // var locksDictFound = locks.TryGetValue(instanceId, out Dictionary? locksDict); + // if (locksDictFound) + // { + // locksDict!.TryGetValue(lockId, out var value); + // locksDict[lockId] = value + 1; + // } + // else + // { + // // The scope hasn't requested a lock yet, so we have to create a dict for it. + // locks.Add(instanceId, new Dictionary()); + // locks[instanceId][lockId] = 1; + // } + + ref Dictionary? locksDict = ref CollectionsMarshal.GetValueRefOrAddDefault(locks, instanceId, out bool locksDictFound); if (locksDictFound) { - locksDict!.TryGetValue(lockId, out var value); - locksDict[lockId] = value + 1; + // By getting a reference to any existing or default 0 value, we can increment it without the expensive write back into the dictionary. + ref int value = ref CollectionsMarshal.GetValueRefOrAddDefault(locksDict!, lockId, out _); + value++; } else { - // The scope hasn't requested a lock yet, so we have to create a dict for it. - locks.Add(instanceId, new Dictionary()); - locks[instanceId][lockId] = 1; + // The scope hasn't requested a lock yet, so we have to create a dictionary for it. + locksDict = new Dictionary { { lockId, 1 } }; } } diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index de47ce762d..75e26358a0 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -2491,8 +2492,8 @@ public class ContentService : RepositoryService, IContentService throw new InvalidOperationException("Parent does not exist or is trashed."); // causes rollback } - // FIXME: Use MoveEventInfo that also takes a parent key when implementing move with parentKey. - var moveEventInfo = new MoveEventInfo(content, content.Path, parentId); + TryGetParentKey(parentId, out Guid? parentKey); + var moveEventInfo = new MoveEventInfo(content, content.Path, parentId, parentKey); var movingNotification = new ContentMovingNotification(moveEventInfo, eventMessages); if (scope.Notifications.PublishCancelable(movingNotification)) @@ -2522,9 +2523,12 @@ public class ContentService : RepositoryService, IContentService new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshBranch, eventMessages)); // changes - // FIXME: Use MoveEventInfo that also takes a parent key when implementing move with parentKey. MoveEventInfo[] moveInfo = moves - .Select(x => new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId)) + .Select(x => + { + TryGetParentKey(x.Item1.ParentId, out Guid? itemParentKey); + return new MoveEventInfo(x.Item1, x.Item2, x.Item1.ParentId, itemParentKey); + }) .ToArray(); scope.Notifications.Publish( @@ -2704,8 +2708,8 @@ public class ContentService : RepositoryService, IContentService using (ICoreScope scope = ScopeProvider.CreateCoreScope()) { - // FIXME: Pass parent key in constructor too when proper Copy method is implemented - if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(content, copy, parentId, eventMessages))) + TryGetParentKey(parentId, out Guid? parentKey); + if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(content, copy, parentId, parentKey, eventMessages))) { scope.Complete(); return null; @@ -2777,8 +2781,7 @@ public class ContentService : RepositoryService, IContentService IContent descendantCopy = descendant.DeepCloneWithResetIdentities(); descendantCopy.ParentId = parentId; - // FIXME: Pass parent key in constructor too when proper Copy method is implemented - if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(descendant, descendantCopy, parentId, eventMessages))) + if (scope.Notifications.PublishCancelable(new ContentCopyingNotification(descendant, descendantCopy, parentId, parentKey, eventMessages))) { continue; } @@ -2815,8 +2818,7 @@ public class ContentService : RepositoryService, IContentService new ContentTreeChangeNotification(copy, TreeChangeTypes.RefreshBranch, eventMessages)); foreach (Tuple x in CollectionsMarshal.AsSpan(copies)) { - // FIXME: Pass parent key in constructor too when proper Copy method is implemented - scope.Notifications.Publish(new ContentCopiedNotification(x.Item1, x.Item2, parentId, relateToOriginal, eventMessages)); + scope.Notifications.Publish(new ContentCopiedNotification(x.Item1, x.Item2, parentId, parentKey, relateToOriginal, eventMessages)); } Audit(AuditType.Copy, userId, content.Id); @@ -2827,6 +2829,13 @@ public class ContentService : RepositoryService, IContentService return copy; } + private bool TryGetParentKey(int parentId, [NotNullWhen(true)] out Guid? parentKey) + { + Attempt parentKeyAttempt = _idKeyMap.GetKeyForId(parentId, UmbracoObjectTypes.Document); + parentKey = parentKeyAttempt.Success ? parentKeyAttempt.Result : null; + return parentKeyAttempt.Success; + } + /// /// Sends an to Publication, which executes handlers and events for the 'Send to Publication' /// action. diff --git a/src/Umbraco.Core/Services/IMemberTwoFactorLoginService.cs b/src/Umbraco.Core/Services/IMemberTwoFactorLoginService.cs new file mode 100644 index 0000000000..3a7ec99c8e --- /dev/null +++ b/src/Umbraco.Core/Services/IMemberTwoFactorLoginService.cs @@ -0,0 +1,37 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +/// A member specific Two factor service, that ensures the member exists before doing the job. +/// +public interface IMemberTwoFactorLoginService +{ + /// + /// Disables a specific two factor provider on a specific member. + /// + Task> DisableAsync(Guid memberKey, string providerName); + + /// + /// Gets the two factor providers on a specific member. + /// + Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid memberKey); + + /// + /// The returned type can be anything depending on the setup providers. You will need to cast it to the type handled by + /// the provider. + /// + Task> GetSetupInfoAsync(Guid memberKey, string providerName); + + /// + /// Validates and Saves. + /// + Task> ValidateAndSaveAsync(string providerName, Guid memberKey, string modelSecret, string modelCode); + + /// + /// Disables 2FA with Code. + /// + Task> DisableByCodeAsync(string providerName, Guid memberKey, string code); +} diff --git a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs index 91e3299a0b..01558376e3 100644 --- a/src/Umbraco.Core/Services/ITwoFactorLoginService.cs +++ b/src/Umbraco.Core/Services/ITwoFactorLoginService.cs @@ -22,6 +22,16 @@ public interface ITwoFactorLoginService : IService /// Task GetSecretForUserAndProviderAsync(Guid userOrMemberKey, string providerName); + /// + /// Gets the setup info for a specific user or member and a specific provider. + /// + /// + /// The returned type can be anything depending on the setup providers. You will need to cast it to the type handled by + /// the provider. + /// + [Obsolete("Use IUserTwoFactorLoginService.GetSetupInfoAsync or IMemberTwoFactorLoginService.GetSetupInfoAsync. Scheduled for removal in Umbraco 16.")] + Task GetSetupInfoAsync(Guid userOrMemberKey, string providerName); + /// /// Gets all registered providers names. /// @@ -46,4 +56,17 @@ public interface ITwoFactorLoginService : IService /// Gets all the enabled 2FA providers for the user or member with the specified key. /// Task> GetEnabledTwoFactorProviderNamesAsync(Guid userOrMemberKey); + + /// + /// Disables 2FA with Code. + /// + [Obsolete("Use IUserTwoFactorLoginService.DisableByCodeAsync or IMemberTwoFactorLoginService.DisableByCodeAsync. Scheduled for removal in Umbraco 16.")] + Task DisableWithCodeAsync(string providerName, Guid userOrMemberKey, string code); + + /// + /// Validates and Saves. + /// + [Obsolete("Use IUserTwoFactorLoginService.ValidateAndSaveAsync or IMemberTwoFactorLoginService.ValidateAndSaveAsync. Scheduled for removal in Umbraco 16.")] + Task ValidateAndSaveAsync(string providerName, Guid userKey, string secret, string code); + } diff --git a/src/Umbraco.Core/Services/MemberTwoFactorLoginService.cs b/src/Umbraco.Core/Services/MemberTwoFactorLoginService.cs new file mode 100644 index 0000000000..b21558b834 --- /dev/null +++ b/src/Umbraco.Core/Services/MemberTwoFactorLoginService.cs @@ -0,0 +1,72 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Services; + +/// +internal class MemberTwoFactorLoginService : TwoFactorLoginServiceBase, IMemberTwoFactorLoginService +{ + private readonly IMemberService _memberService; + + public MemberTwoFactorLoginService( + ITwoFactorLoginService twoFactorLoginService, + IEnumerable twoFactorSetupGenerators, + IMemberService memberService, + ICoreScopeProvider scopeProvider) + : base(twoFactorLoginService, twoFactorSetupGenerators, scopeProvider) => + _memberService = memberService; + + /// + public override async Task> DisableAsync(Guid memberKey, string providerName) + { + IMember? member = _memberService.GetByKey(memberKey); + + if (member is null) + { + return Attempt.Fail(TwoFactorOperationStatus.UserNotFound); + } + + return await base.DisableAsync(memberKey, providerName); + } + + /// + public override async Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid memberKey) + { + IMember? member = _memberService.GetByKey(memberKey); + + if (member is null) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.UserNotFound, Enumerable.Empty()); + } + + return await base.GetProviderNamesAsync(memberKey); + } + + /// + public override async Task> GetSetupInfoAsync(Guid memberKey, string providerName) + { + IMember? member = _memberService.GetByKey(memberKey); + + if (member is null) + { + return Attempt.FailWithStatus(TwoFactorOperationStatus.UserNotFound, new NoopSetupTwoFactorModel()); + } + + return await base.GetSetupInfoAsync(memberKey, providerName); + } + + /// + public override async Task> ValidateAndSaveAsync(string providerName, Guid memberKey, string secret, string code) + { + IMember? member = _memberService.GetByKey(memberKey); + + if (member is null) + { + return Attempt.Fail(TwoFactorOperationStatus.UserNotFound); + } + + return await base.ValidateAndSaveAsync(providerName, memberKey, secret, code); + } +} diff --git a/src/Umbraco.Core/Services/UserService.cs b/src/Umbraco.Core/Services/UserService.cs index cd9acf2bfa..e928ec16a0 100644 --- a/src/Umbraco.Core/Services/UserService.cs +++ b/src/Umbraco.Core/Services/UserService.cs @@ -1288,7 +1288,6 @@ internal partial class UserService : RepositoryService, IUserService includedUserGroupAliases = userGroupKeyConversionAttempt.Result.ToArray(); } - if (mergedFilter.NameFilters is not null) { foreach (var nameFilter in mergedFilter.NameFilters) @@ -1307,17 +1306,19 @@ internal partial class UserService : RepositoryService, IUserService } else { - includeUserStates = new HashSet(filter.IncludeUserStates!); - includeUserStates.IntersectWith(baseFilter.IncludeUserStates); + includeUserStates = new HashSet(baseFilter.IncludeUserStates); + if (filter.IncludeUserStates is not null && filter.IncludeUserStates.Contains(UserState.All) is false) + { + includeUserStates.IntersectWith(filter.IncludeUserStates); + } // This means that we've only chosen to include a user state that is not allowed, so we'll return an empty result - if(includeUserStates.Count == 0) + if (includeUserStates.Count == 0) { return Attempt.SucceedWithStatus(UserOperationStatus.Success, new PagedModel()); } } - PaginationHelper.ConvertSkipTakeToPaging(skip, take, out long pageNumber, out int pageSize); Expression> orderByExpression = GetOrderByExpression(orderBy); diff --git a/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs b/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs index 89241d31f2..10a9de140f 100644 --- a/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs +++ b/src/Umbraco.Core/Services/UserTwoFactorLoginService.cs @@ -32,7 +32,7 @@ internal class UserTwoFactorLoginService : TwoFactorLoginServiceBase, IUserTwoFa return await base.DisableAsync(userKey, providerName); } - /// + /// public override async Task, TwoFactorOperationStatus>> GetProviderNamesAsync(Guid userKey) { IUser? user = await _userService.GetAsync(userKey); @@ -45,7 +45,7 @@ internal class UserTwoFactorLoginService : TwoFactorLoginServiceBase, IUserTwoFa return await base.GetProviderNamesAsync(userKey); } - /// + /// public override async Task> GetSetupInfoAsync(Guid userKey, string providerName) { IUser? user = await _userService.GetAsync(userKey); @@ -58,7 +58,7 @@ internal class UserTwoFactorLoginService : TwoFactorLoginServiceBase, IUserTwoFa return await base.GetSetupInfoAsync(userKey, providerName); } - /// + /// public override async Task> ValidateAndSaveAsync(string providerName, Guid userKey, string secret, string code) { IUser? user = await _userService.GetAsync(userKey); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index c94cc9ce45..c9ea02414b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -156,6 +156,12 @@ public abstract class BlockValuePropertyValueEditorBase : DataV foreach (BlockItemData item in items) { + // if changes were made to the element type variations, we need those changes reflected in the block property values. + // for regular content this happens when a content type is saved (copies of property values are created in the DB), + // but for local block level properties we don't have that kind of handling, so we to do it manually. + // to be friendly we'll map "formerly invariant properties" to the default language ISO code instead of performing a + // hard reset of the property values (which would likely be the most correct thing to do from a data point of view). + item.Values = _blockEditorVarianceHandler.AlignPropertyVarianceAsync(item.Values, culture).GetAwaiter().GetResult(); foreach (BlockPropertyValue blockPropertyValue in item.Values) { IPropertyType? propertyType = blockPropertyValue.PropertyType; @@ -171,13 +177,6 @@ public abstract class BlockValuePropertyValueEditorBase : DataV continue; } - // if changes were made to the element type variation, we need those changes reflected in the block property values. - // for regular content this happens when a content type is saved (copies of property values are created in the DB), - // but for local block level properties we don't have that kind of handling, so we to do it manually. - // to be friendly we'll map "formerly invariant properties" to the default language ISO code instead of performing a - // hard reset of the property values (which would likely be the most correct thing to do from a data point of view). - _blockEditorVarianceHandler.AlignPropertyVarianceAsync(blockPropertyValue, propertyType, culture).GetAwaiter().GetResult(); - if (!valueEditorsByKey.TryGetValue(propertyType.DataTypeKey, out IDataValueEditor? valueEditor)) { var configuration = _dataTypeConfigurationCache.GetConfiguration(propertyType.DataTypeKey); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockEditorVarianceHandler.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockEditorVarianceHandler.cs index 93bb0cc1a2..096221e6df 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockEditorVarianceHandler.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockEditorVarianceHandler.cs @@ -17,15 +17,7 @@ public sealed class BlockEditorVarianceHandler _contentTypeService = contentTypeService; } - /// - /// Aligns a block property value for variance changes. - /// - /// The block property value to align. - /// The underlying property type. - /// The culture being handled (null if invariant). - /// - /// Used for aligning variance changes when editing content. - /// + [Obsolete("Please use the method that allows alignment for a collection of values. Scheduled for removal in V17.")] public async Task AlignPropertyVarianceAsync(BlockPropertyValue blockPropertyValue, IPropertyType propertyType, string? culture) { culture ??= await _languageService.GetDefaultIsoCodeAsync(); @@ -37,6 +29,48 @@ public sealed class BlockEditorVarianceHandler } } + /// + /// Aligns a collection of block property values for variance changes. + /// + /// The block property values to align. + /// The culture being handled (null if invariant). + /// + /// Used for aligning variance changes when editing content. + /// + public async Task> AlignPropertyVarianceAsync(IList blockPropertyValues, string? culture) + { + var defaultIsoCodeAsync = await _languageService.GetDefaultIsoCodeAsync(); + culture ??= defaultIsoCodeAsync; + + var valuesToRemove = new List(); + foreach (BlockPropertyValue blockPropertyValue in blockPropertyValues) + { + IPropertyType? propertyType = blockPropertyValue.PropertyType; + if (propertyType is null) + { + throw new ArgumentException("One or more block properties did not have a resolved property type. Block editor values must be resolved before attempting to map them to editor.", nameof(blockPropertyValues)); + } + + if (propertyType.VariesByCulture() == VariesByCulture(blockPropertyValue)) + { + continue; + } + + if (propertyType.VariesByCulture() is false && blockPropertyValue.Culture.InvariantEquals(defaultIsoCodeAsync) is false) + { + valuesToRemove.Add(blockPropertyValue); + } + else + { + blockPropertyValue.Culture = propertyType.VariesByCulture() + ? culture + : null; + } + } + + return blockPropertyValues.Except(valuesToRemove).ToList(); + } + /// /// Aligns a block property value for variance changes. /// @@ -191,6 +225,8 @@ public sealed class BlockEditorVarianceHandler blockValue.Expose.Add(new BlockItemVariation(contentData.Key, value.Culture, value.Segment)); } } + + blockValue.Expose = blockValue.Expose.DistinctBy(e => $"{e.ContentKey}.{e.Culture}.{e.Segment}").ToList(); } private static bool VariesByCulture(BlockPropertyValue blockPropertyValue) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index 57f555f405..82ad6b85d8 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -1693,16 +1693,6 @@ export default { compositionUsageHeading: 'Where is this composition used?', compositionUsageSpecification: 'This composition is currently used in the composition of the following\n Content Types:\n ', - variantsHeading: 'Allow variations', - cultureVariantHeading: 'Allow vary by culture', - segmentVariantHeading: 'Allow segmentation', - cultureVariantLabel: 'Vary by culture', - segmentVariantLabel: 'Vary by segments', - variantsDescription: 'Allow editors to create content of this type in different languages.', - cultureVariantDescription: 'Allow editors to create content of different languages.', - segmentVariantDescription: 'Allow editors to create segments of this content.', - allowVaryByCulture: 'Allow varying by culture', - allowVaryBySegment: 'Allow segmentation', elementType: 'Element Type', elementHeading: 'Is an Element Type', elementDescription: diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts index 716018a489..882171cd57 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1732,11 +1732,11 @@ export default { compositionUsageHeading: 'Where is this composition used?', compositionUsageSpecification: 'This composition is currently used in the composition of the following\n Content Types:\n ', - variantsHeading: 'Allow variations', + variantsHeading: 'Variation', cultureVariantHeading: 'Allow vary by culture', segmentVariantHeading: 'Allow segmentation', cultureVariantLabel: 'Vary by culture', - segmentVariantLabel: 'Vary by segments', + segmentVariantLabel: 'Vary by segment', variantsDescription: 'Allow editors to create content of this type in different languages.', cultureVariantDescription: 'Allow editors to create content of different languages.', segmentVariantDescription: 'Allow editors to create segments of this content.', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts index 63ef5a5567..5b8cf05294 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/views/design/content-type-design-editor-property.element.ts @@ -299,6 +299,11 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { ${this.localize.term('contentTypeEditor_cultureVariantLabel')} ` : nothing} + ${this.property.variesBySegment + ? html` + ${this.localize.term('contentTypeEditor_segmentVariantLabel')} + ` + : nothing} ${this.property.appearance?.labelOnTop == true ? html` ${this.localize.term('contentTypeEditor_displaySettingsLabelOnTop')} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/views/settings/property-workspace-view-settings.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/views/settings/property-workspace-view-settings.element.ts index 56db8cbf59..8c069f12d1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/views/settings/property-workspace-view-settings.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-type/workspace/views/settings/property-workspace-view-settings.element.ts @@ -203,9 +203,12 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i #onVaryByCultureChange(event: UUIBooleanInputEvent) { const variesByCulture = event.target.checked; - this.updateValue({ - variesByCulture, - }); + this.updateValue({ variesByCulture }); + } + + #onVaryBySegmentChange(event: UUIBooleanInputEvent) { + const variesBySegment = event.target.checked; + this.updateValue({ variesBySegment }); } override render() { @@ -267,14 +270,11 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i

${this.#renderCustomValidation()} -
${this.#renderVariationControls()} -
- - Appearance - -
${this.#renderAlignLeftIcon()} ${this.#renderAlignTopIcon()}
-
+ +
${this.#renderAlignLeftIcon()} ${this.#renderAlignTopIcon()}
+
+ ${this.#renderMemberTypeOptions()} `; @@ -405,18 +405,35 @@ export class UmbPropertyTypeWorkspaceViewSettingsElement extends UmbLitElement i #renderVariationControls() { return this._contentTypeVariesByCulture || this._contentTypeVariesBySegment - ? html`
- Allow variations - ${this._contentTypeVariesByCulture ? this.#renderVaryByCulture() : ''} -
-
` + ? html` + + ${this._contentTypeVariesByCulture ? this.#renderVaryByCulture() : nothing} + ${this._contentTypeVariesBySegment ? this.#renderVaryBySegment() : nothing} + + ` : ''; } + #renderVaryByCulture() { - return html` `; + return html` +
+ +
+ `; + } + + #renderVaryBySegment() { + return html` +
+ +
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts index 0c34d9d974..e2e5d0d806 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.element.ts @@ -9,7 +9,7 @@ import type { UmbTreeExpansionModel } from '../expansion-manager/types.js'; import type { UmbDefaultTreeContext } from './default-tree.context.js'; import { UMB_TREE_CONTEXT } from './default-tree.context-token.js'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { html, nothing, customElement, property, state, repeat } from '@umbraco-cms/backoffice/external/lit'; +import { html, nothing, customElement, property, state, repeat, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @customElement('umb-default-tree') @@ -169,8 +169,14 @@ export class UmbDefaultTreeElement extends UmbLitElement { return nothing; } - return html` `; + return html` `; } + + static override styles = css` + #load-more { + width: 100%; + } + `; } export default UmbDefaultTreeElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.element.ts index fd02f52a8d..a57478d279 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.element.ts @@ -1,14 +1,13 @@ import { customElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbDefaultTreeElement } from '@umbraco-cms/backoffice/tree'; -const elementName = 'umb-document-tree'; -@customElement(elementName) +@customElement('umb-document-tree') export class UmbDocumentTreeElement extends UmbDefaultTreeElement {} export { UmbDocumentTreeElement as element }; declare global { interface HTMLElementTagNameMap { - [elementName]: UmbDocumentTreeElement; + 'umb-document-tree': UmbDocumentTreeElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts index 8515f2f673..5754ba38a8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/dropzone/components/input-dropzone/input-dropzone.element.ts @@ -74,6 +74,16 @@ export class UmbInputDropzoneElement extends UmbFormControlMixin 0); + } + constructor() { super(); @@ -107,7 +117,7 @@ export class UmbInputDropzoneElement extends UmbFormControlMixin @@ -132,7 +142,6 @@ export class UmbInputDropzoneElement extends UmbFormControlMixin> { // Check the parent which children media types are allowed diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts index 5ba36c22b8..b8d403ba09 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-field.element.ts @@ -1,3 +1,5 @@ +import type { UmbImageCropChangeEvent } from './crop-change.event.js'; +import type { UmbFocalPointChangeEvent } from './focalpoint-change.event.js'; import type { UmbImageCropperElement } from './image-cropper.element.js'; import type { UmbImageCropperCrop, @@ -5,15 +7,14 @@ import type { UmbImageCropperFocalPoint, UmbImageCropperPropertyEditorValue, } from './types.js'; -import type { UmbImageCropChangeEvent } from './crop-change.event.js'; -import type { UmbFocalPointChangeEvent } from './focalpoint-change.event.js'; import { css, customElement, html, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; -import './image-cropper.element.js'; import './image-cropper-focus-setter.element.js'; import './image-cropper-preview.element.js'; +import './image-cropper.element.js'; @customElement('umb-image-cropper-field') export class UmbInputImageCropperFieldElement extends UmbLitElement { @@ -46,7 +47,19 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { currentCrop?: UmbImageCropperCrop; @property({ attribute: false }) - file?: File; + set file(file: File | undefined) { + this.#file = file; + if (file) { + this.fileDataUrl = URL.createObjectURL(file); + } else if (this.fileDataUrl) { + URL.revokeObjectURL(this.fileDataUrl); + this.fileDataUrl = undefined; + } + } + get file() { + return this.#file; + } + #file?: File; @property() fileDataUrl?: string; @@ -60,25 +73,29 @@ export class UmbInputImageCropperFieldElement extends UmbLitElement { @state() src = ''; - get source() { - if (this.fileDataUrl) return this.fileDataUrl; - if (this.src) return this.src; - return ''; + @state() + private _serverUrl = ''; + + get source(): string { + if (this.src) { + return `${this._serverUrl}${this.src}`; + } + + return this.fileDataUrl ?? ''; } - override updated(changedProperties: Map) { - super.updated(changedProperties); + constructor() { + super(); - if (changedProperties.has('file')) { - if (this.file) { - const reader = new FileReader(); - reader.onload = (event) => { - this.fileDataUrl = event.target?.result as string; - }; - reader.readAsDataURL(this.file); - } else { - this.fileDataUrl = undefined; - } + this.consumeContext(UMB_APP_CONTEXT, (context) => { + this._serverUrl = context.getServerUrl(); + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + if (this.fileDataUrl) { + URL.revokeObjectURL(this.fileDataUrl); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts index a092f0868c..d203a1af7e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/image-cropper-preview.element.ts @@ -18,13 +18,13 @@ export class UmbImageCropperPreviewElement extends UmbLitElement { label?: string; @property({ attribute: false }) - get focalPoint() { - return this.#focalPoint; - } set focalPoint(value) { this.#focalPoint = value; this.#onFocalPointUpdated(); } + get focalPoint() { + return this.#focalPoint; + } #focalPoint: UmbImageCropperFocalPoint = { left: 0.5, top: 0.5 }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts index b90bd8dd20..1bf0a945c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-image-cropper/input-image-cropper.element.ts @@ -1,21 +1,26 @@ import type { UmbImageCropperPropertyEditorValue } from './types.js'; import type { UmbInputImageCropperFieldElement } from './image-cropper-field.element.js'; -import { html, customElement, property, query, state, css, ifDefined } from '@umbraco-cms/backoffice/external/lit'; -import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; -import { UmbId } from '@umbraco-cms/backoffice/id'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTemporaryFileManager } from '@umbraco-cms/backoffice/temporary-file'; +import { css, customElement, html, ifDefined, property, state } from '@umbraco-cms/backoffice/external/lit'; import { assignToFrozenObject } from '@umbraco-cms/backoffice/observable-api'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFileDropzoneItemStatus, UmbInputDropzoneDashedStyles } from '@umbraco-cms/backoffice/dropzone'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbTemporaryFileConfigRepository } from '@umbraco-cms/backoffice/temporary-file'; import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { + UmbDropzoneChangeEvent, + UmbInputDropzoneElement, + UmbUploadableItem, +} from '@umbraco-cms/backoffice/dropzone'; -import './image-cropper.element.js'; +import './image-cropper-field.element.js'; import './image-cropper-focus-setter.element.js'; import './image-cropper-preview.element.js'; -import './image-cropper-field.element.js'; +import './image-cropper.element.js'; const DefaultFocalPoint = { left: 0.5, top: 0.5 }; -const DefaultValue = { +const DefaultValue: UmbImageCropperPropertyEditorValue = { temporaryFileId: null, src: '', crops: [], @@ -28,9 +33,6 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< typeof UmbLitElement, undefined >(UmbLitElement, undefined) { - @query('#dropzone') - private _dropzone?: UUIFileDropzoneElement; - /** * Sets the input to required, meaning validation will fail if the value is empty. * @type {boolean} @@ -45,10 +47,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< crops: UmbImageCropperPropertyEditorValue['crops'] = []; @state() - file?: File; - - @state() - fileUnique?: string; + private _file?: UmbUploadableItem; @state() private _accept?: string; @@ -56,7 +55,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< @state() private _loading = true; - #manager = new UmbTemporaryFileManager(this); + #config = new UmbTemporaryFileConfigRepository(this); constructor() { super(); @@ -76,9 +75,9 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< } async #observeAcceptedFileTypes() { - const config = await this.#manager.getConfiguration(); + await this.#config.initialized; this.observe( - config.part('imageFileTypes'), + this.#config.part('imageFileTypes'), (imageFileTypes) => { this._accept = imageFileTypes.join(','); this._loading = false; @@ -87,34 +86,27 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< ); } - #onUpload(e: UUIFileDropzoneEvent) { - const file = e.detail.files[0]; - if (!file) return; - const unique = UmbId.new(); + #onUpload(e: UmbDropzoneChangeEvent) { + e.stopImmediatePropagation(); - this.file = file; - this.fileUnique = unique; + const target = e.target as UmbInputDropzoneElement; + const file = target.value?.[0]; - this.value = assignToFrozenObject(this.value ?? DefaultValue, { temporaryFileId: unique }); + if (file?.status !== UmbFileDropzoneItemStatus.COMPLETE) return; - this.#manager?.uploadOne({ temporaryUnique: unique, file }); + this._file = file; + + this.value = assignToFrozenObject(this.value ?? DefaultValue, { + temporaryFileId: file.temporaryFile?.temporaryUnique, + }); this.dispatchEvent(new UmbChangeEvent()); } - #onBrowse(e: Event) { - if (!this._dropzone) return; - e.stopImmediatePropagation(); - this._dropzone.browse(); - } - #onRemove = () => { this.value = undefined; - if (this.fileUnique) { - this.#manager?.removeOne(this.fileUnique); - } - this.fileUnique = undefined; - this.file = undefined; + this._file?.temporaryFile?.abortController?.abort(); + this._file = undefined; this.dispatchEvent(new UmbChangeEvent()); }; @@ -144,7 +136,7 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< return html`
`; } - if (this.value?.src || this.file) { + if (this.value?.src || this._file) { return this.#renderImageCropper(); } @@ -153,14 +145,11 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< #renderDropzone() { return html` - - - + disable-folder-upload + @change="${this.#onUpload}"> `; } @@ -184,31 +173,24 @@ export class UmbInputImageCropperElement extends UmbFormControlMixin< } #renderImageCropper() { - return html` + return html` ${this.localize.term('content_uploadClear')} `; } - static override styles = [ + static override readonly styles = [ + UmbTextStyles, + UmbInputDropzoneDashedStyles, css` #loader { display: flex; justify-content: center; } - - uui-file-dropzone { - position: relative; - display: block; - } - uui-file-dropzone::after { - content: ''; - position: absolute; - inset: 0; - cursor: pointer; - border: 1px dashed var(--uui-color-divider-emphasis); - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index 7c7cd25841..3ada651c56 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -1,33 +1,28 @@ import type { MediaValueType } from '../../property-editors/upload-field/types.js'; import type { ManifestFileUploadPreview } from './file-upload-preview.extension.js'; import { getMimeTypeFromExtension } from './utils.js'; -import { - css, - html, - nothing, - ifDefined, - customElement, - property, - query, - state, - when, -} from '@umbraco-cms/backoffice/external/lit'; -import { formatBytes, stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { css, customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { stringOrStringArrayContains } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { UmbId } from '@umbraco-cms/backoffice/id'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbFileDropzoneItemStatus, UmbInputDropzoneDashedStyles } from '@umbraco-cms/backoffice/dropzone'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTemporaryFileManager, TemporaryFileStatus } from '@umbraco-cms/backoffice/temporary-file'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; +import type { + UmbDropzoneChangeEvent, + UmbInputDropzoneElement, + UmbUploadableFile, +} from '@umbraco-cms/backoffice/dropzone'; import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; -import type { UUIFileDropzoneElement, UUIFileDropzoneEvent } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-input-upload-field') export class UmbInputUploadFieldElement extends UmbLitElement { - @property({ type: Object }) + @property({ type: Object, attribute: false }) set value(value: MediaValueType) { this.#src = value?.src ?? ''; + this.#setPreviewAlias(); } get value(): MediaValueType { return { @@ -42,39 +37,43 @@ export class UmbInputUploadFieldElement extends UmbLitElement { * @type {Array} * @default */ - @property({ type: Array }) - set allowedFileExtensions(value: Array) { - this.#setExtensions(value); - } - get allowedFileExtensions(): Array | undefined { - return this._extensions; - } + @property({ + type: Array, + attribute: 'allowed-file-extensions', + converter(value) { + if (typeof value === 'string') { + return value.split(',').map((ext) => ext.trim()); + } + return value; + }, + }) + allowedFileExtensions?: Array; @state() public temporaryFile?: UmbTemporaryFileModel; - @state() - private _progress = 0; - @state() private _extensions?: string[]; @state() private _previewAlias?: string; - @query('#dropzone') - private _dropzone?: UUIFileDropzoneElement; - - #manager = new UmbTemporaryFileManager(this); + @state() + private _serverUrl = ''; #manifests: Array = []; - override updated(changedProperties: PropertyValueMap | Map) { - super.updated(changedProperties); + constructor() { + super(); - if (changedProperties.has('value') && changedProperties.get('value')?.src !== this.value.src) { - this.#setPreviewAlias(); - } + this.consumeContext(UMB_APP_CONTEXT, (context) => { + this._serverUrl = context.getServerUrl(); + }); + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#clearObjectUrl(); } async #getManifests() { @@ -87,15 +86,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return this.#manifests; } - #setExtensions(extensions: Array) { - if (!extensions?.length) { - this._extensions = undefined; - return; - } - // TODO: The dropzone uui component does not support file extensions without a dot. Remove this when it does. - this._extensions = extensions?.map((extension) => `.${extension}`); - } - async #setPreviewAlias(): Promise { this._previewAlias = await this.#getPreviewElementAlias(); } @@ -151,47 +141,22 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return getMimeTypeFromExtension('.' + extension); } - async #onUpload(e: UUIFileDropzoneEvent) { - try { - //Property Editor for Upload field will always only have one file. - this.temporaryFile = { - temporaryUnique: UmbId.new(), - status: TemporaryFileStatus.WAITING, - file: e.detail.files[0], - onProgress: (p) => { - this._progress = Math.ceil(p); - }, - abortController: new AbortController(), - }; - - const uploaded = await this.#manager.uploadOne(this.temporaryFile); - - if (uploaded.status === TemporaryFileStatus.SUCCESS) { - this.temporaryFile.status = TemporaryFileStatus.SUCCESS; - - const blobUrl = URL.createObjectURL(this.temporaryFile.file); - this.value = { src: blobUrl }; - - this.dispatchEvent(new UmbChangeEvent()); - } else { - this.temporaryFile.status = TemporaryFileStatus.ERROR; - this.requestUpdate('temporaryFile'); - } - } catch { - // If we still have a temporary file, set it to error. - if (this.temporaryFile) { - this.temporaryFile.status = TemporaryFileStatus.ERROR; - this.requestUpdate('temporaryFile'); - } - - // If the error was caused by the upload being aborted, do not show an error message. - } - } - - #handleBrowse(e: Event) { - if (!this._dropzone) return; + async #onUpload(e: UmbDropzoneChangeEvent) { e.stopImmediatePropagation(); - this._dropzone.browse(); + + const target = e.target as UmbInputDropzoneElement; + const file = target.value?.[0]; + + if (file?.status !== UmbFileDropzoneItemStatus.COMPLETE) return; + + this.temporaryFile = (file as UmbUploadableFile).temporaryFile; + + this.#clearObjectUrl(); + + const blobUrl = URL.createObjectURL(this.temporaryFile.file); + this.value = { src: blobUrl }; + + this.dispatchEvent(new UmbChangeEvent()); } override render() { @@ -199,69 +164,28 @@ export class UmbInputUploadFieldElement extends UmbLitElement { return this.#renderDropzone(); } - return html` - ${this.temporaryFile ? this.#renderUploader() : nothing} - ${this.value.src && this._previewAlias ? this.#renderFile(this.value.src) : nothing} - `; + if (this.value?.src && this._previewAlias) { + return this.#renderFile(this.value.src); + } + + return nothing; } #renderDropzone() { return html` - - - - `; - } - - #renderUploader() { - if (!this.temporaryFile) return nothing; - - return html` -
-
- ${when( - this.temporaryFile.status === TemporaryFileStatus.SUCCESS, - () => html``, - )} - ${when( - this.temporaryFile.status === TemporaryFileStatus.ERROR, - () => html``, - )} -
-
-
${this.temporaryFile.file.name}
-
${formatBytes(this.temporaryFile.file.size, { decimals: 2 })}: ${this._progress}%
- ${when( - this.temporaryFile.status === TemporaryFileStatus.WAITING, - () => html`
`, - )} - ${when( - this.temporaryFile.status === TemporaryFileStatus.ERROR, - () => html`
An error occured
`, - )} -
-
- ${when( - this.temporaryFile.status === TemporaryFileStatus.WAITING, - () => html` - - ${this.localize.term('general_cancel')} - - `, - () => this.#renderButtonRemove(), - )} -
-
+ disable-folder-upload + accept=${ifDefined(this._extensions?.join(','))} + @change=${this.#onUpload}> `; } #renderFile(src: string) { + if (!src.startsWith('blob:')) { + src = this._serverUrl + src; + } + return html`
@@ -288,13 +212,25 @@ export class UmbInputUploadFieldElement extends UmbLitElement { // If the upload promise happens to be in progress, cancel it. this.temporaryFile?.abortController?.abort(); + this.#clearObjectUrl(); + this.value = { src: undefined }; this.temporaryFile = undefined; - this._progress = 0; this.dispatchEvent(new UmbChangeEvent()); } + /** + * If there is a former File, revoke the object URL. + */ + #clearObjectUrl(): void { + if (this.value?.src?.startsWith('blob:')) { + URL.revokeObjectURL(this.value.src); + } + } + static override readonly styles = [ + UmbTextStyles, + UmbInputDropzoneDashedStyles, css` :host { position: relative; @@ -323,51 +259,6 @@ export class UmbInputUploadFieldElement extends UmbLitElement { width: fit-content; max-width: 100%; } - - #temporaryFile { - display: grid; - grid-template-columns: auto auto auto; - width: fit-content; - max-width: 100%; - margin: var(--uui-size-layout-1) 0; - padding: var(--uui-size-space-3); - border: 1px dashed var(--uui-color-divider-emphasis); - } - - #fileIcon, - #fileActions { - place-self: center center; - padding: 0 var(--uui-size-layout-1); - } - - #fileName { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--uui-size-5); - } - - #fileSize { - font-size: var(--uui-font-size-small); - color: var(--uui-color-text-alt); - } - - #error { - color: var(--uui-color-danger); - } - - uui-file-dropzone { - position: relative; - display: block; - padding: 3px; /** Dropzone background is blurry and covers slightly into other elements. Hack to avoid this */ - } - uui-file-dropzone::after { - content: ''; - position: absolute; - inset: 0; - cursor: pointer; - border: 1px dashed var(--uui-color-divider-emphasis); - } `, ]; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index 9be8531819..00060a0a99 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -1,4 +1,5 @@ import { css, customElement, html, map, nothing, property, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import { UUISelectElement } from '@umbraco-cms/backoffice/external/uui'; @@ -6,8 +7,7 @@ import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, } from '@umbraco-cms/backoffice/property-editor'; -import type { UUISelectEvent } from '@umbraco-cms/backoffice/external/uui'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import type { UmbInputDropdownListElement } from '@umbraco-cms/backoffice/components'; /** * @element umb-property-editor-ui-dropdown @@ -30,7 +30,7 @@ export class UmbPropertyEditorUIDropdownElement @property({ type: Array }) public override set value(value: Array | string | undefined) { - this.#selection = Array.isArray(value) ? value : value ? [value] : []; + this.#selection = this.#ensureValueIsArray(value); } public override get value(): Array | undefined { return this.#selection; @@ -97,7 +97,11 @@ export class UmbPropertyEditorUIDropdownElement } } - #onChange(event: UUISelectEvent) { + #ensureValueIsArray(value: Array | string | null | undefined): Array { + return Array.isArray(value) ? value : value ? [value] : []; + } + + #onChange(event: CustomEvent & { target: UmbInputDropdownListElement }) { const value = event.target.value as string; this.#setValue(value ? [value] : []); } @@ -110,6 +114,8 @@ export class UmbPropertyEditorUIDropdownElement #setValue(value: Array | string | null | undefined) { if (!value) return; + const selection = this.#ensureValueIsArray(value); + this._options.forEach((item) => (item.selected = selection.includes(item.value))); this.value = value; this.dispatchEvent(new UmbChangeEvent()); } diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index d2685028b3..847fd8258f 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -9,7 +9,7 @@ "@umbraco-cms/backoffice": "15.2.1", "msw": "^2.7.0", "typescript": "^5.7.3", - "vite": "^6.2.2", + "vite": "^6.2.3", "vite-tsconfig-paths": "^5.1.4" }, "engines": { @@ -3772,9 +3772,9 @@ } }, "node_modules/vite": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.2.tgz", - "integrity": "sha512-yW7PeMM+LkDzc7CgJuRLMW2Jz0FxMOsVJ8Lv3gpgW9WLcb9cTW+121UEr1hvmfR7w3SegR5ItvYyzVz1vxNJgQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz", + "integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Login/package.json b/src/Umbraco.Web.UI.Login/package.json index 449fa53bfe..02dc6a9ee9 100644 --- a/src/Umbraco.Web.UI.Login/package.json +++ b/src/Umbraco.Web.UI.Login/package.json @@ -16,7 +16,7 @@ "@umbraco-cms/backoffice": "15.2.1", "msw": "^2.7.0", "typescript": "^5.7.3", - "vite": "^6.2.2", + "vite": "^6.2.3", "vite-tsconfig-paths": "^5.1.4" }, "msw": { diff --git a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs index 53c2f50f10..1fd66da312 100644 --- a/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/ContentBuilder.cs @@ -185,10 +185,7 @@ public class ContentBuilder { if (string.IsNullOrWhiteSpace(name)) { - if (_cultureNames.TryGetValue(culture, out _)) - { - _cultureNames.Remove(culture); - } + _cultureNames.Remove(culture); } else { diff --git a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml index 918b3ea4fe..7cf519f60f 100644 --- a/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml +++ b/tests/Umbraco.Tests.Integration/CompatibilitySuppressions.xml @@ -78,6 +78,13 @@ lib/net9.0/Umbraco.Tests.Integration.dll true + + CP0002 + M:Umbraco.Cms.Tests.Integration.Umbraco.Core.Services.UserServiceCrudTests.Cannot_Request_Disabled_If_Hidden(Umbraco.Cms.Core.Models.Membership.UserState) + lib/net9.0/Umbraco.Tests.Integration.dll + lib/net9.0/Umbraco.Tests.Integration.dll + true + CP0002 M:Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services.ContentPublishingServiceTests.Publish_Branch_Does_Not_Publish_Unpublished_Children_Unless_Explicitly_Instructed_To(System.Boolean) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs index 8f9a0b5f2e..7503a25492 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Filter.cs @@ -11,12 +11,14 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; internal sealed partial class UserServiceCrudTests { - [Test] - [TestCase(UserState.Disabled)] - [TestCase(UserState.All)] - public async Task Cannot_Request_Disabled_If_Hidden(UserState includeState) + [TestCase(null, 1)] // Requesting no filter, will just get the admin user but not the created and disabled one. + // - verifies fix for https://github.com/umbraco/Umbraco-CMS/issues/18812 + [TestCase(UserState.Inactive, 1)] // Requesting inactive, will just get the admin user but not the created and disabled one. + [TestCase(UserState.Disabled, 0)] // Requesting disabled, won't get any as admin user isn't disabled and, whilst the created one is, disabled users are hidden. + [TestCase(UserState.All, 1)] // Requesting all, will just get the admin user but not the created and disabled one. + public async Task Cannot_Request_Disabled_If_Hidden(UserState? includeState, int expectedCount) { - var userService = CreateUserService(new SecuritySettings {HideDisabledUsersInBackOffice = true}); + var userService = CreateUserService(new SecuritySettings { HideDisabledUsersInBackOffice = true }); var editorGroup = await UserGroupService.GetAsync(Constants.Security.EditorGroupKey); var createModel = new UserCreateModel @@ -24,21 +26,25 @@ internal sealed partial class UserServiceCrudTests UserName = "editor@mail.com", Email = "editor@mail.com", Name = "Editor", - UserGroupKeys = new HashSet { editorGroup.Key } + UserGroupKeys = new HashSet { editorGroup.Key }, }; var createAttempt = await userService.CreateAsync(Constants.Security.SuperUserKey, createModel, true); Assert.IsTrue(createAttempt.Success); var disableStatus = - await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet{ createAttempt.Result.CreatedUser!.Key }); + await userService.DisableAsync(Constants.Security.SuperUserKey, new HashSet { createAttempt.Result.CreatedUser!.Key }); Assert.AreEqual(UserOperationStatus.Success, disableStatus); - var filter = new UserFilter {IncludeUserStates = new HashSet {includeState}}; + var filter = new UserFilter(); + if (includeState.HasValue) + { + filter.IncludeUserStates = new HashSet { includeState.Value }; + } var filterAttempt = await userService.FilterAsync(Constants.Security.SuperUserKey, filter, 0, 1000); Assert.IsTrue(filterAttempt.Success); - Assert.AreEqual(0, filterAttempt.Result.Items.Count()); + Assert.AreEqual(expectedCount, filterAttempt.Result.Items.Count()); } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs index 295b134e22..8af4cf4987 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Editing.cs @@ -1,11 +1,10 @@ -using Microsoft.Extensions.DependencyInjection; -using NUnit.Framework; +using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; @@ -742,6 +741,185 @@ internal partial class BlockListElementLevelVariationTests } } + [Test] + public async Task Can_Align_Culture_Variance_For_Variant_Element_Types() + { + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Nothing, blockListDataType); + + var content = CreateContent( + contentType, + elementType, + new List + { + new() { Alias = "invariantText", Value = "The invariant content value" }, + new() { Alias = "variantText", Value = "Another invariant content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "The invariant settings value" }, + new() { Alias = "variantText", Value = "Another invariant settings value" } + }, + false); + + contentType.Variations = ContentVariation.Culture; + ContentTypeService.Save(contentType); + + // re-fetch content + content = ContentService.GetById(content.Key); + + var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor(); + + var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue; + Assert.IsNotNull(blockListValue); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.ContentData.Count); + Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count); + var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.AreEqual("en-US", variantValue.Culture); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.SettingsData.Count); + Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count); + var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.AreEqual("en-US", variantValue.Culture); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.Expose.Count); + Assert.AreEqual("en-US", blockListValue.Expose.First().Culture); + }); + } + + [TestCase(ContentVariation.Culture)] + [TestCase(ContentVariation.Nothing)] + public async Task Can_Turn_Invariant_Element_Variant(ContentVariation contentTypeVariation) + { + var elementType = CreateElementType(ContentVariation.Nothing); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(contentTypeVariation, blockListDataType); + + var content = CreateContent( + contentType, + elementType, + new List + { + new() { Alias = "invariantText", Value = "The invariant content value" }, + new() { Alias = "variantText", Value = "Another invariant content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "The invariant settings value" }, + new() { Alias = "variantText", Value = "Another invariant settings value" } + }, + false); + + elementType.Variations = ContentVariation.Culture; + elementType.PropertyTypes.First(p => p.Alias == "variantText").Variations = ContentVariation.Culture; + ContentTypeService.Save(elementType); + + // re-fetch content + content = ContentService.GetById(content.Key); + + var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor(); + + var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue; + Assert.IsNotNull(blockListValue); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.ContentData.Count); + Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count); + var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.AreEqual("en-US", variantValue.Culture); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.SettingsData.Count); + Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count); + var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.AreEqual("en-US", variantValue.Culture); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.Expose.Count); + Assert.AreEqual("en-US", blockListValue.Expose.First().Culture); + }); + } + + [TestCase(ContentVariation.Nothing)] + [TestCase(ContentVariation.Culture)] + public async Task Can_Turn_Variant_Element_Invariant(ContentVariation contentTypeVariation) + { + var elementType = CreateElementType(ContentVariation.Culture); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(contentTypeVariation, blockListDataType); + + var content = CreateContent( + contentType, + elementType, + new List + { + new() { Alias = "invariantText", Value = "The invariant content value" }, + new() { Alias = "variantText", Value = "Variant content in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "Variant content in Danish", Culture = "da-DK" } + }, + new List + { + new() { Alias = "invariantText", Value = "The invariant settings value" }, + new() { Alias = "variantText", Value = "Variant settings in English", Culture = "en-US" }, + new() { Alias = "variantText", Value = "Variant settings in Danish", Culture = "da-DK" } + }, + false); + + elementType.Variations = ContentVariation.Nothing; + elementType.PropertyTypes.First(p => p.Alias == "variantText").Variations = ContentVariation.Nothing; + ContentTypeService.Save(elementType); + + // re-fetch content + content = ContentService.GetById(content.Key); + + var valueEditor = (BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor)blockListDataType.Editor!.GetValueEditor(); + + var blockListValue = valueEditor.ToEditor(content!.Properties["blocks"]!) as BlockListValue; + Assert.IsNotNull(blockListValue); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.ContentData.Count); + Assert.AreEqual(2, blockListValue.ContentData.First().Values.Count); + var invariantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.ContentData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.IsNull(variantValue.Culture); + Assert.AreEqual("Variant content in English", variantValue.Value); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.SettingsData.Count); + Assert.AreEqual(2, blockListValue.SettingsData.First().Values.Count); + var invariantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "invariantText"); + var variantValue = blockListValue.SettingsData.First().Values.First(value => value.Alias == "variantText"); + Assert.IsNull(invariantValue.Culture); + Assert.IsNull(variantValue.Culture); + Assert.AreEqual("Variant settings in English", variantValue.Value); + }); + Assert.Multiple(() => + { + Assert.AreEqual(1, blockListValue.Expose.Count); + Assert.IsNull(blockListValue.Expose.First().Culture); + }); + } + private async Task CreateLimitedUser() { var userGroupService = GetRequiredService(); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeysTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeysTests.cs new file mode 100644 index 0000000000..fef83541b9 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Persistence/Repositories/RepositoryCacheKeysTests.cs @@ -0,0 +1,26 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors; + +[TestFixture] +public class RepositoryCacheKeysTests +{ + [Test] + public void GetKey_Returns_Expected_Key_For_Type() + { + var key = RepositoryCacheKeys.GetKey(); + Assert.AreEqual("uRepo_IContent_", key); + } + + [Test] + public void GetKey_Returns_Expected_Key_For_Type_And_Id() + { + var key = RepositoryCacheKeys.GetKey(1000); + Assert.AreEqual("uRepo_IContent_1000", key); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scoping/LockingMechanismTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scoping/LockingMechanismTests.cs new file mode 100644 index 0000000000..eb2d6abdfb --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Scoping/LockingMechanismTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using NUnit.Framework; +using Umbraco.Cms.Core.Scoping; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Scoping; + +[TestFixture] +internal class LockingMechanismTests +{ + private const int LockId = 1000; + private const int LockId2 = 1001; + private static readonly Guid _scopeInstanceId = Guid.NewGuid(); + + [Test] + public void IncrementLock_WithoutLocksDictionary_CreatesLock() + { + var locks = new Dictionary>(); + LockingMechanism.IncrementLock(LockId, _scopeInstanceId, ref locks); + Assert.AreEqual(1, locks.Count); + Assert.AreEqual(1, locks[_scopeInstanceId][LockId]); + } + + [Test] + public void IncrementLock_WithExistingLocksDictionary_CreatesLock() + { + var locks = new Dictionary>() + { + { + _scopeInstanceId, + new Dictionary() + { + { LockId, 100 }, + { LockId2, 200 } + } + } + }; + LockingMechanism.IncrementLock(LockId, _scopeInstanceId, ref locks); + Assert.AreEqual(1, locks.Count); + Assert.AreEqual(2, locks[_scopeInstanceId].Count); + Assert.AreEqual(101, locks[_scopeInstanceId][LockId]); + Assert.AreEqual(200, locks[_scopeInstanceId][LockId2]); + } +}