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]);
+ }
+}