diff --git a/src/Umbraco.Core/Extensions/ObjectExtensions.cs b/src/Umbraco.Core/Extensions/ObjectExtensions.cs index c53eee27a5..0497d2cec4 100644 --- a/src/Umbraco.Core/Extensions/ObjectExtensions.cs +++ b/src/Umbraco.Core/Extensions/ObjectExtensions.cs @@ -26,6 +26,15 @@ public static class ObjectExtensions private static readonly char[] _numberDecimalSeparatorsToNormalize = ['.', ',']; private static readonly CustomBooleanTypeConverter _customBooleanTypeConverter = new(); + /// + /// Returns an enumerable containing only the input object. + /// + /// The input object. + /// The type of the enumerable. + /// An enumerable containing only the input object. + [Obsolete("Please replace uses of this extension method with Enumerable.Repeat(input, 1). This extension method is no longer used in Umbraco and is scheduled for removal in Umbraco 19.")] + public static IEnumerable AsEnumerableOfOne(this T input) => Enumerable.Repeat(input, 1); + /// /// Returns an XML serialized safe string representation for the value and type. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 7265b763b8..a200972cf7 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -317,7 +317,7 @@ public class PublishedValueFallback : IPublishedValueFallback } var culture2 = language2.IsoCode; - T? culture2Value = getValue(culture2, segment); + T? culture2Value = TryGetExplicitlyContextualizedValue(getValue, culture2, segment); if (culture2Value != null) { value = culture2Value; @@ -329,25 +329,26 @@ public class PublishedValueFallback : IPublishedValueFallback } private bool TryGetValueWithDefaultLanguageFallback(IPublishedProperty property, string? culture, string? segment, out T? value) - { - value = default; - - if (culture.IsNullOrWhiteSpace()) - { - return false; - } - - string? defaultCulture = _localizationService?.GetDefaultLanguageIsoCode(); - if (culture.InvariantEquals(defaultCulture) == false && property.HasValue(defaultCulture, segment)) - { - value = property.Value(this, defaultCulture, segment); - return true; - } - - return false; - } + => TryGetValueWithDefaultLanguageFallback( + (actualCulture, actualSegment) + => property.HasValue(actualCulture, actualSegment) + ? property.Value(this, actualCulture, actualSegment) + : default, + culture, + segment, + out value); private bool TryGetValueWithDefaultLanguageFallback(IPublishedElement element, string alias, string? culture, string? segment, out T? value) + => TryGetValueWithDefaultLanguageFallback( + (actualCulture, actualSegment) + => element.HasValue(alias, actualCulture, actualSegment) + ? element.Value(this, alias, actualCulture, actualSegment) + : default, + culture, + segment, + out value); + + private bool TryGetValueWithDefaultLanguageFallback(TryGetValueForCultureAndSegment getValue, string? culture, string? segment, out T? value) { value = default; @@ -356,14 +357,39 @@ public class PublishedValueFallback : IPublishedValueFallback return false; } - string? defaultCulture = _localizationService?.GetDefaultLanguageIsoCode(); - if (culture.InvariantEquals(defaultCulture) == false && element.HasValue(alias, defaultCulture, segment)) + var defaultCulture = _localizationService?.GetDefaultLanguageIsoCode(); + if (defaultCulture.IsNullOrWhiteSpace()) { - value = element.Value(this, alias, defaultCulture, segment); - return true; + return false; } - return false; + if (culture.InvariantEquals(defaultCulture)) + { + return false; + } + + T? fallbackValue = TryGetExplicitlyContextualizedValue(getValue, defaultCulture, segment); + if (fallbackValue == null) + { + return false; + } + + value = fallbackValue; + return true; + } + + private T? TryGetExplicitlyContextualizedValue(TryGetValueForCultureAndSegment getValue, string culture, string? segment) + { + VariationContext? current = _variationContextAccessor.VariationContext; + try + { + _variationContextAccessor.VariationContext = new VariationContext(culture, segment); + return getValue(culture, segment); + } + finally + { + _variationContextAccessor.VariationContext = current; + } } private delegate T? TryGetValueForCultureAndSegment(string actualCulture, string? actualSegment); diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 8903cac4f0..f32cd4121a 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Web; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; @@ -26,6 +27,7 @@ internal sealed class ContentPublishingService : IContentPublishingService private readonly IRelationService _relationService; private readonly ILogger _logger; private readonly ILongRunningOperationService _longRunningOperationService; + private readonly IUmbracoContextFactory _umbracoContextFactory; public ContentPublishingService( ICoreScopeProvider coreScopeProvider, @@ -37,7 +39,8 @@ internal sealed class ContentPublishingService : IContentPublishingService IOptionsMonitor optionsMonitor, IRelationService relationService, ILogger logger, - ILongRunningOperationService longRunningOperationService) + ILongRunningOperationService longRunningOperationService, + IUmbracoContextFactory umbracoContextFactory) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; @@ -53,6 +56,7 @@ internal sealed class ContentPublishingService : IContentPublishingService { _contentSettings = contentSettings; }); + _umbracoContextFactory = umbracoContextFactory; } /// @@ -280,7 +284,7 @@ internal sealed class ContentPublishingService : IContentPublishingService return MapInternalPublishingAttempt(minimalAttempt); } - _logger.LogInformation("Starting async background thread for publishing branch."); + _logger.LogDebug("Starting long running operation for publishing branch {Key} on background thread.", key); Attempt enqueueAttempt = await _longRunningOperationService.RunAsync( PublishBranchOperationType, async _ => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, returnContent: false), @@ -314,6 +318,10 @@ internal sealed class ContentPublishingService : IContentPublishingService Guid userKey, bool returnContent) { + // Ensure we have an UmbracoContext in case running on a background thread so operations that run in the published notification handlers + // have access to this (e.g. webhooks). + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); IContent? content = _contentService.GetById(key); if (content is null) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs index 52657a21e0..a5fc5cb67c 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs @@ -329,7 +329,7 @@ public class MediaRepository : ContentRepositoryBase protected override object ConvertToObject(DateTimeDto dateTimeDto) - => DateOnly.FromDateTime(dateTimeDto.Date.UtcDateTime); + => DateOnly.FromDateTime(dateTimeDto.Date.DateTime); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs index da793aeb2f..9bd138e591 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverter.cs @@ -30,6 +30,5 @@ public class DateTimeUnspecifiedValueConverter : DateTimeValueConverterBase /// protected override object ConvertToObject(DateTimeDto dateTimeDto) - => DateTime.SpecifyKind(dateTimeDto.Date.UtcDateTime, DateTimeKind.Unspecified); - + => DateTime.SpecifyKind(dateTimeDto.Date.DateTime, DateTimeKind.Unspecified); } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs index 9cb5d57474..0218a10b63 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; @@ -53,10 +54,10 @@ public class ImageCropperValueConverter : PropertyValueConverterBase, IDeliveryA { value = _jsonSerializer.Deserialize(sourceString); } - catch (Exception ex) + catch (JsonException ex) { - // cannot deserialize, assume it may be a raw image URL - _logger.LogError(ex, "Could not deserialize string '{JsonString}' into an image cropper value.", sourceString); + // Cannot deserialize, assume it may be a raw image URL. + _logger.LogDebug(ex, "Could not deserialize string '{JsonString}' into an image cropper value.", sourceString); value = new ImageCropperValue { Src = sourceString }; } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs index f6862e5b6f..777a20f048 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/TimeOnlyValueConverter.cs @@ -30,5 +30,5 @@ public class TimeOnlyValueConverter : DateTimeValueConverterBase /// protected override object ConvertToObject(DateTimeDto dateTimeDto) - => TimeOnly.FromDateTime(dateTimeDto.Date.UtcDateTime); + => TimeOnly.FromDateTime(dateTimeDto.Date.DateTime); } diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs index ca625dacdf..e97c60fd62 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,4 +1,3 @@ - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -74,7 +73,7 @@ public static class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); - builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); builder.AddCacheSeeding(); return builder; } diff --git a/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs index bebb34e67c..2d3e682f94 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DocumentCache.cs @@ -42,6 +42,9 @@ public sealed class DocumentCache : IPublishedContentCache public IPublishedContent? GetById(Guid contentId) => GetByIdAsync(contentId).GetAwaiter().GetResult(); + [Obsolete("This method is no longer used in Umbraco and is not defined on the interface. " + + "Any usage can be replaced with a call to IDocumentNavigationQueryService.TryGetRootKeys to retrieve the document keys, " + + "with each key passed to IPublishedContentCache.GetById to retrieve the IPublishedContent instances. Scheduled for removal in Umbraco 19.")] public IEnumerable GetAtRoot(bool preview, string? culture = null) { if (_documentNavigationQueryService.TryGetRootKeys(out IEnumerable rootKeys) is false) diff --git a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs index ee1b7aefda..ccd5897494 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; @@ -7,19 +8,24 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; /// internal static class HybridCacheExtensions { + // Per-key semaphores to ensure the GetOrCreateAsync + RemoveAsync sequence + // executes atomically for a given cache key. + private static readonly ConcurrentDictionary _keyLocks = new(); + /// /// Returns true if the cache contains an item with a matching key. /// /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// True if the item exists already. False if it doesn't. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { - (bool exists, _) = await TryGetValueAsync(cache, key); + (bool exists, _) = await TryGetValueAsync(cache, key, token).ConfigureAwait(false); return exists; } @@ -29,34 +35,55 @@ internal static class HybridCacheExtensions /// The type of the value of the item in the cache. /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// A tuple of and the object (if found) retrieved from the cache. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { var exists = true; - T? result = await cache.GetOrCreateAsync( - key, - null!, - (_, _) => - { - exists = false; - return new ValueTask(default(T)!); - }, - new HybridCacheEntryOptions(), - null, - CancellationToken.None); + // Acquire a per-key semaphore so that GetOrCreateAsync and the possible RemoveAsync + // complete without another thread retrieving/creating the same key in-between. + SemaphoreSlim sem = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. - // So remove it again. - if (exists is false) + await sem.WaitAsync().ConfigureAwait(false); + + try { - await cache.RemoveAsync(key); - } + T? result = await cache.GetOrCreateAsync( + key, + cancellationToken => + { + exists = false; + return default; + }, + new HybridCacheEntryOptions(), + null, + token).ConfigureAwait(false); - return (exists, result); + // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. + // So remove it again. Because we're holding the per-key lock there is no chance another thread + // will observe the temporary entry between GetOrCreateAsync and RemoveAsync. + if (exists is false) + { + await cache.RemoveAsync(key).ConfigureAwait(false); + } + + return (exists, result); + } + finally + { + sem.Release(); + + // Only remove the semaphore mapping if it still points to the same instance we used. + // This avoids removing another thread's semaphore or corrupting the map. + if (_keyLocks.TryGetValue(key, out SemaphoreSlim? current) && ReferenceEquals(current, sem)) + { + _keyLocks.TryRemove(key, out _); + } + } } } diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs index 0581bd2654..38a6618c70 100644 --- a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; @@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.HybridCache.Services; namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; -internal sealed class SeedingNotificationHandler : INotificationAsyncHandler +internal sealed class SeedingNotificationHandler : INotificationAsyncHandler { private readonly IDocumentCacheService _documentCacheService; private readonly IMediaCacheService _mediaCacheService; @@ -24,7 +24,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (existsInCache is false) { uncachedKeys.Add(key); @@ -278,7 +278,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService return false; } - return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview)); + return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview), CancellationToken.None); } public async Task RefreshContentAsync(IContent content) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 65b8f91945..46d782bdbe 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -133,7 +133,7 @@ internal sealed class MediaCacheService : IMediaCacheService return false; } - return await _hybridCache.ExistsAsync($"{keyAttempt.Result}"); + return await _hybridCache.ExistsAsync($"{keyAttempt.Result}", CancellationToken.None); } public async Task RefreshMediaAsync(IMedia media) @@ -170,7 +170,7 @@ internal sealed class MediaCacheService : IMediaCacheService var cacheKey = GetCacheKey(key, false); - var existsInCache = await _hybridCache.ExistsAsync(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, CancellationToken.None); if (existsInCache is false) { uncachedKeys.Add(key); diff --git a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts index 6ab13a8459..34328f20b7 100644 --- a/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts +++ b/src/Umbraco.Web.UI.Client/examples/picker-data-source/example-custom-picker-collection-data-source.ts @@ -1,15 +1,22 @@ import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbCollectionFilterModel, UmbCollectionItemModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbItemModel } from '@umbraco-cms/backoffice/entity-item'; import type { UmbPickerCollectionDataSource, UmbPickerSearchableDataSource, } from '@umbraco-cms/backoffice/picker-data-source'; import type { UmbSearchRequestArgs } from '@umbraco-cms/backoffice/search'; +interface ExampleCollectionItemModel extends UmbCollectionItemModel { + isPickable: boolean; +} + export class ExampleCustomPickerCollectionPropertyEditorDataSource extends UmbControllerBase - implements UmbPickerCollectionDataSource, UmbPickerSearchableDataSource + implements UmbPickerCollectionDataSource, UmbPickerSearchableDataSource { + collectionPickableFilter = (item: ExampleCollectionItemModel) => item.isPickable; + async requestCollection(args: UmbCollectionFilterModel) { // TODO: use args to filter/paginate etc console.log(args); @@ -41,35 +48,40 @@ export class ExampleCustomPickerCollectionPropertyEditorDataSource export { ExampleCustomPickerCollectionPropertyEditorDataSource as api }; -const customItems: Array = [ +const customItems: Array = [ { unique: '1', entityType: 'example', name: 'Example 1', icon: 'icon-shape-triangle', + isPickable: true, }, { unique: '2', entityType: 'example', name: 'Example 2', icon: 'icon-shape-triangle', + isPickable: true, }, { unique: '3', entityType: 'example', name: 'Example 3', icon: 'icon-shape-triangle', + isPickable: true, }, { unique: '4', entityType: 'example', name: 'Example 4', icon: 'icon-shape-triangle', + isPickable: false, }, { unique: '5', entityType: 'example', name: 'Example 5', icon: 'icon-shape-triangle', + isPickable: true, }, ]; diff --git a/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts index bf0bbd9046..ff52ddc79a 100644 --- a/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts +++ b/src/Umbraco.Web.UI.Client/examples/user-permission/localization/en.ts @@ -1,6 +1,6 @@ export default { user: { // eslint-disable-next-line @typescript-eslint/naming-convention - permissionsEntityGroup_dictionary: 'Dictionary', + permissionsEntityGroup_dictionary: 'Dictionary permissions', }, }; diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 2e8f7a23db..9c08dfb59f 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -58,7 +58,7 @@ "typescript": "5.9.3", "typescript-eslint": "^8.45.0", "typescript-json-schema": "^0.65.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-plugin-static-copy": "^3.1.3", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16339,9 +16339,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index ca5cc8853a..b22f6f4e74 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -263,7 +263,7 @@ "typescript": "5.9.3", "typescript-eslint": "^8.45.0", "typescript-json-schema": "^0.65.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-plugin-static-copy": "^3.1.3", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts index 95e907a38a..26af65490b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts @@ -6,7 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; export class UmbPreviewExitElement extends UmbLitElement { async #onClick() { const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT); - previewContext?.exitPreview(0); + await previewContext?.exitPreview(0); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts index aba28768e5..4a77454df8 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts @@ -6,7 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; export class UmbPreviewOpenWebsiteElement extends UmbLitElement { async #onClick() { const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT); - previewContext?.openWebsite(); + await previewContext?.openWebsite(); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index 21899e41f8..2acbd754de 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -1,10 +1,15 @@ -import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; +import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import { UmbDocumentPreviewRepository } from '@umbraco-cms/backoffice/document'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { HubConnectionBuilder, type HubConnection } from '@umbraco-cms/backoffice/external/signalr'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; const UMB_LOCALSTORAGE_SESSION_KEY = 'umb:previewSessions'; @@ -29,7 +34,7 @@ export class UmbPreviewContext extends UmbContextBase { #culture?: string | null; #segment?: string | null; #serverUrl: string = ''; - #webSocket?: WebSocket; + #connection?: HubConnection; #iframeReady = new UmbBooleanState(false); public readonly iframeReady = this.#iframeReady.asObservable(); @@ -39,12 +44,13 @@ export class UmbPreviewContext extends UmbContextBase { #documentPreviewRepository = new UmbDocumentPreviewRepository(this); + #notificationContext?: typeof UMB_NOTIFICATION_CONTEXT.TYPE; + #localize = new UmbLocalizationController(this); + constructor(host: UmbControllerHost) { super(host, UMB_PREVIEW_CONTEXT); this.consumeContext(UMB_SERVER_CONTEXT, (instance) => { - this.#serverUrl = instance?.getServerUrl() ?? ''; - const params = new URLSearchParams(window.location.search); this.#unique = params.get('id'); @@ -56,37 +62,75 @@ export class UmbPreviewContext extends UmbContextBase { return; } - this.#setPreviewUrl(); + const serverUrl = instance?.getServerUrl(); + + if (!serverUrl) { + console.error('No server URL found in context'); + return; + } + + this.#serverUrl = serverUrl; + + this.#setPreviewUrl({ serverUrl }); + + this.#initHubConnection(serverUrl); + }); + + this.consumeContext(UMB_NOTIFICATION_CONTEXT, (instance) => { + this.#notificationContext = instance; }); } - #configureWebSocket() { - if (this.#webSocket && this.#webSocket.readyState < 2) return; + async #initHubConnection(serverUrl: string) { + const previewHubUrl = `${serverUrl}/umbraco/PreviewHub`; - const url = `${this.#serverUrl.replace('https://', 'wss://')}/umbraco/PreviewHub`; + // Make sure that no previous connection exists. + if (this.#connection) { + await this.#connection.stop(); + this.#connection = undefined; + } - this.#webSocket = new WebSocket(url); + this.#connection = new HubConnectionBuilder().withUrl(previewHubUrl).build(); - this.#webSocket.addEventListener('open', () => { - // NOTE: SignalR protocol handshake; it requires a terminating control character. - const endChar = String.fromCharCode(30); - this.#webSocket?.send(`{"protocol":"json","version":1}${endChar}`); - }); - - this.#webSocket.addEventListener('message', (event: MessageEvent) => { - if (!event?.data) return; - - // NOTE: Strip the terminating control character, (from SignalR). - const data = event.data.substring(0, event.data.length - 1); - const json = JSON.parse(data) as { type: number; target: string; arguments: Array }; - - if (json.type === 1 && json.target === 'refreshed') { - const pageId = json.arguments?.[0]; - if (pageId === this.#unique) { - this.#setPreviewUrl({ rnd: Math.random() }); - } + this.#connection.on('refreshed', (payload) => { + if (payload === this.#unique) { + this.#setPreviewUrl({ rnd: Math.random() }); } }); + + this.#connection.onclose(() => { + this.#notificationContext?.peek('warning', { + data: { + headline: this.#localize.term('general_preview'), + message: this.#localize.term('preview_connectionLost'), + }, + }); + }); + + try { + await this.#connection.start(); + } catch (error) { + console.error('The SignalR connection could not be established', error); + this.#notificationContext?.peek('warning', { + data: { + headline: this.#localize.term('general_preview'), + message: this.#localize.term('preview_connectionFailed'), + }, + }); + } + } + + async #getPublishedUrl(): Promise { + if (!this.#unique) return null; + + // NOTE: We should be reusing `UmbDocumentUrlRepository` here, but the preview app doesn't register the `itemStore` extensions, so can't resolve/consume `UMB_DOCUMENT_URL_STORE_CONTEXT`. [LK] + const { data } = await tryExecute(this, DocumentService.getDocumentUrls({ query: { id: [this.#unique] } })); + + if (!data?.length) return null; + const urlInfo = this.#culture ? data[0].urlInfos.find((x) => x.culture === this.#culture) : data[0].urlInfos[0]; + + if (!urlInfo?.url) return null; + return urlInfo.url.startsWith('/') ? `${this.#serverUrl}${urlInfo.url}` : urlInfo.url; } #getSessionCount(): number { @@ -129,7 +173,7 @@ export class UmbPreviewContext extends UmbContextBase { params.delete(segmentParam); } - const previewUrl = new URL(url.pathname + '?' + params.toString(), host); + const previewUrl = new URL(`${url.pathname}?${params.toString()}`, host); const previewUrlString = previewUrl.toString(); this.#previewUrl.setValue(previewUrlString); @@ -165,12 +209,17 @@ export class UmbPreviewContext extends UmbContextBase { await this.#documentPreviewRepository.exit(); } - if (this.#webSocket) { - this.#webSocket.close(); - this.#webSocket = undefined; + if (this.#connection) { + await this.#connection.stop(); + this.#connection = undefined; + } + + let url = await this.#getPublishedUrl(); + + if (!url) { + url = this.#previewUrl.getValue() as string; } - const url = this.#previewUrl.getValue() as string; window.location.replace(url); } @@ -182,7 +231,6 @@ export class UmbPreviewContext extends UmbContextBase { iframeLoaded(iframe: HTMLIFrameElement) { if (!iframe) return; - this.#configureWebSocket(); this.#iframeReady.setValue(true); } @@ -190,8 +238,13 @@ export class UmbPreviewContext extends UmbContextBase { return this.getHostElement().shadowRoot?.querySelector('#wrapper') as HTMLElement; } - openWebsite() { - const url = this.#previewUrl.getValue() as string; + async openWebsite() { + let url = await this.#getPublishedUrl(); + + if (!url) { + url = this.#previewUrl.getValue() as string; + } + window.open(url, '_blank'); } diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts index fad3f6064c..02b918c910 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts @@ -80,6 +80,7 @@ export default { content: 'Indhold', administration: 'Administration', structure: 'Struktur', + general: 'Generelt', other: 'Andet', }, actionDescriptions: { @@ -2082,10 +2083,10 @@ export default { permissionsGranularHelp: 'Sæt rettigheder for specifikke noder', granularRightsLabel: 'Dokumenter', granularRightsDescription: 'Tillad adgang til specifikke dokumenter', - permissionsEntityGroup_document: 'Indhold', - permissionsEntityGroup_media: 'Medie', - permissionsEntityGroup_member: 'Medlemmer', - 'permissionsEntityGroup_document-property-value': 'Dokumentegenskabsværdi', + permissionsEntityGroup_document: 'Indholdsrettigheder', + permissionsEntityGroup_media: 'Medierettigheder', + permissionsEntityGroup_member: 'Medlemsrettigheder', + 'permissionsEntityGroup_document-property-value': 'Feltrettigheder', permissionNoVerbs: 'Ingen tilladte rettigheder', profile: 'Profil', searchAllChildren: "Søg alle 'børn'", @@ -2602,13 +2603,16 @@ export default { returnToPreviewHeadline: 'Forhåndsvisning af indholdet?', returnToPreviewDescription: 'Du har afslutet forhåndsvisning, vil du starte forhåndsvisning igen for at\n se seneste gemte version af indholdet?\n ', + returnToPreviewAcceptButton: 'Start forhåndsvisning igen', returnToPreviewDeclineButton: 'Se udgivet indhold', viewPublishedContentHeadline: 'Se udgivet indhold?', viewPublishedContentDescription: 'Du er i forhåndsvisning, vil du afslutte for at se den udgivet\n version?\n ', viewPublishedContentAcceptButton: 'Se udgivet version', viewPublishedContentDeclineButton: 'Forbliv i forhåndsvisning', - returnToPreviewAcceptButton: 'Preview latest version', + connectionFailed: + 'Kunne ikke etablere forbindelse til serveren, forhåndsvisning af liveopdateringer vil ikke fungere.', + connectionLost: 'Forbindelse til serveren mistet, forhåndsvisning af liveopdateringer vil ikke fungere.', }, permissions: { FolderCreation: 'Mappeoprettelse', 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 70a0ad0020..810d9dbb4d 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -81,6 +81,7 @@ export default { content: 'Content', administration: 'Administration', structure: 'Structure', + general: 'General', other: 'Other', }, actionDescriptions: { @@ -294,6 +295,7 @@ export default { titleOptional: 'Title (optional)', altTextOptional: 'Alternative text (optional)', captionTextOptional: 'Caption (optional)', + trashed: 'Trashed', type: 'Type', unpublish: 'Unpublish', unpublished: 'Unpublished', @@ -472,16 +474,16 @@ export default { "Defines a re-usable set of properties that can be included in the definition of multiple other Document Types. For example, a set of 'Common Page Settings'.", folder: 'Folder', folderDescription: 'Used to organise items and other folders. Keep items structured and easy to access.', - newFolder: 'New folder', - newDataType: 'New Data Type', + newFolder: 'Folder', + newDataType: 'Data Type', newDataTypeDescription: 'Used to define a configuration for a Property Type on a Content Type.', - newJavascriptFile: 'New JavaScript file', - newEmptyPartialView: 'New empty partial view', - newPartialViewMacro: 'New partial view macro', - newPartialViewFromSnippet: 'New partial view from snippet', - newPartialViewMacroFromSnippet: 'New partial view macro from snippet', - newPartialViewMacroNoMacro: 'New partial view macro (without macro)', - newStyleSheetFile: 'New Stylesheet', + newJavascriptFile: 'JavaScript file', + newEmptyPartialView: 'Empty partial view', + newPartialViewMacro: 'Partial view macro', + newPartialViewFromSnippet: 'Partial view from snippet', + newPartialViewMacroFromSnippet: 'Partial view macro from snippet', + newPartialViewMacroNoMacro: 'Partial view macro (without macro)', + newStyleSheetFile: 'Stylesheet file', }, dashboard: { browser: 'Browse your website', @@ -2105,10 +2107,10 @@ export default { permissionsGranularHelp: 'Set permissions for specific nodes', granularRightsLabel: 'Documents', granularRightsDescription: 'Assign permissions to specific documents', - permissionsEntityGroup_document: 'Document', - permissionsEntityGroup_media: 'Media', - permissionsEntityGroup_member: 'Member', - 'permissionsEntityGroup_document-property-value': 'Document Property Value', + permissionsEntityGroup_document: 'Document permissions', + permissionsEntityGroup_media: 'Media permissions', + permissionsEntityGroup_member: 'Member permissions', + 'permissionsEntityGroup_document-property-value': 'Document Property Value permissions', permissionNoVerbs: 'No allowed permissions', profile: 'Profile', searchAllChildren: 'Search all children', @@ -2749,6 +2751,8 @@ export default { 'You are in Preview Mode, do you want exit in order to view the published version of your website?', viewPublishedContentAcceptButton: 'View published version', viewPublishedContentDeclineButton: 'Stay in preview mode', + connectionFailed: 'Could not establish a connection to the server, preview live updates will not work.', + connectionLost: 'Connection to the server lost, preview live updates will not work.', }, permissions: { FolderCreation: 'Folder creation', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/index.ts index 5f32ec4ac1..20dce58e6a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/index.ts @@ -9,6 +9,7 @@ export * from './conditions/index.js'; export * from './constants.js'; export * from './default/collection-default.element.js'; export * from './global-components.js'; +export * from './menu/index.js'; export * from './workspace-view/index.js'; export * from './default/collection-default.context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/constants.ts index 5b0fcb8cb6..edcb448557 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/constants.ts @@ -1 +1,2 @@ export { UMB_COLLECTION_MENU_CONTEXT } from './default/default-collection-menu.context.token.js'; +export * from './menu-item/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/default/default-collection-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/default/default-collection-menu.element.ts index a1273792f3..c4f74e2085 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/default/default-collection-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/default/default-collection-menu.element.ts @@ -1,7 +1,6 @@ import type { UmbCollectionItemModel } from '../../item/types.js'; import type { UmbCollectionSelectionConfiguration } from '../../types.js'; import type { UmbDefaultCollectionMenuContext } from './default-collection-menu.context.js'; -import { getItemFallbackIcon, getItemFallbackName } from '@umbraco-cms/backoffice/entity-item'; import { html, customElement, @@ -14,6 +13,8 @@ import { } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import '../menu-item/collection-menu-item.element.js'; + @customElement('umb-default-collection-menu') export class UmbDefaultCollectionMenuElement extends UmbLitElement { private _api: UmbDefaultCollectionMenuContext | undefined; @@ -115,16 +116,11 @@ export class UmbDefaultCollectionMenuElement extends UmbLitElement { #renderItem(item: UmbCollectionItemModel) { return html` - this._api?.selection.select(item.unique)} - @deselected=${() => this._api?.selection.deselect(item.unique)} - ?selected=${this._api?.selection.isSelected(item.unique)}> - ${item.icon - ? html`` - : html``} - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/index.ts new file mode 100644 index 0000000000..91173f9401 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/index.ts @@ -0,0 +1 @@ +export * from './menu-item/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item-context.interface.ts new file mode 100644 index 0000000000..22082ea596 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item-context.interface.ts @@ -0,0 +1,17 @@ +import type { UmbCollectionItemModel } from '../../item/types.js'; +import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import type { UmbApi } from '@umbraco-cms/backoffice/extension-api'; +import type { UmbContextMinimal } from '@umbraco-cms/backoffice/context-api'; + +export interface UmbCollectionMenuItemContext< + CollectionMenuItemType extends UmbCollectionItemModel = UmbCollectionItemModel, +> extends UmbApi, + UmbContextMinimal { + item: Observable; + isSelectable: Observable; + isSelected: Observable; + getItem(): CollectionMenuItemType | undefined; + setItem(item: CollectionMenuItemType | undefined): void; + select(): void; + deselect(): void; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.context.token.ts new file mode 100644 index 0000000000..0b36cdf25e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.context.token.ts @@ -0,0 +1,6 @@ +import type { UmbCollectionMenuItemContext } from './collection-menu-item-context.interface.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_COLLECTION_MENU_ITEM_CONTEXT = new UmbContextToken( + 'UmbCollectionMenuItemContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.element.ts new file mode 100644 index 0000000000..90f12f474f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/collection-menu-item.element.ts @@ -0,0 +1,96 @@ +import { UmbDefaultCollectionMenuItemContext } from './default/index.js'; +import type { ManifestCollectionMenuItem } from './extension/types.js'; +import { customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { + UmbExtensionElementAndApiSlotElementBase, + umbExtensionsRegistry, +} from '@umbraco-cms/backoffice/extension-registry'; +import { createObservablePart } from '@umbraco-cms/backoffice/observable-api'; + +@customElement('umb-collection-menu-item') +export class UmbCollectionMenuItemElement extends UmbExtensionElementAndApiSlotElementBase { + @property({ type: String, reflect: true }) + get entityType() { + return this.#entityType; + } + set entityType(newVal) { + this.#entityType = newVal; + this.#observeEntityType(); + } + #entityType?: string; + + @property({ type: Object, attribute: false }) + override set props(newVal: Record | undefined) { + super.props = newVal; + this.#assignProps(); + } + override get props() { + return super.props; + } + + #observeEntityType() { + if (!this.#entityType) return; + + const filterByEntityType = (manifest: ManifestCollectionMenuItem) => { + if (!this.#entityType) return false; + return manifest.forEntityTypes.includes(this.#entityType); + }; + + // Check if we can find a matching collection menu item for the current entity type. + // If we can, we will use that one, if not we will render a fallback collection menu item. + this.observe( + // TODO: what should we do if there are multiple collection menu items for an entity type? + // This method gets all extensions based on a type, then filters them based on the entity type. and then we get the alias of the first one [NL] + createObservablePart( + umbExtensionsRegistry.byTypeAndFilter(this.getExtensionType(), filterByEntityType), + (x) => x[0]?.alias, + ), + (alias) => { + this.alias = alias; + + // If we don't find any registered collection menu items for this specific entity type, we will render a fallback collection menu item. + // This is on purpose not done with the extension initializer since we don't want to spin up a real extension unless we have to. + if (!alias) { + this.#renderFallbackItem(); + } + }, + 'umbObserveAlias', + ); + } + + #renderFallbackItem() { + // TODO: make creating of elements with apis a shared function. + const element = document.createElement('umb-default-collection-menu-item'); + const api = new UmbDefaultCollectionMenuItemContext(element); + element.api = api; + this._element = element; + this.#assignProps(); + this.requestUpdate('_element'); + } + + getExtensionType() { + return 'collectionMenuItem'; + } + + getDefaultElementName() { + return 'umb-default-collection-menu-item'; + } + + #assignProps() { + if (!this._element || !this.props) return; + + Object.keys(this.props).forEach((key) => { + (this._element as any)[key] = this.props![key]; + }); + } + + override getDefaultApiConstructor() { + return UmbDefaultCollectionMenuItemContext; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-collection-menu-item': UmbCollectionMenuItemElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/constants.ts new file mode 100644 index 0000000000..a238646f6e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/constants.ts @@ -0,0 +1 @@ +export * from './default/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/constants.ts new file mode 100644 index 0000000000..84ef3547b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/constants.ts @@ -0,0 +1 @@ +export { UMB_COLLECTION_MENU_ITEM_DEFAULT_KIND_MANIFEST } from './manifests.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.context.ts new file mode 100644 index 0000000000..a56480c6ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.context.ts @@ -0,0 +1,114 @@ +import type { UmbCollectionMenuItemContext } from '../collection-menu-item-context.interface.js'; +import { UMB_COLLECTION_MENU_ITEM_CONTEXT } from '../collection-menu-item.context.token.js'; +import type { UmbCollectionItemModel } from '../../../types.js'; +import type { ManifestCollectionMenuItem } from '../extension/types.js'; +import { UMB_COLLECTION_MENU_CONTEXT } from '../../default/default-collection-menu.context.token.js'; +import { UmbBooleanState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { map } from '@umbraco-cms/backoffice/external/rxjs'; + +export class UmbDefaultCollectionMenuItemContext< + CollectionMenuItemType extends UmbCollectionItemModel = UmbCollectionItemModel, + > + extends UmbContextBase + implements UmbCollectionMenuItemContext +{ + #manifest?: ManifestCollectionMenuItem; + + protected readonly _item = new UmbObjectState(undefined); + readonly item = this._item.asObservable(); + + #isSelectable = new UmbBooleanState(false); + readonly isSelectable = this.#isSelectable.asObservable(); + + #isSelectableContext = new UmbBooleanState(false); + readonly isSelectableContext = this.#isSelectableContext.asObservable(); + + #isSelected = new UmbBooleanState(false); + readonly isSelected = this.#isSelected.asObservable(); + + #collectionMenuContext?: typeof UMB_COLLECTION_MENU_CONTEXT.TYPE; + + constructor(host: UmbControllerHost) { + super(host, UMB_COLLECTION_MENU_ITEM_CONTEXT); + this.#consumeContexts(); + } + + async #consumeContexts() { + this.consumeContext(UMB_COLLECTION_MENU_CONTEXT, (context) => { + this.#collectionMenuContext = context; + this.#observeIsSelectable(); + this.#observeIsSelected(); + }); + } + + public set manifest(manifest: ManifestCollectionMenuItem | undefined) { + if (this.#manifest === manifest) return; + this.#manifest = manifest; + } + public get manifest() { + return this.#manifest; + } + + public setItem(item: CollectionMenuItemType | undefined) { + this._item.setValue(item); + + if (item) { + this.#observeIsSelectable(); + this.#observeIsSelected(); + } + } + + public select() { + const unique = this.getItem()?.unique; + if (!unique) throw new Error('Could not select. Unique is missing'); + this.#collectionMenuContext?.selection.select(unique); + } + + public deselect() { + const unique = this.getItem()?.unique; + if (!unique) throw new Error('Could not deselect. Unique is missing'); + this.#collectionMenuContext?.selection.deselect(unique); + } + + getItem() { + return this._item.getValue(); + } + + #observeIsSelectable() { + if (!this.#collectionMenuContext) return; + const item = this.getItem(); + if (!item) return; + + this.observe( + this.#collectionMenuContext.selection.selectable, + (value) => { + this.#isSelectableContext.setValue(value); + + // If the collection menu is selectable, check if this item is selectable + if (value === true) { + const isSelectable = this.#collectionMenuContext?.selectableFilter?.(item) ?? true; + this.#isSelectable.setValue(isSelectable); + } + }, + 'observeIsSelectable', + ); + } + + #observeIsSelected() { + if (!this.#collectionMenuContext) return; + const unique = this.getItem()?.unique; + if (!unique) return; + + this.observe( + this.#collectionMenuContext.selection.selection.pipe(map((selection) => selection.includes(unique))), + (isSelected) => { + this.#isSelected.setValue(isSelected); + }, + 'observeIsSelected', + ); + } +} + +export { UmbDefaultCollectionMenuItemContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts new file mode 100644 index 0000000000..3dfed63ced --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/default-collection-menu-item.element.ts @@ -0,0 +1,80 @@ +import type { UmbCollectionItemModel } from '../../../item/types.js'; +import type { UmbCollectionMenuItemContext } from '../collection-menu-item-context.interface.js'; +import { html, state, property, customElement, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { getItemFallbackIcon, getItemFallbackName } from '@umbraco-cms/backoffice/entity-item'; + +@customElement('umb-default-collection-menu-item') +export class UmbDefaultCollectionMenuItemElement extends UmbLitElement { + @property({ type: Object, attribute: false }) + set item(newVal: UmbCollectionItemModel) { + this._item = newVal; + + if (this._item) { + this.#initItem(); + } + } + get item(): UmbCollectionItemModel | undefined { + return this._item; + } + protected _item?: UmbCollectionItemModel; + + @property({ type: Object, attribute: false }) + public set api(value: UmbCollectionMenuItemContext | undefined) { + this.#api = value; + + if (this.#api) { + this.observe(this.#api.isSelectable, (value) => (this._isSelectable = value)); + this.observe(this.#api.isSelected, (value) => (this._isSelected = value)); + this.#initItem(); + } + } + public get api(): UmbCollectionMenuItemContext | undefined { + return this.#api; + } + #api: UmbCollectionMenuItemContext | undefined; + + @state() + protected _isActive = false; + + @state() + protected _isSelected = false; + + @state() + protected _isSelectable = false; + + #initItem() { + if (!this.#api) return; + if (!this._item) return; + this.#api.setItem(this._item); + } + + override render() { + const item = this._item; + if (!item) return nothing; + + return html` + this.#api?.select()} + @deselected=${() => this.#api?.deselect()}> + ${item.icon + ? html`` + : html``} + + `; + } + + static override styles = [UmbTextStyles]; +} + +export { UmbDefaultCollectionMenuItemElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-default-collection-menu-item': UmbDefaultCollectionMenuItemElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/index.ts new file mode 100644 index 0000000000..8ad34c7c7e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/index.ts @@ -0,0 +1,2 @@ +export { UmbDefaultCollectionMenuItemContext } from './default-collection-menu-item.context.js'; +export { UmbDefaultCollectionMenuItemElement } from './default-collection-menu-item.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/manifests.ts new file mode 100644 index 0000000000..0e55f0d326 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/default/manifests.ts @@ -0,0 +1,17 @@ +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const UMB_COLLECTION_MENU_ITEM_DEFAULT_KIND_MANIFEST: UmbExtensionManifestKind = { + type: 'kind', + alias: 'Umb.Kind.CollectionMenuItem.Default', + matchKind: 'default', + matchType: 'collectionMenuItem', + manifest: { + type: 'collectionMenuItem', + api: () => import('./default-collection-menu-item.context.js'), + element: () => import('./default-collection-menu-item.element.js'), + }, +}; + +export const manifests: Array = [ + UMB_COLLECTION_MENU_ITEM_DEFAULT_KIND_MANIFEST, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/collection-menu-item.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/collection-menu-item.extension.ts new file mode 100644 index 0000000000..4cc20eb332 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/collection-menu-item.extension.ts @@ -0,0 +1,12 @@ +import type { ManifestElementAndApi } from '@umbraco-cms/backoffice/extension-api'; + +export interface ManifestCollectionMenuItem extends ManifestElementAndApi { + type: 'collectionMenuItem'; + forEntityTypes: Array; +} + +declare global { + interface UmbExtensionManifestMap { + UmbCollectionMenuItem: ManifestCollectionMenuItem; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/types.ts new file mode 100644 index 0000000000..2cd985cb85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/extension/types.ts @@ -0,0 +1 @@ +export type * from './collection-menu-item.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/index.ts new file mode 100644 index 0000000000..e3d7b85788 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/index.ts @@ -0,0 +1,4 @@ +export * from './default/index.js'; +export * from './collection-menu-item.context.token.js'; +export * from './collection-menu-item.element.js'; +export type * from './collection-menu-item-context.interface.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/manifests.ts new file mode 100644 index 0000000000..43d020c74e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/menu/menu-item/manifests.ts @@ -0,0 +1,4 @@ +import { manifests as defaultManifests } from './default/manifests.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [...defaultManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts index ab0654c5de..176e643496 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/common/create/modal/entity-create-option-action-list-modal.element.ts @@ -132,7 +132,7 @@ export class UmbEntityCreateOptionActionListModalElement extends UmbModalBaseEle return html` ; + getItemHref(structureItem: UmbVariantStructureItemModel): string | undefined; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts index 5cd6290474..db57dba46c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts @@ -7,9 +7,14 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbAncestorsEntityContext, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; -import { UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { + UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT, + UMB_VARIANT_WORKSPACE_CONTEXT, +} from '@umbraco-cms/backoffice/workspace'; import { linkEntityExpansionEntries } from '@umbraco-cms/backoffice/utils'; import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal'; +import { UMB_SECTION_CONTEXT } from '@umbraco-cms/backoffice/section'; +import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; interface UmbMenuVariantTreeStructureWorkspaceContextBaseArgs { treeRepositoryAlias: string; @@ -31,11 +36,15 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um */ public readonly parent = this.#parent.asObservable(); + protected _sectionContext?: typeof UMB_SECTION_CONTEXT.TYPE; + #parentContext = new UmbParentEntityContext(this); #ancestorContext = new UmbAncestorsEntityContext(this); #sectionSidebarMenuContext?: typeof UMB_SECTION_SIDEBAR_MENU_SECTION_CONTEXT.TYPE; #isModalContext: boolean = false; #isNew: boolean | undefined = undefined; + #variantWorkspaceContext?: typeof UMB_VARIANT_WORKSPACE_CONTEXT.TYPE; + #workspaceActiveVariantId?: UmbVariantId; public readonly IS_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT = true; @@ -49,6 +58,16 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um this.#isModalContext = modalContext !== undefined; }); + this.consumeContext(UMB_SECTION_CONTEXT, (instance) => { + this._sectionContext = instance; + }); + + this.consumeContext(UMB_VARIANT_WORKSPACE_CONTEXT, (instance) => { + if (!instance) return; + this.#variantWorkspaceContext = instance; + this.#observeWorkspaceActiveVariant(); + }); + this.consumeContext(UMB_SECTION_SIDEBAR_MENU_SECTION_CONTEXT, (instance) => { this.#sectionSidebarMenuContext = instance; }); @@ -75,6 +94,10 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um }); } + getItemHref(structureItem: UmbVariantStructureItemModel): string | undefined { + return `section/${this._sectionContext?.getPathname()}/workspace/${structureItem.entityType}/edit/${structureItem.unique}/${this.#workspaceActiveVariantId?.toCultureString()}`; + } + async #requestStructure() { const isNew = this.#workspaceContext?.getIsNew(); const uniqueObservable = isNew @@ -191,6 +214,19 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um this.#sectionSidebarMenuContext?.expansion.expandItems(expandableItemsWithMenuItem); } + #observeWorkspaceActiveVariant() { + this.observe( + this.#variantWorkspaceContext?.splitView.activeVariantsInfo, + (value) => { + if (!value) return; + if (value?.length === 0) return; + this.#workspaceActiveVariantId = UmbVariantId.Create(value[0]); + }, + + 'breadcrumbWorkspaceActiveVariantObserver', + ); + } + override destroy(): void { super.destroy(); this.#structure.destroy(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts index 9e7d4fe545..bd395ad28f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/index.ts @@ -1,3 +1,6 @@ export { UmbTrashEntityAction, UmbEntityTrashedEvent } from './trash/index.js'; -export { UmbRestoreFromRecycleBinEntityAction } from './restore-from-recycle-bin/restore-from-recycle-bin.action.js'; +export { + UmbRestoreFromRecycleBinEntityAction, + UmbEntityRestoredFromRecycleBinEvent, +} from './restore-from-recycle-bin/index.js'; export { UmbEmptyRecycleBinEntityAction } from './empty-recycle-bin/empty-recycle-bin.action.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/index.ts index 98805cb678..171b7ec3ed 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/index.ts @@ -1,2 +1,3 @@ export * from './restore-from-recycle-bin.action.js'; +export * from './restore-from-recycle-bin.event.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/restore-from-recycle-bin.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/restore-from-recycle-bin.action.ts index af7d3e410c..d5043008b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/restore-from-recycle-bin.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/entity-action/restore-from-recycle-bin/restore-from-recycle-bin.action.ts @@ -1,5 +1,6 @@ import { UMB_RESTORE_FROM_RECYCLE_BIN_MODAL } from './modal/restore-from-recycle-bin-modal.token.js'; import type { MetaEntityActionRestoreFromRecycleBinKind } from './types.js'; +import { UmbEntityRestoredFromRecycleBinEvent } from './restore-from-recycle-bin.event.js'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import { UmbEntityActionBase, @@ -45,6 +46,13 @@ export class UmbRestoreFromRecycleBinEntityAction extends UmbEntityActionBase = meta: { icon: 'icon-folder', label: '#create_folder', - description: '#create_folderDescription', + additionalOptions: true, }, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-breadcrumb/workspace-variant-menu-breadcrumb/workspace-variant-menu-breadcrumb.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-breadcrumb/workspace-variant-menu-breadcrumb/workspace-variant-menu-breadcrumb.element.ts index 04fe73d6a8..b11b0459c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-breadcrumb/workspace-variant-menu-breadcrumb/workspace-variant-menu-breadcrumb.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-breadcrumb/workspace-variant-menu-breadcrumb/workspace-variant-menu-breadcrumb.element.ts @@ -6,7 +6,6 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; import { UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/menu'; -import { UMB_SECTION_CONTEXT } from '@umbraco-cms/backoffice/section'; import type { UmbAppLanguageContext } from '@umbraco-cms/backoffice/language'; import type { UmbVariantStructureItemModel } from '@umbraco-cms/backoffice/menu'; @@ -24,10 +23,9 @@ export class UmbWorkspaceVariantMenuBreadcrumbElement extends UmbLitElement { @state() private _appDefaultCulture?: string; - #sectionContext?: typeof UMB_SECTION_CONTEXT.TYPE; #workspaceContext?: UmbVariantDatasetWorkspaceContext; #appLanguageContext?: UmbAppLanguageContext; - #structureContext?: typeof UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT.TYPE; + #menuStructureContext?: typeof UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT.TYPE; constructor() { super(); @@ -37,10 +35,6 @@ export class UmbWorkspaceVariantMenuBreadcrumbElement extends UmbLitElement { this.#observeDefaultCulture(); }); - this.consumeContext(UMB_SECTION_CONTEXT, (instance) => { - this.#sectionContext = instance; - }); - this.consumeContext(UMB_VARIANT_WORKSPACE_CONTEXT, (instance) => { if (!instance) return; this.#workspaceContext = instance; @@ -50,15 +44,15 @@ export class UmbWorkspaceVariantMenuBreadcrumbElement extends UmbLitElement { this.consumeContext(UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT, (instance) => { if (!instance) return; - this.#structureContext = instance; + this.#menuStructureContext = instance; this.#observeStructure(); }); } #observeStructure() { - if (!this.#structureContext || !this.#workspaceContext) return; + if (!this.#menuStructureContext || !this.#workspaceContext) return; - this.observe(this.#structureContext.structure, (value) => { + this.observe(this.#menuStructureContext.structure, (value) => { if (!this.#workspaceContext) return; const unique = this.#workspaceContext.getUnique(); // exclude the current unique from the structure. We append this with an observer of the name @@ -113,15 +107,8 @@ export class UmbWorkspaceVariantMenuBreadcrumbElement extends UmbLitElement { return structureItem.variants?.[0]?.name ?? '(#general_unknown)'; } - #getHref(structureItem: any) { - if (structureItem.isFolder) return undefined; - - let href = `section/${this.#sectionContext?.getPathname()}`; - if (structureItem.unique) { - href += `/workspace/${structureItem.entityType}/edit/${structureItem.unique}/${this._workspaceActiveVariantId?.toCultureString()}`; - } - - return href; + #getHref(structureItem: UmbVariantStructureItemModel) { + return this.#menuStructureContext?.getItemHref(structureItem); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/create/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/create/folder/manifests.ts index dc54d290a8..d9abd0ac95 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/create/folder/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/entity-actions/create/folder/manifests.ts @@ -12,7 +12,7 @@ export const manifests: Array = meta: { icon: 'icon-folder', label: '#create_folder', - description: '#create_folderDescription', + additionalOptions: true, folderRepositoryAlias: UMB_DATA_TYPE_FOLDER_REPOSITORY_ALIAS, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/constants.ts index 6d261ca4c4..4cb54e036c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/constants.ts @@ -14,7 +14,6 @@ export const UMB_DOCUMENT_BLUEPRINT_OPTIONS_CREATE_MODAL = new UmbModalToken< UmbDocumentBlueprintOptionsCreateModalValue >('Umb.Modal.DocumentBlueprintOptionsCreate', { modal: { - type: 'sidebar', - size: 'small', + type: 'dialog', }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/document-blueprint-options-create-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/document-blueprint-options-create-modal.element.ts index 41bb58dc45..93e188b442 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/document-blueprint-options-create-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/entity-actions/create/modal/document-blueprint-options-create-modal.element.ts @@ -8,9 +8,13 @@ import type { } from './constants.js'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { html, customElement, css, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { type UmbSelectedEvent, UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; -import { UmbCreateFolderEntityAction, type UmbTreeElement } from '@umbraco-cms/backoffice/tree'; +import { UmbModalBaseElement, umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbCreateFolderEntityAction } from '@umbraco-cms/backoffice/tree'; +import { + UMB_DOCUMENT_TYPE_PICKER_MODAL, + type UmbDocumentTypeTreeItemModel, +} from '@umbraco-cms/backoffice/document-type'; @customElement('umb-document-blueprint-options-create-modal') export class UmbDocumentBlueprintOptionsCreateModalElement extends UmbModalBaseElement< @@ -58,40 +62,42 @@ export class UmbDocumentBlueprintOptionsCreateModalElement extends UmbModalBaseE .catch(() => {}); } - #onSelected(event: UmbSelectedEvent) { + async #onCreateBlueprintClick(event: PointerEvent) { event.stopPropagation(); - const element = event.target as UmbTreeElement; - this.value = { documentTypeUnique: element.getSelection()[0] }; + const value = await umbOpenModal(this, UMB_DOCUMENT_TYPE_PICKER_MODAL, { + data: { + hideTreeRoot: true, + pickableFilter: (item: UmbDocumentTypeTreeItemModel) => item.isElement == false, + }, + }); + + const selection = value.selection.filter((x) => x !== null); + this.value = { documentTypeUnique: selection[0] }; this.modalContext?.dispatchEvent(new UmbSelectionChangeEvent()); this._submitModal(); } override render() { return html` - - - - - - - - - Select the Document Type you want to make a Document Blueprint for - - item.isElement == false, - }} - @selected=${this.#onSelected}> - + + + + + + + - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/folder/manifests.ts index 95ba1046b7..a7d9d0365f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/folder/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/entity-actions/create/folder/manifests.ts @@ -15,7 +15,7 @@ export const manifests: Array = meta: { icon: 'icon-folder', label: '#create_folder', - description: '#create_folderDescription', + additionalOptions: true, folderRepositoryAlias: UMB_DOCUMENT_TYPE_FOLDER_REPOSITORY_ALIAS, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/document-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/document-menu-structure.context.ts index 55caf0e070..6076367a37 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/document-menu-structure.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/document-menu-structure.context.ts @@ -1,11 +1,23 @@ import { UMB_DOCUMENT_TREE_REPOSITORY_ALIAS } from '../tree/index.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbMenuVariantTreeStructureWorkspaceContextBase } from '@umbraco-cms/backoffice/menu'; +import { + UmbMenuVariantTreeStructureWorkspaceContextBase, + type UmbVariantStructureItemModel, +} from '@umbraco-cms/backoffice/menu'; export class UmbDocumentMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { constructor(host: UmbControllerHost) { super(host, { treeRepositoryAlias: UMB_DOCUMENT_TREE_REPOSITORY_ALIAS }); } + + override getItemHref(structureItem: UmbVariantStructureItemModel): string | undefined { + // The Document menu does not have a root item, so we do not have a href for it. + if (!structureItem.unique) { + return `section/${this._sectionContext?.getPathname()}`; + } else { + return super.getItemHref(structureItem); + } + } } export default UmbDocumentMenuStructureContext; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/manifests.ts index 50309def83..d12af0177c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/menu/manifests.ts @@ -1,3 +1,4 @@ +import { UMB_DOCUMENT_WORKSPACE_ALIAS } from '../constants.js'; import { UMB_DOCUMENT_TREE_ALIAS } from '../tree/index.js'; import { UMB_DOCUMENT_MENU_ITEM_ALIAS } from './constants.js'; import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; @@ -36,7 +37,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, - match: 'Umb.Workspace.Document', + match: UMB_DOCUMENT_WORKSPACE_ALIAS, }, { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, @@ -51,7 +52,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, - match: 'Umb.Workspace.Document', + match: UMB_DOCUMENT_WORKSPACE_ALIAS, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/constants.ts index 0002782c2b..15ea9ece9b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/constants.ts @@ -1,3 +1,4 @@ +export * from './menu/constants.js'; export * from './repository/constants.js'; +export * from './root/constants.js'; export * from './tree/constants.js'; -export const UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE = 'document-recycle-bin-root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/manifests.ts index cc3ebf859f..3bf5f764f5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/manifests.ts @@ -1,6 +1,7 @@ import { manifests as entityActionManifests } from './entity-action/manifests.js'; -import { manifests as menuItemManifests } from './menu-item/manifests.js'; +import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as rootManifests } from './root/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; export const manifests: Array = [ @@ -11,7 +12,8 @@ export const manifests: Array = [ api: () => import('./allow-document-recycle-bin.condition.js'), }, ...entityActionManifests, - ...menuItemManifests, + ...menuManifests, ...repositoryManifests, + ...rootManifests, ...treeManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu-item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu-item/manifests.ts deleted file mode 100644 index 65fca591fd..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu-item/manifests.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { UMB_CONTENT_MENU_ALIAS } from '../../menu/manifests.js'; -import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS } from '../constants.js'; - -export const manifests: Array = [ - { - type: 'menuItem', - kind: 'tree', - alias: 'Umb.MenuItem.Document.RecycleBin', - name: 'Document Recycle Bin Menu Item', - weight: 100, - meta: { - treeAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS, - label: 'Recycle Bin', - icon: 'icon-trash', - menus: [UMB_CONTENT_MENU_ALIAS], - }, - conditions: [ - { - alias: 'Umb.Condition.CurrentUser.AllowDocumentRecycleBin', - }, - ], - }, -]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/constants.ts new file mode 100644 index 0000000000..b7af8eaecb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/constants.ts @@ -0,0 +1 @@ +export const UMB_DOCUMENT_RECYCLE_BIN_MENU_ITEM_ALIAS = 'Umb.MenuItem.Document.RecycleBin'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/document-recycle-bin-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/document-recycle-bin-menu-structure.context.ts new file mode 100644 index 0000000000..94a68d442f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/document-recycle-bin-menu-structure.context.ts @@ -0,0 +1,11 @@ +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS } from '../tree/constants.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbMenuVariantTreeStructureWorkspaceContextBase } from '@umbraco-cms/backoffice/menu'; + +export class UmbDocumentRecycleBinMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { + constructor(host: UmbControllerHost) { + super(host, { treeRepositoryAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS }); + } +} + +export { UmbDocumentRecycleBinMenuStructureContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/manifests.ts new file mode 100644 index 0000000000..9b53239f0f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/menu/manifests.ts @@ -0,0 +1,46 @@ +import { UMB_CONTENT_MENU_ALIAS } from '../../menu/manifests.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS } from '../constants.js'; +import { UMB_DOCUMENT_WORKSPACE_ALIAS } from '../../constants.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_MENU_ITEM_ALIAS } from './constants.js'; +import { UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'menuItem', + kind: 'tree', + alias: UMB_DOCUMENT_RECYCLE_BIN_MENU_ITEM_ALIAS, + name: 'Document Recycle Bin Menu Item', + weight: 100, + meta: { + treeAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS, + label: 'Recycle Bin', + icon: 'icon-trash', + menus: [UMB_CONTENT_MENU_ALIAS], + }, + conditions: [ + { + alias: 'Umb.Condition.CurrentUser.AllowDocumentRecycleBin', + }, + ], + }, + { + type: 'workspaceContext', + kind: 'menuStructure', + name: 'Document Recycle Bin Menu Structure Workspace Context', + alias: 'Umb.Context.DocumentRecycleBin.Menu.Structure', + api: () => import('./document-recycle-bin-menu-structure.context.js'), + meta: { + menuItemAlias: UMB_DOCUMENT_RECYCLE_BIN_MENU_ITEM_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_DOCUMENT_WORKSPACE_ALIAS, + }, + { + alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/constants.ts new file mode 100644 index 0000000000..2fb39332b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/constants.ts @@ -0,0 +1,2 @@ +export { UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from './entity.js'; +export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/entity.ts new file mode 100644 index 0000000000..56a25afd0f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/entity.ts @@ -0,0 +1,3 @@ +export const UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE = 'document-recycle-bin-root'; + +export type UmbDocumentRecycleBinRootEntityType = typeof UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/manifests.ts new file mode 100644 index 0000000000..f3edde04ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as workspaceManifests } from './workspace/manifests.js'; + +export const manifests: Array = [...workspaceManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/constants.ts new file mode 100644 index 0000000000..7ed8ef9286 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/constants.ts @@ -0,0 +1 @@ +export const UMB_DOCUMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS = 'Umb.Workspace.Document.RecycleBin.Root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/manifests.ts new file mode 100644 index 0000000000..fb21ec4e50 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/root/workspace/manifests.ts @@ -0,0 +1,35 @@ +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from '../../tree/constants.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS } from './constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspace', + kind: 'default', + alias: UMB_DOCUMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + name: 'Document Recycle Bin Root Workspace', + meta: { + entityType: UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, + headline: '#general_recycleBin', + }, + }, + { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.DocumentRecycleBinRoot.Root', + name: 'Document Recycle Bin Root Collection Workspace View', + meta: { + label: 'Collection', + pathname: 'collection', + icon: 'icon-layers', + collectionAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_DOCUMENT_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/constants.ts index f6af6f8ac0..2eb9967b50 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/constants.ts @@ -6,3 +6,5 @@ export const UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_ALIAS = 'Umb.Store.Document.Rec export const UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS = 'Umb.Tree.Document.RecycleBin'; export { UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_CONTEXT } from './data/document-recycle-bin-tree.store.context-token.js'; + +export * from './tree-item-children/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/manifests.ts index c4b3774d97..17637d2cbd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/manifests.ts @@ -1,8 +1,8 @@ -import { UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../constants.js'; import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS, UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS } from './constants.js'; import { manifests as dataManifests } from './data/manifests.js'; import { manifests as reloadTreeItemChildrenManifests } from './reload-tree-item-children/manifests.js'; import { manifests as rootTreeItemManifests } from './tree-item/manifests.js'; +import { manifests as treeItemChildrenManifests } from './tree-item-children/manifests.js'; export const manifests: Array = [ { @@ -14,17 +14,8 @@ export const manifests: Array = [ repositoryAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS, }, }, - { - type: 'workspace', - kind: 'default', - alias: 'Umb.Workspace.Document.RecycleBin.Root', - name: 'Document Recycle Bin Root Workspace', - meta: { - entityType: UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE, - headline: '#general_recycleBin', - }, - }, ...dataManifests, ...reloadTreeItemChildrenManifests, ...rootTreeItemManifests, + ...treeItemChildrenManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/constants.ts new file mode 100644 index 0000000000..a5b3be6459 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/constants.ts @@ -0,0 +1,3 @@ +export const UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS = + 'Umb.Collection.DocumentRecycleBin.TreeItemChildren'; +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/index.ts new file mode 100644 index 0000000000..6c11f6abbb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/manifests.ts new file mode 100644 index 0000000000..356876e066 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/manifests.ts @@ -0,0 +1,18 @@ +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as viewManifests } from './views/manifests.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from './constants.js'; +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; + +export const manifests: Array = [ + { + type: 'collection', + kind: 'default', + alias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + name: 'Document Recycle Bin Tree Item Children Collection', + meta: { + repositoryAlias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + }, + }, + ...repositoryManifests, + ...viewManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/constants.ts new file mode 100644 index 0000000000..9b43b21276 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/constants.ts @@ -0,0 +1,2 @@ +export const UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS = + 'Umb.Repository.DocumentRecycleBin.TreeItemChildrenCollection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/document-recycle-bin-tree-item-children-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/document-recycle-bin-tree-item-children-collection.repository.ts new file mode 100644 index 0000000000..a14b067aa8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/document-recycle-bin-tree-item-children-collection.repository.ts @@ -0,0 +1,33 @@ +import { UmbDocumentRecycleBinTreeRepository } from '../../../index.js'; +import type { UmbCollectionFilterModel, UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { UMB_ENTITY_CONTEXT, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export class UmbDocumentRecycleBinTreeItemChildrenCollectionRepository + extends UmbRepositoryBase + implements UmbCollectionRepository +{ + #treeRepository = new UmbDocumentRecycleBinTreeRepository(this); + + async requestCollection(filter: UmbCollectionFilterModel) { + // TODO: get parent from args + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) throw new Error('Entity context not found'); + + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Unique not found'); + + const parent: UmbEntityModel = { entityType, unique }; + + if (parent.unique === null) { + return this.#treeRepository.requestTreeRootItems({ skip: filter.skip, take: filter.take }); + } else { + return this.#treeRepository.requestTreeItemsOf({ parent, skip: filter.skip, take: filter.take }); + } + } +} + +export { UmbDocumentRecycleBinTreeItemChildrenCollectionRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/index.ts new file mode 100644 index 0000000000..4f07201dcf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts new file mode 100644 index 0000000000..437753a9af --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + name: 'Document Recycle Bin Tree Item Children Collection Repository', + api: () => import('./document-recycle-bin-tree-item-children-collection.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/types.ts new file mode 100644 index 0000000000..ac2209ac74 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/types.ts @@ -0,0 +1,6 @@ +import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbDocumentRecycleBinTreeItemChildrenCollectionFilterModel extends UmbCollectionFilterModel { + parent: UmbEntityModel; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/document-recycle-bin-tree-item-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/document-recycle-bin-tree-item-table-collection-view.element.ts new file mode 100644 index 0000000000..58feca6dd5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/document-recycle-bin-tree-item-table-collection-view.element.ts @@ -0,0 +1,101 @@ +import type { UmbDocumentRecycleBinTreeItemModel } from '../../../types.js'; +import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; +import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; +import type { UmbTableColumn, UmbTableConfig, UmbTableItem } from '@umbraco-cms/backoffice/components'; +import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; + +import './trashed-document-name-table-column.element.js'; + +@customElement('umb-document-recycle-bin-tree-item-table-collection-view') +export class UmbDocumentRecycleBinTreeItemTableCollectionViewElement extends UmbLitElement { + @state() + private _tableConfig: UmbTableConfig = { + allowSelection: false, + }; + + @state() + private _tableColumns: Array = [ + { + name: this.localize.term('general_name'), + alias: 'name', + elementName: 'umb-trashed-document-name-table-column', + }, + { + name: '', + alias: 'entityActions', + align: 'right', + }, + ]; + + @state() + private _tableItems: Array = []; + + #collectionContext?: UmbDefaultCollectionContext; + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + constructor() { + super(); + this.#isTrashedContext.setIsTrashed(true); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { + this.#collectionContext = instance; + this.#observeCollectionItems(); + }); + } + + #observeCollectionItems() { + if (!this.#collectionContext) return; + this.observe(this.#collectionContext.items, (items) => this.#createTableItems(items), 'umbCollectionItemsObserver'); + } + + #createTableItems(items: Array) { + this._tableItems = items.map((item) => { + return { + id: item.unique, + icon: item.documentType.icon, + data: [ + { + columnAlias: 'name', + value: item, + }, + { + columnAlias: 'entityActions', + value: html``, + }, + ], + }; + }); + } + + override render() { + return html` + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbDocumentRecycleBinTreeItemTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + ['umb-document-recycle-bin-tree-item-table-collection-view']: UmbDocumentRecycleBinTreeItemTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/manifests.ts new file mode 100644 index 0000000000..ef40a35fe2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + alias: 'Umb.CollectionView.DocumentRecycleBin.TreeItem.Table', + name: 'Document Recycle Bin Tree Item Table Collection View', + element: () => import('./document-recycle-bin-tree-item-table-collection-view.element.js'), + weight: 300, + meta: { + label: 'Table', + icon: 'icon-list', + pathName: 'table', + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_DOCUMENT_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/trashed-document-name-table-column.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/trashed-document-name-table-column.element.ts new file mode 100644 index 0000000000..fffd779a9e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/collection/views/trashed-document-name-table-column.element.ts @@ -0,0 +1,62 @@ +import { UmbDocumentItemDataResolver } from '../../../../../item/index.js'; +import type { UmbDocumentRecycleBinTreeItemModel } from '../../../types.js'; +import { UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN } from '../../../../../paths.js'; +import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; + +@customElement('umb-trashed-document-name-table-column') +export class UmbTrashedDocumentNameTableColumnElement extends UmbLitElement implements UmbTableColumnLayoutElement { + #resolver = new UmbDocumentItemDataResolver(this); + + @state() + private _name = ''; + + @state() + private _editPath = ''; + + column!: UmbTableColumn; + item!: UmbTableItem; + + @property({ attribute: false }) + public set value(value: UmbDocumentRecycleBinTreeItemModel) { + this.#value = value; + + if (value) { + this.#resolver.setData(value); + + this._editPath = UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN.generateAbsolute({ + unique: value.unique, + }); + } + } + public get value(): UmbDocumentRecycleBinTreeItemModel { + return this.#value; + } + #value!: UmbDocumentRecycleBinTreeItemModel; + + constructor() { + super(); + this.#resolver.observe(this.#resolver.name, (name) => (this._name = name || '')); + } + + override render() { + if (!this.value) return nothing; + if (!this._name) return nothing; + return html``; + } + + static override styles = [ + css` + uui-button { + text-align: left; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-trashed-document-name-table-column': UmbTrashedDocumentNameTableColumnElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/constants.ts new file mode 100644 index 0000000000..d7b529d49c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/constants.ts @@ -0,0 +1 @@ +export * from './collection/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/manifests.ts new file mode 100644 index 0000000000..bfab902d3c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/tree-item-children/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as collectionManifests } from './collection/manifests.js'; + +export const manifests: Array = [...collectionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document-property-value/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document-property-value/manifests.ts index 3141019a4a..4f608eaa67 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document-property-value/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document-property-value/manifests.ts @@ -39,13 +39,14 @@ export const manifests: Array = alias: 'Umb.UserGranularPermission.Document.PropertyValue', name: 'Document Property Values Granular User Permission', weight: 950, + forEntityTypes: [UMB_DOCUMENT_PROPERTY_VALUE_ENTITY_TYPE], element: () => import( './input-document-property-value-user-permission/input-document-property-value-user-permission.element.js' ), meta: { schemaType: 'DocumentPropertyValuePermissionPresentationModel', - label: 'Document Property Values', + label: '#user_permissionsGranular', description: 'Assign permissions to Document property values', }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts index 4aacd927b4..a71c2f1db8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/input-document-granular-user-permission/input-document-granular-user-permission.element.ts @@ -28,7 +28,7 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro } @property({ type: Array, attribute: false }) - fallbackPermissions: Array = []; + public fallbackPermissions: Array = []; @state() private _items?: Array; @@ -50,7 +50,13 @@ export class UmbInputDocumentGranularUserPermissionElement extends UUIFormContro async #observePickedDocuments(uniques: Array) { const { asObservable } = await this.#documentItemRepository.requestItems(uniques); - this.observe(asObservable?.(), (items) => (this._items = items), 'observeItems'); + this.observe( + asObservable?.(), + (items) => { + this._items = items; + }, + 'observeItems', + ); } async #editGranularPermission(item: UmbDocumentItemModel) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/manifests.ts index f8aec35d4b..ba47700cba 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/document/manifests.ts @@ -6,7 +6,6 @@ import { UMB_USER_PERMISSION_DOCUMENT_CREATE, UMB_USER_PERMISSION_DOCUMENT_NOTIFICATIONS, UMB_USER_PERMISSION_DOCUMENT_PUBLISH, - UMB_USER_PERMISSION_DOCUMENT_PERMISSIONS, UMB_USER_PERMISSION_DOCUMENT_UNPUBLISH, UMB_USER_PERMISSION_DOCUMENT_UPDATE, UMB_USER_PERMISSION_DOCUMENT_DUPLICATE, @@ -91,7 +90,7 @@ const permissions: Array = [ description: '#actionDescriptions_publish', }, }, - { + /*{ type: 'entityUserPermission', alias: UMB_USER_PERMISSION_DOCUMENT_PERMISSIONS, name: 'Document Permissions User Permission', @@ -101,7 +100,7 @@ const permissions: Array = [ label: '#actions_setPermissions', description: '#actionDescriptions_rights', }, - }, + },*/ { type: 'entityUserPermission', alias: UMB_USER_PERMISSION_DOCUMENT_UNPUBLISH, @@ -204,11 +203,12 @@ export const granularPermissions: Array = [ alias: 'Umb.UserGranularPermission.Document', name: 'Document Granular User Permission', weight: 1000, + forEntityTypes: [UMB_DOCUMENT_ENTITY_TYPE], element: () => import('./input-document-granular-user-permission/input-document-granular-user-permission.element.js'), meta: { schemaType: 'DocumentPermissionPresentationModel', - label: '#user_granularRightsLabel', + label: '#user_permissionsGranular', description: '{#user_granularRightsDescription}', }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts index 66f6e15b29..9eef3cde1b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace.context.ts @@ -22,9 +22,13 @@ import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-reg import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { umbPeekError } from '@umbraco-cms/backoffice/notification'; import { UmbContentDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/content'; -import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; +import { UmbDeprecation, type UmbVariantGuardRule } from '@umbraco-cms/backoffice/utils'; import { UmbDocumentBlueprintDetailRepository } from '@umbraco-cms/backoffice/document-blueprint'; -import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import { + UmbEntityRestoredFromRecycleBinEvent, + UmbEntityTrashedEvent, + UmbIsTrashedEntityContext, +} from '@umbraco-cms/backoffice/recycle-bin'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import { UmbWorkspaceIsNewRedirectController, @@ -35,6 +39,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDocumentTypeDetailModel } from '@umbraco-cms/backoffice/document-type'; import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import type { UmbVariantPropertyGuardRule } from '@umbraco-cms/backoffice/property'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; type ContentModel = UmbDocumentDetailModel; type ContentTypeModel = UmbDocumentTypeDetailModel; @@ -64,6 +69,7 @@ export class UmbDocumentWorkspaceContext #isTrashedContext = new UmbIsTrashedEntityContext(this); #documentSegmentRepository = new UmbDocumentSegmentRepository(this); + #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; constructor(host: UmbControllerHost) { super(host, { @@ -110,6 +116,12 @@ export class UmbDocumentWorkspaceContext } }); + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (actionEventContext) => { + this.#removeEventListeners(); + this.#actionEventContext = actionEventContext; + this.#addEventListeners(); + }); + this.observe( this.contentTypeUnique, (unique) => { @@ -139,6 +151,8 @@ export class UmbDocumentWorkspaceContext null, ); + this.observe(this.isTrashed, (isTrashed) => this.#onTrashStateChange(isTrashed)); + this.routes.setRoutes([ { path: UMB_CREATE_FROM_BLUEPRINT_DOCUMENT_WORKSPACE_PATH_PATTERN.toString(), @@ -211,16 +225,6 @@ export class UmbDocumentWorkspaceContext this.#isTrashedContext.setIsTrashed(false); } - override async load(unique: string) { - const response = await super.load(unique); - - if (response?.data) { - this.#isTrashedContext.setIsTrashed(response.data.isTrashed); - } - - return response; - } - protected override async loadSegments(): Promise { this.observe( this.unique, @@ -401,6 +405,53 @@ export class UmbDocumentWorkspaceContext }, ); } + + #addEventListeners() { + this.#actionEventContext?.addEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.addEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #removeEventListeners() { + this.#actionEventContext?.removeEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.removeEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #onRecycleBinEvent = (event: UmbEntityTrashedEvent | UmbEntityRestoredFromRecycleBinEvent) => { + const unique = this.getUnique(); + const entityType = this.getEntityType(); + if (event.getUnique() !== unique || event.getEntityType() !== entityType) return; + this.reload(); + }; + + #onTrashStateChange(isTrashed?: boolean) { + this.#isTrashedContext.setIsTrashed(isTrashed ?? false); + + const guardUnique = `UMB_PREVENT_EDIT_TRASHED_ITEM`; + + if (!isTrashed) { + this.readOnlyGuard.removeRule(guardUnique); + return; + } + + const rule: UmbVariantGuardRule = { + unique: guardUnique, + permitted: true, + }; + + // TODO: Change to use property write guard when it supports making the name read-only. + this.readOnlyGuard.addRule(rule); + } + + public override destroy(): void { + this.#removeEventListeners(); + super.destroy(); + } } export default UmbDocumentWorkspaceContext; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts index 129c64ea77..12e77046e0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts @@ -14,6 +14,7 @@ import type { UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; import { UMB_SETTINGS_SECTION_ALIAS } from '@umbraco-cms/backoffice/settings'; +import { UMB_IS_TRASHED_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/recycle-bin'; @customElement('umb-document-workspace-view-info') export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { @@ -49,6 +50,9 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { @state() private _hasSettingsAccess: boolean = false; + @state() + private _isTrashed: boolean = false; + #workspaceContext?: typeof UMB_DOCUMENT_WORKSPACE_CONTEXT.TYPE; #templateRepository = new UmbTemplateItemRepository(this); #documentPublishingWorkspaceContext?: typeof UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT.TYPE; @@ -85,6 +89,16 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { this.#observePendingChanges(); }); + this.consumeContext(UMB_IS_TRASHED_ENTITY_CONTEXT, (context) => { + this.observe( + context?.isTrashed, + (isTrashed) => { + this._isTrashed = isTrashed ?? false; + }, + '_isTrashed', + ); + }); + createExtensionApiByAlias(this, UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS, [ { config: { @@ -165,6 +179,12 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { `; } + case DocumentVariantStateModel.TRASHED: + return html` + + ${this.localize.term('content_trashed')} + + `; default: return html` @@ -202,7 +222,7 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { href=${ifDefined( this._hasSettingsAccess ? editDocumentTypePath + 'edit/' + this._documentTypeUnique : undefined, )} - ?readonly=${!this._hasSettingsAccess} + ?readonly=${!this._hasSettingsAccess || this._isTrashed} name=${ifDefined(this.localize.string(this._documentTypeName ?? ''))}> @@ -231,13 +251,15 @@ export class UmbDocumentWorkspaceViewInfoElement extends UmbLitElement { href=${ifDefined( this._hasSettingsAccess ? editTemplatePath + 'edit/' + this._templateUnique : undefined, )} - ?readonly=${!this._hasSettingsAccess}> + ?readonly=${!this._hasSettingsAccess || this._isTrashed}> - - - + ${!this._isTrashed + ? html` + + ` + : nothing} ` : html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/folder/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/folder/manifests.ts index 101d70fc08..90994ed1c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/folder/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/entity-actions/create/folder/manifests.ts @@ -12,7 +12,7 @@ export const manifests: Array = meta: { icon: 'icon-folder', label: '#create_folder', - description: '#create_folderDescription', + additionalOptions: true, folderRepositoryAlias: UMB_MEDIA_TYPE_FOLDER_REPOSITORY_ALIAS, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/utils.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/utils.ts index e410f350d4..773169de4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/utils.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/audit-log/info-app/utils.ts @@ -30,6 +30,36 @@ export function getMediaHistoryTagStyleAndText(type: UmbMediaAuditLogType): Hist text: { label: 'auditTrails_smallSave', desc: 'auditTrails_save' }, }; + case UmbMediaAuditLog.COPY: + return { + style: { look: 'secondary', color: 'default' }, + text: { label: 'auditTrails_smallCopy', desc: 'auditTrails_copy' }, + }; + + case UmbMediaAuditLog.MOVE: + return { + style: { look: 'secondary', color: 'default' }, + text: { label: 'auditTrails_smallMove', desc: 'auditTrails_move' }, + }; + + case UmbMediaAuditLog.DELETE: + return { + style: { look: 'secondary', color: 'danger' }, + text: { label: 'auditTrails_smallDelete', desc: 'auditTrails_delete' }, + }; + + case UmbMediaAuditLog.SORT: + return { + style: { look: 'secondary', color: 'default' }, + text: { label: 'auditTrails_smallSort', desc: 'auditTrails_sort' }, + }; + + case UmbMediaAuditLog.CUSTOM: + return { + style: { look: 'placeholder', color: 'default' }, + text: { label: 'auditTrails_smallCustom', desc: 'auditTrails_custom' }, + }; + default: return { style: { look: 'placeholder', color: 'danger' }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/manifests.ts index 2d5ae3ad50..34de09b5b3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/manifests.ts @@ -1,4 +1,4 @@ -import { UMB_MEDIA_TREE_ALIAS } from '../constants.js'; +import { UMB_MEDIA_TREE_ALIAS, UMB_MEDIA_WORKSPACE_ALIAS } from '../constants.js'; import { UMB_MEDIA_MENU_ALIAS, UMB_MEDIA_MENU_ITEM_ALIAS } from './constants.js'; import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; @@ -34,7 +34,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, - match: 'Umb.Workspace.Media', + match: UMB_MEDIA_WORKSPACE_ALIAS, }, { alias: UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, @@ -49,7 +49,7 @@ export const manifests: Array = [ conditions: [ { alias: UMB_WORKSPACE_CONDITION_ALIAS, - match: 'Umb.Workspace.Media', + match: UMB_MEDIA_WORKSPACE_ALIAS, }, ], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/media-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/media-menu-structure.context.ts index 156b745b3c..8fe321e470 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/media-menu-structure.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/menu/media-menu-structure.context.ts @@ -1,11 +1,23 @@ import { UMB_MEDIA_TREE_REPOSITORY_ALIAS } from '../constants.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbMenuVariantTreeStructureWorkspaceContextBase } from '@umbraco-cms/backoffice/menu'; +import { + UmbMenuVariantTreeStructureWorkspaceContextBase, + type UmbVariantStructureItemModel, +} from '@umbraco-cms/backoffice/menu'; export class UmbMediaMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { constructor(host: UmbControllerHost) { super(host, { treeRepositoryAlias: UMB_MEDIA_TREE_REPOSITORY_ALIAS }); } + + override getItemHref(structureItem: UmbVariantStructureItemModel): string | undefined { + // The Media menu does not have a root item, so we do not have a href for it. + if (!structureItem.unique) { + return `section/${this._sectionContext?.getPathname()}`; + } else { + return super.getItemHref(structureItem); + } + } } export default UmbMediaMenuStructureContext; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/constants.ts index e05617eb5a..6ba1d184de 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/constants.ts @@ -1,3 +1,4 @@ -export * from './tree/constants.js'; +export * from './menu/constants.js'; export * from './repository/constants.js'; -export const UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE = 'media-recycle-bin-root'; +export * from './tree/constants.js'; +export * from './root/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts index 144684b99c..a043847c99 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts @@ -2,9 +2,10 @@ import { UMB_MEDIA_ITEM_REPOSITORY_ALIAS, UMB_MEDIA_TREE_PICKER_MODAL, UMB_MEDIA_ENTITY_TYPE, + UMB_MEDIA_RECYCLE_BIN_REPOSITORY_ALIAS, } from '../../constants.js'; -import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE, UMB_MEDIA_RECYCLE_BIN_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS } from '../../reference/constants.js'; +import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../root/entity.js'; import { manifests as bulkTrashManifests } from './bulk-trash/manifests.js'; import { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from '@umbraco-cms/backoffice/entity-action'; import { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/manifests.ts index 1765a1dd7b..ff4bb517f2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/manifests.ts @@ -1,6 +1,7 @@ import { manifests as entityActionManifests } from './entity-action/manifests.js'; -import { manifests as menuItemManifests } from './menu-item/manifests.js'; +import { manifests as menuManifests } from './menu/manifests.js'; import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as rootManifests } from './root/manifests.js'; import { manifests as treeManifests } from './tree/manifests.js'; export const manifests: Array = [ @@ -11,7 +12,8 @@ export const manifests: Array = [ api: () => import('./allow-media-recycle-bin.condition.js'), }, ...entityActionManifests, - ...menuItemManifests, + ...menuManifests, ...repositoryManifests, + ...rootManifests, ...treeManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu-item/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu-item/manifests.ts deleted file mode 100644 index fdabbc6855..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu-item/manifests.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { UMB_MEDIA_MENU_ALIAS, UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS } from '../../constants.js'; - -export const manifests: Array = [ - { - type: 'menuItem', - kind: 'tree', - alias: 'Umb.MenuItem.Media.RecycleBin', - name: 'Media Recycle Bin Menu Item', - weight: 100, - meta: { - treeAlias: UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS, - label: 'Recycle Bin', - icon: 'icon-trash', - menus: [UMB_MEDIA_MENU_ALIAS], - }, - conditions: [ - { - alias: 'Umb.Condition.CurrentUser.AllowMediaRecycleBin', - }, - ], - }, -]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/constants.ts new file mode 100644 index 0000000000..2224ce4c3b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/constants.ts @@ -0,0 +1 @@ +export const UMB_MEDIA_RECYCLE_BIN_MENU_ITEM_ALIAS = 'Umb.MenuItem.Media.RecycleBin'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/manifests.ts new file mode 100644 index 0000000000..fb7a75f6e9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/manifests.ts @@ -0,0 +1,44 @@ +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; +import { UMB_MEDIA_MENU_ALIAS, UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS, UMB_MEDIA_WORKSPACE_ALIAS } from '../../constants.js'; +import { UMB_MEDIA_RECYCLE_BIN_MENU_ITEM_ALIAS } from './constants.js'; +import { UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS } from '@umbraco-cms/backoffice/recycle-bin'; + +export const manifests: Array = [ + { + type: 'menuItem', + kind: 'tree', + alias: UMB_MEDIA_RECYCLE_BIN_MENU_ITEM_ALIAS, + name: 'Media Recycle Bin Menu Item', + weight: 100, + meta: { + treeAlias: UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS, + label: 'Recycle Bin', + icon: 'icon-trash', + menus: [UMB_MEDIA_MENU_ALIAS], + }, + conditions: [ + { + alias: 'Umb.Condition.CurrentUser.AllowMediaRecycleBin', + }, + ], + }, + { + type: 'workspaceContext', + kind: 'menuStructure', + name: 'Media Recycle Bin Menu Structure Workspace Context', + alias: 'Umb.Context.MediaRecycleBin.Menu.Structure', + api: () => import('./media-recycle-bin-menu-structure.context.js'), + meta: { + menuItemAlias: UMB_MEDIA_RECYCLE_BIN_MENU_ITEM_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_MEDIA_WORKSPACE_ALIAS, + }, + { + alias: UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/media-recycle-bin-menu-structure.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/media-recycle-bin-menu-structure.context.ts new file mode 100644 index 0000000000..0b015047e9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/menu/media-recycle-bin-menu-structure.context.ts @@ -0,0 +1,11 @@ +import { UMB_MEDIA_RECYCLE_BIN_TREE_REPOSITORY_ALIAS } from '../tree/constants.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbMenuVariantTreeStructureWorkspaceContextBase } from '@umbraco-cms/backoffice/menu'; + +export class UmbMediaRecycleBinMenuStructureContext extends UmbMenuVariantTreeStructureWorkspaceContextBase { + constructor(host: UmbControllerHost) { + super(host, { treeRepositoryAlias: UMB_MEDIA_RECYCLE_BIN_TREE_REPOSITORY_ALIAS }); + } +} + +export { UmbMediaRecycleBinMenuStructureContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/constants.ts new file mode 100644 index 0000000000..27491976dc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/constants.ts @@ -0,0 +1,2 @@ +export { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE } from './entity.js'; +export * from './workspace/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/entity.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/entity.ts new file mode 100644 index 0000000000..60448b8919 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/entity.ts @@ -0,0 +1,3 @@ +export const UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE = 'media-recycle-bin-root'; + +export type UmbMediaRecycleBinRootEntityType = typeof UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/manifests.ts new file mode 100644 index 0000000000..f3edde04ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as workspaceManifests } from './workspace/manifests.js'; + +export const manifests: Array = [...workspaceManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/constants.ts new file mode 100644 index 0000000000..48a3bf749a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/constants.ts @@ -0,0 +1 @@ +export const UMB_MEDIA_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS = 'Umb.Workspace.Media.RecycleBin.Root'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/manifests.ts new file mode 100644 index 0000000000..22276ca6a6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/root/workspace/manifests.ts @@ -0,0 +1,35 @@ +import { UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from '../../tree/constants.js'; +import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../entity.js'; +import { UMB_MEDIA_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS } from './constants.js'; +import { UMB_WORKSPACE_CONDITION_ALIAS } from '@umbraco-cms/backoffice/workspace'; + +export const manifests: Array = [ + { + type: 'workspace', + kind: 'default', + alias: UMB_MEDIA_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + name: 'Media Recycle Bin Root Workspace', + meta: { + entityType: UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE, + headline: '#general_recycleBin', + }, + }, + { + type: 'workspaceView', + kind: 'collection', + alias: 'Umb.WorkspaceView.Media.RecycleBin.Root', + name: 'Media Recycle Bin Root Collection Workspace View', + meta: { + label: 'Collection', + pathname: 'collection', + icon: 'icon-layers', + collectionAlias: UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + }, + conditions: [ + { + alias: UMB_WORKSPACE_CONDITION_ALIAS, + match: UMB_MEDIA_RECYCLE_BIN_ROOT_WORKSPACE_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/constants.ts index d5a44c7ff5..e84b5d520f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/constants.ts @@ -6,3 +6,5 @@ export const UMB_MEDIA_RECYCLE_BIN_TREE_STORE_ALIAS = 'Umb.Store.Media.RecycleBi export const UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS = 'Umb.Tree.Media.RecycleBin'; export { UMB_MEDIA_RECYCLE_BIN_TREE_STORE_CONTEXT } from './media-recycle-bin-tree.store.context-token.js'; + +export * from './tree-item-children/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/manifests.ts index 956585aea8..e51d86f3c1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/manifests.ts @@ -1,11 +1,12 @@ import { UMB_MEDIA_ENTITY_TYPE } from '../../entity.js'; -import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../constants.js'; +import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE } from '../root/entity.js'; import { UMB_MEDIA_RECYCLE_BIN_TREE_ALIAS, UMB_MEDIA_RECYCLE_BIN_TREE_REPOSITORY_ALIAS, UMB_MEDIA_RECYCLE_BIN_TREE_STORE_ALIAS, } from './constants.js'; import { manifests as reloadTreeItemChildrenManifests } from './reload-tree-item-children/manifests.js'; +import { manifests as treeItemChildrenManifests } from './tree-item-children/manifests.js'; export const manifests: Array = [ { @@ -39,15 +40,6 @@ export const manifests: Array = [ supportedEntityTypes: [UMB_MEDIA_ENTITY_TYPE], }, }, - { - type: 'workspace', - kind: 'default', - alias: 'Umb.Workspace.Media.RecycleBin.Root', - name: 'Media Recycle Bin Root Workspace', - meta: { - entityType: UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE, - headline: '#general_recycleBin', - }, - }, ...reloadTreeItemChildrenManifests, + ...treeItemChildrenManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/constants.ts new file mode 100644 index 0000000000..334e56bcd8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/constants.ts @@ -0,0 +1,3 @@ +export const UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS = + 'Umb.Collection.Media.RecycleBin.TreeItemChildren'; +export * from './repository/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/index.ts new file mode 100644 index 0000000000..6c11f6abbb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/index.ts @@ -0,0 +1,2 @@ +export * from './constants.js'; +export * from './repository/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/manifests.ts new file mode 100644 index 0000000000..9239b10314 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/manifests.ts @@ -0,0 +1,18 @@ +import { manifests as repositoryManifests } from './repository/manifests.js'; +import { manifests as viewManifests } from './views/manifests.js'; +import { UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from './constants.js'; +import { UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; + +export const manifests: Array = [ + { + type: 'collection', + kind: 'default', + alias: UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + name: 'Media Recycle Bin Tree Item Children Collection', + meta: { + repositoryAlias: UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + }, + }, + ...repositoryManifests, + ...viewManifests, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/constants.ts new file mode 100644 index 0000000000..2124b318d9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/constants.ts @@ -0,0 +1,2 @@ +export const UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS = + 'Umb.Repository.Media.RecycleBin.TreeItemChildrenCollection'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/index.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/index.ts new file mode 100644 index 0000000000..4f07201dcf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/index.ts @@ -0,0 +1 @@ +export * from './constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts new file mode 100644 index 0000000000..8e354bc7a5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/manifests.ts @@ -0,0 +1,10 @@ +import { UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'repository', + alias: UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_REPOSITORY_ALIAS, + name: 'Media Recycle Bin Tree Item Children Collection Repository', + api: () => import('./media-recycle-bin-tree-item-children-collection.repository.js'), + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/media-recycle-bin-tree-item-children-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/media-recycle-bin-tree-item-children-collection.repository.ts new file mode 100644 index 0000000000..bd0de3d126 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/repository/media-recycle-bin-tree-item-children-collection.repository.ts @@ -0,0 +1,33 @@ +import { UmbMediaRecycleBinTreeRepository } from '../../../index.js'; +import type { UmbCollectionFilterModel, UmbCollectionRepository } from '@umbraco-cms/backoffice/collection'; +import { UmbRepositoryBase } from '@umbraco-cms/backoffice/repository'; +import { UMB_ENTITY_CONTEXT, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export class UmbMediaRecycleBinTreeItemChildrenCollectionRepository + extends UmbRepositoryBase + implements UmbCollectionRepository +{ + #treeRepository = new UmbMediaRecycleBinTreeRepository(this); + + async requestCollection(filter: UmbCollectionFilterModel) { + // TODO: get parent from args + const entityContext = await this.getContext(UMB_ENTITY_CONTEXT); + if (!entityContext) throw new Error('Entity context not found'); + + const entityType = entityContext.getEntityType(); + const unique = entityContext.getUnique(); + + if (!entityType) throw new Error('Entity type not found'); + if (unique === undefined) throw new Error('Unique not found'); + + const parent: UmbEntityModel = { entityType, unique }; + + if (parent.unique === null) { + return this.#treeRepository.requestTreeRootItems({ skip: filter.skip, take: filter.take }); + } else { + return this.#treeRepository.requestTreeItemsOf({ parent, skip: filter.skip, take: filter.take }); + } + } +} + +export { UmbMediaRecycleBinTreeItemChildrenCollectionRepository as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/types.ts new file mode 100644 index 0000000000..ec21427cb0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/types.ts @@ -0,0 +1,6 @@ +import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; + +export interface UmbMediaRecycleBinTreeItemChildrenCollectionFilterModel extends UmbCollectionFilterModel { + parent: UmbEntityModel; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/manifests.ts new file mode 100644 index 0000000000..abf3fab48f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS } from '../constants.js'; +import { UMB_COLLECTION_ALIAS_CONDITION } from '@umbraco-cms/backoffice/collection'; + +export const manifests: Array = [ + { + type: 'collectionView', + alias: 'Umb.CollectionView.Media.RecycleBin.TreeItem.Table', + name: 'Media Recycle Bin Tree Item Table Collection View', + element: () => import('./media-recycle-bin-tree-item-table-collection-view.element.js'), + weight: 300, + meta: { + label: 'Table', + icon: 'icon-list', + pathName: 'table', + }, + conditions: [ + { + alias: UMB_COLLECTION_ALIAS_CONDITION, + match: UMB_MEDIA_RECYCLE_BIN_TREE_ITEM_CHILDREN_COLLECTION_ALIAS, + }, + ], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/media-recycle-bin-tree-item-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/media-recycle-bin-tree-item-table-collection-view.element.ts new file mode 100644 index 0000000000..a6fc251858 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/media-recycle-bin-tree-item-table-collection-view.element.ts @@ -0,0 +1,101 @@ +import type { UmbMediaRecycleBinTreeItemModel } from '../../../types.js'; +import type { UmbDefaultCollectionContext } from '@umbraco-cms/backoffice/collection'; +import { UMB_COLLECTION_CONTEXT } from '@umbraco-cms/backoffice/collection'; +import type { UmbTableColumn, UmbTableConfig, UmbTableItem } from '@umbraco-cms/backoffice/components'; +import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; + +import './trashed-media-name-table-column.element.js'; + +@customElement('umb-media-recycle-bin-tree-item-table-collection-view') +export class UmbMediaRecycleBinTreeItemTableCollectionViewElement extends UmbLitElement { + @state() + private _tableConfig: UmbTableConfig = { + allowSelection: false, + }; + + @state() + private _tableColumns: Array = [ + { + name: this.localize.term('general_name'), + alias: 'name', + elementName: 'umb-trashed-media-name-table-column', + }, + { + name: '', + alias: 'entityActions', + align: 'right', + }, + ]; + + @state() + private _tableItems: Array = []; + + #collectionContext?: UmbDefaultCollectionContext; + #isTrashedContext = new UmbIsTrashedEntityContext(this); + + constructor() { + super(); + this.#isTrashedContext.setIsTrashed(true); + + this.consumeContext(UMB_COLLECTION_CONTEXT, (instance) => { + this.#collectionContext = instance; + this.#observeCollectionItems(); + }); + } + + #observeCollectionItems() { + if (!this.#collectionContext) return; + this.observe(this.#collectionContext.items, (items) => this.#createTableItems(items), 'umbCollectionItemsObserver'); + } + + #createTableItems(items: Array) { + this._tableItems = items.map((item) => { + return { + id: item.unique, + icon: item.mediaType.icon, + data: [ + { + columnAlias: 'name', + value: item, + }, + { + columnAlias: 'entityActions', + value: html``, + }, + ], + }; + }); + } + + override render() { + return html` + + `; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + display: flex; + flex-direction: column; + } + `, + ]; +} + +export { UmbMediaRecycleBinTreeItemTableCollectionViewElement as element }; + +declare global { + interface HTMLElementTagNameMap { + ['umb-media-recycle-bin-tree-item-table-collection-view']: UmbMediaRecycleBinTreeItemTableCollectionViewElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/trashed-media-name-table-column.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/trashed-media-name-table-column.element.ts new file mode 100644 index 0000000000..3a797d224f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/collection/views/trashed-media-name-table-column.element.ts @@ -0,0 +1,54 @@ +import type { UmbMediaRecycleBinTreeItemModel } from '../../../types.js'; +import { UMB_EDIT_MEDIA_WORKSPACE_PATH_PATTERN } from '../../../../../paths.js'; +import { css, customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; + +@customElement('umb-trashed-media-name-table-column') +export class UmbTrashedMediaNameTableColumnElement extends UmbLitElement implements UmbTableColumnLayoutElement { + @state() + private _name = ''; + + @state() + private _editPath = ''; + + column!: UmbTableColumn; + item!: UmbTableItem; + + @property({ attribute: false }) + public set value(value: UmbMediaRecycleBinTreeItemModel) { + this.#value = value; + + if (value) { + this._name = value.variants[0]?.name || ''; + + this._editPath = UMB_EDIT_MEDIA_WORKSPACE_PATH_PATTERN.generateAbsolute({ + unique: value.unique, + }); + } + } + public get value(): UmbMediaRecycleBinTreeItemModel { + return this.#value; + } + #value!: UmbMediaRecycleBinTreeItemModel; + + override render() { + if (!this.value) return nothing; + if (!this._name) return nothing; + return html``; + } + + static override styles = [ + css` + uui-button { + text-align: left; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-trashed-media-name-table-column': UmbTrashedMediaNameTableColumnElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/constants.ts new file mode 100644 index 0000000000..d7b529d49c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/constants.ts @@ -0,0 +1 @@ +export * from './collection/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/manifests.ts new file mode 100644 index 0000000000..bfab902d3c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/tree-item-children/manifests.ts @@ -0,0 +1,3 @@ +import { manifests as collectionManifests } from './collection/manifests.js'; + +export const manifests: Array = [...collectionManifests]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts index 4668315e9b..495c121ad8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/media-workspace.context.ts @@ -9,7 +9,11 @@ import { UMB_MEDIA_COLLECTION_ALIAS } from '../collection/constants.js'; import type { UmbMediaDetailRepository } from '../repository/index.js'; import { UMB_MEDIA_WORKSPACE_ALIAS, UMB_MEMBER_DETAIL_MODEL_VARIANT_SCAFFOLD } from './constants.js'; import { UmbContentDetailWorkspaceContextBase, type UmbContentWorkspaceContext } from '@umbraco-cms/backoffice/content'; -import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; +import { + UmbEntityRestoredFromRecycleBinEvent, + UmbEntityTrashedEvent, + UmbIsTrashedEntityContext, +} from '@umbraco-cms/backoffice/recycle-bin'; import { UmbWorkspaceIsNewRedirectController, UmbWorkspaceIsNewRedirectControllerAlias, @@ -17,6 +21,8 @@ import { import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbMediaTypeDetailModel } from '@umbraco-cms/backoffice/media-type'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import type { UmbVariantGuardRule } from '@umbraco-cms/backoffice/utils'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; type ContentModel = UmbMediaDetailModel; type ContentTypeModel = UmbMediaTypeDetailModel; @@ -30,6 +36,8 @@ export class UmbMediaWorkspaceContext > implements UmbContentWorkspaceContext { + readonly isTrashed = this._data.createObservablePartOfCurrent((data) => data?.isTrashed); + readonly contentTypeUnique = this._data.createObservablePartOfCurrent((data) => data?.mediaType.unique); /* * @deprecated Use `collection.hasCollection` instead, will be removed in v.18 @@ -38,6 +46,7 @@ export class UmbMediaWorkspaceContext readonly contentTypeIcon = this._data.createObservablePartOfCurrent((data) => data?.mediaType.icon); #isTrashedContext = new UmbIsTrashedEntityContext(this); + #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; constructor(host: UmbControllerHost) { super(host, { @@ -61,6 +70,14 @@ export class UmbMediaWorkspaceContext null, ); + this.consumeContext(UMB_ACTION_EVENT_CONTEXT, (actionEventContext) => { + this.#removeEventListeners(); + this.#actionEventContext = actionEventContext; + this.#addEventListeners(); + }); + + this.observe(this.isTrashed, (isTrashed) => this.#onTrashStateChange(isTrashed)); + this.propertyViewGuard.fallbackToPermitted(); this.propertyWriteGuard.fallbackToPermitted(); @@ -102,16 +119,6 @@ export class UmbMediaWorkspaceContext this.removeUmbControllerByAlias(UmbWorkspaceIsNewRedirectControllerAlias); } - public override async load(unique: string) { - const response = await super.load(unique); - - if (response?.data) { - this.#isTrashedContext.setIsTrashed(response.data.isTrashed); - } - - return response; - } - /* * @deprecated Use `createScaffold` instead. */ @@ -154,6 +161,53 @@ export class UmbMediaWorkspaceContext ): UmbMediaPropertyDatasetContext { return new UmbMediaPropertyDatasetContext(host, this, variantId); } + + #addEventListeners() { + this.#actionEventContext?.addEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.addEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #removeEventListeners() { + this.#actionEventContext?.removeEventListener(UmbEntityTrashedEvent.TYPE, this.#onRecycleBinEvent as EventListener); + this.#actionEventContext?.removeEventListener( + UmbEntityRestoredFromRecycleBinEvent.TYPE, + this.#onRecycleBinEvent as EventListener, + ); + } + + #onRecycleBinEvent = (event: UmbEntityTrashedEvent | UmbEntityRestoredFromRecycleBinEvent) => { + const unique = this.getUnique(); + const entityType = this.getEntityType(); + if (event.getUnique() !== unique || event.getEntityType() !== entityType) return; + this.reload(); + }; + + #onTrashStateChange(isTrashed?: boolean) { + this.#isTrashedContext.setIsTrashed(isTrashed ?? false); + + const guardUnique = `UMB_PREVENT_EDIT_TRASHED_ITEM`; + + if (!isTrashed) { + this.readOnlyGuard.removeRule(guardUnique); + return; + } + + const rule: UmbVariantGuardRule = { + unique: guardUnique, + permitted: true, + }; + + // TODO: Change to use property write guard when it supports making the name read-only. + this.readOnlyGuard.addRule(rule); + } + + public override destroy(): void { + this.#removeEventListeners(); + super.destroy(); + } } export { UmbMediaWorkspaceContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/views/info/media-workspace-view-info.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/views/info/media-workspace-view-info.element.ts index 0dd30bbea2..630631bed3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/views/info/media-workspace-view-info.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/workspace/views/info/media-workspace-view-info.element.ts @@ -10,6 +10,7 @@ import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; import { UMB_SECTION_USER_PERMISSION_CONDITION_ALIAS } from '@umbraco-cms/backoffice/section'; import { UMB_SETTINGS_SECTION_ALIAS } from '@umbraco-cms/backoffice/settings'; +import { UMB_IS_TRASHED_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/recycle-bin'; @customElement('umb-media-workspace-view-info') export class UmbMediaWorkspaceViewInfoElement extends UmbLitElement { @@ -41,6 +42,9 @@ export class UmbMediaWorkspaceViewInfoElement extends UmbLitElement { @state() private _hasSettingsAccess: boolean = false; + @state() + private _isTrashed: boolean = false; + constructor() { super(); @@ -70,6 +74,16 @@ export class UmbMediaWorkspaceViewInfoElement extends UmbLitElement { this.#getData(); this.#observeContent(); }); + + this.consumeContext(UMB_IS_TRASHED_ENTITY_CONTEXT, (context) => { + this.observe( + context?.isTrashed, + (isTrashed) => { + this._isTrashed = isTrashed ?? false; + }, + '_isTrashed', + ); + }); } async #getData() { @@ -115,7 +129,7 @@ export class UmbMediaWorkspaceViewInfoElement extends UmbLitElement { #renderGeneralSection() { return html` - ${this.#renderCreateDate()} ${this.#renderUpdateDate()} + ${this.#renderTrashState()} ${this.#renderCreateDate()} ${this.#renderUpdateDate()}
Media Type ${this._mediaTypeIcon ? html`` : nothing} @@ -135,6 +149,20 @@ export class UmbMediaWorkspaceViewInfoElement extends UmbLitElement { `; } + #renderTrashState() { + if (!this._isTrashed) return nothing; + + return html` +
+ + + ${this.localize.term('content_trashed')} + + +
+ `; + } + #renderCreateDate() { if (!this._createDate) return nothing; return html` diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts index 8c0b1ce9aa..07e196850a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/date-time/property-editor-ui-date-time-picker-base.ts @@ -19,8 +19,8 @@ import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import type { UUIComboboxElement, UUIComboboxEvent } from '@umbraco-cms/backoffice/external/uui'; interface UmbDateTime { - date: string | undefined; - timeZone: string | undefined; + date: string | null; + timeZone: string | null; } interface UmbTimeZonePickerOption extends UmbTimeZone { @@ -34,6 +34,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase { private _timeZoneOptions: Array = []; private _clientTimeZone: UmbTimeZone | undefined; + private _timeZoneMode: UmbTimeZonePickerValue['mode'] | undefined; @property({ type: Boolean, reflect: true }) readonly = false; @@ -104,6 +105,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase () => { return ( this._displayTimeZone && + this._timeZoneMode !== 'local' && !!this.value?.timeZone && !this._timeZoneOptions.some((opt) => opt.value === this.value?.timeZone && !opt.invalid) ); @@ -120,8 +122,13 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase if (this._displayTimeZone) { timeZonePickerConfig = config.getValueByAlias('timeZones'); } + this.#setTimeInputStep(timeFormat); this.#prefillValue(timeZonePickerConfig); + + // To ensure the expected value matches the prefilled value, we trigger an update. + // If the values match, no change event will be fired. + this.#updateValue(this._selectedDate?.toISO({ includeOffset: false }) ?? null); } #prefillValue(timeZonePickerConfig: UmbTimeZonePickerValue | undefined) { @@ -158,8 +165,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase #prefillTimeZones(config: UmbTimeZonePickerValue | undefined, selectedDate: DateTime | undefined) { // Retrieve the time zones from the config this._clientTimeZone = getClientTimeZone(); + this._timeZoneMode = config?.mode; - // Retrieve the time zones from the config const dateToCalculateOffset = selectedDate ?? DateTime.now(); switch (config?.mode) { case 'all': @@ -219,7 +226,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase return; } } else if (this.value?.date) { - return; // If there is a date but no time zone, we don't preselect anything + // If there is no time zone in the value, but there is a date, we leave the time zone unselected + return; } // Check if we can pre-select the client time zone @@ -269,16 +277,8 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase return; } - if (!newPickerValue) { - this._datePickerValue = ''; - this.value = undefined; - this._selectedDate = undefined; - this.dispatchEvent(new UmbChangeEvent()); - return; - } - this._datePickerValue = newPickerValue; - this.#updateValue(value, true); + this.#updateValue(value); } #onTimeZoneChange(event: UUIComboboxEvent) { @@ -291,7 +291,7 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase if (!this._selectedTimeZone) { if (this.value?.date) { - this.value = { date: this.value.date, timeZone: undefined }; + this.value = { date: this.value.date, timeZone: null }; } else { this.value = undefined; } @@ -303,46 +303,84 @@ export abstract class UmbPropertyEditorUiDateTimePickerElementBase return; } - this.#updateValue(this._selectedDate.toISO({ includeOffset: false }) || ''); + this.#updateValue(this._selectedDate.toISO({ includeOffset: false })); } - #updateValue(date: string, updateOffsets = false) { + #updateValue(date: string | null) { // Try to parse the date with the selected time zone - const newDate = DateTime.fromISO(date, { zone: this._selectedTimeZone ?? 'UTC' }); + const newDate = date ? DateTime.fromISO(date, { zone: this._selectedTimeZone || 'UTC' }) : null; // If the date is invalid, we reset the value - if (!newDate.isValid) { + if (!newDate || !newDate.isValid) { + if (!this.value) { + return; // No change + } this.value = undefined; this._selectedDate = undefined; this.dispatchEvent(new UmbChangeEvent()); + this.#updateOffsets(DateTime.now()); return; } + const previousDate = this._selectedDate; this._selectedDate = newDate; - this.value = { - date: this.#getCurrentDateValue(), - timeZone: this._selectedTimeZone, + + let timeZoneToStore = null; + if (!this._displayTimeZone || !this._timeZoneMode) { + timeZoneToStore = null; + } else if (this._timeZoneMode === 'local') { + timeZoneToStore = 'UTC'; + } else { + timeZoneToStore = this._selectedTimeZone ?? null; + } + + const dateToStore = + timeZoneToStore && this._selectedTimeZone !== timeZoneToStore ? newDate.setZone(timeZoneToStore) : newDate; + + const newValue = { + date: this.#formatDateValue(dateToStore), + timeZone: timeZoneToStore, }; - if (updateOffsets) { - this._timeZoneOptions.forEach((opt) => { - opt.offset = getTimeZoneOffset(opt.value, newDate); - }); - // Update the time zone options (mostly for the offset) - this._filteredTimeZoneOptions = this._timeZoneOptions; + // Only update the stored data if it has actually changed to avoid firing unnecessary change events + const previousValue = this.value; + if (previousValue?.date === newValue.date && previousValue?.timeZone === newValue.timeZone) { + return; } + + this.value = newValue; this.dispatchEvent(new UmbChangeEvent()); + + // Only update offsets if the date timestamp has changed + if (previousDate?.toUnixInteger() !== newDate.toUnixInteger()) { + this.#updateOffsets(newDate); + } } - #getCurrentDateValue(): string | undefined { + #updateOffsets(date: DateTime) { + if (!this._displayTimeZone) return; + this._timeZoneOptions.forEach((opt) => { + opt.offset = getTimeZoneOffset(opt.value, date); + }); + // Update the time zone options (mostly for the offset) + this._filteredTimeZoneOptions = this._timeZoneOptions; + } + + #formatDateValue(date: DateTime): string | null { + let formattedDate: string | undefined; switch (this._dateInputType) { case 'date': - return this._selectedDate?.toISODate() ?? undefined; + formattedDate = date.toFormat('yyyy-MM-dd'); + break; case 'time': - return this._selectedDate?.toISOTime({ includeOffset: false }) ?? undefined; + formattedDate = date.toFormat('HH:mm:ss'); + break; default: - return this._selectedDate?.toISO({ includeOffset: !!this._selectedTimeZone }) ?? undefined; + formattedDate = date.toFormat(`yyyy-MM-dd'T'HH:mm:ss${this._timeZoneMode ? 'ZZ' : ''}`); + break; } + + return formattedDate ?? null; } #onTimeZoneSearch(event: UUIComboboxEvent) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/options-modal/partial-view-create-options-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/options-modal/partial-view-create-options-modal.element.ts index 5f7b27b28a..0bde152792 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/options-modal/partial-view-create-options-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/entity-actions/create/options-modal/partial-view-create-options-modal.element.ts @@ -1,7 +1,7 @@ import { UMB_PARTIAL_VIEW_FROM_SNIPPET_MODAL } from '../snippet-modal/index.js'; import { UMB_PARTIAL_VIEW_FOLDER_REPOSITORY_ALIAS } from '../../../constants.js'; import type { UmbPartialViewCreateOptionsModalData } from './index.js'; -import { html, customElement } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, css } from '@umbraco-cms/backoffice/external/lit'; import { UmbModalBaseElement, umbOpenModal } from '@umbraco-cms/backoffice/modal'; import { UmbCreateFolderEntityAction } from '@umbraco-cms/backoffice/tree'; @@ -62,7 +62,7 @@ export class UmbPartialViewCreateOptionsModalElement extends UmbModalBaseElement override render() { return html` - + `; } + + static override styles = [ + css` + uui-dialog-layout { + --uui-menu-item-flat-structure: 1; + } + `, + ]; } export default UmbPartialViewCreateOptionsModalElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/options-modal/index.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/options-modal/index.ts index bb3f144fdd..a15bf4f8c0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/options-modal/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/entity-actions/create/options-modal/index.ts @@ -5,8 +5,7 @@ export const UMB_SCRIPT_CREATE_OPTIONS_MODAL = new UmbModalToken - + + - - } - + + - - } - - + + - + `; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/entity-actions/create/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/entity-actions/create/manifests.ts index 1b13642c84..fae97fe98c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/entity-actions/create/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/entity-actions/create/manifests.ts @@ -46,7 +46,7 @@ const entityCreateOptionActions: Array = [ meta: { icon: 'icon-folder', label: '#create_folder', - description: '#create_folderDescription', + additionalOptions: true, folderRepositoryAlias: UMB_STYLESHEET_FOLDER_REPOSITORY_ALIAS, }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts index 0598f61223..3a44356d53 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts @@ -92,11 +92,11 @@ export class UmbTiptapToolbarElement extends UmbLitElement { } #renderActions(aliases: Array) { - return repeat(aliases, (alias) => this.#lookup?.get(alias) ?? this.#renderActionPlaceholder()); + return repeat(aliases, (alias) => this.#lookup?.get(alias) ?? this.#renderActionPlaceholder(alias)); } - #renderActionPlaceholder() { - return html``; + #renderActionPlaceholder(alias: string) { + return html``; } static override readonly styles = css` diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts index 47523bb921..86ec324126 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts @@ -16,6 +16,7 @@ export const manifests: Array = [ alias: 'Umb.Tiptap.Toolbar.HorizontalRule', name: 'Horizontal Rule Tiptap Toolbar Extension', api: () => import('./horizontal-rule.tiptap-toolbar-api.js'), + forExtensions: ['Umb.Tiptap.HorizontalRule'], meta: { alias: 'horizontalRule', icon: 'icon-horizontal-rule', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts index 60c0894e84..b6a98f1ddf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts @@ -4,6 +4,7 @@ export const manifests: Array = [ kind: 'styleMenu', alias: 'Umb.Tiptap.Toolbar.StyleSelect', name: 'Style Select Tiptap Extension', + forExtensions: ['Umb.Tiptap.Heading', 'Umb.Tiptap.Blockquote', 'Umb.Tiptap.CodeBlock'], items: [ { label: 'Headers', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts index ad71fe5568..89ac81a68f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts @@ -1,14 +1,4 @@ -import { - css, - customElement, - html, - ifDefined, - nothing, - property, - state, - repeat, - when, -} from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing, property, state, repeat, when } from '@umbraco-cms/backoffice/external/lit'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; @@ -166,7 +156,7 @@ export class UmbPropertyEditorUiTiptapExtensionsConfigurationElement ${repeat( group.extensions, (item) => html` -
  • +
  • this.#context.removeStatusbarItem([areaIndex, itemIndex])} - @dragend=${this.#onDragEnd} - @dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [areaIndex, itemIndex])}> -
    - ${when(item.icon, (icon) => html``)} - ${label} -
    - - `; + switch (item.kind) { + case 'unknown': + return html` + this.#context.removeStatusbarItem([areaIndex, itemIndex])}> + `; + + default: + return html` + this.#context.removeStatusbarItem([areaIndex, itemIndex])} + @dragend=${this.#onDragEnd} + @dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [areaIndex, itemIndex])}> +
    + ${when(item.icon, (icon) => html``)} + ${label} +
    +
    + `; + } } static override readonly styles = [ @@ -303,8 +317,8 @@ export class UmbPropertyEditorUiTiptapStatusbarConfigurationElement --color-standalone: var(--uui-color-danger-standalone); --color-emphasis: var(--uui-color-danger-emphasis); --color-contrast: var(--uui-color-danger); - --uui-button-contrast-disabled: var(--uui-color-danger); - --uui-button-border-color-disabled: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-danger); } div { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts index 8b3e29cf6e..84844d673f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts @@ -31,6 +31,7 @@ export class UmbTiptapStatusbarConfigurationContext extends UmbContextBase { const _extensions = extensions .sort((a, b) => a.alias.localeCompare(b.alias)) .map((ext) => ({ + kind: 'default', alias: ext.alias, label: ext.meta.label, icon: ext.meta.icon, @@ -75,8 +76,8 @@ export class UmbTiptapStatusbarConfigurationContext extends UmbContextBase { .filter((ext) => ext.alias?.toLowerCase().includes(query) || ext.label?.toLowerCase().includes(query)); } - public getExtensionByAlias(alias: string): UmbTiptapStatusbarExtension | undefined { - return this.#lookup?.get(alias); + public getExtensionByAlias(alias: string): UmbTiptapStatusbarExtension { + return this.#lookup?.get(alias) ?? { label: '', alias, icon: '', kind: 'unknown' }; } public isExtensionEnabled(alias: string): boolean { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts index bb000b6d7c..cebfc2463c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts @@ -255,9 +255,7 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement #renderGroup(group?: UmbTiptapToolbarGroupViewModel, rowIndex = 0, groupIndex = 0) { if (!group) return nothing; const showActionBar = this._toolbar[rowIndex].data.length > 1 && group.data.length === 0; - const items: UmbTiptapToolbarExtension[] = group!.data - .map((alias) => this.#context?.getExtensionByAlias(alias)) - .filter((item): item is UmbTiptapToolbarExtension => !!item); + const items = group.data.map((alias) => this.#context?.getExtensionByAlias(alias)); return html`
    ext.alias?.toLowerCase().includes(query) || ext.label?.toLowerCase().includes(query)); } - public getExtensionByAlias(alias: string): UmbTiptapToolbarExtension | undefined { - return this.#lookup?.get(alias); + public getExtensionByAlias(alias: string): UmbTiptapToolbarExtension { + return this.#lookup?.get(alias) ?? { label: '', alias, icon: '', kind: 'unknown' }; } public isExtensionEnabled(alias: string): boolean { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts index cc7b02d45c..dd8eba4ac8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts @@ -10,9 +10,9 @@ export class UmbTiptapToolbarGroupConfigurationElement< TiptapToolbarItem extends UmbTiptapToolbarExtension = UmbTiptapToolbarExtension, > extends UmbLitElement { #sorter = new UmbSorterController(this, { - getUniqueOfElement: (element) => element.getAttribute('tiptap-toolbar-alias'), - getUniqueOfModel: (modelEntry) => modelEntry.alias!, - itemSelector: 'uui-button', + getUniqueOfElement: (element) => element.dataset.mark, + getUniqueOfModel: (modelEntry) => `tiptap-toolbar-item:${modelEntry.alias}`, + itemSelector: '.draggable', identifier: 'umb-tiptap-toolbar-sorter', containerSelector: '.items', resolvePlacement: UmbSorterResolvePlacementAsGrid, @@ -71,7 +71,7 @@ export class UmbTiptapToolbarGroupConfigurationElement< } #renderItem(item: TiptapToolbarItem, index = 0) { - const label = this.localize.string(item.label); + const label = this.localize.string(item.label) || item.alias; const forbidden = !this.#context?.isExtensionEnabled(item.alias); switch (item.kind) { @@ -80,13 +80,11 @@ export class UmbTiptapToolbarGroupConfigurationElement< return html` this.#onRequestRemove(item, index)}>
    ${label} @@ -95,18 +93,29 @@ export class UmbTiptapToolbarGroupConfigurationElement< `; + case 'unknown': + return html` + this.#onRequestRemove(item, index)}> + `; + case 'button': + case 'colorPickerButton': default: return html` this.#onRequestRemove(item, index)}>
    ${when( @@ -131,23 +140,18 @@ export class UmbTiptapToolbarGroupConfigurationElement< uui-button { --uui-button-font-weight: normal; - &[draggable='true'], - &[draggable='true'] > .inner { + &.draggable, + &.draggable > .inner { cursor: move; } - &[disabled], - &[disabled] > .inner { - cursor: not-allowed; - } - &.forbidden { --color: var(--uui-color-danger); --color-standalone: var(--uui-color-danger-standalone); --color-emphasis: var(--uui-color-danger-emphasis); --color-contrast: var(--uui-color-danger); - --uui-button-contrast-disabled: var(--uui-color-danger); - --uui-button-border-color-disabled: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-danger); } div { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts index 303d2f543d..92de056d94 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts @@ -1,17 +1,18 @@ export type UmbTiptapSortableViewModel = { unique: string; data: T }; -export type UmbTiptapStatusbarExtension = { +export type UmbTiptapExtensionBase = { + kind?: string; alias: string; label: string; icon: string; dependencies?: Array; }; +export type UmbTiptapStatusbarExtension = UmbTiptapExtensionBase; + export type UmbTiptapStatusbarViewModel = UmbTiptapSortableViewModel>; -export type UmbTiptapToolbarExtension = UmbTiptapStatusbarExtension & { - kind?: string; -}; +export type UmbTiptapToolbarExtension = UmbTiptapExtensionBase; export type UmbTiptapToolbarRowViewModel = UmbTiptapSortableViewModel>; export type UmbTiptapToolbarGroupViewModel = UmbTiptapSortableViewModel>; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-granular-permission-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-granular-permissions.element.ts similarity index 81% rename from src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-granular-permission-list.element.ts rename to src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-granular-permissions.element.ts index 3721b89985..9fb9d37715 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-granular-permission-list.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-granular-permissions.element.ts @@ -2,12 +2,15 @@ import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.contex import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import type { UmbExtensionElementInitializer } from '@umbraco-cms/backoffice/extension-api'; import type { ManifestGranularUserPermission } from '@umbraco-cms/backoffice/user-permission'; -import { html, customElement, state, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, state, nothing, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { filterFrozenArray } from '@umbraco-cms/backoffice/observable-api'; -@customElement('umb-user-group-granular-permission-list') +@customElement('umb-user-group-entity-type-granular-permissions') export class UmbUserGroupGranularPermissionListElement extends UmbLitElement { + @property() + public entityType?: string; + @state() private _userGroupPermissions?: Array; @@ -57,9 +60,21 @@ export class UmbUserGroupGranularPermissionListElement extends UmbLitElement { override render() { if (!this._userGroupPermissions) return; + + if (!this.entityType) { + return html` + + manifest.forEntityTypes === undefined || manifest.forEntityTypes?.length === 0} + .renderMethod=${this.#renderProperty}> + `; + } + return html` + manifest.forEntityTypes?.includes(this.entityType!) || manifest.forEntityTypes?.length === 0} .renderMethod=${this.#renderProperty}>`; } @@ -95,6 +110,6 @@ export default UmbUserGroupGranularPermissionListElement; declare global { interface HTMLElementTagNameMap { - 'umb-user-group-granular-permission-list': UmbUserGroupGranularPermissionListElement; + 'umb-user-group-entity-type-granular-permissions': UmbUserGroupGranularPermissionListElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts new file mode 100644 index 0000000000..2d2d09dff9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permission-groups.element.ts @@ -0,0 +1,92 @@ +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { html, customElement, state, repeat, css, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; + +import './user-group-entity-type-permissions.element.js'; +import './user-group-entity-type-granular-permissions.element.js'; + +@customElement('umb-user-group-entity-type-permission-groups') +export class UmbUserGroupEntityTypePermissionGroupsElement extends UmbLitElement { + @state() + private _groups: Array<{ entityType: string; headline: string }> = []; + + @state() + private _hasGranularPermissionsWithNoEntityType = false; + + constructor() { + super(); + + this.observe( + umbExtensionsRegistry.byType('entityUserPermission'), + (manifests) => { + const entityTypes = [...new Set(manifests.flatMap((manifest) => manifest.forEntityTypes))]; + this._groups = entityTypes + .map((entityType) => { + return { + entityType, + headline: this.localize.term(`user_permissionsEntityGroup_${entityType}`), + }; + }) + .sort((a, b) => a.headline.localeCompare(b.headline)); + }, + 'umbUserPermissionsObserver', + ); + + this.#observeGranularPermissionsWithNoEntityType(); + } + + #observeGranularPermissionsWithNoEntityType() { + this.observe( + umbExtensionsRegistry.byTypeAndFilter( + 'userGranularPermission', + (manifest) => manifest.forEntityTypes === undefined || manifest.forEntityTypes.length === 0, + ), + (manifests) => { + this._hasGranularPermissionsWithNoEntityType = manifests.length > 0; + }, + ); + } + + override render() { + return html`${repeat( + this._groups, + (group) => group.entityType, + (group) => + html` +
    ${group.headline}
    + + + + +
    `, + )} + ${this.#renderUngroupedGranularPermissions()}`; + } + + #renderUngroupedGranularPermissions() { + if (!this._hasGranularPermissionsWithNoEntityType) return nothing; + return html` + `; + } + + static override styles = [ + UmbTextStyles, + css` + uui-box { + margin-top: var(--uui-size-space-6); + } + `, + ]; +} + +export default UmbUserGroupEntityTypePermissionGroupsElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-group-entity-type-permission-groups': UmbUserGroupEntityTypePermissionGroupsElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts new file mode 100644 index 0000000000..0edd3e9c33 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-type-permissions.element.ts @@ -0,0 +1,60 @@ +import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.context-token.js'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { html, customElement, state, nothing, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; + +@customElement('umb-user-group-entity-type-permissions') +export class UmbUserGroupEntityTypePermissionsElement extends UmbLitElement { + @property() + public entityType?: string; + + @state() + private _fallBackPermissions?: Array; + + #userGroupWorkspaceContext?: typeof UMB_USER_GROUP_WORKSPACE_CONTEXT.TYPE; + + constructor() { + super(); + + this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (instance) => { + this.#userGroupWorkspaceContext = instance; + this.observe( + this.#userGroupWorkspaceContext?.fallbackPermissions, + (fallbackPermissions) => { + this._fallBackPermissions = fallbackPermissions; + }, + 'umbUserGroupEntityUserPermissionsObserver', + ); + }); + } + + #onPermissionChange(event: UmbSelectionChangeEvent) { + event.stopPropagation(); + const target = event.target as any; + const verbs = target.allowedVerbs; + if (verbs === undefined || verbs === null) throw new Error('The verbs are not defined'); + this.#userGroupWorkspaceContext?.setFallbackPermissions(verbs); + } + + override render() { + return this.entityType + ? html` + + ` + : nothing; + } + + static override styles = [UmbTextStyles]; +} + +export default UmbUserGroupEntityTypePermissionsElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-group-entity-type-permissions': UmbUserGroupEntityTypePermissionsElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-user-permission-list.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-user-permission-list.element.ts deleted file mode 100644 index d9ecbdbf19..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/components/user-group-entity-user-permission-list.element.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { UMB_USER_GROUP_WORKSPACE_CONTEXT } from '../user-group-workspace.context-token.js'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { UmbSelectionChangeEvent } from '@umbraco-cms/backoffice/event'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; - -@customElement('umb-user-group-entity-user-permission-list') -export class UmbUserGroupEntityUserPermissionListElement extends UmbLitElement { - @state() - private _fallBackPermissions?: Array; - - @state() - private _groups: Array<{ entityType: string; headline: string }> = []; - - #userGroupWorkspaceContext?: typeof UMB_USER_GROUP_WORKSPACE_CONTEXT.TYPE; - - constructor() { - super(); - - this.#observeEntityUserPermissions(); - - this.consumeContext(UMB_USER_GROUP_WORKSPACE_CONTEXT, (instance) => { - this.#userGroupWorkspaceContext = instance; - this.observe( - this.#userGroupWorkspaceContext?.fallbackPermissions, - (fallbackPermissions) => { - this._fallBackPermissions = fallbackPermissions; - }, - 'umbUserGroupEntityUserPermissionsObserver', - ); - }); - } - - #observeEntityUserPermissions() { - this.observe( - umbExtensionsRegistry.byType('entityUserPermission'), - (manifests) => { - const entityTypes = [...new Set(manifests.flatMap((manifest) => manifest.forEntityTypes))]; - this._groups = entityTypes - .map((entityType) => { - return { - entityType, - headline: this.localize.term(`user_permissionsEntityGroup_${entityType}`), - }; - }) - .sort((a, b) => a.headline.localeCompare(b.headline)); - }, - 'umbUserPermissionsObserver', - ); - } - - #onPermissionChange(event: UmbSelectionChangeEvent) { - event.stopPropagation(); - const target = event.target as any; - const verbs = target.allowedVerbs; - if (verbs === undefined || verbs === null) throw new Error('The verbs are not defined'); - this.#userGroupWorkspaceContext?.setFallbackPermissions(verbs); - } - - override render() { - return html` ${this._groups.map((group) => this.#renderPermissionsForEntityType(group))}`; - } - - #renderPermissionsForEntityType(group: { entityType: string; headline: string }) { - return html` -

    ${group.headline}

    - - `; - } - - static override styles = [UmbTextStyles]; -} - -export default UmbUserGroupEntityUserPermissionListElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-user-group-default-permission-list': UmbUserGroupEntityUserPermissionListElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts index 701ff856e9..cbbad3824f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace-editor.element.ts @@ -12,8 +12,7 @@ import type { UmbInputLanguageElement } from '@umbraco-cms/backoffice/language'; import { UMB_ICON_PICKER_MODAL } from '@umbraco-cms/backoffice/icon'; import type { UmbInputWithAliasElement } from '@umbraco-cms/backoffice/components'; -import './components/user-group-entity-user-permission-list.element.js'; -import './components/user-group-granular-permission-list.element.js'; +import './components/user-group-entity-type-permission-groups.element.js'; @customElement('umb-user-group-workspace-editor') export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { @@ -33,7 +32,7 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { private _aliasCanBeChanged?: UmbUserGroupDetailModel['aliasCanBeChanged'] = true; @state() - private _icon: UmbUserGroupDetailModel['icon'] = null; + private _icon?: UmbUserGroupDetailModel['icon']; @state() private _sections: UmbUserGroupDetailModel['sections'] = []; @@ -68,45 +67,44 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { } #observeUserGroup() { - if (!this.#workspaceContext) return; - this.observe(this.#workspaceContext.isNew, (value) => (this._isNew = value), '_observeIsNew'); - this.observe(this.#workspaceContext.unique, (value) => (this._unique = value ?? undefined), '_observeUnique'); - this.observe(this.#workspaceContext.name, (value) => (this._name = value), '_observeName'); - this.observe(this.#workspaceContext.alias, (value) => (this._alias = value), '_observeAlias'); + this.observe(this.#workspaceContext?.isNew, (value) => (this._isNew = value), '_observeIsNew'); + this.observe(this.#workspaceContext?.unique, (value) => (this._unique = value ?? undefined), '_observeUnique'); + this.observe(this.#workspaceContext?.name, (value) => (this._name = value), '_observeName'); + this.observe(this.#workspaceContext?.alias, (value) => (this._alias = value), '_observeAlias'); this.observe( - this.#workspaceContext.aliasCanBeChanged, + this.#workspaceContext?.aliasCanBeChanged, (value) => (this._aliasCanBeChanged = value), '_observeAliasCanBeChanged', ); - this.observe(this.#workspaceContext.icon, (value) => (this._icon = value), '_observeIcon'); - this.observe(this.#workspaceContext.sections, (value) => (this._sections = value), '_observeSections'); - this.observe(this.#workspaceContext.languages, (value) => (this._languages = value), '_observeLanguages'); + this.observe(this.#workspaceContext?.icon, (value) => (this._icon = value), '_observeIcon'); + this.observe(this.#workspaceContext?.sections, (value) => (this._sections = value ?? []), '_observeSections'); + this.observe(this.#workspaceContext?.languages, (value) => (this._languages = value ?? []), '_observeLanguages'); this.observe( - this.#workspaceContext.hasAccessToAllLanguages, - (value) => (this._hasAccessToAllLanguages = value), + this.#workspaceContext?.hasAccessToAllLanguages, + (value) => (this._hasAccessToAllLanguages = value ?? false), '_observeHasAccessToAllLanguages', ); this.observe( - this.#workspaceContext.documentRootAccess, - (value) => (this._documentRootAccess = value), + this.#workspaceContext?.documentRootAccess, + (value) => (this._documentRootAccess = value ?? false), '_observeDocumentRootAccess', ); this.observe( - this.#workspaceContext.documentStartNode, + this.#workspaceContext?.documentStartNode, (value) => (this._documentStartNode = value), '_observeDocumentStartNode', ); this.observe( - this.#workspaceContext.mediaRootAccess, - (value) => (this._mediaRootAccess = value), + this.#workspaceContext?.mediaRootAccess, + (value) => (this._mediaRootAccess = value ?? false), '_observeMediaRootAccess', ); this.observe( - this.#workspaceContext.mediaStartNode, + this.#workspaceContext?.mediaStartNode, (value) => (this._mediaStartNode = value), '_observeMediaStartNode', ); @@ -242,20 +240,7 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { ${this.#renderLanguageAccess()} ${this.#renderDocumentAccess()} ${this.#renderMediaAccess()} - -
    - - - - -
    - - -
    - -
    + ${this.#renderPermissionGroups()}
    `; @@ -337,6 +322,10 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement { `; } + #renderPermissionGroups() { + return html` `; + } + static override styles = [ UmbTextStyles, css` diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/components/input-entity-user-permission/input-entity-user-permission.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/components/input-entity-user-permission/input-entity-user-permission.element.ts index f6fe7c2b9c..5c654b5ed8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/components/input-entity-user-permission/input-entity-user-permission.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/components/input-entity-user-permission/input-entity-user-permission.element.ts @@ -1,7 +1,7 @@ import type { ManifestEntityUserPermission } from '../../entity-user-permission.extension.js'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; -import { html, customElement, property, state, nothing, ifDefined, css } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, property, state, ifDefined, css, repeat } from '@umbraco-cms/backoffice/external/lit'; import type { UmbObserverController } from '@umbraco-cms/backoffice/observable-api'; import type { UmbUserPermissionVerbElement } from '@umbraco-cms/backoffice/user'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -24,7 +24,7 @@ export class UmbInputEntityUserPermissionElement extends UmbFormControlMixin(Umb allowedVerbs: Array = []; @state() - private _manifests: Array = []; + private _groupedPermissions: Array<[string, ManifestEntityUserPermission[]]> = []; #manifestObserver?: UmbObserverController>; @@ -40,11 +40,18 @@ export class UmbInputEntityUserPermissionElement extends UmbFormControlMixin(Umb this.#manifestObserver?.destroy(); this.#manifestObserver = this.observe( - umbExtensionsRegistry.byType('entityUserPermission'), - (userPermissionManifests) => { - this._manifests = userPermissionManifests.filter((manifest) => - manifest.forEntityTypes.includes(this.entityType), - ); + umbExtensionsRegistry.byTypeAndFilter('entityUserPermission', (manifest) => + manifest.forEntityTypes.includes(this.entityType), + ), + (manifests) => { + // TODO: groupBy is not known by TS yet + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const groupedPermissions = Object.groupBy( + manifests, + (manifest: ManifestEntityUserPermission) => manifest.meta.group, + ) as Record>; + this._groupedPermissions = Object.entries(groupedPermissions); }, 'umbUserPermissionManifestsObserver', ); @@ -73,27 +80,14 @@ export class UmbInputEntityUserPermissionElement extends UmbFormControlMixin(Umb } override render() { - return html`${this.#renderGroupedPermissions(this._manifests)} `; - } - - #renderGroupedPermissions(permissionManifests: Array) { - // TODO: groupBy is not known by TS yet - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - const groupedPermissions = Object.groupBy( - permissionManifests, - (manifest: ManifestEntityUserPermission) => manifest.meta.group, - ) as Record>; - return html` - ${Object.entries(groupedPermissions).map( - ([group, manifests]) => html` - ${group !== 'undefined' - ? html`
    ${group}
    ` - : nothing} -
    ${manifests.map((manifest) => html` ${this.#renderPermission(manifest)} `)}
    - `, - )} - `; + return repeat(this._groupedPermissions, ([group, manifests]) => { + const headline = group !== 'undefined' ? `#actionCategories_${group}` : `#actionCategories_general`; + return html` + +
    ${repeat(manifests, (manifest) => html` ${this.#renderPermission(manifest)} `)}
    +
    + `; + }); } #renderPermission(manifest: ManifestEntityUserPermission) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/modals/settings/entity-user-permission-settings-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/modals/settings/entity-user-permission-settings-modal.token.ts index 974200ac95..ffbbf3fc47 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/modals/settings/entity-user-permission-settings-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/modals/settings/entity-user-permission-settings-modal.token.ts @@ -16,5 +16,6 @@ export const UMB_ENTITY_USER_PERMISSION_MODAL = new UmbModalToken< >('Umb.Modal.EntityUserPermissionSettings', { modal: { type: 'sidebar', + size: 'medium', }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/user-granular-permission.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/user-granular-permission.extension.ts index 70801b97b3..5023055136 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/user-granular-permission.extension.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-permission/user-granular-permission.extension.ts @@ -2,6 +2,7 @@ import type { ManifestElement } from '@umbraco-cms/backoffice/extension-api'; export interface ManifestGranularUserPermission extends ManifestElement { type: 'userGranularPermission'; + forEntityTypes?: Array; meta: MetaGranularUserPermission; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/constants.ts index b45660db04..07bfa14320 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/constants.ts @@ -1,3 +1,4 @@ export * from './views/constants.js'; export const UMB_USER_COLLECTION_ALIAS = 'Umb.Collection.User'; export { UMB_USER_COLLECTION_CONTEXT } from './user-collection.context-token.js'; +export * from './menu/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/manifests.ts index b54121406d..59374f44b5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/manifests.ts @@ -1,7 +1,8 @@ import { UMB_USER_COLLECTION_REPOSITORY_ALIAS } from './repository/index.js'; +import { manifests as collectionActionManifests } from './action/manifests.js'; +import { manifests as collectionMenuManifests } from './menu/manifests.js'; import { manifests as collectionRepositoryManifests } from './repository/manifests.js'; import { manifests as collectionViewManifests } from './views/manifests.js'; -import { manifests as collectionActionManifests } from './action/manifests.js'; import { UMB_USER_COLLECTION_ALIAS } from './constants.js'; export const manifests: Array = [ @@ -15,7 +16,8 @@ export const manifests: Array = [ repositoryAlias: UMB_USER_COLLECTION_REPOSITORY_ALIAS, }, }, + ...collectionActionManifests, + ...collectionMenuManifests, ...collectionRepositoryManifests, ...collectionViewManifests, - ...collectionActionManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/constants.ts new file mode 100644 index 0000000000..9b0faca55e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/constants.ts @@ -0,0 +1 @@ +export const UMB_USER_COLLECTION_MENU_ALIAS = 'Umb.CollectionMenu.User'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/manifests.ts new file mode 100644 index 0000000000..4c5ede48d2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/manifests.ts @@ -0,0 +1,23 @@ +import { UMB_USER_ENTITY_TYPE } from '../../entity.js'; +import { UMB_USER_COLLECTION_REPOSITORY_ALIAS } from '../repository/constants.js'; +import { UMB_USER_COLLECTION_MENU_ALIAS } from './constants.js'; + +export const manifests: Array = [ + { + type: 'collectionMenu', + kind: 'default', + alias: UMB_USER_COLLECTION_MENU_ALIAS, + name: 'User Collection Menu', + meta: { + collectionRepositoryAlias: UMB_USER_COLLECTION_REPOSITORY_ALIAS, + }, + }, + { + type: 'collectionMenuItem', + kind: 'default', + alias: 'Umb.CollectionMenuItem.User', + name: 'User Collection Menu Item', + element: () => import('./user-collection-menu-item.element.js'), + forEntityTypes: [UMB_USER_ENTITY_TYPE], + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/user-collection-menu-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/user-collection-menu-item.element.ts new file mode 100644 index 0000000000..a0ec66d78a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/menu/user-collection-menu-item.element.ts @@ -0,0 +1,88 @@ +import type { UmbUserDetailModel } from '../../types.js'; +import { html, customElement, css, state, property, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbCollectionMenuItemContext } from '@umbraco-cms/backoffice/collection'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; + +@customElement('umb-user-collection-menu-item') +export class UmbUserCollectionMenuItemElement extends UmbLitElement { + @property({ type: Object, attribute: false }) + set item(newVal: UmbUserDetailModel) { + this._item = newVal; + + if (this._item) { + this.#initItem(); + } + } + get item(): UmbUserDetailModel | undefined { + return this._item; + } + protected _item?: UmbUserDetailModel; + + @property({ type: Object, attribute: false }) + public set api(value: UmbCollectionMenuItemContext | undefined) { + this.#api = value; + + if (this.#api) { + this.observe(this.#api.isSelectable, (value) => (this._isSelectable = value)); + this.observe(this.#api.isSelected, (value) => (this._isSelected = value)); + this.#initItem(); + } + } + public get api(): UmbCollectionMenuItemContext | undefined { + return this.#api; + } + #api: UmbCollectionMenuItemContext | undefined; + + @state() + protected _isActive = false; + + @state() + protected _isSelected = false; + + @state() + protected _isSelectable = false; + + #initItem() { + if (!this.#api) return; + if (!this._item) return; + this.#api.setItem(this._item); + } + + override render() { + const item = this._item; + if (!item) return nothing; + + return html` + this.#api?.select()} + @deselected=${() => this.#api?.deselect()}> + + + `; + } + + static override styles = [ + UmbTextStyles, + css` + umb-user-avatar { + font-size: 10px; + } + `, + ]; +} + +export { UmbUserCollectionMenuItemElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-user-collection-menu-item': UmbUserCollectionMenuItemElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/manifests.ts index 03273e88df..ec1100d7a6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/manifests.ts @@ -3,7 +3,6 @@ export const manifests: Array = [ type: 'modal', alias: 'Umb.Modal.User.Picker', name: 'User Picker Modal', - js: () => import('./user-picker/user-picker-modal.element.js'), }, { type: 'modal', diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts deleted file mode 100644 index e79f930e88..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.element.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { UmbUserCollectionRepository } from '../../collection/repository/user-collection.repository.js'; -import type { UmbUserItemModel } from '../../repository/item/index.js'; -import type { UmbUserPickerModalData, UmbUserPickerModalValue } from './user-picker-modal.token.js'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { css, html, customElement, state, ifDefined } from '@umbraco-cms/backoffice/external/lit'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; - -@customElement('umb-user-picker-modal') -export class UmbUserPickerModalElement extends UmbModalBaseElement { - @state() - private _users: Array = []; - - #selectionManager = new UmbSelectionManager(this); - #userCollectionRepository = new UmbUserCollectionRepository(this); - - override connectedCallback(): void { - super.connectedCallback(); - - // TODO: in theory this config could change during the lifetime of the modal, so we could observe it - this.#selectionManager.setMultiple(this.data?.multiple ?? false); - this.#selectionManager.setSelection(this.value?.selection ?? []); - } - - protected override firstUpdated(_changedProperties: PropertyValueMap | Map): void { - super.firstUpdated(_changedProperties); - this.#requestUsers(); - } - - async #requestUsers() { - if (!this.#userCollectionRepository) return; - const { data } = await this.#userCollectionRepository.requestCollection(); - - if (data) { - this._users = data.items; - } - } - - #submit() { - this.value = { selection: this.#selectionManager.getSelection() }; - this.modalContext?.submit(); - } - - #close() { - this.modalContext?.reject(); - } - - override render() { - return html` - - - ${this._users.map( - (user) => html` - this.#selectionManager.select(user.unique)} - @deselected=${() => this.#selectionManager.deselect(user.unique)} - ?selected=${this.#selectionManager.isSelected(user.unique)}> - - - `, - )} - -
    - - -
    -
    - `; - } - - static override styles = [ - UmbTextStyles, - css` - umb-user-avatar { - font-size: 12px; - } - `, - ]; -} - -export default UmbUserPickerModalElement; - -declare global { - interface HTMLElementTagNameMap { - 'umb-user-picker-modal': UmbUserPickerModalElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.token.ts index a72fc779c8..2f2e030b6c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/modals/user-picker/user-picker-modal.token.ts @@ -1,19 +1,29 @@ import type { UmbUserDetailModel } from '../../types.js'; -import type { UmbPickerModalData } from '@umbraco-cms/backoffice/modal'; +import { UMB_USER_COLLECTION_MENU_ALIAS } from '../../collection/constants.js'; +import type { + UmbCollectionItemPickerModalData, + UmbCollectionItemPickerModalValue, +} from '@umbraco-cms/backoffice/collection'; import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; -export type UmbUserPickerModalData = UmbPickerModalData; +export type UmbUserPickerModalData = UmbCollectionItemPickerModalData; -export interface UmbUserPickerModalValue { - selection: Array; -} +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbUserPickerModalValue extends UmbCollectionItemPickerModalValue {} export const UMB_USER_PICKER_MODAL = new UmbModalToken( - 'Umb.Modal.User.Picker', + /* TODO: use constant. We had to use the string directly here to avoid a circular dependency. + When we have removed the dataType (dependency on content) from the picker context we update this */ + 'Umb.Modal.CollectionItemPicker', { modal: { type: 'sidebar', size: 'small', }, + data: { + collection: { + menuAlias: UMB_USER_COLLECTION_MENU_ALIAS, + }, + }, }, ); diff --git a/src/Umbraco.Web.UI/Program.cs b/src/Umbraco.Web.UI/Program.cs index ad68d28351..a9834ed784 100644 --- a/src/Umbraco.Web.UI/Program.cs +++ b/src/Umbraco.Web.UI/Program.cs @@ -25,9 +25,6 @@ app.UseUmbraco() }) .WithEndpoints(u => { - /*#if (UmbracoRelease = 'LTS') - u.UseInstallerEndpoints(); - #endif */ u.UseBackOfficeEndpoints(); u.UseWebsiteEndpoints(); }); diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 051ecc5cfd..2ade2ca396 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -26,6 +26,11 @@ + + diff --git a/templates/UmbracoProject/.template.config/starterkits.template.json b/templates/UmbracoProject/.template.config/starterkits.template.json index 745eef20ae..4dbb28c005 100644 --- a/templates/UmbracoProject/.template.config/starterkits.template.json +++ b/templates/UmbracoProject/.template.config/starterkits.template.json @@ -34,11 +34,11 @@ "cases": [ { "condition": "(StarterKit == 'Umbraco.TheStarterKit' && (UmbracoRelease == 'Latest' || UmbracoRelease == 'Custom'))", - "value": "16.0.0" + "value": "17.0.0-rc" }, { "condition": "(StarterKit == 'Umbraco.TheStarterKit' && UmbracoRelease == 'LTS')", - "value": "13.0.0" + "value": "17.0.0" } ] } diff --git a/templates/UmbracoProject/Dockerfile b/templates/UmbracoProject/Dockerfile index 40347ec2ec..9f2fcbe193 100644 --- a/templates/UmbracoProject/Dockerfile +++ b/templates/UmbracoProject/Dockerfile @@ -26,8 +26,5 @@ USER root RUN mkdir umbraco RUN mkdir umbraco/Logs RUN chown $APP_UID umbraco --recursive -#if (UmbracoRelease = 'LTS') -RUN chown $APP_UID wwwroot/umbraco --recursive -#endif USER $APP_UID ENTRYPOINT ["dotnet", "UmbracoProject.dll"] diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 87ed32ef3a..4b057b1d03 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.40", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.4", + "@umbraco/playwright-testhelpers": "^17.0.0-beta.7", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -58,21 +58,21 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.40", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.40.tgz", - "integrity": "sha512-Yqojp/0akRgXsnjg18+MjMdkRvFrmlUNbfITgZ3d1h/PIRbWXPNKY1YAfZmdUv+g1SRSHrbIRpPPtSy+gNOjHw==", + "version": "2.0.41", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.41.tgz", + "integrity": "sha512-rCNUHCOpcuWIj7xUhk0lpcn4jzk9y82jHs9FSb7kxH716AnDyYvwuI+J0Ayd4hhWtXXqNCRqugCNYjG+rvzshQ==", "license": "MIT", "dependencies": { "camelize": "^1.0.1" } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "17.0.0-beta.4", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.4.tgz", - "integrity": "sha512-+OE1A2oAdFel4myf5T/jJLuw0aLvSOUBplkUfsYFj2ACeLygfAp/MM7q2RQ+YlCym/wdF+jAqJM3g+zsKEDjaQ==", + "version": "17.0.0-beta.7", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-17.0.0-beta.7.tgz", + "integrity": "sha512-5fhmVVSpJkH6Inx8nA9qqqvZzYuPdDxJdQF2IzY0oSf8C0eti+TJ2BKrYfTLmZTfVqmHUas72BMGser5pfpl9A==", "license": "MIT", "dependencies": { - "@umbraco/json-models-builders": "2.0.40", + "@umbraco/json-models-builders": "2.0.41", "node-fetch": "^2.6.7" } }, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index f1a7b85fa4..013bfdd438 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.40", - "@umbraco/playwright-testhelpers": "^17.0.0-beta.4", + "@umbraco/playwright-testhelpers": "^17.0.0-beta.7", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts index 65437ebcac..ec7d32041d 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts @@ -51,7 +51,7 @@ test('cannot create child content if allowed child node is disabled', async ({um // Assert await umbracoUi.content.isDocumentTypeNameVisible(documentTypeName, false); - await umbracoUi.content.doesModalHaveText(noAllowedDocumentTypeAvailableMessage); + await umbracoUi.content.doesDocumentModalHaveText(noAllowedDocumentTypeAvailableMessage); }); test('can create multiple child nodes with different document types', async ({umbracoApi, umbracoUi}) => { diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CreateContentFromDocumentBlueprint.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CreateContentFromDocumentBlueprint.spec.ts index 7ec3a9223f..0b3bf06cf0 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CreateContentFromDocumentBlueprint.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/CreateContentFromDocumentBlueprint.spec.ts @@ -51,7 +51,7 @@ test('can create content using an invariant document blueprint', async ({umbraco await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.clickSaveButtonForContent(); // Assert @@ -75,7 +75,7 @@ test('can create content using a variant document blueprint', async ({umbracoApi await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.clickSaveButtonForContent(); await umbracoUi.content.clickSaveButton(); @@ -104,7 +104,7 @@ test('can create content with different name using an invariant document bluepri await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.enterContentName(contentName); await umbracoUi.content.clickSaveButtonForContent(); @@ -130,7 +130,7 @@ test('can create content with different name using a variant document blueprint' await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.enterContentName(contentName); await umbracoUi.content.clickSaveButtonForContent(); await umbracoUi.content.clickSaveButton(); @@ -161,7 +161,7 @@ test('can create content using a document blueprint with block list', async ({um await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.clickSaveButtonForContent(); // Assert @@ -187,7 +187,7 @@ test('can create content using a document blueprint with block grid', async ({um await umbracoUi.content.clickActionsMenuAtRoot(); await umbracoUi.content.clickCreateActionMenuOption(); await umbracoUi.content.chooseDocumentType(documentTypeName); - await umbracoUi.content.clickModalMenuItemWithName(documentBlueprintName); + await umbracoUi.content.selectDocumentBlueprintWithName(documentBlueprintName); await umbracoUi.content.clickSaveButtonForContent(); // Assert @@ -197,4 +197,4 @@ test('can create content using a document blueprint with block grid', async ({um expect(contentData.values[0].value.contentData[0].values[0].value.markup).toEqual(textContent); const blockListValue = contentData.values.find(item => item.editorAlias === "Umbraco.BlockGrid")?.value; expect(blockListValue).toBeTruthy(); -}); \ No newline at end of file +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentBlueprint/DocumentBlueprint.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentBlueprint/DocumentBlueprint.spec.ts index fdcef65d63..f9e7ce5a72 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentBlueprint/DocumentBlueprint.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentBlueprint/DocumentBlueprint.spec.ts @@ -23,7 +23,9 @@ test('can create a document blueprint from the settings menu', {tag: '@smoke'}, // Act await umbracoUi.documentBlueprint.clickActionsMenuAtRoot(); await umbracoUi.documentBlueprint.clickCreateActionMenuOption(); + await umbracoUi.documentBlueprint.clickCreateNewDocumentBlueprintButton(); await umbracoUi.documentBlueprint.clickTextButtonWithName(documentTypeName); + await umbracoUi.documentBlueprint.clickChooseButton(); await umbracoUi.documentBlueprint.enterDocumentBlueprintName(documentBlueprintName); await umbracoUi.documentBlueprint.clickSaveButton(); @@ -108,7 +110,9 @@ test('can create a variant document blueprint', {tag: '@release'}, async ({umbra // Act await umbracoUi.documentBlueprint.clickActionsMenuAtRoot(); await umbracoUi.documentBlueprint.clickCreateActionMenuOption(); + await umbracoUi.documentBlueprint.clickCreateNewDocumentBlueprintButton(); await umbracoUi.documentBlueprint.clickTextButtonWithName(documentTypeName); + await umbracoUi.documentBlueprint.clickChooseButton(); await umbracoUi.documentBlueprint.enterDocumentBlueprintName(documentBlueprintName); await umbracoUi.documentBlueprint.clickSaveButton(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts index 650c7d09d6..51544486d5 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/Webhook.spec.ts @@ -173,7 +173,7 @@ test('can remove a header from a webhook', async ({umbracoApi, umbracoUi}) => { expect(await umbracoApi.webhook.doesWebhookHaveHeader(webhookName, headerName, headerValue)).toBeFalsy(); }); -test('cannot add both content event and media event for a webhook', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +test('cannot add both content event and media event for a webhook', async ({umbracoApi, umbracoUi}) => { // Arrange const event = 'Content Published'; await umbracoApi.webhook.createDefaultWebhook(webhookName, webhookSiteToken, event); @@ -185,4 +185,4 @@ test('cannot add both content event and media event for a webhook', {tag: '@rele // Assert await umbracoUi.webhook.isModalMenuItemWithNameDisabled('Media Saved'); await umbracoUi.webhook.isModalMenuItemWithNameDisabled('Media Deleted'); -}); \ No newline at end of file +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/WebhookTrigger.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/WebhookTrigger.spec.ts index 51dc9ede2d..9d4f1c68a2 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/WebhookTrigger.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Webhook/WebhookTrigger.spec.ts @@ -27,7 +27,7 @@ test.afterEach(async ({umbracoApi}) => { await umbracoApi.media.ensureNameNotExists(mediaName); }); -test('can trigger when content is published', {tag: '@release'}, async ({umbracoApi, umbracoUi}) => { +test('can trigger when content is published', async ({umbracoApi, umbracoUi}) => { test.slow(); // Arrange diff --git a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs index 86e1508801..fdf02388a9 100644 --- a/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs +++ b/tests/Umbraco.Tests.Integration/Testing/UmbracoIntegrationTestWithContentEditing.cs @@ -34,9 +34,7 @@ public abstract class UmbracoIntegrationTestWithContentEditing : UmbracoIntegrat protected ContentCreateModel Textpage { get; private set; } - protected ContentScheduleCollection ContentSchedule { get; private set; } - - protected CultureAndScheduleModel CultureAndSchedule { get; private set; } + protected ICollection CultureAndSchedule { get; private set; } protected int TextpageId { get; private set; } @@ -91,11 +89,7 @@ public abstract class UmbracoIntegrationTestWithContentEditing : UmbracoIntegrat } // Sets the culture and schedule for the content, in this case, we are publishing immediately for all cultures - ContentSchedule = new ContentScheduleCollection(); - CultureAndSchedule = new CultureAndScheduleModel - { - CulturesToPublishImmediately = new HashSet { "*" }, Schedules = ContentSchedule, - }; + CultureAndSchedule = [new CulturePublishScheduleModel { Culture = "*", Schedule = null }]; // Create and Save Content "Text Page 1" based on "umbTextpage" -> 1054 PublishedTextPage = ContentEditingBuilder.CreateSimpleContent(ContentType.Key, "Published Page"); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs index f051aa1557..6739487317 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/PublishedContent/PublishedContentFallbackTests.cs @@ -35,6 +35,8 @@ public class PublishedContentFallbackTests : UmbracoIntegrationTest private IApiContentBuilder ApiContentBuilder => GetRequiredService(); + private ILanguageService LanguageService => GetRequiredService(); + protected override void CustomTestSetup(IUmbracoBuilder builder) => builder .AddUmbracoHybridCache() @@ -98,6 +100,36 @@ public class PublishedContentFallbackTests : UmbracoIntegrationTest Assert.AreEqual(invariantTitle, invariantValue); } + [TestCase("Danish title", true)] + [TestCase("Danish title", false)] + [TestCase(null, true)] + [TestCase(null, false)] + public async Task Property_Value_Can_Perform_Explicit_Language_Fallback(string? danishTitle, bool performFallbackToDefaultLanguage) + { + var danishLanguage = new Language("da-DK", "Danish") + { + FallbackIsoCode = "en-US" + }; + await LanguageService.CreateAsync(danishLanguage, Constants.Security.SuperUserKey); + + UmbracoContextFactory.EnsureUmbracoContext(); + + const string englishTitle = "English title"; + var publishedContent = await SetupCultureVariantContentAsync(englishTitle, danishTitle); + + VariationContextAccessor.VariationContext = new VariationContext(culture: "da-DK", segment: null); + var danishValue = publishedContent.Value(PublishedValueFallback, "title"); + Assert.AreEqual(danishTitle ?? string.Empty, danishValue); + + var fallback = performFallbackToDefaultLanguage ? Fallback.ToDefaultLanguage : Fallback.ToLanguage; + var fallbackValue = publishedContent.Value(PublishedValueFallback, "title", fallback: fallback); + Assert.AreEqual(danishTitle ?? englishTitle, fallbackValue); + + VariationContextAccessor.VariationContext = new VariationContext(culture: "en-US", segment: null); + var englishValue = publishedContent.Value(PublishedValueFallback, "title"); + Assert.AreEqual(englishTitle, englishValue); + } + private async Task SetupSegmentedContentAsync(string? invariantTitle, string? segmentedTitle) { var contentType = new ContentTypeBuilder() @@ -124,11 +156,47 @@ public class PublishedContentFallbackTests : UmbracoIntegrationTest ContentService.Save(content); ContentService.Publish(content, ["*"]); + return GetPublishedContent(content.Key); + } + + private async Task SetupCultureVariantContentAsync(string englishTitle, string? danishTitle) + { + var contentType = new ContentTypeBuilder() + .WithAlias("theContentType") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "EN") + .WithCultureName("da-DK", "DA") + .WithName("Content") + .Build(); + content.SetValue("title", englishTitle, culture: "en-US"); + content.SetValue("title", danishTitle, culture: "da-DK"); + ContentService.Save(content); + ContentService.Publish(content, ["en-US", "da-DK"]); + + return GetPublishedContent(content.Key); + } + + private IPublishedContent GetPublishedContent(Guid key) + { ContentCacheRefresher.Refresh([new ContentCacheRefresher.JsonPayload { ChangeTypes = TreeChangeTypes.RefreshAll }]); UmbracoContextAccessor.Clear(); var umbracoContext = UmbracoContextFactory.EnsureUmbracoContext().UmbracoContext; - var publishedContent = umbracoContext.Content.GetById(content.Key); + var publishedContent = umbracoContext.Content.GetById(key); Assert.IsNotNull(publishedContent); return publishedContent; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Publishing.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Publishing.cs index 48e225045d..db57a313e8 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Publishing.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/BlockListElementLevelVariationTests.Publishing.cs @@ -1932,4 +1932,95 @@ internal partial class BlockListElementLevelVariationTests Assert.AreEqual(expectedPickedContent.Key, actualPickedPublishedContent.Key); } } + + [TestCase(ContentVariation.Culture, false)] + [TestCase(ContentVariation.Culture, true)] + [TestCase(ContentVariation.Nothing, false)] + [TestCase(ContentVariation.Nothing, true)] + public async Task Can_Perform_Language_Fallback(ContentVariation elementTypeVariation, bool performFallbackToDefaultLanguage) + { + var daDkLanguage = await LanguageService.GetAsync("da-DK"); + Assert.IsNotNull(daDkLanguage); + daDkLanguage.FallbackIsoCode = "en-US"; + var saveLanguageResult = await LanguageService.UpdateAsync(daDkLanguage, Constants.Security.SuperUserKey); + Assert.IsTrue(saveLanguageResult.Success); + + daDkLanguage = await LanguageService.GetAsync("da-DK"); + Assert.AreEqual("en-US", daDkLanguage?.FallbackIsoCode); + + var elementType = CreateElementType(elementTypeVariation); + var blockListDataType = await CreateBlockListDataType(elementType); + var contentType = CreateContentType(ContentVariation.Culture, blockListDataType, ContentVariation.Culture); + + var content = CreateContent( + contentType, + elementType, + new [] + { + new BlockProperty( + new List + { + new() { Alias = "invariantText", Value = "English invariantText content value" }, + new() { Alias = "variantText", Value = "English variantText content value" } + }, + new List + { + new() { Alias = "invariantText", Value = "English invariantText settings value" }, + new() { Alias = "variantText", Value = "English variantText settings value" } + }, + "en-US", + null) + }, + true); + + AssertPropertyValuesWithFallback("en-US", + "English invariantText content value", "English variantText content value", + "English invariantText settings value", "English variantText settings value"); + + AssetEmptyPropertyValues("da-DK"); + + AssertPropertyValuesWithFallback("da-DK", + "English invariantText content value", "English variantText content value", + "English invariantText settings value", "English variantText settings value"); + + void AssertPropertyValuesWithFallback(string culture, + string expectedInvariantContentValue, string expectedVariantContentValue, + string expectedInvariantSettingsValue, string expectedVariantSettingsValue) + { + SetVariationContext(culture, null); + var publishedContent = GetPublishedContent(content.Key); + + var fallback = performFallbackToDefaultLanguage ? Fallback.ToDefaultLanguage : Fallback.ToLanguage; + + var publishedValueFallback = GetRequiredService(); + var value = publishedContent.Value(publishedValueFallback, "blocks", fallback: fallback); + Assert.IsNotNull(value); + Assert.AreEqual(1, value.Count); + + var blockListItem = value.First(); + Assert.AreEqual(2, blockListItem.Content.Properties.Count()); + Assert.Multiple(() => + { + Assert.AreEqual(expectedInvariantContentValue, blockListItem.Content.Value("invariantText")); + Assert.AreEqual(expectedVariantContentValue, blockListItem.Content.Value("variantText")); + }); + + Assert.AreEqual(2, blockListItem.Settings.Properties.Count()); + Assert.Multiple(() => + { + Assert.AreEqual(expectedInvariantSettingsValue, blockListItem.Settings.Value("invariantText")); + Assert.AreEqual(expectedVariantSettingsValue, blockListItem.Settings.Value("variantText")); + }); + } + + void AssetEmptyPropertyValues(string culture) + { + SetVariationContext(culture, null); + var publishedContent = GetPublishedContent(content.Key); + + var value = publishedContent.Value("blocks"); + Assert.NotNull(value); + Assert.IsEmpty(value); + } + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs index 7a1a5d0dac..48f8a035be 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/DateTimePropertyEditorTests.cs @@ -8,7 +8,6 @@ using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; -using Umbraco.Cms.Infrastructure.HybridCache; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; @@ -39,10 +38,10 @@ public class DateTimePropertyEditorTests : UmbracoIntegrationTest private static readonly object[] _sourceList1 = [ - new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 1, 22) }, + new object[] { Constants.PropertyEditors.Aliases.DateOnly, false, new DateOnly(2025, 6, 22) }, new object[] { Constants.PropertyEditors.Aliases.TimeOnly, false, new TimeOnly(18, 33, 1) }, - new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 1, 22, 18, 33, 1) }, - new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 1, 22, 18, 33, 1, TimeSpan.Zero) }, + new object[] { Constants.PropertyEditors.Aliases.DateTimeUnspecified, false, new DateTime(2025, 6, 22, 18, 33, 1) }, + new object[] { Constants.PropertyEditors.Aliases.DateTimeWithTimeZone, true, new DateTimeOffset(2025, 6, 22, 18, 33, 1, TimeSpan.FromHours(2)) }, ]; [TestCaseSource(nameof(_sourceList1))] @@ -106,7 +105,7 @@ public class DateTimePropertyEditorTests : UmbracoIntegrationTest .WithValue( new JsonObject { - ["date"] = "2025-01-22T18:33:01.0000000+00:00", + ["date"] = "2025-06-22T18:33:01.0000000+02:00", ["timeZone"] = "Europe/Copenhagen", }) .Done() @@ -127,7 +126,6 @@ public class DateTimePropertyEditorTests : UmbracoIntegrationTest Assert.IsTrue(publishResult.Success); - var test = ((DocumentCache)PublishedContentCache).GetAtRoot(false); var publishedContent = await PublishedContentCache.GetByIdAsync(createContentResult.Result.Content.Key, false); Assert.IsNotNull(publishedContent); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs index 1aec16e4a0..45d192587f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -26,10 +27,10 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); - private IContentEditingService ContentEditingService => GetRequiredService(); - private IContentPublishingService ContentPublishingService => GetRequiredService(); + private IDocumentCacheService DocumentCacheService => GetRequiredService(); + private const string NewName = "New Name"; private const string NewTitle = "New Title"; @@ -467,6 +468,61 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte Assert.IsNull(textPage); } + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Check_Where_Not_Found() + { + // Arrange + var testPageKey = Guid.NewGuid(); + + // Act & Assert + // - assert we cannot get the content that doesn't yet exist from the cache + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + // - create and publish the content + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + // - assert we can now get the content from the cache + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + } + + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Exists_Check() + { + // Act + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(PublishedTextPageId); + Assert.IsTrue(hasContentForTextPageCached); + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId); + + // Assert + AssertPublishedTextPage(textPage); + } + + [Test] + public async Task Can_Do_Exists_Check_On_Created_Published_Content() + { + var testPageKey = Guid.NewGuid(); + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(testPage.Id); + Assert.IsTrue(hasContentForTextPageCached); + } + private void AssertTextPage(IPublishedContent textPage) { Assert.Multiple(() => diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs index 1461364d02..38c233e2b9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/CoreThings/ObjectExtensionsTests.cs @@ -1,11 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System; -using System.Collections.Generic; using System.Globalization; -using System.Linq; -using System.Threading; using Microsoft.Extensions.Primitives; using NUnit.Framework; using Umbraco.Cms.Core.PropertyEditors; @@ -31,6 +27,17 @@ public class ObjectExtensionsTests private CultureInfo _savedCulture; + [Test] + public void Can_Create_Enumerable_Of_One() + { + var input = "hello"; +#pragma warning disable CS0618 // Type or member is obsolete + var result = input.AsEnumerableOfOne(); +#pragma warning restore CS0618 // Type or member is obsolete + Assert.AreEqual(1, result.Count()); + Assert.AreEqual("hello", result.First()); + } + [Test] public void Can_Convert_List_To_Enumerable() { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs index dd7395f5d0..1f38c034f2 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/DateTimeUnspecifiedValueConverterTests.cs @@ -86,7 +86,7 @@ public class DateTimeUnspecifiedValueConverterTests private static object[] _dateTimeUnspecifiedConvertToObjectCases = [ new object[] { null, null }, - new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T17:30:00") }, + new object[] { _convertToObjectInputDate, DateTime.Parse("2025-08-20T16:30:00") }, ]; [TestCaseSource(nameof(_dateTimeUnspecifiedConvertToObjectCases))] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs index a0bcd82ec9..ee3da51671 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ValueConverters/TimeOnlyValueConverterTests.cs @@ -86,7 +86,7 @@ public class TimeOnlyValueConverterTests private static object[] _timeOnlyConvertToObjectCases = [ new object[] { null, null }, - new object[] { _convertToObjectInputDate, TimeOnly.Parse("17:30") }, + new object[] { _convertToObjectInputDate, TimeOnly.Parse("16:30") }, ]; [TestCaseSource(nameof(_timeOnlyConvertToObjectCases))] diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs index 152fe28b4e..8da30cd118 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.Extensions.Caching.Hybrid; using Moq; using NUnit.Framework; @@ -33,15 +34,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -56,24 +57,24 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => { - return factory(state!, token); + return factory(state, token); }); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists); @@ -89,15 +90,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -114,15 +115,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -138,15 +139,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(null!); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -160,16 +161,16 @@ public class HybridCacheExtensionsTests string key = "test-key"; _cacheMock.Setup(cache => cache.GetOrCreateAsync( - key, - null, - It.IsAny>>(), - It.IsAny(), - null, - CancellationToken.None)) + key, + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), + It.IsAny(), + null, + CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => @@ -178,7 +179,7 @@ public class HybridCacheExtensionsTests }); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists);