From d15b5b221e9f6cbf06cf60f74e6dfd1426a5fb1a Mon Sep 17 00:00:00 2001 From: Callum Whyte Date: Mon, 20 Jan 2025 20:27:54 +1100 Subject: [PATCH 01/74] Allow skipSelect blueprints only when one blueprint exists (#17818) --- .../src/views/content/content.create.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 54753a94f1b0..64e366c34222 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -86,7 +86,7 @@ function contentCreateController($scope, }); $scope.docType = docType; if (blueprints.length) { - if (blueprintConfig.skipSelect) { + if (blueprintConfig.skipSelect && blueprints.length === 1) { createFromBlueprint(blueprints[0].id); } else { $scope.selectContentType = false; From 7552e315fbfd588fd22469bf063b1eacd4f8b16e Mon Sep 17 00:00:00 2001 From: Callum Whyte Date: Mon, 20 Jan 2025 20:27:54 +1100 Subject: [PATCH 02/74] Allow skipSelect blueprints only when one blueprint exists (#17818) --- .../src/views/content/content.create.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js index 54753a94f1b0..64e366c34222 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/content.create.controller.js @@ -86,7 +86,7 @@ function contentCreateController($scope, }); $scope.docType = docType; if (blueprints.length) { - if (blueprintConfig.skipSelect) { + if (blueprintConfig.skipSelect && blueprints.length === 1) { createFromBlueprint(blueprints[0].id); } else { $scope.selectContentType = false; From 25628a8b7602b80abe238b10d18c3e9f02493b90 Mon Sep 17 00:00:00 2001 From: Alex Clark Date: Mon, 20 Jan 2025 03:04:02 -0600 Subject: [PATCH 03/74] Lucene Package Update to Address CVE-2024-43383 (#17942) * Update Lucene Package to 4.8.0-beta00017 * Add Package Reference --------- Co-authored-by: Sebastiaan Janssen --- Directory.Packages.props | 4 +++- src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d0ced18737f3..eb15199e0a8b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,6 +91,8 @@ + + @@ -98,4 +100,4 @@ - \ No newline at end of file + diff --git a/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj b/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj index 7e28eb69713f..dd9d5a41154c 100644 --- a/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj +++ b/src/Umbraco.Examine.Lucene/Umbraco.Examine.Lucene.csproj @@ -10,6 +10,8 @@ + + From 95eb58587b95b091fa861216842e3cdf9af27c32 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 20 Jan 2025 14:14:28 +0100 Subject: [PATCH 04/74] Merge commit from fork --- .../Controllers/PreviewController.cs | 28 +++++++++++++++++++ .../Views/UmbracoViewPage.cs | 5 +++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 17875c295067..f1531ecb8d8f 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; @@ -130,6 +131,11 @@ public async Task Application() [Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)] public ActionResult Frame(int id, string culture) { + if (ValidateProvidedCulture(culture) is false) + { + throw new InvalidOperationException($"Could not recognise the provided culture: {culture}"); + } + EnterPreview(id); // use a numeric URL because content may not be in cache and so .Url would fail @@ -138,6 +144,28 @@ public ActionResult Frame(int id, string culture) return RedirectPermanent($"../../{id}{query}"); } + private static bool ValidateProvidedCulture(string culture) + { + if (string.IsNullOrEmpty(culture)) + { + return true; + } + + // We can be confident the backoffice will have provided a valid culture in linking to the + // preview, so we don't need to check that the culture matches an Umbraco language. + // We are only concerned here with protecting against XSS attacks from a fiddled preview + // URL, so we can just confirm we have a valid culture. + try + { + CultureInfo.GetCultureInfo(culture, true); + return true; + } + catch (CultureNotFoundException) + { + return false; + } + } + public ActionResult? EnterPreview(int id) { IUser? user = _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser; diff --git a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs index 086a0b0c81a5..403e6324e197 100644 --- a/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs +++ b/src/Umbraco.Web.Common/Views/UmbracoViewPage.cs @@ -141,7 +141,10 @@ public void WriteUmbracoContent(TagHelperOutput tagHelperOutput) string.Format( ContentSettings.PreviewBadge, HostingEnvironment.ToAbsolute(GlobalSettings.UmbracoPath), - Context.Request.GetEncodedUrl(), + System.Web.HttpUtility.HtmlEncode(Context.Request.GetEncodedUrl()), // Belt and braces - via a browser at least it doesn't seem possible to have anything other than + // a valid culture code provided in the querystring of this URL. + // But just to be sure of prevention of an XSS vulnterablity we'll HTML encode here too. + // An expected URL is untouched by this encoding. UmbracoContext.PublishedRequest?.PublishedContent?.Id); } else From e934a943b5068913be40a32bfbc5fa92091eb95c Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 20 Jan 2025 17:37:04 +0100 Subject: [PATCH 05/74] build on windows --- build/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index c8e370d51327..572f93df0a87 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -71,7 +71,7 @@ stages: - job: A displayName: Build Umbraco CMS pool: - vmImage: 'ubuntu-latest' + vmImage: 'windows-latest' steps: - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) From e77e9c5691685e6a84886d9c6b77888e5debdcaa Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 24 Oct 2024 14:45:23 +0200 Subject: [PATCH 06/74] Format sql statement (#17354) (cherry picked from commit aa9f194d7611bb830a8fc3b80295c8115fb5b2d6) --- .../SyntaxProvider/SqlServerSyntaxProviderTests.cs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs index be90d8695bbf..7e1d1f163f8f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/SyntaxProvider/SqlServerSyntaxProviderTests.cs @@ -68,12 +68,8 @@ string c(string x) } Assert.AreEqual( - @$"DELETE FROM {t("cmsContentNu")} WHERE {c("nodeId")} IN (SELECT {c("nodeId")} FROM (SELECT DISTINCT cmsContentNu.nodeId -FROM {t("cmsContentNu")} -INNER JOIN {t("umbracoNode")} -ON {t("cmsContentNu")}.{c("nodeId")} = {t("umbracoNode")}.{c("id")} -WHERE (({t("umbracoNode")}.{c("nodeObjectType")} = @0))) x)".Replace(Environment.NewLine, " ").Replace("\n", " ") - .Replace("\r", " "), + @$"DELETE FROM {t("cmsContentNu")} WHERE {c("nodeId")} IN (SELECT {c("nodeId")} FROM (SELECT DISTINCT cmsContentNu.nodeId FROM {t("cmsContentNu")} INNER JOIN {t("umbracoNode")} ON {t("cmsContentNu")}.{c("nodeId")} = {t("umbracoNode")}.{c("id")} WHERE (({t("umbracoNode")}.{c("nodeObjectType")} = @0))) x)".Replace(Environment.NewLine, " ") + .Replace("\n", " ").Replace("\r", " "), sqlOutput.SQL.Replace(Environment.NewLine, " ").Replace("\n", " ").Replace("\r", " ")); Assert.AreEqual(1, sqlOutput.Arguments.Length); From 2161edb871b416faf8f4b14e1634e2f4fcdd4d66 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Tue, 21 Jan 2025 07:41:06 +0100 Subject: [PATCH 07/74] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index ba35bbaff3c8..a88b4d4fd19a 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.5.2", + "version": "13.5.3", "assemblyVersion": { "precision": "build" }, From c7d157bbfd807aff521d65afef8ec815db87dab2 Mon Sep 17 00:00:00 2001 From: Zeegaan Date: Tue, 21 Jan 2025 09:02:00 +0100 Subject: [PATCH 08/74] build on ubuntu --- build/azure-pipelines.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 572f93df0a87..c8e370d51327 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -71,7 +71,7 @@ stages: - job: A displayName: Build Umbraco CMS pool: - vmImage: 'windows-latest' + vmImage: 'ubuntu-latest' steps: - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) From 7567990da19eec5c57dcf2227c887febe280a065 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 21 Jan 2025 09:10:43 +0100 Subject: [PATCH 09/74] Add NoopCurrentMemberClaimsProvider so Umbraco can boot without the Delivery API enabled (#18049) --- .../Controllers/Security/CurrentMemberController.cs | 2 +- .../Services/CurrentMemberClaimsProvider.cs | 1 + .../DeliveryApi}/ICurrentMemberClaimsProvider.cs | 2 +- .../DeliveryApi/NoopCurrentMemberClaimsProvider.cs | 6 ++++++ .../DependencyInjection/UmbracoBuilder.CoreServices.cs | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) rename src/{Umbraco.Cms.Api.Delivery/Services => Umbraco.Core/DeliveryApi}/ICurrentMemberClaimsProvider.cs (87%) create mode 100644 src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs index 9e71636324b0..41d1970d069c 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using OpenIddict.Server.AspNetCore; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Security; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs index 3250b24ae69b..8a358f11a86f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs @@ -1,4 +1,5 @@ using OpenIddict.Abstractions; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Delivery.Services; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs similarity index 87% rename from src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs rename to src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs index 902129af6b1d..cd636cb06e09 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Api.Delivery.Services; +namespace Umbraco.Cms.Core.DeliveryApi; public interface ICurrentMemberClaimsProvider { diff --git a/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs new file mode 100644 index 000000000000..8080c27562e2 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class NoopCurrentMemberClaimsProvider : ICurrentMemberClaimsProvider +{ + public Task> GetClaimsAsync() => Task.FromResult(new Dictionary()); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a9ffc67f646a..ef0e25204c60 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -454,6 +454,7 @@ private static IUmbracoBuilder AddDeliveryApiCoreServices(this IUmbracoBuilder b builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From a2fd82a3f36593c2b2d8f6e675226f41bf789357 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 21 Jan 2025 09:10:43 +0100 Subject: [PATCH 10/74] Add NoopCurrentMemberClaimsProvider so Umbraco can boot without the Delivery API enabled (#18049) --- .../Controllers/Security/CurrentMemberController.cs | 2 +- .../Services/CurrentMemberClaimsProvider.cs | 1 + .../DeliveryApi}/ICurrentMemberClaimsProvider.cs | 2 +- .../DeliveryApi/NoopCurrentMemberClaimsProvider.cs | 6 ++++++ .../DependencyInjection/UmbracoBuilder.CoreServices.cs | 1 + 5 files changed, 10 insertions(+), 2 deletions(-) rename src/{Umbraco.Cms.Api.Delivery/Services => Umbraco.Core/DeliveryApi}/ICurrentMemberClaimsProvider.cs (87%) create mode 100644 src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs index 9e71636324b0..41d1970d069c 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Security/CurrentMemberController.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.Mvc; using OpenIddict.Server.AspNetCore; using Umbraco.Cms.Api.Delivery.Routing; -using Umbraco.Cms.Api.Delivery.Services; +using Umbraco.Cms.Core.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Security; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs index 3250b24ae69b..8a358f11a86f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/CurrentMemberClaimsProvider.cs @@ -1,4 +1,5 @@ using OpenIddict.Abstractions; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Security; namespace Umbraco.Cms.Api.Delivery.Services; diff --git a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs similarity index 87% rename from src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs rename to src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs index 902129af6b1d..cd636cb06e09 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/ICurrentMemberClaimsProvider.cs +++ b/src/Umbraco.Core/DeliveryApi/ICurrentMemberClaimsProvider.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Api.Delivery.Services; +namespace Umbraco.Cms.Core.DeliveryApi; public interface ICurrentMemberClaimsProvider { diff --git a/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs new file mode 100644 index 000000000000..8080c27562e2 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/NoopCurrentMemberClaimsProvider.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public class NoopCurrentMemberClaimsProvider : ICurrentMemberClaimsProvider +{ + public Task> GetClaimsAsync() => Task.FromResult(new Dictionary()); +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index a9ffc67f646a..ef0e25204c60 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -454,6 +454,7 @@ private static IUmbracoBuilder AddDeliveryApiCoreServices(this IUmbracoBuilder b builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); From 6bd11bf233fc8d792bb9886f4e4ca7dca8ffb30f Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Jan 2025 09:19:31 +0100 Subject: [PATCH 11/74] Fixes failing front-end unit test to align with new behaviour from PR #17818 ( Allow skipSelect blueprints only when one blueprint exists). --- .../unit/app/content/create-content-controller.spec.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js b/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js index 5954a2f98437..86495b59ea63 100644 --- a/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js +++ b/src/Umbraco.Web.UI.Client/test/unit/app/content/create-content-controller.spec.js @@ -1,4 +1,4 @@ -(function () { +(function () { describe("create content dialog", function () { @@ -89,13 +89,16 @@ expect(searcher.search).toHaveBeenCalledWith("blueprintId", "1"); }); - it("skips selection and creates first blueprint when configured to", + it("skips selection and creates first blueprint when configured to and only one blueprint exists", function () { initialize({ allowBlank: true, skipSelect: true }); + // Ensure only one blueprint is available. + allowedTypes[1].blueprints = { "1": "a" }; + scope.createOrSelectBlueprintIfAny(allowedTypes[1]); expect(location.path).toHaveBeenCalledWith("/content/content/edit/1234"); From 0e4f883bc1d48804405693fb54b12b71de06886e Mon Sep 17 00:00:00 2001 From: Martin Vennevold Date: Tue, 21 Jan 2025 09:44:22 +0100 Subject: [PATCH 12/74] Fix create child issue in list view with infinite editor (#13355). (#17637) --- .../src/views/propertyeditors/listview/listview.controller.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js index 0edcd67c1924..c9fc26e0535e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/listview/listview.controller.js @@ -781,7 +781,7 @@ function listViewController($scope, $interpolate, $routeParams, $injector, $time $scope.options.allowBulkDelete; if ($scope.isTrashed === false) { - getContentTypesCallback(id).then(function (listViewAllowedTypes) { + getContentTypesCallback($scope.contentId).then(function (listViewAllowedTypes) { $scope.listViewAllowedTypes = listViewAllowedTypes; var blueprints = false; From 1a18d6c035d9ec3d32c3a578d387b62c1a110003 Mon Sep 17 00:00:00 2001 From: Nathaniel Nunes Date: Tue, 21 Jan 2025 18:26:40 +0530 Subject: [PATCH 13/74] Replaced deprecated navigator.platform with navigator.userAgent for platform detection. (#17373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Niels Lyngsø --- .../src/views/common/drawers/help/help.controller.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js index f69467b0a1d7..ede50dc93e86 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/drawers/help/help.controller.js @@ -67,7 +67,7 @@ if(browserInfo != null){ vm.systemInfo.push({name :"Browser", data: browserInfo.name + " " + browserInfo.version}); } - vm.systemInfo.push({name :"Browser OS", data: getPlatform()}); + vm.systemInfo.push({name :"Browser (user agent)", data: getPlatform()}); } ); tourService.getGroupedTours().then(function(groupedTours) { vm.tours = groupedTours; @@ -257,7 +257,7 @@ } function getPlatform() { - return window.navigator.platform; + return navigator.userAgent; } evts.push(eventsService.on("appState.tour.complete", function (event, tour) { From edc78a5a4c5e8fbc85517da261e3a22596b6b46a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 22 Jan 2025 12:26:06 +0100 Subject: [PATCH 14/74] Handles migration case where an expected constraint is renamed but the constraint does not exist. (#18063) --- .../Upgrade/V_13_3_0/AlignUpgradedDatabase.cs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs index 6ee48ce0e7a3..f45b5d371b66 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_3_0/AlignUpgradedDatabase.cs @@ -1,4 +1,4 @@ -using NPoco; +using NPoco; using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; using ColumnInfo = Umbraco.Cms.Infrastructure.Persistence.SqlSyntax.ColumnInfo; @@ -153,16 +153,26 @@ JOIN sys.columns columns "); var currentConstraintName = Database.ExecuteScalar(constraintNameQuery); - - // only rename the constraint if necessary + // Only rename the constraint if necessary. if (currentConstraintName == expectedConstraintName) { return; } - Sql renameConstraintQuery = Database.SqlContext.Sql( - $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); - Database.Execute(renameConstraintQuery); + if (currentConstraintName is null) + { + // Constraint does not exist, so we need to create it. + Sql createConstraintStatement = Database.SqlContext.Sql(@$" +ALTER TABLE umbracoContentVersion ADD CONSTRAINT [DF_umbracoContentVersion_versionDate] DEFAULT (getdate()) FOR [versionDate]"); + Database.Execute(createConstraintStatement); + } + else + { + // Constraint exists, and differs from the expected name, so we need to rename it. + Sql renameConstraintQuery = Database.SqlContext.Sql( + $"EXEC sp_rename N'{currentConstraintName}', N'{expectedConstraintName}', N'OBJECT'"); + Database.Execute(renameConstraintQuery); + } } private void UpdateExternalLoginIndexes(IEnumerable> indexes) From 3d253f5f06c182255810dbd209774c03c09aec77 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 23 Jan 2025 11:15:26 +0100 Subject: [PATCH 15/74] bump version --- version.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/version.json b/version.json index 571b476065f0..54e67d46be36 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.6.0-rc2", + "version": "13.6.0-rc3", "assemblyVersion": { "precision": "build" }, @@ -9,8 +9,7 @@ "semVer": 2.0 }, "publicReleaseRefSpec": [ - "^refs/heads/main$", - "^refs/heads/release/" + "^refs/heads/main$", "^refs/heads/release/" ], "release": { "branchName": "release/{version}", From 8485458896095150cf506ffdf7085ce20a9d3f4c Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Thu, 23 Jan 2025 17:01:55 +0100 Subject: [PATCH 16/74] Add clientside validation to webhook events (#18089) --- .../src/views/webhooks/edit.controller.js | 1 + src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js index 4a12d5254d04..ce5de98d4c2b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js @@ -247,6 +247,7 @@ function save() { if (!formHelper.submitForm({ scope: $scope })) { + vm.saveButtonState = 'error'; return; } diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html index f64ef5d69bce..5a4678c5eb92 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html @@ -5,7 +5,7 @@
- + - +
@@ -50,6 +50,8 @@ alias="webhookEvents" required="true"> + + - + - +
From 44bf3b77b397cd73f328b8766851f31bbe19c66a Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 24 Jan 2025 14:41:30 +0100 Subject: [PATCH 17/74] Make it possible to reset media picker crops (#18110) Co-authored-by: Andy Butland --- .../mediaentryeditor.controller.js | 43 +++++++++++-------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js index 05be10c5d010..aed213383980 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/mediaentryeditor/mediaentryeditor.controller.js @@ -3,15 +3,15 @@ angular.module("umbraco") function ($scope, localizationService, entityResource, editorService, overlayService, eventsService, mediaHelper) { var unsubscribe = []; - + const vm = this; - + vm.loading = true; vm.model = $scope.model; vm.mediaEntry = vm.model.mediaEntry; vm.currentCrop = null; vm.title = ""; - + vm.focalPointChanged = focalPointChanged; vm.onImageLoaded = onImageLoaded; vm.openMedia = openMedia; @@ -20,7 +20,7 @@ angular.module("umbraco") vm.deselectCrop = deselectCrop; vm.resetCrop = resetCrop; vm.submitAndClose = submitAndClose; - vm.close = close; + vm.close = close; function init() { @@ -58,6 +58,12 @@ angular.module("umbraco") return; } + // the focal point can be null in some cases - most often right after a save. this throws the crop + // thumbnails (previews) off, so let's enforce the default focal point. + if (!vm.mediaEntry.focalPoint){ + vm.mediaEntry.focalPoint = {left: 0.5, top: 0.5}; + } + vm.loading = true; entityResource.getById(vm.mediaEntry.mediaKey, "Media").then(function (mediaEntity) { @@ -85,12 +91,12 @@ angular.module("umbraco") }); }); } - + function onImageLoaded(isCroppable, hasDimensions) { vm.isCroppable = isCroppable; vm.hasDimensions = hasDimensions; } - + function repickMedia() { vm.model.propertyEditor.changeMediaFor(vm.model.mediaEntry, onMediaReplaced); } @@ -105,7 +111,7 @@ angular.module("umbraco") updateMedia(); } - + function openMedia() { const mediaEditor = { @@ -117,7 +123,7 @@ angular.module("umbraco") editorService.close(); } }; - + editorService.mediaEditor(mediaEditor); } @@ -131,23 +137,24 @@ angular.module("umbraco") // set form to dirty to track changes setDirty(); } - + function selectCrop(targetCrop) { vm.currentCrop = targetCrop; setDirty(); // TODO: start watchin values of crop, first when changed set to dirty. } - + function deselectCrop() { vm.currentCrop = null; } - + function resetCrop() { if (vm.currentCrop) { - $scope.$evalAsync( () => { - vm.model.propertyEditor.resetCrop(vm.currentCrop); - vm.forceUpdateCrop = Math.random(); - }); + vm.model.propertyEditor.resetCrop(vm.currentCrop); + // deselecting the crop here has a dual purpose: + // 1. it replicates the behaviour of the image cropper (e.g. on media items). + // 2. it ensures that the newly reset crop does not get overwritten by a new crop with default values. + deselectCrop(); } } @@ -160,7 +167,7 @@ angular.module("umbraco") vm.model.submit(vm.model); } } - + function close() { if (vm.model && vm.model.close) { @@ -169,7 +176,7 @@ angular.module("umbraco") const labelKeys = vm.model.createFlow === true ? ["mediaPicker_confirmCancelMediaEntryCreationHeadline", "mediaPicker_confirmCancelMediaEntryCreationMessage"] : ["prompt_discardChanges", "mediaPicker_confirmCancelMediaEntryHasChanges"]; - + localizationService.localizeMany(labelKeys).then(localizations => { const confirm = { title: localizations[0], @@ -196,7 +203,7 @@ angular.module("umbraco") } init(); - + $scope.$on("$destroy", function () { unsubscribe.forEach(x => x()); }); From 7850078623c1339fd358a634cfb36a2f1ed04526 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 24 Jan 2025 19:46:28 +0100 Subject: [PATCH 18/74] Redirect to the published URL when exiting preview (#18114) Co-authored-by: Andy Butland --- .../Controllers/PreviewController.cs | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs index 17875c295067..6ab88844b793 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/PreviewController.cs @@ -1,9 +1,12 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Editors; using Umbraco.Cms.Core.Features; using Umbraco.Cms.Core.Hosting; @@ -27,7 +30,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [DisableBrowserCache] [Area(Constants.Web.Mvc.BackOfficeArea)] -public class PreviewController : Controller +public partial class PreviewController : Controller { private readonly IBackOfficeSecurityAccessor _backofficeSecurityAccessor; private readonly ICookieManager _cookieManager; @@ -39,7 +42,9 @@ public class PreviewController : Controller private readonly IRuntimeMinifier _runtimeMinifier; private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly ICompositeViewEngine _viewEngines; + private readonly WebRoutingSettings _webRoutingSettings; + [Obsolete("Please use the non-obsolete constructor.")] public PreviewController( UmbracoFeatures features, IOptionsSnapshot globalSettings, @@ -51,9 +56,38 @@ public PreviewController( IRuntimeMinifier runtimeMinifier, ICompositeViewEngine viewEngines, IUmbracoContextAccessor umbracoContextAccessor) + : this( + features, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>(), + publishedSnapshotService, + backofficeSecurityAccessor, + localizationService, + hostingEnvironment, + cookieManager, + runtimeMinifier, + viewEngines, + umbracoContextAccessor) + { + } + + [ActivatorUtilitiesConstructor] + public PreviewController( + UmbracoFeatures features, + IOptionsSnapshot globalSettings, + IOptionsSnapshot webRoutingSettings, + IPublishedSnapshotService publishedSnapshotService, + IBackOfficeSecurityAccessor backofficeSecurityAccessor, + ILocalizationService localizationService, + IHostingEnvironment hostingEnvironment, + ICookieManager cookieManager, + IRuntimeMinifier runtimeMinifier, + ICompositeViewEngine viewEngines, + IUmbracoContextAccessor umbracoContextAccessor) { _features = features; _globalSettings = globalSettings.Value; + _webRoutingSettings = webRoutingSettings.Value; _publishedSnapshotService = publishedSnapshotService; _backofficeSecurityAccessor = backofficeSecurityAccessor; _localizationService = localizationService; @@ -153,6 +187,43 @@ public ActionResult End(string? redir = null) // Expire Client-side cookie that determines whether the user has accepted to be in Preview Mode when visiting the website. _cookieManager.ExpireCookie(Constants.Web.AcceptPreviewCookieName); + // are we attempting a redirect to the default route (by ID with optional culture)? + Match match = DefaultPreviewRedirectRegex().Match(redir ?? string.Empty); + if (match.Success) + { + var id = int.Parse(match.Groups["id"].Value); + + // first try to resolve the published URL + if (_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext) && + umbracoContext.Content is not null) + { + IPublishedContent? publishedContent = umbracoContext.Content.GetById(id); + if (publishedContent is null) + { + // content is not published, redirect to root + return Redirect("/"); + } + + var culture = publishedContent.ContentType.VariesByCulture() + && match.Groups.TryGetValue("culture", out Group? group) + ? group.Value + : null; + + var publishedUrl = publishedContent.Url(culture); + if (WebPath.IsWellFormedWebPath(publishedUrl, UriKind.RelativeOrAbsolute)) + { + return Redirect(publishedUrl); + } + } + + // could not resolve the published URL - are we allowed to route content by ID? + if (_webRoutingSettings.DisableFindContentByIdPath) + { + // no we are not - redirect to root instead + return Redirect("/"); + } + } + if (WebPath.IsWellFormedWebPath(redir, UriKind.Relative) && Uri.TryCreate(redir, UriKind.Relative, out Uri? url)) { @@ -161,4 +232,7 @@ public ActionResult End(string? redir = null) return Redirect("/"); } + + [GeneratedRegex("^\\/(?\\d*)(\\?culture=(?[\\w-]*))?$")] + private static partial Regex DefaultPreviewRedirectRegex(); } From 313417cb91b04b253f81e56050626ee2eb3e1e69 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 28 Jan 2025 10:52:21 +0100 Subject: [PATCH 19/74] Tidied up XML header comment in ITagQuery. --- src/Umbraco.Core/PublishedCache/ITagQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Core/PublishedCache/ITagQuery.cs b/src/Umbraco.Core/PublishedCache/ITagQuery.cs index e0c6a135c90e..8df363dbe447 100644 --- a/src/Umbraco.Core/PublishedCache/ITagQuery.cs +++ b/src/Umbraco.Core/PublishedCache/ITagQuery.cs @@ -33,7 +33,7 @@ public interface ITagQuery /// /// Gets all document tags. /// - /// /// + /// /// If no culture is specified, it retrieves tags with an invariant culture. /// If a culture is specified, it only retrieves tags for that culture. /// Use "*" to retrieve tags for all cultures. From bf340cd7d4c139f08d6cc0dc669038c3d1ab6022 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:53:00 +0100 Subject: [PATCH 20/74] fix: remove unused parameters and documentation (#18095) this fixes an issue where unused parameters were published as supported, but they were in fact never supported. --- .../src/common/services/assets.service.js | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js index 8149e45ee869..73ac74847b34 100644 --- a/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js +++ b/src/Umbraco.Web.UI.Client/src/common/services/assets.service.js @@ -6,7 +6,7 @@ * @requires angularHelper * * @description - * Promise-based utillity service to lazy-load client-side dependencies inside angular controllers. + * Promise-based utility service to lazy-load client-side dependencies inside angular controllers. * * ##usage * To use, simply inject the assetsService into any controller that needs it, and make @@ -20,10 +20,10 @@ * }); * * - * You can also load individual files, which gives you greater control over what attibutes are passed to the file, as well as timeout + * You can also load individual files, which gives you greater control over what attributes are passed to the file: * *
- *      angular.module("umbraco").controller("my.controller". function(assetsService){
+ *      angular.module("umbraco").controller("my.controller". function(assetsService) {
  *          assetsService.loadJs("script.js", $scope, {charset: 'utf-8'}, 10000 }).then(function(){
  *                 //this code executes when the script is done loading
  *          });
@@ -33,7 +33,7 @@
  * For these cases, there are 2 individual methods, one for javascript, and one for stylesheets:
  *
  * 
- *      angular.module("umbraco").controller("my.controller". function(assetsService){
+ *      angular.module("umbraco").controller("my.controller". function(assetsService) {
  *          assetsService.loadCss("stye.css", $scope, {media: 'print'}, 10000 }).then(function(){
  *                 //loadcss cannot determine when the css is done loading, so this will trigger instantly
  *          });
@@ -55,7 +55,7 @@ angular.module('umbraco.services')
             var _op = (url.indexOf("?") > 0) ? "&" : "?";
             url = url + _op + "umb__rnd=" + rnd;
             return url;
-        };
+        }
 
         function convertVirtualPath(path) {
             //make this work for virtual paths
@@ -72,7 +72,7 @@ angular.module('umbraco.services')
         function getFlatpickrLocales(locales, supportedLocales) {
             return getLocales(locales, supportedLocales, 'lib/flatpickr/l10n/');
         }
-        
+
         function getLocales(locales, supportedLocales, path) {
             var localeUrls = [];
             locales = locales.split(',');
@@ -168,17 +168,13 @@ angular.module('umbraco.services')
              *
              * @param {String} path path to the css file to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {Object} keyvalue collection of attributes to pass to the stylesheet element
-             * @param {Number} timeout in milliseconds
              * @returns {Promise} Promise object which resolves when the file has loaded
              */
-            loadCss: function (path, scope, attributes, timeout) {
+            loadCss: function (path, scope) {
 
                 path = convertVirtualPath(path);
 
-                var asset = this._getAssetPromise(path); // $q.defer();
-                var t = timeout || 5000;
-                var a = attributes || undefined;
+                const asset = this._getAssetPromise(path);
 
                 if (asset.state === "new") {
                     asset.state = "loading";
@@ -207,17 +203,13 @@ angular.module('umbraco.services')
              *
              * @param {String} path path to the js file to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {Object} keyvalue collection of attributes to pass to the script element
-             * @param {Number} timeout in milliseconds
              * @returns {Promise} Promise object which resolves when the file has loaded
              */
-            loadJs: function (path, scope, attributes, timeout) {
+            loadJs: function (path, scope) {
 
                 path = convertVirtualPath(path);
 
-                var asset = this._getAssetPromise(path); // $q.defer();
-                var t = timeout || 5000;
-                var a = attributes || undefined;
+                const asset = this._getAssetPromise(path);
 
                 if (asset.state === "new") {
                     asset.state = "loading";
@@ -250,7 +242,7 @@ angular.module('umbraco.services')
              *
              * @param {Array} pathArray string array of paths to the files to load
              * @param {Scope} scope optional scope to pass into the loader
-             * @param {string} defaultAssetType optional default asset type used to load assets with no extension 
+             * @param {string} defaultAssetType optional default asset type used to load assets with no extension
              * @returns {Promise} Promise object which resolves when all the files has loaded
              */
             load: function (pathArray, scope, defaultAssetType) {

From f54b6033ab722343156e228883d014f107de73ed Mon Sep 17 00:00:00 2001
From: Andy Butland 
Date: Wed, 29 Jan 2025 10:27:17 +0100
Subject: [PATCH 21/74] Added Resharper test assemblies to exclude list on
 TypeFinder. (#18145)

---
 src/Umbraco.Core/Composing/TypeFinder.cs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Umbraco.Core/Composing/TypeFinder.cs b/src/Umbraco.Core/Composing/TypeFinder.cs
index e3b7ddef9bb0..5e02336ef5e8 100644
--- a/src/Umbraco.Core/Composing/TypeFinder.cs
+++ b/src/Umbraco.Core/Composing/TypeFinder.cs
@@ -34,7 +34,7 @@ public class TypeFinder : ITypeFinder
         "ServiceStack.", "SqlCE4Umbraco,", "Superpower,", // used by Serilog
         "System.", "TidyNet,", "TidyNet.", "WebDriver,", "itextsharp,", "mscorlib,", "NUnit,", "NUnit.", "NUnit3.",
         "Selenium.", "ImageProcessor", "MiniProfiler.", "Owin,", "SQLite",
-        "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
+        "ReSharperTestRunner", "ReSharperTestRunner32", "ReSharperTestRunner64", "ReSharperTestRunnerArm32", "ReSharperTestRunnerArm64", // These are used by the Jetbrains Rider IDE and Visual Studio ReSharper Extension
     };
 
     private static readonly ConcurrentDictionary TypeNamesCache = new();

From 59a46495284d4f056fb6dbbc56af2bf46004dc30 Mon Sep 17 00:00:00 2001
From: Kenn Jacobsen 
Date: Wed, 29 Jan 2025 10:29:58 +0100
Subject: [PATCH 22/74] Do not allow editing read-only properties by clicking
 their labels (#18152)

* Do not allow editing read-only properties by clicking their labels

* Simplify the fix :)

* Fix linting issue
---
 .../components/content/umbtabbedcontent.directive.js   |  4 ++++
 .../components/property/umbproperty.directive.js       |  7 ++++---
 .../views/components/content/umb-tabbed-content.html   | 10 ++++++----
 .../src/views/components/property/umb-property.html    |  2 +-
 4 files changed, 15 insertions(+), 8 deletions(-)

diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
index e76da32a545b..fe16d1cf9d2f 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/umbtabbedcontent.directive.js
@@ -218,6 +218,10 @@
 
                 return !canEditCulture || !canEditSegment;
             }
+
+            $scope.isPreview = function(property) {
+              return ((property.readonly || !$scope.allowUpdate) && !property.supportsReadOnly) || ($scope.propertyEditorDisabled(property) && $scope.allowUpdate);
+            }
         }
 
         var directive = {
diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
index 11efb4b81150..073df54a7ed9 100644
--- a/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
+++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/property/umbproperty.directive.js
@@ -25,11 +25,12 @@
                 propertyAlias: "@",
                 showInherit: "<",
                 inheritsFrom: "<",
-                hideLabel: " 1 && property.variation !== 'CultureAndSegment'"
-                    inherits-from="defaultVariant.displayName">
+                    inherits-from="defaultVariant.displayName"
+                    preview="isPreview(property)">
 
                     
@@ -49,12 +50,13 @@
                     property="property"
                     node="contentNodeModel"
                     show-inherit="contentNodeModel.variants.length > 1 && property.variation !== 'CultureAndSegment'"
-                    inherits-from="defaultVariant.displayName">
+                    inherits-from="defaultVariant.displayName"
+                    preview="isPreview(property)">
 
                     
diff --git a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
index e3a65a215bdf..e4166e8e98d0 100644
--- a/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
+++ b/src/Umbraco.Web.UI.Client/src/views/components/property/umb-property.html
@@ -9,7 +9,7 @@
 
                 
- + From 5d48bc7371345ece8e98d52537e3181c3ab30721 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 29 Jan 2025 13:33:49 +0100 Subject: [PATCH 23/74] Provides an option to remove the inessential version number from the generated models (#18081) * Provides an option to remove the inessential version number from the generated models. * Clarified comment. --- .../Configuration/Models/ModelsBuilderSettings.cs | 13 +++++++++++++ .../ModelsBuilder/Building/TextBuilder.cs | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs index be86cf1f2b7c..127b7d9330df 100644 --- a/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ModelsBuilderSettings.cs @@ -16,6 +16,7 @@ public class ModelsBuilderSettings internal const string StaticModelsDirectory = "~/umbraco/models"; internal const bool StaticAcceptUnsafeModelsDirectory = false; internal const int StaticDebugLevel = 0; + internal const bool StaticIncludeVersionNumberInGeneratedModels = true; private bool _flagOutOfDateModels = true; /// @@ -78,4 +79,16 @@ public bool FlagOutOfDateModels /// 0 means minimal (safe on live site), anything else means more and more details (maybe not safe). [DefaultValue(StaticDebugLevel)] public int DebugLevel { get; set; } = StaticDebugLevel; + + /// + /// Gets or sets a value indicating whether the version number should be included in generated models. + /// + /// + /// By default this is written to the output in + /// generated code for each property of the model. This can be useful for debugging purposes but isn't essential, + /// and it has the causes the generated code to change every time Umbraco is upgraded. In turn, this leads + /// to unnecessary code file changes that need to be checked into source control. Default is true. + /// + [DefaultValue(StaticIncludeVersionNumberInGeneratedModels)] + public bool IncludeVersionNumberInGeneratedModels { get; set; } = StaticIncludeVersionNumberInGeneratedModels; } diff --git a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs index 22160b0ef49f..7484741b5812 100644 --- a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs +++ b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextBuilder.cs @@ -143,14 +143,17 @@ public void WriteClrType(StringBuilder sb, Type type) // // note that the blog post above clearly states that "Nor should it be applied at the type level if the type being generated is a partial class." // and since our models are partial classes, we have to apply the attribute against the individual members, not the class itself. - private static void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( + private void WriteGeneratedCodeAttribute(StringBuilder sb, string tabs) => sb.AppendFormat( "{0}[global::System.CodeDom.Compiler.GeneratedCodeAttribute(\"Umbraco.ModelsBuilder.Embedded\", \"{1}\")]\n", - tabs, ApiVersion.Current.Version); + tabs, + Config.IncludeVersionNumberInGeneratedModels ? ApiVersion.Current.Version : null); // writes an attribute that specifies that an output may be null. // (useful for consuming projects with nullable reference types enabled) private static void WriteMaybeNullAttribute(StringBuilder sb, string tabs, bool isReturn = false) => - sb.AppendFormat("{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", tabs, + sb.AppendFormat( + "{0}[{1}global::System.Diagnostics.CodeAnalysis.MaybeNull]\n", + tabs, isReturn ? "return: " : string.Empty); private static string MixinStaticGetterName(string clrName) => string.Format("Get{0}", clrName); From ee2d7bbb1b66ff5f0855fea6ef531e83af2795d2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 29 Jan 2025 21:10:11 +0100 Subject: [PATCH 24/74] Excluded tags from trashed content (#18164) --- .../Repositories/Implement/TagRepository.cs | 7 +- .../Repositories/TagRepositoryTest.cs | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs index ecc6600d4c97..a722bea2a03a 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/TagRepository.cs @@ -387,7 +387,9 @@ private static IEnumerable Map(IEnumerable dtos) }).ToList(); /// - public IEnumerable GetTagsForEntityType(TaggableObjectTypes objectType, string? group = null, + public IEnumerable GetTagsForEntityType( + TaggableObjectTypes objectType, + string? group = null, string? culture = null) { Sql sql = GetTagsSql(culture, true); @@ -401,6 +403,9 @@ public IEnumerable GetTagsForEntityType(TaggableObjectTypes objectType, st .Where(dto => dto.NodeObjectType == nodeObjectType); } + sql = sql + .Where(dto => !dto.Trashed); + if (group.IsNullOrWhiteSpace() == false) { sql = sql diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs index 1ce5eeefd99b..297bd5069900 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Persistence/Repositories/TagRepositoryTest.cs @@ -638,6 +638,89 @@ public void Can_Get_Tags_For_Property_For_Group() } } + [Test] + public void Can_Get_Tags_For_Entity_Type_Excluding_Trashed_Entity() + { + var provider = ScopeProvider; + using (ScopeProvider.CreateScope()) + { + var template = TemplateBuilder.CreateTextPageTemplate(); + FileService.SaveTemplate(template); + + var contentType = ContentTypeBuilder.CreateSimpleContentType("test", "Test", defaultTemplateId: template.Id); + ContentTypeRepository.Save(contentType); + + var content1 = ContentBuilder.CreateSimpleContent(contentType); + content1.PublishCulture(CultureImpact.Invariant); + content1.PublishedState = PublishedState.Publishing; + DocumentRepository.Save(content1); + + var content2 = ContentBuilder.CreateSimpleContent(contentType); + content2.PublishCulture(CultureImpact.Invariant); + content2.PublishedState = PublishedState.Publishing; + content2.Trashed = true; + DocumentRepository.Save(content2); + + var mediaType = MediaTypeBuilder.CreateImageMediaType("image2"); + MediaTypeRepository.Save(mediaType); + + var media1 = MediaBuilder.CreateMediaImage(mediaType, -1); + MediaRepository.Save(media1); + + var media2 = MediaBuilder.CreateMediaImage(mediaType, -1); + media2.Trashed = true; + MediaRepository.Save(media2); + + var repository = CreateRepository(provider); + Tag[] tags = + { + new Tag {Text = "tag1", Group = "test"}, + new Tag {Text = "tag2", Group = "test1"}, + new Tag {Text = "tag3", Group = "test"} + }; + + Tag[] tags2 = +{ + new Tag {Text = "tag4", Group = "test"}, + new Tag {Text = "tag5", Group = "test1"}, + new Tag {Text = "tag6", Group = "test"} + }; + + repository.Assign( + content1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + content2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + repository.Assign( + media1.Id, + contentType.PropertyTypes.First().Id, + tags, + false); + + repository.Assign( + media2.Id, + contentType.PropertyTypes.First().Id, + tags2, + false); + + var result1 = repository.GetTagsForEntityType(TaggableObjectTypes.Content).ToArray(); + var result2 = repository.GetTagsForEntityType(TaggableObjectTypes.Media).ToArray(); + var result3 = repository.GetTagsForEntityType(TaggableObjectTypes.All).ToArray(); + + const string ExpectedTags = "tag1,tag2,tag3"; + Assert.AreEqual(ExpectedTags, string.Join(",", result1.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result2.Select(x => x.Text))); + Assert.AreEqual(ExpectedTags, string.Join(",", result3.Select(x => x.Text))); + } + } + [Test] public void Can_Get_Tags_For_Entity_Type() { From bb73ec6c68db1ed8de53288c4fd665cff4a8faf2 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Thu, 30 Jan 2025 09:52:50 +0100 Subject: [PATCH 25/74] Set release version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 54e67d46be36..0d1b19886972 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.6.0-rc3", + "version": "13.6.0", "assemblyVersion": { "precision": "build" }, From b9837ac77ccddef66ed37bbf9827167e0b88aad2 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Fri, 29 Nov 2024 12:20:19 +0100 Subject: [PATCH 26/74] Optimize Azure pipeline (#17674) * Only fetch single commit * Hopefully fixes Nerdbank.GitVersioning.GitException: Shallow clone lacks the objects required to calculate version height. Use full clones or clones with a history at least as deep as the last version height resetting change. * Do not checkout again * More test pipeline * Another attempt * yet another attempt * more attempts * Revert "more attempts" This reverts commit 5694d97ba620e90fdeea287936f58002f2a5ddba. * Test without building backoffice and login explicitly * Fix mem leak in integration tests * Fixes sqlserver lock test # Conflicts: # build/azure-pipelines.yml --- build/azure-pipelines.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index d5b02db6f224..8d3f9edc0f34 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -73,6 +73,10 @@ stages: pool: vmImage: 'ubuntu-latest' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 500 - task: NodeTool@0 displayName: Use Node.js $(nodeVersion) retryCountOnTaskFailure: 3 @@ -198,6 +202,11 @@ stages: pool: vmImage: 'ubuntu-latest' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 - task: NodeTool@0 displayName: Use Node.js 10.15.x retryCountOnTaskFailure: 3 @@ -249,6 +258,11 @@ stages: pool: vmImage: $(vmImage) steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 - task: DownloadPipelineArtifact@2 displayName: Download build artifacts inputs: @@ -288,6 +302,11 @@ stages: variables: Tests__Database__DatabaseType: 'Sqlite' steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 # Setup test environment - task: DownloadPipelineArtifact@2 displayName: Download build artifacts From 7a2d6b6c63bd1603e3b6bfbba1dfb32ce4d84ecd Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Fri, 31 Jan 2025 14:26:07 +0100 Subject: [PATCH 27/74] More robust resolving of Delivery API redirects (#18160) --- .../Services/RequestRedirectService.cs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs index 882525c8d073..daf5e1b1984f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRedirectService.cs @@ -42,31 +42,42 @@ public RequestRedirectService( { requestedPath = requestedPath.EnsureStartsWith("/"); + IPublishedContent? startItem = GetStartItem(); + // must append the root content url segment if it is not hidden by config, because // the URL tracking is based on the actual URL, including the root content url segment - if (_globalSettings.HideTopLevelNodeFromPath == false) + if (_globalSettings.HideTopLevelNodeFromPath == false && startItem?.UrlSegment != null) { - IPublishedContent? startItem = GetStartItem(); - if (startItem?.UrlSegment != null) - { - requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; - } + requestedPath = $"{startItem.UrlSegment.EnsureStartsWith("/")}{requestedPath}"; } var culture = _requestCultureService.GetRequestedCulture(); - // append the configured domain content ID to the path if we have a domain bound request, - // because URL tracking registers the tracked url like "{domain content ID}/{content path}" - Uri contentRoute = GetDefaultRequestUri(requestedPath); - DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); - if (domainAndUri != null) + // important: redirect URLs are always tracked without trailing slashes + requestedPath = requestedPath.TrimEnd("/"); + IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + + // if a redirect URL was not found, try by appending the start item ID because URL tracking might have tracked + // a redirect with "{root content ID}/{content path}" + if (redirectUrl is null && startItem is not null) { - requestedPath = GetContentRoute(domainAndUri, contentRoute); - culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl($"{startItem.Id}{requestedPath}", culture); + } + + // still no redirect URL found - try looking for a configured domain if we have a domain bound request, + // because URL tracking might have tracked a redirect with "{domain content ID}/{content path}" + if (redirectUrl is null) + { + Uri contentRoute = GetDefaultRequestUri(requestedPath); + DomainAndUri? domainAndUri = GetDomainAndUriForRoute(contentRoute); + if (domainAndUri is not null) + { + requestedPath = GetContentRoute(domainAndUri, contentRoute); + culture ??= domainAndUri.Culture; + redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath, culture); + } } - // important: redirect URLs are always tracked without trailing slashes - IRedirectUrl? redirectUrl = _redirectUrlService.GetMostRecentRedirectUrl(requestedPath.TrimEnd("/"), culture); IPublishedContent? content = redirectUrl != null ? _apiPublishedContentCache.GetById(redirectUrl.ContentKey) : null; From 17615f966b8658bc2322fb518d9ce8156a5d5ba7 Mon Sep 17 00:00:00 2001 From: TimBoonstra Date: Fri, 31 Jan 2025 14:46:56 +0100 Subject: [PATCH 28/74] Fix out of memory of 2gb+ (max 4gb) error introduced by #14657 SVG xss security fix (#17421) Co-authored-by: Andy Butland --- src/Umbraco.Web.BackOffice/Controllers/MediaController.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs index db47ba599f6e..f8e28989b123 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MediaController.cs @@ -788,8 +788,7 @@ public async Task PostAddFile([FromForm] string path, [FromForm] continue; } - using var stream = new MemoryStream(); - await formFile.CopyToAsync(stream); + await using var stream = formFile.OpenReadStream(); if (_fileStreamSecurityValidator != null && _fileStreamSecurityValidator.IsConsideredSafe(stream) == false) { tempFiles.Notifications.Add(new BackOfficeNotification( From 5dfff212d077a4fb0b8fd906e82a9af92c7aa555 Mon Sep 17 00:00:00 2001 From: Gareth Wright Date: Fri, 31 Jan 2025 13:59:25 +0000 Subject: [PATCH 29/74] Update auth.element.ts (#18192) auth_username => general_username --- src/Umbraco.Web.UI.Login/src/auth.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index 3f5bfd1428d9..e195f3986a21 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -179,7 +179,7 @@ export default class UmbAuthElement extends LitElement { }); this._usernameLabel = createLabel({ forId: 'username-input', - localizeAlias: this.usernameIsEmail ? 'general_email' : 'auth_username', + localizeAlias: this.usernameIsEmail ? 'general_email' : 'general_username', localizeFallback: this.usernameIsEmail ? 'Email' : 'Username', }); this._passwordLabel = createLabel({forId: 'password-input', localizeAlias: 'general_password', localizeFallback: 'Password'}); From 9f357173c3d88dcd1e437dec0b2dcd2200b9a057 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 3 Feb 2025 12:50:23 +0100 Subject: [PATCH 30/74] Enforce user start nodes for media uploads through the RTE (#18204) --- .../RichTextEditorPastedImages.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs index 8dbe6ad5b351..64e349c245f1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextEditorPastedImages.cs @@ -8,12 +8,14 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.Hosting; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Media; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Membership; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services; @@ -38,6 +40,9 @@ public sealed class RichTextEditorPastedImages private readonly IUmbracoContextAccessor _umbracoContextAccessor; private readonly string _tempFolderAbsolutePath; private readonly IImageUrlGenerator _imageUrlGenerator; + private readonly IEntityService _entityService; + private readonly IUserService _userService; + private readonly AppCaches _appCaches; private readonly ContentSettings _contentSettings; private readonly Dictionary _uploadedImages = new(); @@ -67,6 +72,7 @@ public RichTextEditorPastedImages( { } + [Obsolete("Use the non-obsolete constructor. Scheduled for removal in v14")] public RichTextEditorPastedImages( IUmbracoContextAccessor umbracoContextAccessor, ILogger logger, @@ -79,6 +85,39 @@ public RichTextEditorPastedImages( IPublishedUrlProvider publishedUrlProvider, IImageUrlGenerator imageUrlGenerator, IOptions contentSettings) + : this( + umbracoContextAccessor, + logger, + hostingEnvironment, + mediaService, + contentTypeBaseServiceProvider, + mediaFileManager, + mediaUrlGenerators, + shortStringHelper, + publishedUrlProvider, + imageUrlGenerator, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService(), + contentSettings) + { + } + + public RichTextEditorPastedImages( + IUmbracoContextAccessor umbracoContextAccessor, + ILogger logger, + IHostingEnvironment hostingEnvironment, + IMediaService mediaService, + IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, + MediaFileManager mediaFileManager, + MediaUrlGeneratorCollection mediaUrlGenerators, + IShortStringHelper shortStringHelper, + IPublishedUrlProvider publishedUrlProvider, + IImageUrlGenerator imageUrlGenerator, + IEntityService entityService, + IUserService userService, + AppCaches appCaches, + IOptions contentSettings) { _umbracoContextAccessor = umbracoContextAccessor ?? throw new ArgumentNullException(nameof(umbracoContextAccessor)); @@ -92,6 +131,9 @@ public RichTextEditorPastedImages( _shortStringHelper = shortStringHelper; _publishedUrlProvider = publishedUrlProvider; _imageUrlGenerator = imageUrlGenerator; + _entityService = entityService; + _userService = userService; + _appCaches = appCaches; _contentSettings = contentSettings.Value; _tempFolderAbsolutePath = _hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempImageUploads); @@ -270,7 +312,7 @@ private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, : Constants.Conventions.MediaTypes.Image; IMedia mediaFile = mediaParentFolder == Guid.Empty - ? _mediaService.CreateMedia(mediaItemName, Constants.System.Root, mediaType, userId) + ? _mediaService.CreateMedia(mediaItemName, GetDefaultMediaRoot(userId), mediaType, userId) : _mediaService.CreateMedia(mediaItemName, mediaParentFolder, mediaType, userId); var fileInfo = new FileInfo(absoluteTempImagePath); @@ -354,4 +396,11 @@ private void PersistMediaItem(Guid mediaParentFolder, int userId, HtmlNode img, } private bool IsValidPath(string imagePath) => imagePath.StartsWith(_tempFolderAbsolutePath); + + private int GetDefaultMediaRoot(int userId) + { + IUser user = _userService.GetUserById(userId) ?? throw new ArgumentException("User could not be found"); + var userStartNodes = user.CalculateMediaStartNodeIds(_entityService, _appCaches); + return userStartNodes?.FirstOrDefault() ?? Constants.System.Root; + } } From e7411244fde73cbc5d85a536a48caaff8f2b49fd Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 3 Feb 2025 13:24:58 +0100 Subject: [PATCH 31/74] Show notifications menu only to users with permission for the feature. (#18184) --- src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs index 4cdd8cef7c3c..0ef895e2072a 100644 --- a/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs +++ b/src/Umbraco.Web.BackOffice/Trees/ContentTreeController.cs @@ -317,13 +317,7 @@ protected MenuItemCollection GetAllNodeMenuItems(IUmbracoEntity item) if (_emailSender.CanSendRequiredEmail()) { - menu.Items.Add(new MenuItem("notify", LocalizedTextService) - { - Icon = "icon-megaphone", - SeparatorBefore = true, - OpensDialog = true, - UseLegacyIcon = false - }); + AddActionNode(item, menu, hasSeparator: true, opensDialog: true, useLegacyIcon: false); } if ((item is DocumentEntitySlim documentEntity && documentEntity.IsContainer) == false) From b4a9dc0770a389b7e92a362e86e1e7b3f8490114 Mon Sep 17 00:00:00 2001 From: Mole Date: Mon, 3 Feb 2025 19:48:08 +0100 Subject: [PATCH 32/74] V13: Fix members while using basic auth. (#18206) * Flow additional identities to new principal * Add extension to more easily get member identity * Ensure the member is used instead of the backoffice user in `MemberManager` * Update snippet * Fix the comment that I broke * Update src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../Snippets/LoginStatus.cshtml | 4 +-- .../Extensions/HttpContextExtensions.cs | 11 ++++++-- .../MemberClaimsPrincipalExtensions.cs | 18 +++++++++++++ .../Security/MemberManager.cs | 26 ++++++++++++------- 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 src/Umbraco.Web.Common/Extensions/MemberClaimsPrincipalExtensions.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml index 8f5477bca476..aa70da23c8e6 100644 --- a/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml +++ b/src/Umbraco.Core/EmbeddedResources/Snippets/LoginStatus.cshtml @@ -5,7 +5,7 @@ @using Umbraco.Extensions @{ - var isLoggedIn = Context.User?.Identity?.IsAuthenticated ?? false; + var isLoggedIn = Context.User.GetMemberIdentity()?.IsAuthenticated ?? false; var logoutModel = new PostRedirectModel(); // You can modify this to redirect to a different URL instead of the current one logoutModel.RedirectUrl = null; @@ -15,7 +15,7 @@ { public Func? PerformCount { get; set; } + /// + /// True if the Get method will cache null results so that the db is not hit for repeated lookups + /// + public bool CacheNullValues { get; set; } + /// /// True/false as to validate the total item count when all items are returned from cache, the default is true but this /// means that a db lookup will occur - though that lookup will probably be significantly less expensive than the diff --git a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs index 7f7f8d678422..9494ed2eea58 100644 --- a/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs +++ b/src/Umbraco.Infrastructure/Cache/DefaultRepositoryCachePolicy.cs @@ -24,6 +24,8 @@ public class DefaultRepositoryCachePolicy : RepositoryCachePolicyB private static readonly TEntity[] _emptyEntities = new TEntity[0]; // const private readonly RepositoryCachePolicyOptions _options; + private const string NullRepresentationInCache = "*NULL*"; + public DefaultRepositoryCachePolicy(IAppPolicyCache cache, IScopeAccessor scopeAccessor, RepositoryCachePolicyOptions options) : base(cache, scopeAccessor) => _options = options ?? throw new ArgumentNullException(nameof(options)); @@ -116,6 +118,7 @@ public override void Delete(TEntity entity, Action persistDeleted) { // whatever happens, clear the cache var cacheKey = GetEntityCacheKey(entity.Id); + Cache.Clear(cacheKey); // if there's a GetAllCacheAllowZeroCount cache, ensure it is cleared @@ -127,20 +130,36 @@ public override void Delete(TEntity entity, Action persistDeleted) public override TEntity? Get(TId? id, Func performGet, Func?> performGetAll) { var cacheKey = GetEntityCacheKey(id); + TEntity? fromCache = Cache.GetCacheItem(cacheKey); - // if found in cache then return else fetch and cache - if (fromCache != null) + // If found in cache then return immediately. + if (fromCache is not null) { return fromCache; } + // Because TEntity can never be a string, we will never be in a position where the proxy value collides withs a real value. + // Therefore this point can only be reached if there is a proxy null value => becomes null when cast to TEntity above OR the item simply does not exist. + // If we've cached a "null" value, return null. + if (_options.CacheNullValues && Cache.GetCacheItem(cacheKey) == NullRepresentationInCache) + { + return null; + } + + // Otherwise go to the database to retrieve. TEntity? entity = performGet(id); if (entity != null && entity.HasIdentity) { + // If we've found an identified entity, cache it for subsequent retrieval. InsertEntity(cacheKey, entity); } + else if (entity is null && _options.CacheNullValues) + { + // If we've not found an entity, and we're caching null values, cache a "null" value. + InsertNull(cacheKey); + } return entity; } @@ -248,6 +267,15 @@ protected string GetEntityCacheKey(TId? id) protected virtual void InsertEntity(string cacheKey, TEntity entity) => Cache.Insert(cacheKey, () => entity, TimeSpan.FromMinutes(5), true); + protected virtual void InsertNull(string cacheKey) + { + // We can't actually cache a null value, as in doing so wouldn't be able to distinguish between + // a value that does exist but isn't yet cached, or a value that has been explicitly cached with a null value. + // Both would return null when we retrieve from the cache and we couldn't distinguish between the two. + // So we cache a special value that represents null, and then we can check for that value when we retrieve from the cache. + Cache.Insert(cacheKey, () => NullRepresentationInCache, TimeSpan.FromMinutes(5), true); + } + protected virtual void InsertEntities(TId[]? ids, TEntity[]? entities) { if (ids?.Length == 0 && entities?.Length == 0 && _options.GetAllCacheAllowZeroCount) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs index 909c9cfec23e..bf4799e938a0 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/DictionaryRepository.cs @@ -102,11 +102,10 @@ protected override IRepositoryCachePolicy CreateCachePolic var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } protected IDictionaryItem ConvertFromDto(DictionaryDto dto) @@ -190,11 +189,10 @@ protected override IRepositoryCachePolicy CreateCachePoli var options = new RepositoryCachePolicyOptions { // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } } @@ -228,12 +226,13 @@ protected override IRepositoryCachePolicy CreateCachePo { var options = new RepositoryCachePolicyOptions { + // allow null to be cached + CacheNullValues = true, // allow zero to be cached - GetAllCacheAllowZeroCount = true, + GetAllCacheAllowZeroCount = true }; - return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, - options); + return new SingleItemsOnlyRepositoryCachePolicy(GlobalIsolatedCache, ScopeAccessor, options); } } From cce67b055c4da940ab90ed14324f4344e305e7a8 Mon Sep 17 00:00:00 2001 From: Karita <49212909+ainokarita@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:44:09 +0100 Subject: [PATCH 35/74] Entity permission translation for US, UK and DK (#18225) * Translation for user permissions DK and EN * Added UI Culture variants for EN-US user permissions section * removed redundant line * improved consistency for wording, and moved keys under user --- src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts | 2 ++ src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts | 2 ++ src/Umbraco.Web.UI.Client/src/assets/lang/en.ts | 2 ++ .../workspace/user-group/user-group-workspace-editor.element.ts | 2 +- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index 56f2fa1b8127..287aa3d7eeaa 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -1930,6 +1930,8 @@ export default { chooseUserGroup: (multiple: boolean) => { return multiple ? 'Vælg brugergrupper' : 'Vælg brugergruppe'; }, + entityPermissionsLabel: 'Handlingsrettigheder', + entityPermissionsDescription: 'Tildel tilladelser til handlinger', noStartNode: 'Ingen startnode valgt', noStartNodes: 'Ingen startnoder valgt', startnode: 'Indhold startnode', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index 21862b35426c..f897c6797e46 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -1983,6 +1983,8 @@ export default { chooseUserGroup: (multiple: boolean) => { return multiple ? 'Choose User Groups' : 'Choose User Group'; }, + entityPermissionsLabel: 'Permissions', + entityPermissionsDescription: 'Assign permissions for actions', noStartNode: 'No start node selected', noStartNodes: 'No start nodes selected', startnode: 'Content start node', 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 b6e9b4d43414..055132c86fce 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2025,6 +2025,8 @@ export default { chooseUserGroup: (multiple: boolean) => { return multiple ? 'Choose User Groups' : 'Choose User Group'; }, + entityPermissionsLabel: 'Permissions', + entityPermissionsDescription: 'Assign permissions for actions', noStartNode: 'No start node selected', noStartNodes: 'No start nodes selected', startnode: 'Content start node', 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 97124b0c4c1a..aae6d579e995 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 @@ -244,7 +244,7 @@ export class UmbUserGroupWorkspaceEditorElement extends UmbLitElement {
- +
From 095a73132c028093b54487ca834eeb1f84b98b99 Mon Sep 17 00:00:00 2001 From: jasont0101 Date: Wed, 5 Feb 2025 03:38:40 -0800 Subject: [PATCH 36/74] Review: Allow Duplicate Email for Members (#16202) * init * Aligned default values on security settings. * Added validator for security settings. * Provide default implementation for get members by email. * Refactored constructor of MemberController. * Validate on unique member email only when configured to do so. * Further code tidy and use of DI in constructor. * Used new constructor in tests. * Add unit test for modified behaviour. * Removed validator for security settings (it's not necessary, I got confused with users and members). * Spelling. --------- Co-authored-by: Andy Butland --- .../Configuration/Models/SecuritySettings.cs | 11 +- src/Umbraco.Core/Services/IMemberService.cs | 15 +++ src/Umbraco.Core/Services/MemberService.cs | 13 ++- .../Controllers/MemberController.cs | 68 +++++++++-- .../Filters/MemberSaveModelValidator.cs | 27 +++-- .../Filters/MemberSaveValidationAttribute.cs | 24 ++-- .../ConfigureMemberIdentityOptions.cs | 2 +- .../Controllers/MemberControllerUnitTests.cs | 108 +++++++++++++++--- 8 files changed, 219 insertions(+), 49 deletions(-) diff --git a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs index f1005e5d1b93..e68162e6efe0 100644 --- a/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs +++ b/src/Umbraco.Core/Configuration/Models/SecuritySettings.cs @@ -19,6 +19,8 @@ public class SecuritySettings internal const bool StaticAllowEditInvariantFromNonDefault = false; internal const bool StaticAllowConcurrentLogins = false; internal const string StaticAuthCookieName = "UMB_UCONTEXT"; + internal const bool StaticUsernameIsEmail = true; + internal const bool StaticMemberRequireUniqueEmail = true; internal const string StaticAllowedUserNameCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+\\"; @@ -58,7 +60,14 @@ public class SecuritySettings /// /// Gets or sets a value indicating whether the user's email address is to be considered as their username. /// - public bool UsernameIsEmail { get; set; } = true; + [DefaultValue(StaticUsernameIsEmail)] + public bool UsernameIsEmail { get; set; } = StaticUsernameIsEmail; + + /// + /// Gets or sets a value indicating whether the member's email address must be unique. + /// + [DefaultValue(StaticMemberRequireUniqueEmail)] + public bool MemberRequireUniqueEmail { get; set; } = StaticMemberRequireUniqueEmail; /// /// Gets or sets the set of allowed characters for a username diff --git a/src/Umbraco.Core/Services/IMemberService.cs b/src/Umbraco.Core/Services/IMemberService.cs index a1be0b4a4cba..7d78a979c836 100644 --- a/src/Umbraco.Core/Services/IMemberService.cs +++ b/src/Umbraco.Core/Services/IMemberService.cs @@ -210,6 +210,21 @@ IMember CreateMemberWithIdentity(string username, string email, string name, str /// IMember? GetById(int id); + /// + /// Get an list of for all members with the specified email. + /// + //// Email to use for retrieval + /// + /// + /// + IEnumerable GetMembersByEmail(string email) + => + // TODO (V16): Remove this default implementation. + // The following is very inefficient, but will return the correct data, so probably better than throwing a NotImplementedException + // in the default implentation here, for, presumably rare, cases where a custom IMemberService implementation has been registered and + // does not override this method. + GetAllMembers().Where(x => x.Email.Equals(email)); + /// /// Gets all Members for the specified MemberType alias /// diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 43b5b8f28ba0..493ab313a73a 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -389,16 +389,23 @@ public IEnumerable GetAll( } /// - /// Get an by email + /// Get an by email. If RequireUniqueEmailForMembers is set to false, then the first member found with the specified email will be returned. /// /// Email to use for retrieval /// - public IMember? GetByEmail(string email) + public IMember? GetByEmail(string email) => GetMembersByEmail(email).FirstOrDefault(); + + /// + /// Get an list of for all members with the specified email. + /// + /// Email to use for retrieval + /// + public IEnumerable GetMembersByEmail(string email) { using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true); scope.ReadLock(Constants.Locks.MemberTree); IQuery query = Query().Where(x => x.Email.Equals(email)); - return _memberRepository.Get(query)?.FirstOrDefault(); + return _memberRepository.Get(query); } /// diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 8851a73a2b07..4a18bf462001 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -8,8 +8,11 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.ContentApps; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Mapping; @@ -26,7 +29,6 @@ using Umbraco.Cms.Web.BackOffice.ModelBinders; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Cms.Web.Common.Filters; using Umbraco.Cms.Web.Common.Security; using Umbraco.Extensions; @@ -55,6 +57,7 @@ public class MemberController : ContentControllerBase private readonly ITwoFactorLoginService _twoFactorLoginService; private readonly IShortStringHelper _shortStringHelper; private readonly IUmbracoMapper _umbracoMapper; + private readonly SecuritySettings _securitySettings; /// /// Initializes a new instance of the class. @@ -75,6 +78,7 @@ public class MemberController : ContentControllerBase /// The password changer /// The core scope provider /// The two factor login service + /// The security settings [ActivatorUtilitiesConstructor] public MemberController( ICultureDictionary cultureDictionary, @@ -92,7 +96,8 @@ public MemberController( IJsonSerializer jsonSerializer, IPasswordChanger passwordChanger, ICoreScopeProvider scopeProvider, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions securitySettings) : base(cultureDictionary, loggerFactory, shortStringHelper, eventMessages, localizedTextService, jsonSerializer) { _propertyEditors = propertyEditors; @@ -108,9 +113,49 @@ public MemberController( _passwordChanger = passwordChanger; _scopeProvider = scopeProvider; _twoFactorLoginService = twoFactorLoginService; + _securitySettings = securitySettings.Value; } - [Obsolete("Use constructor that also takes an ITwoFactorLoginService. Scheduled for removal in V13")] + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] + public MemberController( + ICultureDictionary cultureDictionary, + ILoggerFactory loggerFactory, + IShortStringHelper shortStringHelper, + IEventMessagesFactory eventMessages, + ILocalizedTextService localizedTextService, + PropertyEditorCollection propertyEditors, + IUmbracoMapper umbracoMapper, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberManager memberManager, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IJsonSerializer jsonSerializer, + IPasswordChanger passwordChanger, + ICoreScopeProvider scopeProvider, + ITwoFactorLoginService twoFactorLoginService) + : this( + cultureDictionary, + loggerFactory, + shortStringHelper, + eventMessages, + localizedTextService, + propertyEditors, + umbracoMapper, + memberService, + memberTypeService, + memberManager, + dataTypeService, + backOfficeSecurityAccessor, + jsonSerializer, + passwordChanger, + scopeProvider, + twoFactorLoginService, + StaticServiceProvider.Instance.GetRequiredService>()) + { + } + + [Obsolete("Please use the constructor that takes all paramters. Scheduled for removal in V14")] public MemberController( ICultureDictionary cultureDictionary, ILoggerFactory loggerFactory, @@ -461,7 +506,7 @@ private async Task> CreateMemberAsync(MemberSave contentItem) } // now re-look up the member, which will now exist - IMember? member = _memberService.GetByEmail(contentItem.Email); + IMember? member = _memberService.GetByUsername(contentItem.Username); if (member is null) { @@ -699,13 +744,16 @@ private async Task ValidateMemberDataAsync(MemberSave contentItem) return false; } - IMember? byEmail = _memberService.GetByEmail(contentItem.Email); - if (byEmail != null && byEmail.Key != contentItem.Key) + if (_securitySettings.MemberRequireUniqueEmail) { - ModelState.AddPropertyError( - new ValidationResult("Email address is already in use", new[] { "value" }), - $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); - return false; + IMember? byEmail = _memberService.GetByEmail(contentItem.Email); + if (byEmail != null && byEmail.Key != contentItem.Key) + { + ModelState.AddPropertyError( + new ValidationResult("Email address is already in use", new[] { "value" }), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); + return false; + } } return true; diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs index 6b29803e0521..68d37bba3c32 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveModelValidator.cs @@ -2,8 +2,12 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; @@ -16,27 +20,29 @@ namespace Umbraco.Cms.Web.BackOffice.Filters; /// /// Validator for /// -internal class - MemberSaveModelValidator : ContentModelValidator> +internal class MemberSaveModelValidator : ContentModelValidator> { private readonly IBackOfficeSecurity? _backofficeSecurity; private readonly IMemberService _memberService; private readonly IMemberTypeService _memberTypeService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveModelValidator( - ILogger logger, - IBackOfficeSecurity? backofficeSecurity, - IMemberTypeService memberTypeService, - IMemberService memberService, - IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) - : base(logger, propertyValidationService) + ILogger logger, + IBackOfficeSecurity? backofficeSecurity, + IMemberTypeService memberTypeService, + IMemberService memberService, + IShortStringHelper shortStringHelper, + IPropertyValidationService propertyValidationService, + SecuritySettings securitySettings) + : base(logger, propertyValidationService) { _backofficeSecurity = backofficeSecurity; _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); + _securitySettings = securitySettings; } public override bool ValidatePropertiesData( @@ -64,8 +70,7 @@ public override bool ValidatePropertiesData( $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}email"); } - var validEmail = ValidateUniqueEmail(model); - if (validEmail == false) + if (_securitySettings.MemberRequireUniqueEmail && ValidateUniqueEmail(model) is false) { modelState.AddPropertyError( new ValidationResult("Email address is already in use", new[] { "value" }), diff --git a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs index 61e119b66a0e..568d1e82402f 100644 --- a/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/MemberSaveValidationAttribute.cs @@ -1,6 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; @@ -25,6 +27,7 @@ private sealed class MemberSaveValidationFilter : IActionFilter private readonly IMemberTypeService _memberTypeService; private readonly IPropertyValidationService _propertyValidationService; private readonly IShortStringHelper _shortStringHelper; + private readonly SecuritySettings _securitySettings; public MemberSaveValidationFilter( ILoggerFactory loggerFactory, @@ -32,16 +35,16 @@ public MemberSaveValidationFilter( IMemberTypeService memberTypeService, IMemberService memberService, IShortStringHelper shortStringHelper, - IPropertyValidationService propertyValidationService) + IPropertyValidationService propertyValidationService, + IOptions securitySettings) { - _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); - _backofficeSecurityAccessor = backofficeSecurityAccessor ?? - throw new ArgumentNullException(nameof(backofficeSecurityAccessor)); - _memberTypeService = memberTypeService ?? throw new ArgumentNullException(nameof(memberTypeService)); - _memberService = memberService ?? throw new ArgumentNullException(nameof(memberService)); - _shortStringHelper = shortStringHelper ?? throw new ArgumentNullException(nameof(shortStringHelper)); - _propertyValidationService = propertyValidationService ?? - throw new ArgumentNullException(nameof(propertyValidationService)); + _loggerFactory = loggerFactory; + _backofficeSecurityAccessor = backofficeSecurityAccessor; + _memberTypeService = memberTypeService; + _memberService = memberService; + _shortStringHelper = shortStringHelper; + _propertyValidationService = propertyValidationService; + _securitySettings = securitySettings.Value; } public void OnActionExecuting(ActionExecutingContext context) @@ -53,7 +56,8 @@ public void OnActionExecuting(ActionExecutingContext context) _memberTypeService, _memberService, _shortStringHelper, - _propertyValidationService); + _propertyValidationService, + _securitySettings); //now do each validation step if (contentItemValidator.ValidateExistingContent(model, context)) { diff --git a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs index 0fcc41d9d02c..1c9b88b6cfdc 100644 --- a/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs +++ b/src/Umbraco.Web.Common/Security/ConfigureMemberIdentityOptions.cs @@ -24,7 +24,7 @@ public void Configure(IdentityOptions options) options.SignIn.RequireConfirmedEmail = false; // not implemented options.SignIn.RequireConfirmedPhoneNumber = false; // not implemented - options.User.RequireUniqueEmail = true; + options.User.RequireUniqueEmail = _securitySettings.MemberRequireUniqueEmail; // Support validation of member names using Down-Level Logon Name format options.User.AllowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index 6a9559f14c83..2cfcf526ed8a 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -70,11 +70,13 @@ public void PostSaveMember_WhenModelStateIsNotValid_ExpectFailureResponse( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange SetupMemberTestData(out var fakeMemberData, out _, ContentSaveAction.SaveNew); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -84,7 +86,8 @@ public void PostSaveMember_WhenModelStateIsNotValid_ExpectFailureResponse( backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); sut.ModelState.AddModelError("key", "Invalid model state"); Mock.Get(umbracoMembersUserManager) @@ -116,7 +119,6 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -138,6 +140,8 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -147,7 +151,8 @@ public async Task PostSaveMember_SaveNew_NoCustomField_WhenAllIsSetupCorrectly_E backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -170,7 +175,6 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -192,6 +196,8 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp .Returns(() => member); Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -201,7 +207,8 @@ public async Task PostSaveMember_SaveNew_CustomField_WhenAllIsSetupCorrectly_Exp backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -256,6 +263,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupCorrectly_ExpectSucc .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -265,7 +274,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupCorrectly_ExpectSucc backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -316,6 +326,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupWithPasswordIncorrec .Returns(() => null) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -325,7 +337,8 @@ public async Task PostSaveMember_SaveExisting_WhenAllIsSetupWithPasswordIncorrec backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -382,7 +395,6 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon IBackOfficeSecurity backOfficeSecurity, IPasswordChanger passwordChanger, IOptions globalSettings, - IUser user, ITwoFactorLoginService twoFactorLoginService) { // arrange @@ -403,6 +415,8 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon x => x.GetByEmail(It.IsAny())) .Returns(() => member); + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -412,7 +426,8 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = sut.PostSave(fakeMemberData).Result; @@ -424,6 +439,66 @@ public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_ExpectFailRespon Assert.AreEqual(StatusCodes.Status400BadRequest, validation?.StatusCode); } + [Test] + [AutoMoqData] + public void PostSaveMember_SaveNew_WhenMemberEmailAlreadyExists_AndDuplicateEmailsAreAllowed_ExpectSuccessResponse( + [Frozen] IMemberManager umbracoMembersUserManager, + IMemberService memberService, + IMemberTypeService memberTypeService, + IMemberGroupService memberGroupService, + IDataTypeService dataTypeService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IBackOfficeSecurity backOfficeSecurity, + IPasswordChanger passwordChanger, + IOptions globalSettings, + ITwoFactorLoginService twoFactorLoginService) + { + // arrange + var member = SetupMemberTestData(out var fakeMemberData, out var memberDisplay, ContentSaveAction.SaveNew); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.ValidatePasswordAsync(It.IsAny())) + .ReturnsAsync(() => IdentityResult.Success); + Mock.Get(umbracoMembersUserManager) + .Setup(x => x.GetRolesAsync(It.IsAny())) + .ReturnsAsync(() => Array.Empty()); + Mock.Get(memberTypeService).Setup(x => x.GetDefault()).Returns("fakeAlias"); + Mock.Get(backOfficeSecurityAccessor).Setup(x => x.BackOfficeSecurity).Returns(backOfficeSecurity); + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny())) + .Returns(() => null) + .Returns(() => member); + Mock.Get(memberService).Setup(x => x.GetByUsername(It.IsAny())).Returns(() => member); + + Mock.Get(memberService).SetupSequence( + x => x.GetByEmail(It.IsAny())) + .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings { MemberRequireUniqueEmail = false }); + + var sut = CreateSut( + memberService, + memberTypeService, + memberGroupService, + umbracoMembersUserManager, + dataTypeService, + backOfficeSecurityAccessor, + passwordChanger, + globalSettings, + twoFactorLoginService, + securitySettings); + + // act + var result = sut.PostSave(fakeMemberData).Result; + var validation = result.Result as ValidationErrorResult; + + // assert + Assert.IsNull(result.Result); + Assert.IsNotNull(result.Value); + } + [Test] [AutoMoqData] public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectSuccessResponse( @@ -472,6 +547,9 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS x => x.GetByEmail(It.IsAny())) .Returns(() => null) .Returns(() => member); + + var securitySettings = Options.Create(new SecuritySettings()); + var sut = CreateSut( memberService, memberTypeService, @@ -481,7 +559,8 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS backOfficeSecurityAccessor, passwordChanger, globalSettings, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); // act var result = await sut.PostSave(fakeMemberData); @@ -512,6 +591,7 @@ public async Task PostSaveMember_SaveExistingMember_WithNoRoles_Add1Role_ExpectS /// Password changer class /// The global settings /// The two factor login service + /// The security settings /// A member controller for the tests private MemberController CreateSut( IMemberService memberService, @@ -522,7 +602,8 @@ private MemberController CreateSut( IBackOfficeSecurityAccessor backOfficeSecurityAccessor, IPasswordChanger passwordChanger, IOptions globalSettings, - ITwoFactorLoginService twoFactorLoginService) + ITwoFactorLoginService twoFactorLoginService, + IOptions securitySettings) { var httpContextAccessor = new HttpContextAccessor(); @@ -623,7 +704,8 @@ private MemberController CreateSut( new ConfigurationEditorJsonSerializer(), passwordChanger, scopeProvider, - twoFactorLoginService); + twoFactorLoginService, + securitySettings); } /// From cf8cead8a9936ffa227d87a7ec3383deb9267417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 5 Feb 2025 21:26:18 +0100 Subject: [PATCH 37/74] Update README.md (#18238) --- src/Umbraco.Web.UI.Client/.github/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/.github/README.md b/src/Umbraco.Web.UI.Client/.github/README.md index cc99c870070b..d1e27aa2b06c 100644 --- a/src/Umbraco.Web.UI.Client/.github/README.md +++ b/src/Umbraco.Web.UI.Client/.github/README.md @@ -48,6 +48,8 @@ Open this file in an editor: `src/Umbraco.Web.UI/appsettings.Development.json` a This will override the backoffice host URL, enabling the Client to run from a different origin. +Then start the backend server by running the command: `dotnet run` in the `Umbraco.Web.UI` folder. + #### Run the front-end server Now start the Vite server by running the command: `npm run dev:server` in the `Umbraco.Web.UI.Client` folder. From 066045f139bdc984955ea7d5d08c9e08aaf8ce5c Mon Sep 17 00:00:00 2001 From: Karita <49212909+ainokarita@users.noreply.github.com> Date: Fri, 7 Feb 2025 06:38:12 +0100 Subject: [PATCH 38/74] Granular permissions translation DK, EN, US (#18251) * Translation for user permissions DK and EN * Added UI Culture variants for EN-US user permissions section * removed redundant line * improved consistency for wording, and moved keys under user * Translated granular permissions label and description EN, US, DK --- src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts | 2 ++ src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts | 2 ++ src/Umbraco.Web.UI.Client/src/assets/lang/en.ts | 2 ++ .../documents/documents/user-permissions/manifests.ts | 4 ++-- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index 287aa3d7eeaa..6a24c20d510e 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -1914,6 +1914,8 @@ export default { permissionsDefault: 'Standardrettigheder', permissionsGranular: 'Granulære rettigheder', permissionsGranularHelp: 'Sæt rettigheder for specifikke noder', + granularRightsLabel: 'Dokumenter', + granularRightsDescription: 'Tillad adgang til specifikke dokumenter', permissionsEntityGroup_document: 'Indhold', permissionsEntityGroup_media: 'Medie', permissionsEntityGroup_member: 'Medlemmer', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index f897c6797e46..c9e3dae3e6a3 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -1967,6 +1967,8 @@ export default { permissionsDefault: 'Default permissions', permissionsGranular: 'Granular permissions', permissionsGranularHelp: 'Set permissions for specific nodes', + granularRightsLabel: 'Documents', + granularRightsDescription: 'Assign permissions to specific documents', permissionsEntityGroup_document: 'Content', permissionsEntityGroup_media: 'Media', permissionsEntityGroup_member: 'Member', 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 055132c86fce..db156b6cfc82 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2009,6 +2009,8 @@ export default { permissionsDefault: 'Default permissions', permissionsGranular: 'Granular permissions', permissionsGranularHelp: 'Set permissions for specific nodes', + granularRightsLabel: 'Documents', + granularRightsDescription: 'Assign permissions to specific documents', permissionsEntityGroup_document: 'Content', permissionsEntityGroup_media: 'Media', permissionsEntityGroup_member: 'Member', diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/manifests.ts index aa8d4b2c2906..30505ba03ad9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/user-permissions/manifests.ts @@ -206,8 +206,8 @@ export const granularPermissions: Array = [ import('./input-document-granular-user-permission/input-document-granular-user-permission.element.js'), meta: { schemaType: 'DocumentPermissionPresentationModel', - label: 'Documents', - description: 'Assign permissions to specific documents', + label: '#user_granularRightsLabel', + description: '{#user_granularRightsDescription}', }, }, ]; From 280cb7f2b15723f9a1b9e9ad954d3ee8ce60b262 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 7 Feb 2025 07:00:34 +0100 Subject: [PATCH 39/74] Fix issues in newly added buttongroup localization (#18254) * Fix #18253 nullref exception * Fix #18239 by listening to broader scope changes --- .../buttons/umbbuttongroup.directive.js | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js index 8c7836a2e69e..2a526ae2af4d 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/buttons/umbbuttongroup.directive.js @@ -90,9 +90,7 @@ Use this directive to render a button with a dropdown of alternative actions. **/ (function () { 'use strict'; - function ButtonGroupDirective() { - function controller($scope, localizationService) { $scope.toggleStyle = null; $scope.blockElement = false; @@ -125,18 +123,24 @@ Use this directive to render a button with a dropdown of alternative actions. // As the directive doesn't support Angular expressions as fallback, we instead listen for changes // to the label key of the default button, and if detected, we update the button label with the localized value // received from the localization service - $scope.$watch("defaultButton.labelKey", function () { - if (!$scope.defaultButton.labelKey) return; + $scope.$watch("defaultButton", localizeDefaultButtonLabel); + $scope.$watch("defaultButton.labelKey", localizeDefaultButtonLabel); + + function localizeDefaultButtonLabel() { + if (!$scope.defaultButton?.labelKey) return; localizationService.localize($scope.defaultButton.labelKey).then(value => { if (value && value.indexOf("[") === 0) return; $scope.defaultButton.label = value; }); - }); + } // In a similar way, we must listen for changes to the sub buttons (or their label keys), and if detected, update // the label with the localized value received from the localization service - $scope.$watch("defaultButton.subButtons", function () { - if (!Array.isArray($scope.subButtons)) return; + $scope.$watch("subButtons", localizeSubButtons, true); + $scope.$watch("defaultButton.subButtons", localizeSubButtons, true); + + function localizeSubButtons() { + if (!$scope.subButtons || !Array.isArray($scope.subButtons)) return; $scope.subButtons.forEach(function (sub) { if (!sub.labelKey) return; localizationService.localize(sub.labelKey).then(value => { @@ -144,7 +148,7 @@ Use this directive to render a button with a dropdown of alternative actions. sub.label = value; }); }); - }, true); + } } From 4ca68d69951d685bce328a4266ef96317219e6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 7 Feb 2025 12:43:04 +0100 Subject: [PATCH 40/74] Add client-side validation for RTE (#18257) --- .../views/propertyeditors/rte/rte.component.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js index 2794f0bd16fa..bb9006779dc9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/rte/rte.component.js @@ -83,6 +83,7 @@ } })); + vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model. vm.availableBlockTypes = []; // Available block entries of this property editor. vm.labels = {}; @@ -190,6 +191,9 @@ vm.containerHeight = "auto"; vm.containerOverflow = "inherit" + // Add client validation for the markup part. + unsubscribe.push($scope.$watch(() => vm.model?.value?.markup, validate)); + //queue file loading tinyMceAssets.forEach(function (tinyJsAsset) { assetPromises.push(assetsService.loadJs(tinyJsAsset, $scope)); @@ -337,6 +341,18 @@ } } + function validate() { + var isValid = !vm.model.validation.mandatory || ( + vm.model.value != null + && vm.model.value.markup != null + && vm.model.value.markup != "" + ); + vm.propertyForm.$setValidity("required", isValid); + if (vm.umbProperty) { + vm.umbProperty.setPropertyError(vm.model.validation.mandatoryMessage || "Value cannot be empty"); + } + }; + // Called when we save the value, the server may return an updated data and our value is re-synced // we need to deal with that here so that our model values are all in sync so we basically re-initialize. function onServerValueChanged(newVal, oldVal) { From 03690fb330bd0bbe1d568ab0c01413563eb06ea2 Mon Sep 17 00:00:00 2001 From: Karita <49212909+ainokarita@users.noreply.github.com> Date: Fri, 7 Feb 2025 15:26:19 +0100 Subject: [PATCH 41/74] RTE property editor settings translation for DK&US (#18264) --- src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts | 2 ++ src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts index 6a24c20d510e..8a50e6aef3f8 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da-dk.ts @@ -1673,6 +1673,7 @@ export default { elementDoesNotSupport: 'Dette benyttes ikke for en Element-type', propertyHasChanges: 'Du har lavet ændringer til denne egenskab. Er du sikker på at du vil kassere dem?\n ', displaySettingsHeadline: 'Visning', + displaySettingsLabelOnLeft: 'Label på venstre side', displaySettingsLabelOnTop: 'Label hen over (fuld bredde)', removeChildNode: 'Du fjerner noden', removeChildNodeWarning: @@ -2020,6 +2021,7 @@ export default { }, validation: { validation: 'Validering', + validateNothing: 'Ingen validering', validateAsEmail: 'Valider som e-mail', validateAsNumber: 'Valider som tal', validateAsUrl: 'Valider som URL', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index c9e3dae3e6a3..10e803bf5528 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -1711,6 +1711,7 @@ export default { elementDoesNotSupport: 'This is not applicable for an Element Type', propertyHasChanges: 'You have made changes to this property. Are you sure you want to discard them?', displaySettingsHeadline: 'Appearance', + displaySettingsLabelOnLeft: 'Label to the left', displaySettingsLabelOnTop: 'Label above (full-width)', confirmDeleteTabMessage: 'Are you sure you want to delete the tab %0%?', confirmDeleteGroupMessage: 'Are you sure you want to delete the group %0%?', @@ -2060,6 +2061,7 @@ export default { }, validation: { validation: 'Validation', + validateNothing: 'No validation', validateAsEmail: 'Validate as an email address', validateAsNumber: 'Validate as a number', validateAsUrl: 'Validate as a URL', From a3b77cff63aa7597eb4eea1b07a780525bdfe6a2 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 10 Feb 2025 10:40:53 +0100 Subject: [PATCH 42/74] Add validation to prevent update of a user or member to an invalid username (13) (#18261) * Add validation to prevent update of a user or member to an invalid username. * Avoid password manager updates of user name field on user details screen. --- .../Controllers/MemberController.cs | 11 +++++++++++ .../Controllers/UsersController.cs | 9 +++++++++ .../src/views/users/views/user/details.html | 4 +++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs index 4a18bf462001..d03fa87a4ac6 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/MemberController.cs @@ -723,6 +723,17 @@ private async Task ValidateMemberDataAsync(MemberSave contentItem) return false; } + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the IdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (contentItem.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddPropertyError( + new ValidationResult("Username contains invalid characters"), + $"{Constants.PropertyEditors.InternalGenericPropertiesPrefix}login"); + return false; + } + if (contentItem.Password != null && !contentItem.Password.NewPassword.IsNullOrWhiteSpace()) { IdentityResult validPassword = await _memberManager.ValidatePasswordAsync(contentItem.Password.NewPassword); diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index c855a87ea4b2..2f128f1f09dd 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -714,6 +714,15 @@ private async Task SendUserInviteEmailAsync(UserBasic? userDisplay, string? from var hasErrors = false; + // User names can only contain the configured allowed characters. This is validated by ASP.NET Identity on create + // as the setting is applied to the BackOfficeIdentityOptions, but we need to check ourselves for updates. + var allowedUserNameCharacters = _securitySettings.AllowedUserNameCharacters; + if (userSave.Username.Any(c => allowedUserNameCharacters.Contains(c) == false)) + { + ModelState.AddModelError("Username", "Username contains invalid characters"); + hasErrors = true; + } + // we need to check if there's any Deny Local login providers present, if so we need to ensure that the user's email address cannot be changed var hasDenyLocalLogin = _externalLogins.HasDenyLocalLogin(); if (hasDenyLocalLogin) diff --git a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html index eaa92b7a6e78..1eb6840fd3b5 100644 --- a/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html +++ b/src/Umbraco.Web.UI.Client/src/views/users/views/user/details.html @@ -1,4 +1,4 @@ -
+
@@ -45,6 +45,8 @@ ng-model="model.user.username" umb-auto-focus name="username" required + autocomplete="off" + no-password-manager val-server-field="Username" /> Required From 026e80e02662929943a9d0886598f66b49d98296 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 10 Feb 2025 12:51:35 +0100 Subject: [PATCH 43/74] Avoid an exception on sign out when the principal is populated from an incomplete external login (#18078) * Avoid an exception on signout when the principal is populated from an incomplete external login. * Tidied up comment. --- .../Security/BackOfficeUserStore.cs | 15 ++++++++++++++- .../Security/UmbracoUserStore.cs | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs index 0d2767dd25c8..21f8978e7188 100644 --- a/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/BackOfficeUserStore.cs @@ -281,7 +281,20 @@ public override Task DeleteAsync( cancellationToken.ThrowIfCancellationRequested(); ThrowIfDisposed(); - IUser? user = _userService.GetUserById(UserIdToInt(userId)); + // In the external login flow - see BackOfficeController.ExternalSignInAsync - we can have a situation where an + // error has occured but the user is signed in. For that reason, at the end of the process, if errors are + // recorded, the user is signed out. + // Before signing out, we request the user in order to update the security stamp - see UmbracoSignInManager.SignOutAsync. + // But we can have a situation where the signed in principal has the ID from the external provider, which may not be something + // we can parse to an integer. + // If that's the case, return null rather than throwing an exception. Without an Umbraco user, we can't update the security stamp, + // so no need to fail here. + if (!TryUserIdToInt(userId, out var userIdAsInt)) + { + return Task.FromResult((BackOfficeIdentityUser?)null)!; + } + + IUser? user = _userService.GetUserById(userIdAsInt); if (user == null) { return Task.FromResult((BackOfficeIdentityUser?)null)!; diff --git a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs index 35a8f2eea9f0..544a8dbd745a 100644 --- a/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/UmbracoUserStore.cs @@ -33,18 +33,29 @@ protected UmbracoUserStore(IdentityErrorDescriber describer) protected static int UserIdToInt(string? userId) { - if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) + if (TryUserIdToInt(userId, out int result)) { return result; } + throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + } + + protected static bool TryUserIdToInt(string? userId, out int result) + { + if (int.TryParse(userId, NumberStyles.Integer, CultureInfo.InvariantCulture, out result)) + { + return true; + } + if (Guid.TryParse(userId, out Guid key)) { // Reverse the IntExtensions.ToGuid - return BitConverter.ToInt32(key.ToByteArray(), 0); + result = BitConverter.ToInt32(key.ToByteArray(), 0); + return true; } - throw new InvalidOperationException($"Unable to convert user ID ({userId})to int using InvariantCulture"); + return false; } protected static string UserIdToString(int userId) => string.Intern(userId.ToString(CultureInfo.InvariantCulture)); From f30e6cfe7f207ebae942b10a6ea449bdc026a04a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 09:18:01 +0100 Subject: [PATCH 44/74] Prevents folder selection in media picker when used from the multi URL picker. (#18288) --- .../common/infiniteeditors/linkpicker/linkpicker.controller.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js index 673e1a5d3dd0..e3799def3c72 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/linkpicker/linkpicker.controller.js @@ -189,6 +189,7 @@ angular.module("umbraco").controller("Umbraco.Editors.LinkPickerController", startNodeId: startNodeId, startNodeIsVirtual: startNodeIsVirtual, dataTypeKey: dialogOptions.dataTypeKey, + disableFolderSelect: true, submit: function (model) { var media = model.selection[0]; From 447b36ab677ad6a41be7ddf1a06b3431c3769771 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:19:48 +0000 Subject: [PATCH 45/74] Bump vite from 5.4.11 to 5.4.14 in /src/Umbraco.Web.UI.Client Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.4.11 to 5.4.14. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.4.14/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.4.14/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 8 ++++---- src/Umbraco.Web.UI.Client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 52e78f1956ac..2620634ac14c 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -96,7 +96,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", "typescript-json-schema": "^0.65.1", - "vite": "^5.4.11", + "vite": "^5.4.14", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16371,9 +16371,9 @@ } }, "node_modules/vite": { - "version": "5.4.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", - "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 3fc044ac38cc..ed297de4d012 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -284,7 +284,7 @@ "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", "typescript-json-schema": "^0.65.1", - "vite": "^5.4.11", + "vite": "^5.4.14", "vite-plugin-static-copy": "^2.2.0", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" From 5457521f19a367c9b5194bffc020a188ac440bfb Mon Sep 17 00:00:00 2001 From: Chris Houston Date: Tue, 11 Feb 2025 04:59:06 -0500 Subject: [PATCH 46/74] Fixing the background color of the "re-login" screen when your session has timed out. (#18284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updated modal background color The background color of the authentication modal has been updated to use a CSS variable for better customization. The default value remains the same. * Update umb-app-auth-modal.element.ts to use less specific css custom property --------- Co-authored-by: Niels Lyngsø --- .../src/packages/core/auth/modals/umb-app-auth-modal.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts index a92627d95d39..eed8e34b5375 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/auth/modals/umb-app-auth-modal.element.ts @@ -170,7 +170,7 @@ export class UmbAppAuthModalElement extends UmbModalBaseElement Date: Tue, 11 Feb 2025 12:28:22 +0100 Subject: [PATCH 47/74] bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 9e2922ce589e..1264b7fef2af 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "15.2.0", + "version": "15.2.1", "assemblyVersion": { "precision": "build" }, From 649cfcab5807bfab46747619e86c42fbe79c4c19 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 11 Feb 2025 12:45:09 +0100 Subject: [PATCH 48/74] Custom Partial variancy support for RTE as it uses a wrapped model (#18290) --- .../BlockValuePropertyValueEditorBase.cs | 14 +++- .../PropertyEditors/RichTextPropertyEditor.cs | 64 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs index 88bc28acb546..22e9384bf201 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyValueEditorBase.cs @@ -296,6 +296,18 @@ private void MapBlockItemDataFromEditor(List items) BlockEditorData? source = BlockEditorValues.DeserializeAndClean(sourceValue); BlockEditorData? target = BlockEditorValues.DeserializeAndClean(targetValue); + TValue? mergedBlockValue = + MergeVariantInvariantPropertyValueTyped(source, target, canUpdateInvariantData, allowedCultures); + + return _jsonSerializer.Serialize(mergedBlockValue); + } + + internal virtual TValue? MergeVariantInvariantPropertyValueTyped( + BlockEditorData? source, + BlockEditorData? target, + bool canUpdateInvariantData, + HashSet allowedCultures) + { source = UpdateSourceInvariantData(source, target, canUpdateInvariantData); if (source is null && target is null) @@ -328,7 +340,7 @@ private void MapBlockItemDataFromEditor(List items) CleanupVariantValues(source.BlockValue.ContentData, target.BlockValue.ContentData, canUpdateInvariantData, allowedCultures); CleanupVariantValues(source.BlockValue.SettingsData, target.BlockValue.SettingsData, canUpdateInvariantData, allowedCultures); - return _jsonSerializer.Serialize(target.BlockValue); + return target.BlockValue; } private void CleanupVariantValues( diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 88a237ca24d0..92c0a05e940b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -274,6 +274,70 @@ public override IEnumerable ConfiguredElementTypeKeys() return configuration?.Blocks?.SelectMany(ConfiguredElementTypeKeys) ?? Enumerable.Empty(); } + internal override object? MergeVariantInvariantPropertyValue( + object? sourceValue, + object? targetValue, + bool canUpdateInvariantData, + HashSet allowedCultures) + { + TryParseEditorValue(sourceValue, out RichTextEditorValue? sourceRichTextEditorValue); + TryParseEditorValue(targetValue, out RichTextEditorValue? targetRichTextEditorValue); + + var mergedBlockValue = MergeBlockVariantInvariantData( + sourceRichTextEditorValue?.Blocks, + targetRichTextEditorValue?.Blocks, + canUpdateInvariantData, + allowedCultures); + + var mergedMarkupValue = MergeMarkupValue( + sourceRichTextEditorValue?.Markup ?? string.Empty, + targetRichTextEditorValue?.Markup ?? string.Empty, + mergedBlockValue, + canUpdateInvariantData); + + var mergedEditorValue = new RichTextEditorValue { Markup = mergedMarkupValue, Blocks = mergedBlockValue }; + return RichTextPropertyEditorHelper.SerializeRichTextEditorValue(mergedEditorValue, _jsonSerializer); + } + + private string MergeMarkupValue( + string source, + string target, + RichTextBlockValue? mergedBlockValue, + bool canUpdateInvariantData) + { + // pick source or target based on culture permissions + var mergedMarkup = canUpdateInvariantData ? target : source; + + // todo? strip all invalid block links from markup, those tat are no longer in the layout + return mergedMarkup; + } + + private RichTextBlockValue? MergeBlockVariantInvariantData( + RichTextBlockValue? sourceRichTextBlockValue, + RichTextBlockValue? targetRichTextBlockValue, + bool canUpdateInvariantData, + HashSet allowedCultures) + { + if (sourceRichTextBlockValue is null && targetRichTextBlockValue is null) + { + return null; + } + + BlockEditorData sourceBlockEditorData = + (sourceRichTextBlockValue is not null ? ConvertAndClean(sourceRichTextBlockValue) : null) + ?? new BlockEditorData([], new RichTextBlockValue()); + + BlockEditorData targetBlockEditorData = + (targetRichTextBlockValue is not null ? ConvertAndClean(targetRichTextBlockValue) : null) + ?? new BlockEditorData([], new RichTextBlockValue()); + + return MergeVariantInvariantPropertyValueTyped( + sourceBlockEditorData, + targetBlockEditorData, + canUpdateInvariantData, + allowedCultures); + } + internal override object? MergePartialPropertyValueForCulture(object? sourceValue, object? targetValue, string? culture) { if (sourceValue is null || TryParseEditorValue(sourceValue, out RichTextEditorValue? sourceRichTextEditorValue) is false) From 7bcbc748d49e986b9478d184fb6052e82f1c7d1d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 13:36:19 +0100 Subject: [PATCH 49/74] URL encodes member user names when passing information for public access setting such that those with user names as emails containing a plus will be included in the rule. (#18142) --- .../src/common/resources/publicaccess.resource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js index d91924a2eba3..3e0ac9062014 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/publicaccess.resource.js @@ -79,7 +79,7 @@ function publicAccessResource($http, umbRequestHelper) { publicAccess.groups = groups; } else if (Utilities.isArray(usernames) && usernames.length) { - publicAccess.usernames = usernames; + publicAccess.usernames = usernames.map(u => encodeURIComponent(u)); } else { throw "must supply either userName/password or roles"; From 048f8bcdf91fd2b113085771b82abd528724ec1d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 11 Feb 2025 17:06:18 +0100 Subject: [PATCH 50/74] Fixed userResource request to get all users. (#18105) --- src/Umbraco.Web.BackOffice/Controllers/UsersController.cs | 2 +- .../DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs | 1 + .../src/common/resources/users.resource.js | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 2f128f1f09dd..960afa365e9a 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -322,7 +322,7 @@ public ActionResult PostClearAvatar(int id) /// [OutgoingEditorModelEvent] [Authorize(Policy = AuthorizationPolicies.AdminUserEditsRequireAdmin)] - public ActionResult> GetByIds([FromJsonPath] int[] ids) + public ActionResult> GetByIds([FromQuery] int[] ids) { if (ids == null) { diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs index 0945d3459b0a..fd3bfe71bc31 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilder.BackOfficeAuth.cs @@ -202,6 +202,7 @@ private static void CreatePolicies(AuthorizationOptions options, string backOffi { policy.AuthenticationSchemes.Add(backOfficeAuthenticationScheme); policy.Requirements.Add(new AdminUsersRequirement()); + policy.Requirements.Add(new AdminUsersRequirement("ids")); policy.Requirements.Add(new AdminUsersRequirement("userIds")); }); diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js index 0b69bec3f5e0..c514be49a03f 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/users.resource.js @@ -318,12 +318,14 @@ */ function getUsers(userIds) { + var idQuery = ""; + userIds.forEach(id => idQuery += `ids=${id}&`); return umbRequestHelper.resourcePromise( $http.get( umbRequestHelper.getApiUrl( "userApiBaseUrl", "GetByIds", - { ids: userIds })), + idQuery)), "Failed to retrieve data for users " + userIds); } From a282cc5691c19ea39d4429e31626d1c5413c77e7 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 08:06:50 +0100 Subject: [PATCH 51/74] Backport use of thread delay over sleep and handle dispose in FileSystemMainDomLock (#18151) * Backport use of thread delay over sleep and handle dispose in FileSystemMainDomLock (from PRs #18119 and #18147) * Applied suggestion from code review. --- .../Runtime/FileSystemMainDomLock.cs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs index 6dcd3ef9b0b3..27662f979a1b 100644 --- a/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs +++ b/src/Umbraco.Infrastructure/Runtime/FileSystemMainDomLock.cs @@ -15,6 +15,7 @@ internal class FileSystemMainDomLock : IMainDomLock private readonly string _lockFilePath; private readonly ILogger _logger; private readonly string _releaseSignalFilePath; + private bool _disposed; private Task? _listenForReleaseSignalFileTask; private FileStream? _lockFileStream; @@ -88,16 +89,14 @@ public Task ListenAsync() ListeningLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, - TaskScheduler.Default); + TaskScheduler.Default) + .Unwrap(); // Because ListeningLoop is an async method, we need to use Unwrap to return the inner task. return _listenForReleaseSignalFileTask; } - public void Dispose() - { - _lockFileStream?.Close(); - _lockFileStream = null; - } + /// Releases the resources used by this . + public void Dispose() => Dispose(true); public void CreateLockReleaseSignalFile() => File.Open(_releaseSignalFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, @@ -107,7 +106,27 @@ public void CreateLockReleaseSignalFile() => public void DeleteLockReleaseSignalFile() => File.Delete(_releaseSignalFilePath); - private void ListeningLoop() + /// Releases the resources used by this . + /// true to release both managed resources. + protected virtual void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _logger.LogInformation($"{nameof(FileSystemMainDomLock)} Disposing..."); + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + ReleaseLock(); + _disposed = true; + } + } + + private void ReleaseLock() + { + _lockFileStream?.Close(); + _lockFileStream = null; + } + + private async Task ListeningLoop() { while (true) { @@ -126,12 +145,12 @@ private void ListeningLoop() { _logger.LogDebug("Found lock release signal file, releasing lock on {lockFilePath}", _lockFilePath); } - _lockFileStream?.Close(); - _lockFileStream = null; + + ReleaseLock(); break; } - Thread.Sleep(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval); + await Task.Delay(_globalSettings.CurrentValue.MainDomReleaseSignalPollingInterval, _cancellationTokenSource.Token); } } } From 5322d0f7b597323ca32bb4c2ba7999aadfcac0da Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 08:13:58 +0100 Subject: [PATCH 52/74] Bumped version to 13.8.0-rc. --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index ee882f912bd6..94daac6c7ac6 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "13.7.0-rc", + "version": "13.8.0-rc", "assemblyVersion": { "precision": "build" }, From 9227517a50031f116273582f73cf70167fb65d37 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 12 Feb 2025 12:30:27 +0100 Subject: [PATCH 53/74] Split force for publish descendants into separate options for publish unpublish and re-publish unedited (13) (#18249) * Split force for publish descendents into separate options for publish unpublish and re-publish unedited. * Added integration task verifying updated behaviour. * Variant integration test. * Update test data controller. * Remove usued function parameters. * Refactor to enum. * Fixed flags enum. * Variable name refactor. * Applied changes from code review. * Refactored method name. * Aligned js boolean checks. --- .../EmbeddedResources/Lang/en.xml | 1 + .../EmbeddedResources/Lang/en_us.xml | 1 + .../ContentEditing/ContentSaveAction.cs | 60 ++++-- .../Models/PublishBranchFilter.cs | 28 +++ src/Umbraco.Core/Services/ContentService.cs | 47 ++--- src/Umbraco.Core/Services/IContentService.cs | 40 ++++ .../Controllers/ContentController.cs | 70 +++++-- .../Filters/ContentSaveValidationAttribute.cs | 10 + .../components/content/edit.controller.js | 2 +- .../src/common/resources/content.resource.js | 10 +- .../overlays/publishdescendants.controller.js | 14 +- .../content/overlays/publishdescendants.html | 24 +++ .../UmbracoTestDataController.cs | 2 +- .../Services/ContentServiceTests.cs | 4 +- .../Services/ContentEventsTests.cs | 4 +- .../ContentServicePublishBranchTests.cs | 191 ++++++++++++++++-- .../Services/ContentServiceTagsTests.cs | 2 +- 17 files changed, 417 insertions(+), 93 deletions(-) create mode 100644 src/Umbraco.Core/Models/PublishBranchFilter.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 5f4893976663..cf79f426b751 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -309,6 +309,7 @@ Remove this text box Content root Include unpublished content items. + Publish unchanged items. This value is hidden. If you need access to view this value please contact your website administrator. diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index f86b0825139c..bd387b4a0744 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -308,6 +308,7 @@ Remove this text box Content root Include unpublished content items. + Publish unchanged items. This value is hidden. If you need access to view this value please contact your website administrator. diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs b/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs index 889b03db6d67..929ee7c09769 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentSaveAction.cs @@ -1,69 +1,101 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; /// -/// The action associated with saving a content item +/// The action associated with saving a content item. /// public enum ContentSaveAction { /// - /// Saves the content item, no publish + /// Saves the content item, no publish. /// Save = 0, /// - /// Creates a new content item + /// Creates a new content item. /// SaveNew = 1, /// - /// Saves and publishes the content item + /// Saves and publishes the content item. /// Publish = 2, /// - /// Creates and publishes a new content item + /// Creates and publishes a new content item. /// PublishNew = 3, /// - /// Saves and sends publish notification + /// Saves and sends publish notification. /// SendPublish = 4, /// - /// Creates and sends publish notification + /// Creates and sends publish notification. /// SendPublishNew = 5, /// - /// Saves and schedules publishing + /// Saves and schedules publishing. /// Schedule = 6, /// - /// Creates and schedules publishing + /// Creates and schedules publishing. /// ScheduleNew = 7, /// - /// Saves and publishes the content item including all descendants that have a published version + /// Saves and publishes the content item including all descendants that have a published version. /// PublishWithDescendants = 8, /// - /// Creates and publishes the content item including all descendants that have a published version + /// Creates and publishes the new content item including all descendants that have a published version. /// PublishWithDescendantsNew = 9, /// /// Saves and publishes the content item including all descendants regardless of whether they have a published version - /// or not + /// or not. /// + [Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")] PublishWithDescendantsForce = 10, /// - /// Creates and publishes the content item including all descendants regardless of whether they have a published - /// version or not + /// Creates and publishes the new content item including all descendants regardless of whether they have a published + /// version or not. /// + [Obsolete("This option is no longer used as the 'force' aspect has been extended into options for publishing unpublished and re-publishing changed content. Please use one of those options instead.")] PublishWithDescendantsForceNew = 11, + + /// + /// Saves and publishes the content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsIncludeUnpublished = 12, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsIncludeUnpublishedNew = 13, + + /// + /// Saves and publishes the content item including all descendants irrespective of whether there are any pending changes. + /// + PublishWithDescendantsForceRepublish = 14, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content. + /// + PublishWithDescendantsForceRepublishNew = 15, + + /// + /// Saves and publishes the content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes. + /// + PublishWithDescendantsIncludeUnpublishedAndForceRepublish = 16, + + /// + /// Saves and publishes the new content item including all descendants including publishing previously unpublished content and irrespective of whether there are any pending changes. + /// + PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew = 17, } diff --git a/src/Umbraco.Core/Models/PublishBranchFilter.cs b/src/Umbraco.Core/Models/PublishBranchFilter.cs new file mode 100644 index 000000000000..e47a07f67739 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishBranchFilter.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Describes the options available with publishing a content branch for force publishing. +/// +[Flags] +public enum PublishBranchFilter +{ + /// + /// The default behavior is to publish only the published content that has changed. + /// + Default = 0, + + /// + /// For publishing a branch, publish all changed content, including content that is not published. + /// + IncludeUnpublished = 1, + + /// + /// For publishing a branch, force republishing of all published content, including content that has not changed. + /// + ForceRepublish = 2, + + /// + /// For publishing a branch, publish all content, including content that is not published and content that has not changed. + /// + All = IncludeUnpublished | ForceRepublish, +} diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 5ff81a2165ba..45176629a23a 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -1963,17 +1963,14 @@ private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet? SaveAndPublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, bool force) + private HashSet? SaveAndPublishBranch_ShouldPublish(ref HashSet? cultures, string c, bool published, bool edited, bool isRoot, PublishBranchFilter publishBranchFilter) { // if published, republish if (published) { - if (cultures == null) - { - cultures = new HashSet(); // empty means 'already published' - } + cultures ??= []; // empty means 'already published' - if (edited) + if (edited || publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) { cultures.Add(c); // means 'republish this culture' } @@ -1982,15 +1979,12 @@ private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet(); - } + cultures ??= []; cultures.Add(c); // means 'publish this culture' return cultures; @@ -1998,6 +1992,10 @@ private bool SaveAndPublishBranch_PublishCultures(IContent content, HashSet public IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId) + => SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, culture, userId); + + /// + public IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -2016,13 +2014,13 @@ public IEnumerable SaveAndPublishBranch(IContent content, bool fo // invariant content type if (!c.ContentType.VariesByCulture()) { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter); } // variant content type, specific culture if (culture != "*") { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, culture, c.IsCulturePublished(culture), c.IsCultureEdited(culture), isRoot, publishBranchFilter); } // variant content type, all cultures @@ -2032,23 +2030,27 @@ public IEnumerable SaveAndPublishBranch(IContent content, bool fo // others will have to 'republish this culture' foreach (var x in c.AvailableCultures) { - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter); } return culturesToPublish; } - // if not published, publish if force/root else do nothing - return force || isRoot + // if not published, publish if forcing unpublished/root else do nothing + return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot ? new HashSet { "*" } // "*" means 'publish all' : null; // null means 'nothing to do' } - return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); + return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); } /// public IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId) + => SaveAndPublishBranch(content, force ? PublishBranchFilter.IncludeUnpublished : PublishBranchFilter.Default, cultures, userId); + + /// + public IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId) { // note: EditedValue and PublishedValue are objects here, so it is important to .Equals() // and not to == them, else we would be comparing references, and that is a bad thing @@ -2064,7 +2066,7 @@ public IEnumerable SaveAndPublishBranch(IContent content, bool fo // invariant content type if (!c.ContentType.VariesByCulture()) { - return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, force); + return SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, "*", c.Published, c.Edited, isRoot, publishBranchFilter); } // variant content type, specific cultures @@ -2074,24 +2076,23 @@ public IEnumerable SaveAndPublishBranch(IContent content, bool fo // others will have to 'republish this culture' foreach (var x in cultures) { - SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, force); + SaveAndPublishBranch_ShouldPublish(ref culturesToPublish, x, c.IsCulturePublished(x), c.IsCultureEdited(x), isRoot, publishBranchFilter); } return culturesToPublish; } - // if not published, publish if force/root else do nothing - return force || isRoot + // if not published, publish if forcing unpublished/root else do nothing + return publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) || isRoot ? new HashSet(cultures) // means 'publish specified cultures' : null; // null means 'nothing to do' } - return SaveAndPublishBranch(content, force, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); + return SaveAndPublishBranch(content, ShouldPublish, SaveAndPublishBranch_PublishCultures, userId); } internal IEnumerable SaveAndPublishBranch( IContent document, - bool force, Func?> shouldPublish, Func, IReadOnlyCollection, bool> publishCultures, int userId = Constants.Security.SuperUserId) diff --git a/src/Umbraco.Core/Services/IContentService.cs b/src/Umbraco.Core/Services/IContentService.cs index 1733a741425e..c7b33589b3e0 100644 --- a/src/Umbraco.Core/Services/IContentService.cs +++ b/src/Umbraco.Core/Services/IContentService.cs @@ -431,6 +431,7 @@ public interface IContentService : IContentServiceBase /// published. The root of the branch is always published, regardless of . /// /// + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")] IEnumerable SaveAndPublishBranch(IContent content, bool force, string culture = "*", int userId = Constants.Security.SuperUserId); /// @@ -447,8 +448,47 @@ public interface IContentService : IContentServiceBase /// published. The root of the branch is always published, regardless of . /// /// + [Obsolete("This method is not longer used as the 'force' parameter has been extended into options for publishing unpublished and re-publishing changed content. Please use the overload containing the parameter for those options instead.")] IEnumerable SaveAndPublishBranch(IContent content, bool force, string[] cultures, int userId = Constants.Security.SuperUserId); + /// + /// Saves and publishes a document branch. + /// + /// The root document. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// A culture, or "*" for all cultures. + /// The identifier of the user performing the operation. + /// + /// + /// Unless specified, all cultures are re-published. Otherwise, one culture can be specified. To act on more + /// than one culture, see the other overloads of this method. + /// + /// + /// The root of the branch is always published, regardless of . + /// + /// + IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string culture = "*", int userId = Constants.Security.SuperUserId) +#pragma warning disable CS0618 // Type or member is obsolete + => SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), culture, userId); +#pragma warning restore CS0618 // Type or member is obsolete + + /// + /// Saves and publishes a document branch. + /// + /// The root document. + /// A value indicating options for force publishing unpublished or re-publishing unchanged content. + /// The cultures to publish. + /// The identifier of the user performing the operation. + /// + /// + /// The root of the branch is always published, regardless of . + /// + /// + IEnumerable SaveAndPublishBranch(IContent content, PublishBranchFilter publishBranchFilter, string[] cultures, int userId = Constants.Security.SuperUserId) +#pragma warning disable CS0618 // Type or member is obsolete + => SaveAndPublishBranch(content, publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished), cultures, userId); +#pragma warning restore CS0618 // Type or member is obsolete + ///// ///// Saves and publishes a document branch. ///// diff --git a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs index c676cee2caef..ef935b6b5975 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ContentController.cs @@ -999,18 +999,29 @@ private bool EnsureUniqueName(string? name, IContent? content, string modelName) // if there's only one variant and the model state is not valid we cannot publish so change it to save if (variantCount == 1) { + switch (contentItem.Action) { case ContentSaveAction.Publish: case ContentSaveAction.PublishWithDescendants: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForce: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: case ContentSaveAction.SendPublish: case ContentSaveAction.Schedule: contentItem.Action = ContentSaveAction.Save; break; case ContentSaveAction.PublishNew: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: case ContentSaveAction.SendPublishNew: case ContentSaveAction.ScheduleNew: contentItem.Action = ContentSaveAction.SaveNew; @@ -1144,6 +1155,16 @@ private bool EnsureUniqueName(string? name, IContent? content, string modelName) break; case ContentSaveAction.PublishWithDescendants: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsForce: + case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: { if (!await ValidatePublishBranchPermissionsAsync(contentItem)) { @@ -1154,7 +1175,7 @@ private bool EnsureUniqueName(string? name, IContent? content, string modelName) break; } - var publishStatus = PublishBranchInternal(contentItem, false, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); + var publishStatus = PublishBranchInternal(contentItem, BuildPublishBranchFilter(contentItem.Action), cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); var addedDomainWarnings = AddDomainWarnings(publishStatus, successfulCultures, globalNotifications, defaultCulture); AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); if (addedDomainWarnings is false) @@ -1163,22 +1184,6 @@ private bool EnsureUniqueName(string? name, IContent? content, string modelName) } } break; - case ContentSaveAction.PublishWithDescendantsForce: - case ContentSaveAction.PublishWithDescendantsForceNew: - { - if (!await ValidatePublishBranchPermissionsAsync(contentItem)) - { - globalNotifications.AddErrorNotification( - _localizedTextService.Localize(null, "publish"), - _localizedTextService.Localize("publish", "invalidPublishBranchPermissions")); - wasCancelled = false; - break; - } - - var publishStatus = PublishBranchInternal(contentItem, true, cultureForInvariantErrors, out wasCancelled, out var successfulCultures).ToList(); - AddPublishStatusNotifications(publishStatus, globalNotifications, notifications, successfulCultures); - } - break; default: throw new ArgumentOutOfRangeException(); } @@ -1228,6 +1233,31 @@ private bool EnsureUniqueName(string? name, IContent? content, string modelName) return display; } + private static PublishBranchFilter BuildPublishBranchFilter(ContentSaveAction contentSaveAction) + { + var includeUnpublished = contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublished + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew; + var forceRepublish = contentSaveAction == ContentSaveAction.PublishWithDescendantsForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsForceRepublishNew + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish + || contentSaveAction == ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew; + + PublishBranchFilter publishBranchFilter = PublishBranchFilter.Default; + if (includeUnpublished) + { + publishBranchFilter |= PublishBranchFilter.IncludeUnpublished; + } + + if (forceRepublish) + { + publishBranchFilter |= PublishBranchFilter.ForceRepublish; + } + + return publishBranchFilter; + } + private void AddPublishStatusNotifications( IReadOnlyCollection publishStatus, SimpleNotificationModel globalNotifications, @@ -1668,12 +1698,12 @@ private async Task ValidatePublishBranchPermissionsAsync(ContentItemSave c return authorizationResult.Succeeded; } - private IEnumerable PublishBranchInternal(ContentItemSave contentItem, bool force, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) + private IEnumerable PublishBranchInternal(ContentItemSave contentItem, PublishBranchFilter publishBranchFilter, string? cultureForInvariantErrors, out bool wasCancelled, out string[]? successfulCultures) { if (!contentItem.PersistedContent?.ContentType.VariesByCulture() ?? false) { //its invariant, proceed normally - IEnumerable publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, force, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + IEnumerable publishStatus = _contentService.SaveAndPublishBranch(contentItem.PersistedContent!, publishBranchFilter, userId: _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); // TODO: Deal with multiple cancellations wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); successfulCultures = null; //must be null! this implies invariant @@ -1709,7 +1739,7 @@ private IEnumerable PublishBranchInternal(ContentItemSave content { //proceed to publish if all validation still succeeds IEnumerable publishStatus = _contentService.SaveAndPublishBranch( - contentItem.PersistedContent!, force, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); + contentItem.PersistedContent!, publishBranchFilter, culturesToPublish.WhereNotNull().ToArray(), _backofficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.Id ?? -1); // TODO: Deal with multiple cancellations wasCancelled = publishStatus.Any(x => x.Result == PublishResultType.FailedPublishCancelledByEvent); successfulCultures = contentItem.Variants.Where(x => x.Publish).Select(x => x.Culture).WhereNotNull() diff --git a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs index c75bbd5a80a6..45948b596043 100644 --- a/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs +++ b/src/Umbraco.Web.BackOffice/Filters/ContentSaveValidationAttribute.cs @@ -182,7 +182,12 @@ private async Task ValidateUserAccessAsync( break; case ContentSaveAction.Publish: case ContentSaveAction.PublishWithDescendants: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForce: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublished: + case ContentSaveAction.PublishWithDescendantsForceRepublish: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublish: permissionToCheck.Add(ActionPublish.ActionLetter); contentToCheck = contentItem.PersistedContent; contentIdToCheck = contentToCheck?.Id ?? default; @@ -232,7 +237,12 @@ private async Task ValidateUserAccessAsync( break; case ContentSaveAction.PublishNew: case ContentSaveAction.PublishWithDescendantsNew: +#pragma warning disable CS0618 // Type or member is obsolete case ContentSaveAction.PublishWithDescendantsForceNew: +#pragma warning restore CS0618 // Type or member is obsolete + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedNew: + case ContentSaveAction.PublishWithDescendantsForceRepublishNew: + case ContentSaveAction.PublishWithDescendantsIncludeUnpublishedAndForceRepublishNew: //Publish new requires both ActionNew AND ActionPublish // TODO: Shouldn't publish also require ActionUpdate since it will definitely perform an update to publish but maybe that's just implied diff --git a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js index 72c5f3fec1e5..6d5503301bb4 100644 --- a/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/common/directives/components/content/edit.controller.js @@ -963,7 +963,7 @@ //we need to return this promise so that the dialog can handle the result and wire up the validation response return performSave({ saveMethod: function (content, create, files, showNotifications) { - return contentResource.publishWithDescendants(content, create, model.includeUnpublished, files, showNotifications); + return contentResource.publishWithDescendants(content, create, model.includeUnpublished, model.forceRepublish, files, showNotifications); }, action: "publishDescendants", showNotifications: false, diff --git a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js index b3218b2c7fa3..a86675916f65 100644 --- a/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js +++ b/src/Umbraco.Web.UI.Client/src/common/resources/content.resource.js @@ -1003,14 +1003,18 @@ function contentResource($q, $http, umbDataFormatter, umbRequestHelper) { * @returns {Promise} resourcePromise object containing the saved content item. * */ - publishWithDescendants: function (content, isNew, force, files, showNotifications) { + publishWithDescendants: function (content, isNew, includeUnpublished, forceRepublish, files, showNotifications) { var endpoint = umbRequestHelper.getApiUrl( "contentApiBaseUrl", "PostSave"); var action = "publishWithDescendants"; - if (force === true) { - action += "Force"; + if (includeUnpublished === true && forceRepublish === true) { + action += "IncludeUnpublishedAndForceRepublish"; + } else if (includeUnpublished === true) { + action += "IncludeUnpublished"; + } else if (forceRepublish === true) { + action += "ForceRepublish"; } return saveContentItem(content, action + (isNew ? "New" : ""), files, endpoint, showNotifications); diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js index 1b4c16b28f39..40e7e2d4bab1 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.controller.js @@ -5,10 +5,12 @@ var vm = this; vm.includeUnpublished = $scope.model.includeUnpublished || false; + vm.forceRepublish = $scope.model.forceRepublish || false; vm.publishAll = false; vm.changeSelection = changeSelection; vm.toggleIncludeUnpublished = toggleIncludeUnpublished; + vm.toggleForceRepublish = toggleForceRepublish; vm.changePublishAllSelection = changePublishAllSelection; function onInit() { @@ -28,9 +30,9 @@ vm.labels.includeUnpublished = value; }); } - if (!vm.labels.includeUnpublished) { - localizationService.localize("content_includeUnpublished").then(value => { - vm.labels.includeUnpublished = value; + if (!vm.labels.forceRepublish) { + localizationService.localize("content_forceRepublish").then(value => { + vm.labels.forceRepublish = value; }); } @@ -69,10 +71,14 @@ function toggleIncludeUnpublished() { vm.includeUnpublished = !vm.includeUnpublished; - // make sure this value is pushed back to the scope $scope.model.includeUnpublished = vm.includeUnpublished; } + function toggleForceRepublish() { + vm.forceRepublish = !vm.forceRepublish; + $scope.model.forceRepublish = vm.forceRepublish; + } + /** Returns true if publishing is possible based on if there are un-published mandatory languages */ function canPublish() { var selected = []; diff --git a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html index cbd78fb2d525..a4d337ac714b 100644 --- a/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html +++ b/src/Umbraco.Web.UI.Client/src/views/content/overlays/publishdescendants.html @@ -17,6 +17,18 @@
+
+ + +
+
@@ -36,6 +48,18 @@
+
+ + +
+
diff --git a/tests/Umbraco.TestData/UmbracoTestDataController.cs b/tests/Umbraco.TestData/UmbracoTestDataController.cs index 2e4476403015..56d196f17907 100644 --- a/tests/Umbraco.TestData/UmbracoTestDataController.cs +++ b/tests/Umbraco.TestData/UmbracoTestDataController.cs @@ -86,7 +86,7 @@ public IActionResult CreateTree(int count, int depth, string locale = "en") var imageIds = CreateMediaTree(company, faker, count, depth).ToList(); var contentIds = CreateContentTree(company, faker, count, depth, imageIds, out var root).ToList(); - Services.ContentService.SaveAndPublishBranch(root, true); + Services.ContentService.SaveAndPublishBranch(root, PublishBranchFilter.IncludeUnpublished); scope.Complete(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index a624d6d88579..55e98daa3ae4 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1303,7 +1303,7 @@ public void Can_Publish_Content_Children() // publish parent & its branch // only those that are not already published // only invariant/neutral values - var parentPublished = ContentService.SaveAndPublishBranch(parent, true); + var parentPublished = ContentService.SaveAndPublishBranch(parent, PublishBranchFilter.IncludeUnpublished); foreach (var result in parentPublished) { @@ -1522,7 +1522,7 @@ public void Cannot_Publish_Content_Where_Parent_Is_Unpublished() ContentService.Save(content); // Act - var published = ContentService.SaveAndPublishBranch(content, true); + var published = ContentService.SaveAndPublishBranch(content, PublishBranchFilter.IncludeUnpublished); // Assert Assert.That(published.All(x => x.Success), Is.False); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs index fda265c70577..b0a241a9f5a8 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEventsTests.cs @@ -706,7 +706,7 @@ public void PublishContentBranchWithPublishedChildren() // branch is: ResetEvents(); - ContentService.SaveAndPublishBranch(content1, force: false); // force = false, don't publish unpublished items + ContentService.SaveAndPublishBranch(content1, PublishBranchFilter.Default); // PublishBranchFilter.Default: don't publish unpublished items foreach (EventInstance e in _events) { @@ -743,7 +743,7 @@ public void PublishContentBranchWithAllChildren() ContentService.Unpublish(content1); ResetEvents(); - ContentService.SaveAndPublishBranch(content1, force: true); // force = true, also publish unpublished items + ContentService.SaveAndPublishBranch(content1, PublishBranchFilter.IncludeUnpublished); // PublishBranchFilter.IncludeUnpublished: also publish unpublished items foreach (EventInstance e in _events) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs index 48cc197be660..e9e673d501d3 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServicePublishBranchTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; @@ -16,8 +14,7 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; [TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, - WithApplication = true)] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, PublishedRepositoryEvents = true, WithApplication = true)] public class ContentServicePublishBranchTests : UmbracoIntegrationTest { private IContentService ContentService => GetRequiredService(); @@ -47,9 +44,9 @@ public void Can_Publish_Invariant_Branch(int method) // ii1 !published !edited // ii2 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: root (root is always published) - var r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + var r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); // not forcing, ii1 and ii2 not published yet: only root got published AssertPublishResults(r, x => x.Content.Name, "iroot"); @@ -83,9 +80,9 @@ public void Can_Publish_Invariant_Branch(int method) // ii21 (published) !edited // ii22 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: nothing - r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); // not forcing, ii12 and ii2, ii21, ii22 not published yet: only root, ii1, ii11 got published AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "ii11"); @@ -110,11 +107,11 @@ public void Can_Publish_Invariant_Branch(int method) // ii21 (published) !edited // ii22 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: iroot and ii11 // not forcing, ii12 and ii2, ii21, ii22 not published yet: only root, ii1, ii11 got published - r = SaveAndPublishInvariantBranch(iRoot, false, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.Default, method).ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "ii11"); AssertPublishResults( r, @@ -123,9 +120,9 @@ public void Can_Publish_Invariant_Branch(int method) PublishResultType.SuccessPublishAlready, PublishResultType.SuccessPublish); - // force = publishes everything that has changes + // PublishBranchFilter.IncludeUnpublished = publishes everything that has changes // here: ii12, ii2, ii22 - ii21 was published already but masked - r = SaveAndPublishInvariantBranch(iRoot, true, method).ToArray(); + r = SaveAndPublishInvariantBranch(iRoot, PublishBranchFilter.IncludeUnpublished, method).ToArray(); AssertPublishResults( r, x => x.Content.Name, @@ -182,7 +179,7 @@ public void Can_Publish_Variant_Branch_When_No_Changes_On_Root_All_Cultures() iv1.SetValue("vp", "UPDATED-iv1.de", "de"); ContentService.Save(iv1); - var r = ContentService.SaveAndPublishBranch(vRoot, false) + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default) .ToArray(); // no culture specified so "*" is used, so all cultures Assert.AreEqual(PublishResultType.SuccessPublishAlready, r[0].Result); Assert.AreEqual(PublishResultType.SuccessPublishCulture, r[1].Result); @@ -219,7 +216,7 @@ public void Can_Publish_Variant_Branch_When_No_Changes_On_Root_Specific_Culture( iv1.SetValue("vp", "UPDATED-iv1.de", "de"); var saveResult = ContentService.Save(iv1); - var r = ContentService.SaveAndPublishBranch(vRoot, false, "de").ToArray(); + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default, "de").ToArray(); Assert.AreEqual(PublishResultType.SuccessPublishAlready, r[0].Result); Assert.AreEqual(PublishResultType.SuccessPublishCulture, r[1].Result); } @@ -263,9 +260,9 @@ public void Can_Publish_Variant_Branch() // iv1 !published !edited // iv2 !published !edited - // !force = publishes those that are actually published, and have changes + // PublishBranchFilter.None = publishes those that are actually published, and have changes // here: nothing - var r = ContentService.SaveAndPublishBranch(vRoot, false).ToArray(); // no culture specified = all cultures + var r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default).ToArray(); // no culture specified = all cultures // not forcing, iv1 and iv2 not published yet: only root got published AssertPublishResults(r, x => x.Content.Name, "vroot.de"); @@ -298,7 +295,7 @@ public void Can_Publish_Variant_Branch() Assert.IsTrue(iv1.IsCulturePublished("ru")); Assert.IsFalse(iv1.IsCulturePublished("es")); - r = ContentService.SaveAndPublishBranch(vRoot, false, "de").ToArray(); + r = ContentService.SaveAndPublishBranch(vRoot, PublishBranchFilter.Default, "de").ToArray(); // not forcing, iv2 not published yet: only root and iv1 got published AssertPublishResults(r, x => x.Content.Name, "vroot.de", "iv1.de"); @@ -375,7 +372,7 @@ public void Can_Publish_Mixed_Branch_1() { Can_Publish_Mixed_Branch(out var iRoot, out var ii1, out var iv11); - var r = ContentService.SaveAndPublishBranch(iRoot, false, "de").ToArray(); + var r = ContentService.SaveAndPublishBranch(iRoot, PublishBranchFilter.Default, "de").ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "iv11.de"); AssertPublishResults( r, @@ -401,7 +398,7 @@ public void Can_Publish_MixedBranch_2() { Can_Publish_Mixed_Branch(out var iRoot, out var ii1, out var iv11); - var r = ContentService.SaveAndPublishBranch(iRoot, false, new[] { "de", "ru" }).ToArray(); + var r = ContentService.SaveAndPublishBranch(iRoot, PublishBranchFilter.Default, ["de", "ru"]).ToArray(); AssertPublishResults(r, x => x.Content.Name, "iroot", "ii1", "iv11.de"); AssertPublishResults( r, @@ -422,6 +419,156 @@ public void Can_Publish_MixedBranch_2() Assert.AreEqual("changed.ru", iv11.GetValue("vp", "ru", published: true)); } + [TestCase(PublishBranchFilter.Default)] + [TestCase(PublishBranchFilter.IncludeUnpublished)] + [TestCase(PublishBranchFilter.ForceRepublish)] + [TestCase(PublishBranchFilter.All)] + public void Can_Publish_Invariant_Branch_With_Force_Options(PublishBranchFilter publishBranchFilter) + { + CreateTypes(out var iContentType, out _); + + // Create content (published root, published child, unpublished child, changed child). + IContent iRoot = new Content("iroot", -1, iContentType); + iRoot.SetValue("ip", "iroot"); + ContentService.SaveAndPublish(iRoot); + + IContent ii1 = new Content("ii1", iRoot, iContentType); + ii1.SetValue("ip", "vii1"); + ContentService.SaveAndPublish(ii1); + + IContent ii2 = new Content("ii2", iRoot, iContentType); + ii2.SetValue("ip", "vii2"); + ContentService.Save(ii2); + + IContent ii3 = new Content("ii3", iRoot, iContentType); + ii3.SetValue("ip", "vii3"); + ContentService.SaveAndPublish(ii3); + ii3.SetValue("ip", "vii3a"); + ContentService.Save(ii3); + + var result = ContentService.SaveAndPublishBranch(iRoot, publishBranchFilter).ToArray(); + + var expectedContentNames = GetExpectedContentNamesForForceOptions(publishBranchFilter); + var expectedPublishResultTypes = GetExpectedPublishResultTypesForForceOptions(publishBranchFilter); + AssertPublishResults(result, x => x.Content.Name, expectedContentNames); + AssertPublishResults( + result, + x => x.Result, + expectedPublishResultTypes); + } + + [TestCase("*", PublishBranchFilter.Default)] + [TestCase("*", PublishBranchFilter.IncludeUnpublished)] + [TestCase("*", PublishBranchFilter.ForceRepublish)] + [TestCase("*", PublishBranchFilter.All)] + [TestCase("de", PublishBranchFilter.Default)] + [TestCase("de", PublishBranchFilter.IncludeUnpublished)] + [TestCase("de", PublishBranchFilter.ForceRepublish)] + [TestCase("de", PublishBranchFilter.All)] + public void Can_Publish_Variant_Branch_With_Force_Options(string culture, PublishBranchFilter publishBranchFilter) + { + CreateTypes(out _, out var vContentType); + + // Create content (published root, published child, unpublished child, changed child). + IContent vRoot = new Content("vroot", -1, vContentType); + vRoot.SetCultureName("vroot.de", "de"); + vRoot.SetCultureName("vroot.ru", "ru"); + vRoot.SetValue("ip", "vroot"); + vRoot.SetValue("vp", "vroot.de", "de"); + vRoot.SetValue("vp", "vroot.ru", "ru"); + ContentService.SaveAndPublish(vRoot); + + IContent iv1 = new Content("iv1", vRoot, vContentType, "de"); + iv1.SetCultureName("iv1.de", "de"); + iv1.SetCultureName("iv1.ru", "ru"); + iv1.SetValue("ip", "iv1"); + iv1.SetValue("vp", "iv1.de", "de"); + iv1.SetValue("vp", "iv1.ru", "ru"); + ContentService.SaveAndPublish(iv1); + + IContent iv2 = new Content("iv2", vRoot, vContentType, "de"); + iv2.SetCultureName("iv2.de", "de"); + iv2.SetCultureName("iv2.ru", "ru"); + iv2.SetValue("ip", "iv2"); + iv2.SetValue("vp", "iv2.de", "de"); + iv2.SetValue("vp", "iv2.ru", "ru"); + ContentService.Save(iv2); + + // When testing with a specific culture, publish the other one, so we can test that + // the specified unpublished culture is handled correctly. + if (culture != "*") + { + ContentService.SaveAndPublish(iv2, "ru"); + } + + IContent iv3 = new Content("iv3", vRoot, vContentType, "de"); + iv3.SetCultureName("iv3.de", "de"); + iv3.SetCultureName("iv3.ru", "ru"); + iv3.SetValue("ip", "iv3"); + iv3.SetValue("vp", "iv3.de", "de"); + iv3.SetValue("vp", "iv3.ru", "ru"); + ContentService.SaveAndPublish(iv3); + iv3.SetValue("ip", "iv3a"); + iv3.SetValue("vp", "iv3a.de", "de"); + iv3.SetValue("vp", "iv3a.ru", "ru"); + ContentService.Save(iv3); + + var result = ContentService.SaveAndPublishBranch(vRoot, publishBranchFilter, culture).ToArray(); + + var expectedContentNames = GetExpectedContentNamesForForceOptions(publishBranchFilter, true); + var expectedPublishResultTypes = GetExpectedPublishResultTypesForForceOptions(publishBranchFilter, true); + AssertPublishResults(result, x => x.Content.Name, expectedContentNames); + AssertPublishResults( + result, + x => x.Result, + expectedPublishResultTypes); + } + + private static string[] GetExpectedContentNamesForForceOptions(PublishBranchFilter publishBranchFilter, bool isVariant = false) + { + var rootName = isVariant ? "vroot.de" : "iroot"; + var childPrefix = isVariant ? "iv" : "ii"; + var childSuffix = isVariant ? ".de" : string.Empty; + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished)) + { + return [rootName, $"{childPrefix}1{childSuffix}", $"{childPrefix}2{childSuffix}", $"{childPrefix}3{childSuffix}"]; + } + + return [rootName, $"{childPrefix}1{childSuffix}", $"{childPrefix}3{childSuffix}"]; + } + + private static PublishResultType[] GetExpectedPublishResultTypesForForceOptions(PublishBranchFilter publishBranchFilter, bool isVariant = false) + { + var successPublish = isVariant ? PublishResultType.SuccessPublishCulture : PublishResultType.SuccessPublish; + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished) && publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) + { + return [successPublish, + successPublish, + successPublish, + successPublish]; + } + + if (publishBranchFilter.HasFlag(PublishBranchFilter.IncludeUnpublished)) + { + return [PublishResultType.SuccessPublishAlready, + PublishResultType.SuccessPublishAlready, + successPublish, + successPublish]; + } + + if (publishBranchFilter.HasFlag(PublishBranchFilter.ForceRepublish)) + { + return [successPublish, + successPublish, + + successPublish]; + } + + return [PublishResultType.SuccessPublishAlready, + PublishResultType.SuccessPublishAlready, + successPublish]; + } + private void AssertPublishResults(PublishResult[] values, Func getter, params T[] expected) { if (expected.Length != values.Length) @@ -479,16 +626,16 @@ private void CreateTypes(out IContentType iContentType, out IContentType vConten ContentTypeService.Save(vContentType); } - private IEnumerable SaveAndPublishInvariantBranch(IContent content, bool force, int method) + private IEnumerable SaveAndPublishInvariantBranch(IContent content, PublishBranchFilter publishBranchFilter, int method) { // ReSharper disable RedundantArgumentDefaultValue // ReSharper disable ArgumentsStyleOther switch (method) { case 1: - return ContentService.SaveAndPublishBranch(content, force, "*"); + return ContentService.SaveAndPublishBranch(content, publishBranchFilter, "*"); case 2: - return ContentService.SaveAndPublishBranch(content, force, cultures: new[] { "*" }); + return ContentService.SaveAndPublishBranch(content, publishBranchFilter, cultures: new[] { "*" }); default: throw new ArgumentOutOfRangeException(nameof(method)); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs index 15005f2e080c..cdc3ebf4bf18 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentServiceTagsTests.cs @@ -669,7 +669,7 @@ public void Create_Tag_Data_Bulk_Publish_Operation() ContentService.Save(child2); // Act - ContentService.SaveAndPublishBranch(content, true); + ContentService.SaveAndPublishBranch(content, PublishBranchFilter.IncludeUnpublished); // Assert var propertyTypeId = contentType.PropertyTypes.Single(x => x.Alias == "tags").Id; From 8c2b1ebdc513526a026e0c8a1eec9f920e78aa88 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:30:41 +0100 Subject: [PATCH 54/74] V13: Introduce publishNotifications method on IMembershipMemberService (#18207) * Introduce publishNotifications method on IMembershipMemberService.cs * Fix test * Add PublishNotificationSaveOptions * Fix up according to comments * Use numeric values for flag enum * Update src/Umbraco.Core/Services/MemberService.cs Co-authored-by: Andy Butland * Update src/Umbraco.Core/Services/MemberService.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../Models/PublishNotificationSaveOptions.cs | 28 +++++++++++++++++++ .../Services/IMembershipMemberService.cs | 8 ++++++ src/Umbraco.Core/Services/MemberService.cs | 24 ++++++++++++---- .../Security/MemberUserStore.cs | 2 +- .../Security/MemberUserStoreTests.cs | 4 +-- 5 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs diff --git a/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs b/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs new file mode 100644 index 000000000000..f49e7f30d685 --- /dev/null +++ b/src/Umbraco.Core/Models/PublishNotificationSaveOptions.cs @@ -0,0 +1,28 @@ +namespace Umbraco.Cms.Core.Models; + +/// +/// Specifies options for publishing notifcations when saving. +/// +[Flags] +public enum PublishNotificationSaveOptions +{ + /// + /// Do not publish any notifications. + /// + None = 0, + + /// + /// Only publish the saving notification. + /// + Saving = 1, + + /// + /// Only publish the saved notification. + /// + Saved = 2, + + /// + /// Publish all the notifications. + /// + All = Saving | Saved, +} diff --git a/src/Umbraco.Core/Services/IMembershipMemberService.cs b/src/Umbraco.Core/Services/IMembershipMemberService.cs index 99e64a368616..ee77aedcf703 100644 --- a/src/Umbraco.Core/Services/IMembershipMemberService.cs +++ b/src/Umbraco.Core/Services/IMembershipMemberService.cs @@ -135,6 +135,14 @@ public interface IMembershipMemberService : IService /// or to Save void Save(T entity); + /// + /// Saves an + /// + /// An can be of type or + /// or to Save + /// Enum for deciding which notifications to publish. + void Save(T entity, PublishNotificationSaveOptions publishNotificationSaveOptions) => Save(entity); + /// /// Saves a list of objects /// diff --git a/src/Umbraco.Core/Services/MemberService.cs b/src/Umbraco.Core/Services/MemberService.cs index 493ab313a73a..b405519616bc 100644 --- a/src/Umbraco.Core/Services/MemberService.cs +++ b/src/Umbraco.Core/Services/MemberService.cs @@ -743,7 +743,9 @@ public bool Exists(string username) public void SetLastLogin(string username, DateTime date) => throw new NotImplementedException(); /// - public void Save(IMember member) + public void Save(IMember member) => Save(member, PublishNotificationSaveOptions.All); + + public void Save(IMember member, PublishNotificationSaveOptions publishNotificationSaveOptions) { // trimming username and email to make sure we have no trailing space member.Username = member.Username.Trim(); @@ -752,11 +754,15 @@ public void Save(IMember member) EventMessages evtMsgs = EventMessagesFactory.Get(); using ICoreScope scope = ScopeProvider.CreateCoreScope(); - var savingNotification = new MemberSavingNotification(member, evtMsgs); - if (scope.Notifications.PublishCancelable(savingNotification)) + MemberSavingNotification? savingNotification = null; + if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saving)) { - scope.Complete(); - return; + savingNotification = new MemberSavingNotification(member, evtMsgs); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return; + } } if (string.IsNullOrWhiteSpace(member.Name)) @@ -768,7 +774,13 @@ public void Save(IMember member) _memberRepository.Save(member); - scope.Notifications.Publish(new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + if (publishNotificationSaveOptions.HasFlag(PublishNotificationSaveOptions.Saved)) + { + scope.Notifications.Publish( + savingNotification is null + ? new MemberSavedNotification(member, evtMsgs) + : new MemberSavedNotification(member, evtMsgs).WithStateFrom(savingNotification)); + } Audit(AuditType.Save, 0, member.Id); diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index 4e4b43f50959..a9c7dc5f2a3a 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -110,7 +110,7 @@ public override Task CreateAsync( UpdateMemberProperties(memberEntity, user, out bool _); // create the member - _memberService.Save(memberEntity); + _memberService.Save(memberEntity, PublishNotificationSaveOptions.Saving); // We need to add roles now that the member has an Id. It do not work implicit in UpdateMemberProperties _memberService.AssignRoles( diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs index a9a4d7a91c71..c31a6a258edf 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Security/MemberUserStoreTests.cs @@ -122,7 +122,7 @@ public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShoul _mockMemberService .Setup(x => x.CreateMember(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(mockMember); - _mockMemberService.Setup(x => x.Save(mockMember)); + _mockMemberService.Setup(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving)); // act var identityResult = await sut.CreateAsync(fakeUser, CancellationToken.None); @@ -132,7 +132,7 @@ public async Task GivenICreateANewUser_AndTheUserIsPopulatedCorrectly_ThenIShoul Assert.IsTrue(!identityResult.Errors.Any()); _mockMemberService.Verify(x => x.CreateMember(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())); - _mockMemberService.Verify(x => x.Save(mockMember)); + _mockMemberService.Verify(x => x.Save(mockMember, PublishNotificationSaveOptions.Saving)); } [Test] From def7ebd48c1999de26def8707fab1aad859ef30c Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:25:12 +0100 Subject: [PATCH 55/74] Html encodes the user's name in the invite email. (#18343) --- src/Umbraco.Web.BackOffice/Controllers/UsersController.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs index 960afa365e9a..f5486ff85926 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/UsersController.cs @@ -664,10 +664,11 @@ private async Task SendUserInviteEmailAsync(UserBasic? userDisplay, string? from var emailSubject = _localizedTextService.Localize("user", "inviteEmailCopySubject", // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings)); + var name = userDisplay is null ? string.Empty : System.Web.HttpUtility.HtmlEncode(userDisplay.Name); var emailBody = _localizedTextService.Localize("user", "inviteEmailCopyFormat", // Ensure the culture of the found user is used for the email! UmbracoUserExtensions.GetUserCulture(to?.Language, _localizedTextService, _globalSettings), - new[] { userDisplay?.Name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("
"), inviteUri.ToString(), senderEmail }); + new[] { name, from, WebUtility.HtmlEncode(message)!.ReplaceLineEndings("
"), inviteUri.ToString(), senderEmail }); // This needs to be in the correct mailto format including the name, else // the name cannot be captured in the email sending notification. From 18047a7cfbae21ad618e9c9933601e54dc3768e7 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:45:51 +0100 Subject: [PATCH 56/74] Only filter post retrieval of entities for start nodes if working with entities that support start nodes. (#18287) --- src/Umbraco.Web.BackOffice/Controllers/EntityController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs index c54344a239df..f4ef16e041ff 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/EntityController.cs @@ -895,6 +895,7 @@ public ActionResult> GetPagedChildren( // Filtering out child nodes after getting a paged result is an active choice here, even though the pagination might get off. // This has been the case with this functionality in Umbraco for a long time. .Where(entity => ignoreUserStartNodes || + (objectType == UmbracoObjectTypes.Document || objectType == UmbracoObjectTypes.Media) is false || (ContentPermissions.IsInBranchOfStartNode(entity.Path, startNodeIds, startNodePaths, out var hasPathAccess) && hasPathAccess)) .Select(source => From db1d9997212387d4e278f56b778e0b6ad85ade92 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 17 Feb 2025 12:47:52 +0100 Subject: [PATCH 57/74] Avoid exception when attempting to find member by Id when Id is not an expected Guid or integer, as can be the case with external member providers. (#18320) --- .../Security/MemberUserStore.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs index a9c7dc5f2a3a..82f456bef0bd 100644 --- a/src/Umbraco.Infrastructure/Security/MemberUserStore.cs +++ b/src/Umbraco.Infrastructure/Security/MemberUserStore.cs @@ -321,9 +321,20 @@ public override Task DeleteAsync( throw new ArgumentNullException(nameof(userId)); } - IMember? user = Guid.TryParse(userId, out Guid key) - ? _memberService.GetByKey(key) - : _memberService.GetById(UserIdToInt(userId)); + // With external member providers we can get a ID here that's not a GUID or integer. + // We can't retrieve the member, but if that's the case we shouldn't throw an exception, + // just return null in the same way as when the member isn't found. + // See: https://github.com/umbraco/Umbraco-CMS/issues/14713 + IMember? user = null; + if (Guid.TryParse(userId, out Guid key)) + { + user = _memberService.GetByKey(key); + } + else if (TryUserIdToInt(userId, out int id)) + { + user = _memberService.GetById(id); + } + if (user == null) { return Task.FromResult((MemberIdentityUser)null!)!; From 8bbb12da5a542320dfd770cf320bb1645d14b43e Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Mon, 17 Feb 2025 13:47:02 +0100 Subject: [PATCH 58/74] generate server models + fix client --- .../src/external/backend-api/src/types.gen.ts | 5 +++++ .../src/mocks/data/document/document.db.ts | 2 ++ .../src/mocks/data/tracked-reference.data.ts | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts index be5966855a0b..24fb93da0d8f 100644 --- a/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts +++ b/src/Umbraco.Web.UI.Client/src/external/backend-api/src/types.gen.ts @@ -565,6 +565,7 @@ export type DataTypeTreeItemResponseModel = { }; export type DefaultReferenceResponseModel = { + $type: string; id: string; name?: (string) | null; type?: (string) | null; @@ -640,6 +641,8 @@ export type DocumentCollectionResponseModel = { creator?: (string) | null; sortOrder: number; documentType: (DocumentTypeCollectionReferenceResponseModel); + isTrashed: boolean; + isProtected: boolean; updater?: (string) | null; }; @@ -686,6 +689,7 @@ export type DocumentRecycleBinItemResponseModel = { }; export type DocumentReferenceResponseModel = { + $type: string; id: string; name?: (string) | null; published?: (boolean) | null; @@ -1213,6 +1217,7 @@ export type MediaRecycleBinItemResponseModel = { }; export type MediaReferenceResponseModel = { + $type: string; id: string; name?: (string) | null; mediaType: (TrackedReferenceMediaTypeModel); diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts index a03fd6e6f342..214ed14921f4 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.db.ts @@ -146,6 +146,8 @@ const collectionMapper = (model: UmbMockDocumentModel): DocumentCollectionRespon icon: model.documentType.icon, }, id: model.id, + isProtected: model.isProtected, + isTrashed: model.isTrashed, sortOrder: 0, updater: null, values: model.values, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts index 868f48c3e720..bbd02fb59bc2 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/tracked-reference.data.ts @@ -8,6 +8,7 @@ export const items: Array< DefaultReferenceResponseModel | DocumentReferenceResponseModel | MediaReferenceResponseModel > = [ { + $type: 'DocumentReferenceResponseModel', id: 'simple-document-id', name: 'Simple Document', published: true, @@ -18,6 +19,7 @@ export const items: Array< }, } satisfies DocumentReferenceResponseModel, { + $type: 'DocumentReferenceResponseModel', id: '1234', name: 'Image Block', published: true, @@ -28,6 +30,7 @@ export const items: Array< }, } satisfies DocumentReferenceResponseModel, { + $type: 'MediaReferenceResponseModel', id: 'media-id', name: 'Simple Media', mediaType: { @@ -37,6 +40,7 @@ export const items: Array< }, } satisfies MediaReferenceResponseModel, { + $type: 'DefaultReferenceResponseModel', id: 'default-id', name: 'Some other reference', type: 'Default', From d87a84be14cc7a1759b5c06ed69827a9ee787f95 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 17 Feb 2025 15:27:22 +0100 Subject: [PATCH 59/74] Loosen the RTEValue datacontract to improve migrations (#18349) --- .../Models/RichTextEditorValue.cs | 2 +- .../RichTextPropertyEditorHelperTests.cs | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs b/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs index 9904ea33bb73..cf64747ec2ff 100644 --- a/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs +++ b/src/Umbraco.Infrastructure/Models/RichTextEditorValue.cs @@ -10,5 +10,5 @@ public class RichTextEditorValue public required string Markup { get; set; } [DataMember(Name = "blocks")] - public required RichTextBlockValue? Blocks { get; set; } + public RichTextBlockValue? Blocks { get; set; } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs index 8d2d59337f16..ab68db550f43 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RichTextPropertyEditorHelperTests.cs @@ -94,6 +94,22 @@ public void Can_Parse_JObject() }); } + [Test] + public void Can_Parse_JObject_With_Missing_Blocks() + { + var input = JsonNode.Parse("""" + { + "markup": "

Vælg et af vores mest populære produkter

" + } + """"); + + var result = RichTextPropertyEditorHelper.TryParseRichTextEditorValue(input, JsonSerializer(), Logger(), out RichTextEditorValue? value); + Assert.IsTrue(result); + Assert.IsNotNull(value); + Assert.AreEqual("

Vælg et af vores mest populære produkter

", value.Markup); + Assert.IsNull(value.Blocks); + } + [Test] public void Can_Parse_Blocks_With_Both_Content_And_Settings() { From e753e2cbf0cd5bd147273d694c4770df68ce03e0 Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Mon, 17 Feb 2025 16:01:41 +0000 Subject: [PATCH 60/74] Markdown Editor: adds "Default value" support (#18326) --- .../property-editors/markdown-editor/manifests.ts | 2 +- .../property-editor-ui-markdown-editor.element.ts | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts index e0ea049d904a..11be0a8d8764 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/manifests.ts @@ -24,7 +24,7 @@ export const manifests: Array = [ alias: 'defaultValue', label: 'Default value', description: 'If value is blank, the editor will show this', - propertyEditorUiAlias: 'Umb.PropertyEditorUi.TextArea', + propertyEditorUiAlias: 'Umb.PropertyEditorUi.MarkdownEditor', }, { alias: 'overlaySize', diff --git a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/property-editor-ui-markdown-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/property-editor-ui-markdown-editor.element.ts index 3ef2eb563c4c..a49d71053bc2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/property-editor-ui-markdown-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/markdown-editor/property-editors/markdown-editor/property-editor-ui-markdown-editor.element.ts @@ -39,6 +39,12 @@ export class UmbPropertyEditorUIMarkdownEditorElement extends UmbLitElement impl this._preview = config.getValueByAlias('preview'); this._overlaySize = config.getValueByAlias('overlaySize') ?? 'small'; + + // TODO: To be removed once the "Property Value Presets" feature has been implemented. + const defaultValue = config.getValueByAlias('defaultValue'); + if (defaultValue && this.value === undefined) { + this.value = defaultValue; + } } #onChange(event: Event & { target: UmbInputMarkdownElement }) { From ed63f278c21f52b21da7554b9fe4ebef16a22995 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Mon, 17 Feb 2025 17:31:34 +0100 Subject: [PATCH 61/74] V15: Save the variant before scheduling (#18344) * feat: adds validation checks and saves a version when scheduling content * chore: adds mock handler for validate * docs: adds documentation for umbracoPath * chore: adds deprecation and todos * feat: adds a method to output a list format * test: adds test for list format * feat: rename to list * feat: adds localization for scheduling * feat: adds notifications for publishing by action * test: fixes naming * feat: adds notification for publishing in bulk * feat: fixes todo by adding localization * feat: adds notification when publishing from workspace --------- Co-authored-by: leekelleher --- .../src/assets/lang/en-us.ts | 10 +-- .../src/assets/lang/en.ts | 18 ++--- .../localization.controller.test.ts | 10 +++ .../mocks/data/data-type/data-type.data.ts | 12 +++- .../handlers/document/detail.handlers.ts | 7 ++ .../core/utils/path/umbraco-path.function.ts | 10 +-- .../publish/entity-action/publish.action.ts | 31 +++++++- .../entity-bulk-action/publish.bulk-action.ts | 42 +++++++++-- .../document-publishing.repository.ts | 22 +++--- .../document-publishing.workspace-context.ts | 71 +++++++++++++++---- 10 files changed, 187 insertions(+), 46 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts index 8b876086c390..22cda2c9b219 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en-us.ts @@ -1412,14 +1412,14 @@ export default { cssSavedText: 'Stylesheet saved without any errors', dataTypeSaved: 'Datatype saved', dictionaryItemSaved: 'Dictionary item saved', - editContentPublishedHeader: 'Content published', + editContentPublishedHeader: 'Document published', editContentPublishedText: 'and is visible on the website', - editMultiContentPublishedText: '%0% documents published and visible on the website', - editVariantPublishedText: '%0% published and visible on the website', - editMultiVariantPublishedText: '%0% documents published for languages %1% and visible on the website', + editMultiContentPublishedText: '%0% documents published and are visible on the website', + editVariantPublishedText: '%0% published and is visible on the website', + editMultiVariantPublishedText: '%0% documents published for languages %1% and are visible on the website', editBlueprintSavedHeader: 'Document Blueprint saved', editBlueprintSavedText: 'Changes have been successfully saved', - editContentSavedHeader: 'Content saved', + editContentSavedHeader: 'Document saved', editContentSavedText: 'Remember to publish to make changes visible', editContentScheduledSavedText: 'A schedule for publishing has been updated', editVariantSavedText: '%0% saved', 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 40c1be0e3f3e..3dd24d3ce59f 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -1412,7 +1412,7 @@ export default { folderUploadNotAllowed: 'This file is being uploaded as part of a folder, but creating a new folder is not allowed here', folderCreationNotAllowed: 'Creating a new folder is not allowed here', - contentPublishedFailedByEvent: 'Content could not be published, a 3rd party add-in cancelled the action', + contentPublishedFailedByEvent: 'Document could not be published, a 3rd party add-in cancelled the action', contentTypeDublicatePropertyType: 'Property type already exists', contentTypePropertyTypeCreated: 'Property type created', contentTypePropertyTypeCreatedText: 'Name: %0%
DataType: %1%', @@ -1426,12 +1426,13 @@ export default { cssSavedText: 'Stylesheet saved without any errors', dataTypeSaved: 'Datatype saved', dictionaryItemSaved: 'Dictionary item saved', - editContentPublishedFailedByParent: 'Content could not be published, because a parent page is not published', - editContentPublishedHeader: 'Content published', - editContentPublishedText: 'and visible on the website', + editContentPublishedFailedByValidation: 'Document could not be published, but we saved it for you', + editContentPublishedFailedByParent: 'Document could not be published, because a parent page is not published', + editContentPublishedHeader: 'Document published', + editContentPublishedText: 'and is visible on the website', editBlueprintSavedHeader: 'Document Blueprint saved', editBlueprintSavedText: 'Changes have been successfully saved', - editContentSavedHeader: 'Content saved', + editContentSavedHeader: 'Document saved', editContentSavedText: 'Remember to publish to make changes visible', editContentSendToPublish: 'Sent For Approval', editContentSendToPublishText: 'Changes have been sent for approval', @@ -1493,10 +1494,11 @@ export default { cannotCopyInformation: 'Could not copy your system information to the clipboard', webhookSaved: 'Webhook saved', operationSavedHeaderReloadUser: 'Saved. To view the changes please reload your browser', - editMultiContentPublishedText: '%0% documents published and visible on the website', - editVariantPublishedText: '%0% published and visible on the website', - editMultiVariantPublishedText: '%0% documents published for languages %1% and visible on the website', + editMultiContentPublishedText: '%0% documents published and are visible on the website', + editVariantPublishedText: '%0% published and is visible on the website', + editMultiVariantPublishedText: '%0% documents published for languages %1% and are visible on the website', editContentScheduledSavedText: 'A schedule for publishing has been updated', + editContentScheduledNotSavedText: 'The schedule for publishing could not be updated', editVariantSavedText: '%0% saved', editVariantSendToPublishText: '%0% changes have been sent for approval', contentCultureUnpublished: 'Content variation %0% unpublished', diff --git a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts index 24c2a0d331a4..bb1376e6a848 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/localization-api/localization.controller.test.ts @@ -282,6 +282,16 @@ describe('UmbLocalizeController', () => { }); }); + describe('list format', () => { + it('should return a list with conjunction', () => { + expect(controller.list(['one', 'two', 'three'], { type: 'conjunction' })).to.equal('one, two, and three'); + }); + + it('should return a list with disjunction', () => { + expect(controller.list(['one', 'two', 'three'], { type: 'disjunction' })).to.equal('one, two, or three'); + }); + }); + describe('duration', () => { it('should return a duration', () => { const now = new Date('2020-01-01T00:00:00'); diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts index 0e8b63935a5e..28ca8df5ea61 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts @@ -941,8 +941,16 @@ export const data: Array = [ { alias: 'layouts', value: [ - { icon: 'icon-grid', isSystem: true, name: 'Grid', path: '', selected: true }, - { icon: 'icon-list', isSystem: true, name: 'Table', path: '', selected: true }, + { + icon: 'icon-grid', + name: 'Document Grid Collection View', + collectionView: 'Umb.CollectionView.Document.Grid', + }, + { + icon: 'icon-list', + name: 'Document Table Collection View', + collectionView: 'Umb.CollectionView.Document.Table', + }, ], }, { alias: 'icon', value: 'icon-layers' }, diff --git a/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts b/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts index df508ed15e88..f420b115fe03 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/handlers/document/detail.handlers.ts @@ -41,6 +41,13 @@ export const detailHandlers = [ return res(ctx.status(200), ctx.json(PagedTrackedReference)); }), + rest.put(umbracoPath(`${UMB_SLUG}/:id/validate`, 'v1.1'), (_req, res, ctx) => { + const id = _req.params.id as string; + if (!id) return res(ctx.status(400)); + + return res(ctx.status(200)); + }), + rest.get(umbracoPath(`${UMB_SLUG}/:id`), (req, res, ctx) => { const id = req.params.id as string; if (!id) return res(ctx.status(400)); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/umbraco-path.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/umbraco-path.function.ts index 754e07d3a947..9466adb7c34b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/umbraco-path.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/path/umbraco-path.function.ts @@ -1,8 +1,10 @@ // TODO: Rename to something more obvious, naming wise this can mean anything. I suggest: umbracoManagementApiPath() /** - * - * @param path + * Generates a path to an Umbraco API endpoint. + * @param {string} path - The path to the Umbraco API endpoint. + * @param {string} version - The version of the Umbraco API (default is 'v1'). + * @returns {string} The path to the Umbraco API endpoint. */ -export function umbracoPath(path: string) { - return `/umbraco/management/api/v1${path}`; +export function umbracoPath(path: string, version = 'v1') { + return `/umbraco/management/api/${version}${path}`; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts index e74be197226a..098993fc23e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-action/publish.action.ts @@ -10,6 +10,8 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UMB_CURRENT_USER_CONTEXT } from '@umbraco-cms/backoffice/current-user'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { constructor(host: UmbControllerHost, args: UmbEntityActionArgs) { @@ -19,6 +21,9 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { override async execute() { if (!this.args.unique) throw new Error('The document unique identifier is missing'); + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + const languageRepository = new UmbLanguageCollectionRepository(this._host); const { data: languageData } = await languageRepository.requestCollection({}); @@ -65,7 +70,15 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { if (options.length === 1) { const variantId = UmbVariantId.Create(documentData.variants[0]); const publishingRepository = new UmbDocumentPublishingRepository(this._host); - await publishingRepository.publish(this.args.unique, [{ variantId }]); + const { error } = await publishingRepository.publish(this.args.unique, [{ variantId }]); + if (!error) { + notificationContext.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term('speechBubbles_editContentPublishedText'), + }, + }); + } actionEventContext.dispatchEvent(event); return; } @@ -103,10 +116,24 @@ export class UmbPublishDocumentEntityAction extends UmbEntityActionBase { if (variantIds.length) { const publishingRepository = new UmbDocumentPublishingRepository(this._host); - await publishingRepository.publish( + const { error } = await publishingRepository.publish( this.args.unique, variantIds.map((variantId) => ({ variantId })), ); + + if (!error) { + const documentVariants = documentData.variants.filter((variant) => result.selection.includes(variant.culture!)); + notificationContext.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term( + 'speechBubbles_editVariantPublishedText', + localize.list(documentVariants.map((v) => v.culture ?? v.name)), + ), + }, + }); + } + actionEventContext.dispatchEvent(event); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts index 7c2d18292601..014946c962af 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/entity-bulk-action/publish.bulk-action.ts @@ -11,6 +11,7 @@ import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization- import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbRequestReloadChildrenOfEntityEvent } from '@umbraco-cms/backoffice/entity-action'; +import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase { async execute() { @@ -18,6 +19,9 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase< const entityType = entityContext.getEntityType(); const unique = entityContext.getUnique(); + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + const localize = new UmbLocalizationController(this); + if (!entityType) throw new Error('Entity type not found'); if (unique === undefined) throw new Error('Entity unique not found'); @@ -46,7 +50,7 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase< updateDate: null, segment: null, scheduledPublishDate: null, - scheduledUnpublishDate: null + scheduledUnpublishDate: null, }, unique: new UmbVariantId(language.unique, null).toString(), culture: language.unique, @@ -79,11 +83,24 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase< if (confirm !== false) { const variantId = new UmbVariantId(options[0].language.unique, null); const publishingRepository = new UmbDocumentPublishingRepository(this._host); + let documentCnt = 0; + for (let i = 0; i < this.selection.length; i++) { const id = this.selection[i]; - await publishingRepository.publish(id, [{ variantId }]); + const { error } = await publishingRepository.publish(id, [{ variantId }]); + + if (!error) { + documentCnt++; + } } + notificationContext.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term('speechBubbles_editMultiContentPublishedText', documentCnt), + }, + }); + eventContext.dispatchEvent(event); } return; @@ -116,13 +133,30 @@ export class UmbDocumentPublishEntityBulkAction extends UmbEntityBulkActionBase< const repository = new UmbDocumentPublishingRepository(this._host); if (variantIds.length) { + let documentCnt = 0; for (const unique of this.selection) { - await repository.publish( + const { error } = await repository.publish( unique, variantIds.map((variantId) => ({ variantId })), ); - eventContext.dispatchEvent(event); + + if (!error) { + documentCnt++; + } } + + notificationContext.peek('positive', { + data: { + headline: localize.term('speechBubbles_editContentPublishedHeader'), + message: localize.term( + 'speechBubbles_editMultiVariantPublishedText', + documentCnt, + localize.list(variantIds.map((v) => v.culture ?? '')), + ), + }, + }); + + eventContext.dispatchEvent(event); } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts index dff0946ba2a6..2ed98a2e05ce 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/repository/document-publishing.repository.ts @@ -8,6 +8,10 @@ import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; export class UmbDocumentPublishingRepository extends UmbRepositoryBase { #init!: Promise; #publishingDataSource: UmbDocumentPublishingServerDataSource; + + /** + * @deprecated The calling workspace context should be used instead to show notifications + */ #notificationContext?: UmbNotificationContext; constructor(host: UmbControllerHost) { @@ -36,14 +40,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase { if (!variants.length) throw new Error('variant IDs are missing'); await this.#init; - const { error } = await this.#publishingDataSource.publish(unique, variants); - - if (!error) { - const notification = { data: { message: `Document published` } }; - this.#notificationContext?.peek('positive', notification); - } - - return { error }; + return this.#publishingDataSource.publish(unique, variants); } /** @@ -62,6 +59,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase { if (!error) { const notification = { data: { message: `Document unpublished` } }; + // TODO: Move this to the calling workspace context [JOV] this.#notificationContext?.peek('positive', notification); } @@ -76,7 +74,12 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase { * @param forceRepublish * @memberof UmbDocumentPublishingRepository */ - async publishWithDescendants(id: string, variantIds: Array, includeUnpublishedDescendants: boolean, forceRepublish: boolean) { + async publishWithDescendants( + id: string, + variantIds: Array, + includeUnpublishedDescendants: boolean, + forceRepublish: boolean, + ) { if (!id) throw new Error('id is missing'); if (!variantIds) throw new Error('variant IDs are missing'); await this.#init; @@ -90,6 +93,7 @@ export class UmbDocumentPublishingRepository extends UmbRepositoryBase { if (!error) { const notification = { data: { message: `Document published with descendants` } }; + // TODO: Move this to the calling workspace context [JOV] this.#notificationContext?.peek('positive', notification); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts index 86d6b069b122..2d2c9940bfb7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts @@ -25,6 +25,7 @@ import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { /** @@ -39,6 +40,8 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { + this.#notificationContext = context; + }); } public async publish() { @@ -118,17 +125,47 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase x.variantId); + const saveData = await this.#documentWorkspaceContext.constructSaveData(variantIds); + await this.#documentWorkspaceContext.runMandatoryValidationForSaveData(saveData); + await this.#documentWorkspaceContext.askServerToValidate(saveData, variantIds); - // request reload of this entity - const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique }); - this.#eventContext?.dispatchEvent(structureEvent); - } + // TODO: Only validate the specified selection.. [NL] + return this.#documentWorkspaceContext.validateAndSubmit( + async () => { + if (!this.#documentWorkspaceContext) { + throw new Error('Document workspace context is missing'); + } + + // Save the document before scheduling + await this.#documentWorkspaceContext.performCreateOrUpdate(variantIds, saveData); + + // Schedule the document + const { error } = await this.#publishingRepository.publish(unique, variants); + if (error) { + return Promise.reject(error); + } + + const notification = { data: { message: this.#localize.term('speechBubbles_editContentScheduledSavedText') } }; + this.#notificationContext?.peek('positive', notification); + + // reload the document so all states are updated after the publish operation + await this.#documentWorkspaceContext.reload(); + this.#loadAndProcessLastPublished(); + + // request reload of this entity + const structureEvent = new UmbRequestReloadStructureForEntityEvent({ entityType, unique }); + this.#eventContext?.dispatchEvent(structureEvent); + }, + async () => { + const notificationContext = await this.getContext(UMB_NOTIFICATION_CONTEXT); + notificationContext.peek('danger', { + data: { message: this.#localize.term('speechBubbles_editContentScheduledNotSavedText') }, + }); + + return Promise.reject(); + }, + ); } /** @@ -280,9 +317,8 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase variantIds.some((id) => id.culture === v.culture)); + this.#notificationContext?.peek('positive', { + data: { + headline: this.#localize.term('speechBubbles_editContentPublishedHeader'), + message: this.#localize.term( + 'speechBubbles_editVariantPublishedText', + this.#localize.list(variants.map((v) => v.culture ?? v.name)), + ), + }, + }); + // reload the document so all states are updated after the publish operation await this.#documentWorkspaceContext.reload(); this.#loadAndProcessLastPublished(); From 82d13b50b69d3b5d682fd5028baa8d58ab478a6a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 18 Feb 2025 10:04:00 +0100 Subject: [PATCH 62/74] Limit referenced-by document and media endpoints to references only. (#18354) --- .../Document/References/ReferencedByDocumentController.cs | 4 ++-- .../Media/References/ReferencedByMediaController.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs index a818e70af89f..47df66fd6df6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/References/ReferencedByDocumentController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; @@ -37,7 +37,7 @@ public async Task>> Referen int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, false); + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); var pagedViewModel = new PagedViewModel { diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs index b457ee602c1e..63748741b1f7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/References/ReferencedByMediaController.cs @@ -1,4 +1,4 @@ -using Asp.Versioning; +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; @@ -37,7 +37,7 @@ public async Task>> Referen int skip = 0, int take = 20) { - PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, false); + PagedModel relationItems = await _trackedReferencesService.GetPagedRelationsForItemAsync(id, skip, take, true); var pagedViewModel = new PagedViewModel { From 50b891a79a45c4d139ee9465671486488f1efdf1 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 17 Feb 2025 15:47:27 +0000 Subject: [PATCH 63/74] Updates Tiptap mock data and story. --- .../mocks/data/data-type/data-type.data.ts | 44 +++++++++++++++++++ .../src/mocks/data/document/document.data.ts | 18 ++++++++ .../property-editor-ui-tiptap.stories.ts | 9 ++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts index 28ca8df5ea61..e318d6afd023 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/data-type/data-type.data.ts @@ -1023,9 +1023,53 @@ export const data: Array = [ isDeletable: true, canIgnoreStartNodes: false, values: [ + { + alias: 'extensions', + value: [ + 'Umb.Tiptap.RichTextEssentials', + 'Umb.Tiptap.Embed', + 'Umb.Tiptap.Figure', + 'Umb.Tiptap.Image', + 'Umb.Tiptap.Link', + 'Umb.Tiptap.MediaUpload', + 'Umb.Tiptap.Subscript', + 'Umb.Tiptap.Superscript', + 'Umb.Tiptap.Table', + 'Umb.Tiptap.TextAlign', + 'Umb.Tiptap.Underline', + ], + }, + { + alias: 'toolbar', + value: [ + [ + [ + 'Umb.Tiptap.Toolbar.SourceEditor', + 'Umb.Tiptap.Toolbar.Undo', + 'Umb.Tiptap.Toolbar.Redo', + 'Umb.Tiptap.Toolbar.StyleSelect', + 'Umb.Tiptap.Toolbar.Bold', + 'Umb.Tiptap.Toolbar.Italic', + 'Umb.Tiptap.Toolbar.TextAlignLeft', + 'Umb.Tiptap.Toolbar.TextAlignCenter', + 'Umb.Tiptap.Toolbar.TextAlignRight', + 'Umb.Tiptap.Toolbar.BulletList', + 'Umb.Tiptap.Toolbar.OrderedList', + 'Umb.Tiptap.Toolbar.Blockquote', + 'Umb.Tiptap.Toolbar.Link', + 'Umb.Tiptap.Toolbar.Unlink', + 'Umb.Tiptap.Toolbar.HorizontalRule', + 'Umb.Tiptap.Toolbar.Table', + 'Umb.Tiptap.Toolbar.MediaPicker', + 'Umb.Tiptap.Toolbar.EmbeddedMedia', + ], + ], + ], + }, { alias: 'dimensions', value: { height: 500 } }, { alias: 'maxImageSize', value: 500 }, { alias: 'ignoreUserStartNodes', value: false }, + { alias: 'overlaySize', value: 'medium' }, ], }, { diff --git a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts index cabad2395c5a..2d378c26fe2b 100644 --- a/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts +++ b/src/Umbraco.Web.UI.Client/src/mocks/data/document/document.data.ts @@ -914,6 +914,24 @@ export const data: Array = [

Some value for the RTE with an external link and an internal link foo foo

+ + + + + + + + + + + + + + + + + +
NameAlias
Leelke
Jacobjov

Installer illustration

diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.stories.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.stories.ts index 268abd764923..feb8a9c7c85c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.stories.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/tiptap/property-editor-ui-tiptap.stories.ts @@ -20,7 +20,7 @@ const config = new UmbPropertyEditorConfigCollection([ ['Umb.Tiptap.Toolbar.Bold', 'Umb.Tiptap.Toolbar.Italic', 'Umb.Tiptap.Toolbar.Underline'], ['Umb.Tiptap.Toolbar.TextAlignLeft', 'Umb.Tiptap.Toolbar.TextAlignCenter', 'Umb.Tiptap.Toolbar.TextAlignRight'], ['Umb.Tiptap.Toolbar.BulletList', 'Umb.Tiptap.Toolbar.OrderedList'], - ['Umb.Tiptap.Toolbar.Blockquote', 'Umb.Tiptap.Toolbar.HorizontalRule'], + ['Umb.Tiptap.Toolbar.Blockquote', 'Umb.Tiptap.Toolbar.HorizontalRule', 'Umb.Tiptap.Table'], ['Umb.Tiptap.Toolbar.Link', 'Umb.Tiptap.Toolbar.Unlink'], ['Umb.Tiptap.Toolbar.MediaPicker', 'Umb.Tiptap.Toolbar.EmbeddedMedia'], ], @@ -29,16 +29,17 @@ const config = new UmbPropertyEditorConfigCollection([ { alias: 'extensions', value: [ + 'Umb.Tiptap.RichTextEssentials', 'Umb.Tiptap.Embed', - 'Umb.Tiptap.Link', 'Umb.Tiptap.Figure', 'Umb.Tiptap.Image', + 'Umb.Tiptap.Link', + 'Umb.Tiptap.MediaUpload', 'Umb.Tiptap.Subscript', 'Umb.Tiptap.Superscript', 'Umb.Tiptap.Table', - 'Umb.Tiptap.Underline', 'Umb.Tiptap.TextAlign', - 'Umb.Tiptap.MediaUpload', + 'Umb.Tiptap.Underline', ], }, ]); From 9e0614ff74a8ac88e01e07b09bcf5e7f1c7ff24e Mon Sep 17 00:00:00 2001 From: leekelleher Date: Mon, 17 Feb 2025 15:51:00 +0000 Subject: [PATCH 64/74] Tiptap: relocated the Block extension --- .../block/block.tipap-api.ts} | 0 .../block/block.tiptap-toolbar-api.ts} | 0 .../tiptap/{plugins => extensions/block}/manifests.ts | 8 ++++---- .../src/packages/tiptap/extensions/manifests.ts | 3 ++- .../src/packages/tiptap/manifests.ts | 7 +------ 5 files changed, 7 insertions(+), 11 deletions(-) rename src/Umbraco.Web.UI.Client/src/packages/tiptap/{plugins/block.extension.ts => extensions/block/block.tipap-api.ts} (100%) rename src/Umbraco.Web.UI.Client/src/packages/tiptap/{plugins/block-picker-toolbar.extension.ts => extensions/block/block.tiptap-toolbar-api.ts} (100%) rename src/Umbraco.Web.UI.Client/src/packages/tiptap/{plugins => extensions/block}/manifests.ts (74%) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/block.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tipap-api.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/block.extension.ts rename to src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tipap-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/block-picker-toolbar.extension.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-toolbar-api.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/block-picker-toolbar.extension.ts rename to src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/block.tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/manifests.ts similarity index 74% rename from src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/manifests.ts rename to src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/manifests.ts index f2613a665dfe..06cac85ba45c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/plugins/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/block/manifests.ts @@ -1,12 +1,12 @@ -import type { ManifestTiptapExtension } from '../extensions/tiptap.extension.js'; -import type { ManifestTiptapToolbarExtensionButtonKind } from '../extensions/tiptap-toolbar.extension.js'; +import type { ManifestTiptapExtension } from '../tiptap.extension.js'; +import type { ManifestTiptapToolbarExtensionButtonKind } from '../tiptap-toolbar.extension.js'; export const manifests: Array = [ { type: 'tiptapExtension', alias: 'Umb.Tiptap.Block', name: 'Block Tiptap Extension', - api: () => import('./block.extension.js'), + api: () => import('./block.tipap-api.js'), meta: { icon: 'icon-plugin', label: 'Block', @@ -18,7 +18,7 @@ export const manifests: Array import('./block-picker-toolbar.extension.js'), + api: () => import('./block.tiptap-toolbar-api.js'), forExtensions: ['Umb.Tiptap.Block'], meta: { alias: 'umbblockpicker', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index 9e2e9fd7cab1..786b3fdb5402 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -1,3 +1,4 @@ +import { manifests as blockExtensions } from './block/manifests.js'; import type { ManifestTiptapExtension } from './tiptap.extension.js'; import type { ManifestTiptapToolbarExtension } from './tiptap-toolbar.extension.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; @@ -486,6 +487,6 @@ const toolbarExtensions: Array = [ }, ]; -const extensions = [...coreExtensions, ...toolbarExtensions]; +const extensions = [...coreExtensions, ...toolbarExtensions, ...blockExtensions]; export const manifests = [...kinds, ...extensions]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/manifests.ts index ee59b4ef2d9c..9325da5e4fe2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/manifests.ts @@ -1,10 +1,5 @@ import { manifests as extensions } from './extensions/manifests.js'; import { manifests as propertyEditors } from './property-editors/manifests.js'; -import { manifests as plugins } from './plugins/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; -export const manifests: Array = [ - ...extensions, - ...propertyEditors, - ...plugins, -]; +export const manifests: Array = [...extensions, ...propertyEditors]; From 5c2e4da1cefef3f442cc4844c5713ff160d93050 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Feb 2025 09:31:21 +0000 Subject: [PATCH 65/74] Bump dompurify from 3.2.3 to 3.2.4 in /src/Umbraco.Web.UI.Client Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.3 to 3.2.4. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.2.3...3.2.4) --- updated-dependencies: - dependency-name: dompurify dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 8 ++++---- src/Umbraco.Web.UI.Client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 2620634ac14c..8d77bb0ca093 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -31,7 +31,7 @@ "@umbraco-ui/uui-css": "^1.12.1", "base64-js": "^1.5.1", "diff": "^7.0.0", - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "element-internals-polyfill": "^1.3.12", "lit": "^3.2.1", "marked": "^15.0.6", @@ -7667,9 +7667,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index ed297de4d012..d1b6b4947e7e 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -219,7 +219,7 @@ "@umbraco-ui/uui-css": "^1.12.1", "base64-js": "^1.5.1", "diff": "^7.0.0", - "dompurify": "^3.2.3", + "dompurify": "^3.2.4", "element-internals-polyfill": "^1.3.12", "lit": "^3.2.1", "marked": "^15.0.6", From 04eb94d958223fd8d66ac5be591cff5e1bf46be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Tue, 18 Feb 2025 12:20:33 +0100 Subject: [PATCH 66/74] use display contents instead of setting a height (#18363) --- .../src/packages/core/modal/component/modal.element.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts index 450d762928b2..9313a8de3195 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts @@ -103,8 +103,7 @@ export class UmbModalElement extends UmbLitElement { } else { this.#modalRouterElement = document.createElement('div'); // Notice inline styling here is used cause the element is not appended into this elements shadowDom but outside and there by gets into the element via a slot. - this.#modalRouterElement.style.position = 'relative'; - this.#modalRouterElement.style.height = '100%'; + this.#modalRouterElement.style.display = 'contents'; new UmbContextBoundary(this.#modalRouterElement, UMB_ROUTE_CONTEXT).hostConnected(); } From 96bbed5abf94511b5dfc0ffb05c7b3982904e33b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 18 Feb 2025 13:31:40 +0100 Subject: [PATCH 67/74] On retrieving other URLs, don't attempt to walk up the navigation structure to find parents when content is trashed. (#18355) --- src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs | 2 -- src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs index bbc0f4011b88..7f66f219332b 100644 --- a/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs +++ b/src/Umbraco.Core/Routing/NewDefaultUrlProvider.cs @@ -89,8 +89,6 @@ public virtual IEnumerable GetOtherUrls(int id, Uri current) yield break; } - - // look for domains, walking up the tree IPublishedContent? n = node; IEnumerable? domainUris = diff --git a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs index d4059bcab806..c861d9ba010d 100644 --- a/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs +++ b/src/Umbraco.Core/Routing/PublishedUrlInfoProvider.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Services; @@ -68,6 +68,12 @@ public async Task> GetAllAsync(IContent content) urlInfos.Add(UrlInfo.Url(url, culture)); } + // If the content is trashed, we can't get the other URLs, as we have no parent structure to navigate through. + if (content.Trashed) + { + return urlInfos; + } + // Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains. // for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them. foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Text).ThenBy(x => x.Culture)) From b3b85b8a6a2a02b4589c9c6ec39b60edc8711d3f Mon Sep 17 00:00:00 2001 From: leekelleher Date: Tue, 18 Feb 2025 09:07:08 +0000 Subject: [PATCH 68/74] Adds Tiptap Cascading Menu Popover Example with a Style Select menu, with default (hard coded) options, similar to TinyMCE's unconfigured Style Select menu. --- .../cascading-menu-popover.element.ts | 140 ++++++++++++++++++ .../src/packages/tiptap/components/index.ts | 2 +- .../input-tiptap/tiptap-toolbar.element.ts | 5 +- .../default-tiptap-toolbar-element.api.ts | 5 + .../toolbar/tiptap-toolbar-button.element.ts | 2 +- .../tiptap-toolbar-dropdown-base.element.ts | 120 --------------- .../packages/tiptap/extensions/manifests.ts | 3 +- .../extensions/style-select/manifests.ts | 15 ++ .../style-select-tiptap-toolbar.element.ts | 93 ++++++++++++ .../style-select.tiptap-toolbar-api.ts | 24 --- 10 files changed, 261 insertions(+), 148 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-dropdown-base.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select-tiptap-toolbar.element.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/style-select.tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts new file mode 100644 index 000000000000..3f953367b65f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts @@ -0,0 +1,140 @@ +import { css, customElement, html, property, repeat, when } from '@umbraco-cms/backoffice/external/lit'; +import { UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; + +export type UmbCascadingMenuItem = { + unique: string; + label: string; + icon?: string; + items?: Array; + element?: HTMLElement; + separatorAfter?: boolean; + execute?: () => void; + isActive?: () => boolean; +}; + +@customElement('umb-cascading-menu-popover') +export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { + @property({ type: Array }) + items?: Array; + + #getPopoverById(popoverId: string): UUIPopoverContainerElement | null | undefined { + return this.shadowRoot?.querySelector(`#${popoverId}`) as UUIPopoverContainerElement; + } + + #onMouseEnter(popoverId: string) { + const popover = this.#getPopoverById(popoverId); + if (!popover) return; + + // TODO: This ignorer is just neede for JSON SCHEMA TO WORK, As its not updated with latest TS jet. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + popover.showPopover(); + } + + #onMouseLeave(popoverId: string) { + const popover = this.#getPopoverById(popoverId); + if (!popover) return; + + // TODO: This ignorer is just neede for JSON SCHEMA TO WORK, As its not updated with latest TS jet. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + popover.hidePopover(); + } + + #onClick(item: UmbCascadingMenuItem) { + item.execute?.(); + + setTimeout(() => { + this.#onMouseLeave(item.unique); + }, 100); + } + + override render() { + return html` + + ${when( + this.items?.length, + () => html` + ${repeat( + this.items!, + (item) => item.unique, + (item) => this.#renderItem(item), + )} + ${super.render()} + `, + () => super.render(), + )} + + `; + } + + #renderItem(item: UmbCascadingMenuItem) { + const element = item.element; + if (element) { + element.setAttribute('popovertarget', item.unique); + } + return html` +
this.#onMouseEnter(item.unique)} @mouseleave=${() => this.#onMouseLeave(item.unique)}> + ${when( + element, + () => element, + () => html` + this.#onClick(item)} + class=${item.separatorAfter ? 'separator' : ''}> + ${when(item.icon, (icon) => html``)} + + + `, + )} + + +
+ `; + } + + static override readonly styles = [ + ...UUIPopoverContainerElement.styles, + css` + :host { + --uui-menu-item-flat-structure: 1; + + background: var(--uui-color-surface); + border-radius: var(--uui-border-radius); + box-shadow: var(--uui-shadow-depth-3); + padding: var(--uui-size-space-1); + } + + .menu-item { + flex: 1; + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--uui-size-space-4); + } + + .separator::after { + content: ''; + position: absolute; + border-bottom: 1px solid var(--uui-color-border); + width: 100%; + } + + uui-scroll-container { + max-height: 500px; + } + `, + ]; +} + +export default UmbCascadingMenuPopoverElement; + +declare global { + interface HTMLElementTagNameMap { + 'umb-cascading-menu-popover': UmbCascadingMenuPopoverElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/index.ts index f03903fb0702..edb0192afd0f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/index.ts @@ -1,2 +1,2 @@ export * from './input-tiptap/index.js'; -export * from './toolbar/tiptap-toolbar-dropdown-base.element.js'; +export * from './cascading-menu-popover/cascading-menu-popover.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts index 21b0cecd022b..dab7f27fd233 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-toolbar.element.ts @@ -6,7 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; import type { UmbPropertyEditorConfigCollection } from '@umbraco-cms/backoffice/property-editor'; -import '../toolbar/tiptap-toolbar-dropdown-base.element.js'; +import '../cascading-menu-popover/cascading-menu-popover.element.js'; @customElement('umb-tiptap-toolbar') export class UmbTiptapToolbarElement extends UmbLitElement { @@ -53,6 +53,9 @@ export class UmbTiptapToolbarElement extends UmbLitElement { (extensionControllers) => { this._lookup = new Map(extensionControllers.map((ext) => [ext.alias, ext.component])); }, + undefined, + undefined, + () => import('../toolbar/default-tiptap-toolbar-element.api.js'), ); this.#extensionsController.apiProperties = { configuration: this.configuration }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts new file mode 100644 index 000000000000..9d845ccb92b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/default-tiptap-toolbar-element.api.ts @@ -0,0 +1,5 @@ +import { UmbTiptapToolbarElementApiBase } from '../../extensions/base.js'; + +export default class UmbTiptapToolbarDefaultExtensionApi extends UmbTiptapToolbarElementApiBase { + public override execute() {} +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-button.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-button.element.ts index 971a1828c44a..7a567400b6fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-button.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-button.element.ts @@ -48,7 +48,7 @@ export class UmbTiptapToolbarButtonElement extends UmbLitElement { @click=${() => (this.api && this.editor ? this.api.execute(this.editor) : null)}> ${when( this.manifest?.meta.icon, - () => html``, + (icon) => html``, () => html`${this.manifest?.meta.label}`, )} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-dropdown-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-dropdown-base.element.ts deleted file mode 100644 index a5308a853969..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar-dropdown-base.element.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { css, html, nothing, repeat, type TemplateResult } from '@umbraco-cms/backoffice/external/lit'; -import type { PopoverContainerPlacement, UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; - -export type TiptapDropdownItem = { - alias: string; - label: string; - nested?: TiptapDropdownItem[]; - execute?: () => void; - isActive?: () => boolean; -}; - -export abstract class UmbTiptapToolbarDropdownBaseElement extends UmbLitElement { - protected abstract get items(): TiptapDropdownItem[]; - protected abstract get label(): string; - - readonly #onMouseEnter = (popoverId: string) => { - const popover = this.shadowRoot?.querySelector(`#${this.makeAlias(popoverId)}`) as UUIPopoverContainerElement; - if (!popover) return; - popover.showPopover(); - }; - - readonly #onMouseLeave = (popoverId: string) => { - popoverId = popoverId.replace(/\s/g, '-').toLowerCase(); - const popover = this.shadowRoot?.querySelector(`#${this.makeAlias(popoverId)}`) as UUIPopoverContainerElement; - if (!popover) return; - popover.hidePopover(); - }; - - protected makeAlias(label: string) { - return label.replace(/\s/g, '-').toLowerCase(); - } - - protected renderItem(item: TiptapDropdownItem): TemplateResult { - return html` - - `; - } - - protected renderItems( - label: string, - items: Array, - placement: PopoverContainerPlacement = 'right-start', - ): TemplateResult { - return html` -
- ${repeat( - items, - (item) => item.alias, - (item) => html`${this.renderItem(item)}`, - )} -
-
`; - } - protected override render() { - return html` - - ${this.renderItems(this.label, this.items, 'bottom-start')} - `; - } - - static override readonly styles = [ - UmbTextStyles, - css` - button { - border: unset; - background-color: unset; - font: unset; - text-align: unset; - } - - uui-symbol-expand { - position: absolute; - right: 5px; - top: 5px; - } - - .label { - border-radius: var(--uui-border-radius); - width: 100%; - box-sizing: border-box; - align-content: center; - padding: var(--uui-size-space-1) var(--uui-size-space-3); - padding-right: 21px; - align-items: center; - cursor: pointer; - color: var(--uui-color-text); - position: relative; - } - - .label:hover { - background: var(--uui-color-surface-alt); - color: var(--uui-color-interactive-emphasis); - } - - .selected-value { - background: var(--uui-color-surface-alt); - } - - .popover-content { - background: var(--uui-color-surface); - border-radius: var(--uui-border-radius); - box-shadow: var(--uui-shadow-depth-3); - padding: var(--uui-size-space-1); - } - `, - ]; -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts index 786b3fdb5402..861581070591 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/manifests.ts @@ -1,4 +1,5 @@ import { manifests as blockExtensions } from './block/manifests.js'; +import { manifests as styleSelectExtensions } from './style-select/manifests.js'; import type { ManifestTiptapExtension } from './tiptap.extension.js'; import type { ManifestTiptapToolbarExtension } from './tiptap-toolbar.extension.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; @@ -487,6 +488,6 @@ const toolbarExtensions: Array = [ }, ]; -const extensions = [...coreExtensions, ...toolbarExtensions, ...blockExtensions]; +const extensions = [...coreExtensions, ...toolbarExtensions, ...blockExtensions, ...styleSelectExtensions]; export const manifests = [...kinds, ...extensions]; 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 new file mode 100644 index 000000000000..8e2bbb6fa543 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts @@ -0,0 +1,15 @@ +import type { ManifestTiptapToolbarExtension } from '../types.js'; + +export const manifests: Array = [ + { + type: 'tiptapToolbarExtension', + alias: 'Umb.Tiptap.Toolbar.StyleSelect', + name: 'Style Select Tiptap Extension', + element: () => import('./style-select-tiptap-toolbar.element.js'), + meta: { + alias: 'umbStyleSelect', + icon: 'icon-palette', + label: 'Style Select', + }, + }, +]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select-tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select-tiptap-toolbar.element.ts new file mode 100644 index 000000000000..32d5479d67e5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/style-select-tiptap-toolbar.element.ts @@ -0,0 +1,93 @@ +import type { ManifestTiptapToolbarExtension } from '../tiptap-toolbar.extension.js'; +import type { UmbCascadingMenuItem } from '../../components/cascading-menu-popover/cascading-menu-popover.element.js'; +import { css, customElement, html, ifDefined } from '@umbraco-cms/backoffice/external/lit'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +import '../../components/cascading-menu-popover/cascading-menu-popover.element.js'; + +@customElement('umb-tiptap-style-select-toolbar-element') +export class UmbTiptapToolbarStyleSelectToolbarElement extends UmbLitElement { + #menu: Array = [ + { + unique: 'headers', + label: 'Headers', + items: [ + { + unique: 'h2', + label: 'Page heading', + execute: () => this.editor?.chain().focus().toggleHeading({ level: 2 }).run(), + }, + { + unique: 'h3', + label: 'Section heading', + execute: () => this.editor?.chain().focus().toggleHeading({ level: 3 }).run(), + }, + { + unique: 'h4', + label: 'Paragraph heading', + execute: () => this.editor?.chain().focus().toggleHeading({ level: 4 }).run(), + }, + ], + }, + { + unique: 'blocks', + label: 'Blocks', + items: [ + { + unique: 'p', + label: 'Paragraph', + execute: () => this.editor?.chain().focus().setParagraph().run(), + }, + ], + }, + { + unique: 'containers', + label: 'Containers', + items: [ + { unique: 'blockquote', label: 'Quote', execute: () => this.editor?.chain().focus().toggleBlockquote().run() }, + { unique: 'code', label: 'Code', execute: () => this.editor?.chain().focus().toggleCodeBlock().run() }, + ], + }, + ]; + + public editor?: Editor; + + public manifest?: ManifestTiptapToolbarExtension; + + override render() { + return html` + + Style select + + + + + `; + } + + static override readonly styles = [ + css` + :host { + --uui-button-font-weight: normal; + } + + uui-button > uui-symbol-expand { + margin-left: var(--uui-size-space-4); + } + `, + ]; +} + +export { UmbTiptapToolbarStyleSelectToolbarElement as element }; + +declare global { + interface HTMLElementTagNameMap { + 'umb-tiptap-style-select-toolbar-element': UmbTiptapToolbarStyleSelectToolbarElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/style-select.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/style-select.tiptap-toolbar-api.ts deleted file mode 100644 index 96868aa349c5..000000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/style-select.tiptap-toolbar-api.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { UmbTiptapToolbarDropdownBaseElement, type TiptapDropdownItem } from '../../components/index.js'; -import { customElement, state } from '@umbraco-cms/backoffice/external/lit'; - -const elementName = 'umb-tiptap-style-select-toolbar-element'; - -@customElement(elementName) -export class UmbTiptapToolbarStyleSelectToolbarElement extends UmbTiptapToolbarDropdownBaseElement { - protected override label = 'Style select'; - - @state() - protected override get items(): TiptapDropdownItem[] { - throw new Error('Method not implemented.'); - } - - static override readonly styles = UmbTiptapToolbarDropdownBaseElement.styles; -} - -export { UmbTiptapToolbarStyleSelectToolbarElement as element }; - -declare global { - interface HTMLElementTagNameMap { - [elementName]: UmbTiptapToolbarStyleSelectToolbarElement; - } -} From 7ac57790ffdc34ed6ddcfa3067d574f35989eeb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Feb 2025 10:13:24 +0000 Subject: [PATCH 69/74] Bump dompurify from 3.2.3 to 3.2.4 in /src/Umbraco.Web.UI.Login Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.2.3 to 3.2.4. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.2.3...3.2.4) --- updated-dependencies: - dependency-name: dompurify dependency-type: indirect ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Login/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index 47375eae8a15..4c31fe7d14d4 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -2690,9 +2690,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.3.tgz", - "integrity": "sha512-U1U5Hzc2MO0oW3DF+G9qYN0aT7atAou4AgI0XjWz061nyBPbdxkfdhfy5uMgGn6+oLFCfn44ZGbdDqCzVmlOWA==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz", + "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==", "dev": true, "license": "(MPL-2.0 OR Apache-2.0)", "peer": true, From c216d692c27250f9d3e03ed2f8111a145e679aa8 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 18 Feb 2025 19:52:05 +0100 Subject: [PATCH 70/74] Feature: Display current variant item name (#18311) * wip entity-item-ref extension point * clean up * add ref list element * fix styling * Update document-item-ref.element.ts * move item repo * implement for input member * enable action slot * add null check * fix sorting again * fix sorting again * use member element * add draft styling back * move item repository * implement for user input * pass readonly and standalone props * make editPath a state * Update member-item-ref.element.ts * Fix user item ref * remove open button * remove unused * remove unused * check for section permission * add fallback element * add unique to modal route registration * add unique to modal router * remove unused id * Update member-item-ref.element.ts * append unique * compare with old value * only recreate the controller if the entity type changes * fix console warning * implement for document item ref * move logic to item data resolver * render draft as a tag * Update document-item-ref.element.ts * add more helpers to data resolver * export resolver * add observables * use observables in document item ref * add data resolver to tree item * add observable state * use const * align models * get icon from document type object * observe name and state * update observed value when a new item is set * update method name * update method names * pass model type * pass context type * use api prop instead of context * use api prop instead of context * fix types * use addUniquePaths for modal registration * fix type errors * Update index.ts * clean up * use path pattern --- .../tree-item-base/tree-item-element-base.ts | 81 +++---- .../document-collection.server.data-source.ts | 19 +- .../documents/documents/collection/types.ts | 48 ++++- .../document-table-column-name.element.ts | 36 ++-- .../document-table-column-state.element.ts | 27 ++- .../document-table-collection-view.element.ts | 5 +- .../item/document-item-data-resolver.ts | 203 ++++++++++++++++++ .../item/document-item-ref.element.ts | 83 +++---- .../documents/documents/item/index.ts | 1 + .../documents/item/repository/types.ts | 2 +- .../tree-item/document-tree-item.context.ts | 11 + .../tree-item/document-tree-item.element.ts | 96 +++------ .../utils/all-umb-consts/index.ts | 12 +- 13 files changed, 442 insertions(+), 182 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-data-resolver.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts index b6f13bf61d6b..8cee44081d73 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts @@ -1,18 +1,46 @@ import type { UmbTreeItemContext } from '../index.js'; import type { UmbTreeItemModel } from '../../types.js'; -import { UMB_TREE_ITEM_CONTEXT } from './tree-item-context-base.js'; import { html, nothing, state, ifDefined, repeat, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -export abstract class UmbTreeItemElementBase extends UmbLitElement { - _item?: TreeItemModelType; +export abstract class UmbTreeItemElementBase< + TreeItemModelType extends UmbTreeItemModel, + TreeItemContextType extends UmbTreeItemContext = UmbTreeItemContext, +> extends UmbLitElement { + protected _item?: TreeItemModelType; @property({ type: Object, attribute: false }) get item(): TreeItemModelType | undefined { return this._item; } set item(newVal: TreeItemModelType) { this._item = newVal; - this.#initTreeItem(); + + if (this._item) { + this.#initTreeItem(); + } + } + + #api: TreeItemContextType | undefined; + @property({ type: Object, attribute: false }) + public get api(): TreeItemContextType | undefined { + return this.#api; + } + public set api(value: TreeItemContextType | undefined) { + this.#api = value; + + if (this.#api) { + this.observe(this.#api.childItems, (value) => (this._childItems = value)); + this.observe(this.#api.hasChildren, (value) => (this._hasChildren = value)); + this.observe(this.#api.isActive, (value) => (this._isActive = value)); + this.observe(this.#api.isLoading, (value) => (this._isLoading = value)); + this.observe(this.#api.isSelectableContext, (value) => (this._isSelectableContext = value)); + this.observe(this.#api.isSelectable, (value) => (this._isSelectable = value)); + this.observe(this.#api.isSelected, (value) => (this._isSelected = value)); + this.observe(this.#api.path, (value) => (this._href = value)); + this.observe(this.#api.pagination.currentPage, (value) => (this._currentPage = value)); + this.observe(this.#api.pagination.totalPages, (value) => (this._totalPages = value)); + this.#initTreeItem(); + } } @property({ type: Boolean, attribute: false }) @@ -51,58 +79,31 @@ export abstract class UmbTreeItemElementBase; - - constructor() { - super(); - - // TODO: Notice this can be retrieve via a api property. [NL] - this.consumeContext(UMB_TREE_ITEM_CONTEXT, (instance) => { - this.#treeItemContext = instance; - if (!this.#treeItemContext) return; - - this.#initTreeItem(); - - // TODO: investigate if we can make an observe decorator - this.observe(this.#treeItemContext.treeItem, (value) => (this._item = value)); - this.observe(this.#treeItemContext.childItems, (value) => (this._childItems = value)); - this.observe(this.#treeItemContext.hasChildren, (value) => (this._hasChildren = value)); - this.observe(this.#treeItemContext.isActive, (value) => (this._isActive = value)); - this.observe(this.#treeItemContext.isLoading, (value) => (this._isLoading = value)); - this.observe(this.#treeItemContext.isSelectableContext, (value) => (this._isSelectableContext = value)); - this.observe(this.#treeItemContext.isSelectable, (value) => (this._isSelectable = value)); - this.observe(this.#treeItemContext.isSelected, (value) => (this._isSelected = value)); - this.observe(this.#treeItemContext.path, (value) => (this._href = value)); - this.observe(this.#treeItemContext.pagination.currentPage, (value) => (this._currentPage = value)); - this.observe(this.#treeItemContext.pagination.totalPages, (value) => (this._totalPages = value)); - }); - } - #initTreeItem() { - if (!this.#treeItemContext) return; + if (!this.#api) return; if (!this._item) return; - this.#treeItemContext.setTreeItem(this._item); + this.#api.setTreeItem(this._item); } private _handleSelectedItem(event: Event) { event.stopPropagation(); - this.#treeItemContext?.select(); + this.#api?.select(); } private _handleDeselectedItem(event: Event) { event.stopPropagation(); - this.#treeItemContext?.deselect(); + this.#api?.deselect(); } // TODO: do we want to catch and emit a backoffice event here? private _onShowChildren() { - this.#treeItemContext?.loadChildren(); + this.#api?.loadChildren(); } #onLoadMoreClick = (event: any) => { event.stopPropagation(); const next = (this._currentPage = this._currentPage + 1); - this.#treeItemContext?.pagination.setCurrentPageNumber(next); + this.#api?.pagination.setCurrentPageNumber(next); }; // Note: Currently we want to prevent opening when the item is in a selectable context, but this might change in the future. @@ -168,11 +169,11 @@ export abstract class UmbTreeItemElementBase ` : ''; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts index 99091d6d97a6..04977651d4a7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts @@ -1,4 +1,5 @@ import type { UmbDocumentCollectionFilterModel, UmbDocumentCollectionItemModel } from '../types.js'; +import { UMB_DOCUMENT_ENTITY_TYPE } from '../../entity.js'; import { DirectionModel, DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; import { tryExecuteAndNotify } from '@umbraco-cms/backoffice/resources'; import type { DocumentCollectionResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; @@ -32,16 +33,18 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS if (data) { const items = data.items.map((item: DocumentCollectionResponseModel) => { - // TODO: [LK] Temp solution, review how to get the name from the corresponding variant. + // TODO: remove in v17.0.0 const variant = item.variants[0]; const model: UmbDocumentCollectionItemModel = { unique: item.id, - entityType: 'document', + entityType: UMB_DOCUMENT_ENTITY_TYPE, contentTypeAlias: item.documentType.alias, createDate: new Date(variant.createDate), creator: item.creator, icon: item.documentType.icon, + isProtected: item.isProtected, + isTrashed: item.isTrashed, name: variant.name, sortOrder: item.sortOrder, state: variant.state, @@ -50,6 +53,18 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS values: item.values.map((item) => { return { alias: item.alias, value: item.value as string }; }), + documentType: { + unique: item.documentType.id, + icon: item.documentType.icon, + alias: item.documentType.alias, + }, + variants: item.variants.map((item) => { + return { + name: item.name, + culture: item.culture ?? null, + state: item.state, + }; + }), }; return model; }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts index c3a7b165170e..55107999fc7f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/types.ts @@ -1,3 +1,5 @@ +import type { UmbDocumentEntityType } from '../entity.js'; +import type { UmbDocumentItemVariantModel } from '../item/repository/types.js'; import type { UmbCollectionFilterModel } from '@umbraco-cms/backoffice/collection'; export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterModel { @@ -11,17 +13,49 @@ export interface UmbDocumentCollectionFilterModel extends UmbCollectionFilterMod export interface UmbDocumentCollectionItemModel { unique: string; - entityType: string; - contentTypeAlias: string; - createDate: Date; + entityType: UmbDocumentEntityType; creator?: string | null; - icon: string; - name: string; sortOrder: number; - state: string; - updateDate: Date; updater?: string | null; values: Array<{ alias: string; value: string }>; + isProtected: boolean; + isTrashed: boolean; + documentType: { + unique: string; + icon: string; + alias: string; + }; + variants: Array; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use state in variants array instead. + */ + state: string; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use name in variants array instead. + */ + name: string; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use updateDate in variants array instead. + */ + updateDate: Date; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use createDate in variants array instead. + */ + createDate: Date; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use alias on documentType instead. + */ + contentTypeAlias: string; + + /** + * @deprecated From 15.3.0. Will be removed in 17.0.0. Use icon on documentType instead. + */ + icon: string; } export interface UmbEditableDocumentCollectionItemModel { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts index cc084caa0190..660c27db90d8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-name.element.ts @@ -1,32 +1,40 @@ import type { UmbEditableDocumentCollectionItemModel } from '../../../types.js'; -import { css, customElement, html, nothing, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbDocumentItemDataResolver } from '../../../../item/index.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'; -import type { UUIButtonElement } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-document-table-column-name') export class UmbDocumentTableColumnNameElement extends UmbLitElement implements UmbTableColumnLayoutElement { column!: UmbTableColumn; item!: UmbTableItem; + #value!: UmbEditableDocumentCollectionItemModel; @property({ attribute: false }) - value!: UmbEditableDocumentCollectionItemModel; + public get value(): UmbEditableDocumentCollectionItemModel { + return this.#value; + } + public set value(value: UmbEditableDocumentCollectionItemModel) { + this.#value = value; + + if (value.item) { + this.#item.setData(value.item); + } + } + + @state() + _name = ''; + + #item = new UmbDocumentItemDataResolver(this); - #onClick(event: Event & { target: UUIButtonElement }) { - event.preventDefault(); - event.stopPropagation(); - window.history.pushState(null, '', event.target.href); + constructor() { + super(); + this.#item.observe(this.#item.name, (name) => (this._name = name || '')); } override render() { if (!this.value) return nothing; - return html` - - `; + return html` `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-state.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-state.element.ts index f986920e750e..37bde5b831f0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-state.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-state.element.ts @@ -1,5 +1,6 @@ import type { UmbEditableDocumentCollectionItemModel } from '../../../types.js'; -import { customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; +import { UmbDocumentItemDataResolver } from '../../../../item/index.js'; +import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; import { fromCamelCase } from '@umbraco-cms/backoffice/utils'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { UmbTableColumn, UmbTableColumnLayoutElement, UmbTableItem } from '@umbraco-cms/backoffice/components'; @@ -9,11 +10,31 @@ export class UmbDocumentTableColumnStateElement extends UmbLitElement implements column!: UmbTableColumn; item!: UmbTableItem; + #value!: UmbEditableDocumentCollectionItemModel; @property({ attribute: false }) - value!: UmbEditableDocumentCollectionItemModel; + public get value(): UmbEditableDocumentCollectionItemModel { + return this.#value; + } + public set value(value: UmbEditableDocumentCollectionItemModel) { + this.#value = value; + + if (value.item) { + this.#item.setData(value.item); + } + } + + @state() + _state = ''; + + #item = new UmbDocumentItemDataResolver(this); + + constructor() { + super(); + this.#item.observe(this.#item.state, (state) => (this._state = state || '')); + } override render() { - switch (this.value.item.state) { + switch (this._state) { case 'Published': return html`${this.localize.term('content_published')}`; case 'PublishedPendingChanges': diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts index 679bb81783ef..3ce823efc37e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/document-table-collection-view.element.ts @@ -3,6 +3,7 @@ import { UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN } from '../../../paths.js'; import type { UmbDocumentCollectionItemModel } from '../../types.js'; import type { UmbDocumentCollectionContext } from '../../document-collection.context.js'; import { UMB_DOCUMENT_COLLECTION_CONTEXT } from '../../document-collection.context-token.js'; +import { UMB_DOCUMENT_ENTITY_TYPE } from '../../../entity.js'; import type { UmbCollectionColumnConfiguration } from '@umbraco-cms/backoffice/collection'; import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -166,8 +167,8 @@ export class UmbDocumentTableCollectionViewElement extends UmbLitElement { return { id: item.unique, - icon: item.icon, - entityType: 'document', + icon: item.documentType.icon, + entityType: UMB_DOCUMENT_ENTITY_TYPE, data: data, }; }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-data-resolver.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-data-resolver.ts new file mode 100644 index 000000000000..0fc145e928ab --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-data-resolver.ts @@ -0,0 +1,203 @@ +import { UmbDocumentVariantState } from '../types.js'; +import type { UmbDocumentItemModel } from './types.js'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbAppLanguageContext } from '@umbraco-cms/backoffice/language'; +import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; +import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { UMB_PROPERTY_DATASET_CONTEXT } from '@umbraco-cms/backoffice/property'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +type UmbDocumentItemDataResolverModel = Omit; + +/** + * A controller for resolving data for a document item + * @exports + * @class UmbDocumentItemDataResolver + * @augments {UmbControllerBase} + */ +export class UmbDocumentItemDataResolver extends UmbControllerBase { + #defaultCulture?: string; + #appCulture?: string; + #propertyDataSetCulture?: UmbVariantId; + #data?: DataType | undefined; + + #init: Promise<[UmbAppLanguageContext]>; + + #unique = new UmbStringState(undefined); + public readonly unique = this.#unique.asObservable(); + + #name = new UmbStringState(undefined); + public readonly name = this.#name.asObservable(); + + #icon = new UmbStringState(undefined); + public readonly icon = this.#icon.asObservable(); + + #state = new UmbStringState(undefined); + public readonly state = this.#state.asObservable(); + + #isTrashed = new UmbBooleanState(undefined); + public readonly isTrashed = this.#isTrashed.asObservable(); + + #isDraft = new UmbBooleanState(undefined); + public readonly isDraft = this.#isDraft.asObservable(); + + constructor(host: UmbControllerHost) { + super(host); + + // We do not depend on this context because we know is it only available in some cases + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.#propertyDataSetCulture = context.getVariantId(); + this.#setVariantAwareValues(); + }); + + this.#init = Promise.all([ + this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (context) => { + this.observe(context.appLanguageCulture, (culture) => { + this.#appCulture = culture; + this.#setVariantAwareValues(); + }); + + this.observe(context.appDefaultLanguage, (value) => { + this.#defaultCulture = value?.unique; + this.#setVariantAwareValues(); + }); + }).asPromise(), + ]); + } + + /** + * Get the current item + * @returns {DataType | undefined} The current item + * @memberof UmbDocumentItemDataResolver + */ + getData(): DataType | undefined { + return this.#data; + } + + /** + * Set the current item + * @param {DataType | undefined} data The current item + * @memberof UmbDocumentItemDataResolver + */ + setData(data: DataType | undefined) { + this.#data = data; + + if (!this.#data) { + this.#unique.setValue(undefined); + this.#name.setValue(undefined); + this.#icon.setValue(undefined); + this.#isTrashed.setValue(undefined); + this.#isDraft.setValue(undefined); + return; + } + + this.#unique.setValue(this.#data.unique); + this.#icon.setValue(this.#data.documentType.icon); + this.#isTrashed.setValue(this.#data.isTrashed); + this.#setVariantAwareValues(); + } + + /** + * Get the unique of the item + * @returns {Promise} The unique of the item + * @memberof UmbDocumentItemDataResolver + */ + async getUnique(): Promise { + await this.#init; + return this.#unique.getValue(); + } + + /** + * Get the name of the item + * @returns {Promise} The name of the item + * @memberof UmbDocumentItemDataResolver + */ + async getName(): Promise { + await this.#init; + return this.#name.getValue() || ''; + } + + /** + * Get the icon of the item + * @returns {Promise} The icon of the item + * @memberof UmbDocumentItemDataResolver + */ + async getIcon(): Promise { + await this.#init; + return this.#data?.documentType.icon; + } + + /** + * Get the state of the item + * @returns {Promise} The state of the item + * @memberof UmbDocumentItemDataResolver + */ + async getState(): Promise { + await this.#init; + return this.#getCurrentVariant()?.state; + } + + /** + * Get the isDraft of the item + * @returns {Promise} The isDraft of the item + * @memberof UmbDocumentItemDataResolver + */ + async getIsDraft(): Promise { + await this.#init; + return this.#isDraft.getValue() ?? false; + } + + /** + * Get the isTrashed of the item + * @returns {Promise} The isTrashed of the item + * @memberof UmbDocumentItemDataResolver + */ + async getIsTrashed(): Promise { + await this.#init; + return this.#data?.isTrashed ?? false; + } + + #setVariantAwareValues() { + this.#setName(); + this.#setIsDraft(); + this.#setState(); + } + + #setName() { + const variant = this.#getCurrentVariant(); + const fallbackName = this.#findVariant(this.#defaultCulture)?.name; + const name = variant?.name ?? `(${fallbackName})`; + this.#name.setValue(name); + } + + #setIsDraft() { + const variant = this.#getCurrentVariant(); + const isDraft = variant?.state === UmbDocumentVariantState.DRAFT || false; + this.#isDraft.setValue(isDraft); + } + + #setState() { + const variant = this.#getCurrentVariant(); + const state = variant?.state || UmbDocumentVariantState.NOT_CREATED; + this.#state.setValue(state); + } + + #findVariant(culture: string | undefined) { + return this.#data?.variants.find((x) => x.culture === culture); + } + + #getCurrentVariant() { + if (this.#isInvariant()) { + return this.#data?.variants?.[0]; + } + + const culture = this.#propertyDataSetCulture?.culture || this.#appCulture; + return this.#findVariant(culture); + } + + #isInvariant() { + return this.#data?.variants?.[0]?.culture === null; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-ref.element.ts index 3b57e75ca9bd..35b20856406f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-ref.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/document-item-ref.element.ts @@ -1,41 +1,34 @@ import { UMB_DOCUMENT_ENTITY_TYPE } from '../entity.js'; +import { UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN } from '../paths.js'; import type { UmbDocumentItemModel } from './types.js'; -import { - classMap, - css, - customElement, - html, - ifDefined, - nothing, - property, - state, -} from '@umbraco-cms/backoffice/external/lit'; +import { UmbDocumentItemDataResolver } from './document-item-data-resolver.js'; +import { customElement, html, ifDefined, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; @customElement('umb-document-item-ref') export class UmbDocumentItemRefElement extends UmbLitElement { - #item?: UmbDocumentItemModel | undefined; + #item = new UmbDocumentItemDataResolver(this); @property({ type: Object }) public get item(): UmbDocumentItemModel | undefined { - return this.#item; + return this.#item.getData(); } public set item(value: UmbDocumentItemModel | undefined) { - const oldValue = this.#item; - this.#item = value; + const oldValue = this.#item.getData(); + this.#item.setData(value); - if (!this.#item) { + if (!value) { this.#modalRoute?.destroy(); return; } - if (oldValue?.unique === this.#item.unique) { + if (oldValue?.unique === value.unique) { return; } - this.#modalRoute?.setUniquePathValue('unique', this.#item.unique); + this.#modalRoute?.setUniquePathValue('unique', value.unique); } @property({ type: Boolean }) @@ -44,6 +37,21 @@ export class UmbDocumentItemRefElement extends UmbLitElement { @property({ type: Boolean }) standalone = false; + @state() + _unique = ''; + + @state() + _name = ''; + + @state() + _icon = ''; + + @state() + _isTrashed = false; + + @state() + _isDraft = false; + @state() _editPath = ''; @@ -61,14 +69,17 @@ export class UmbDocumentItemRefElement extends UmbLitElement { .observeRouteBuilder((routeBuilder) => { this._editPath = routeBuilder({}); }); - } - #isDraft(item: UmbDocumentItemModel) { - return item.variants[0]?.state === 'Draft'; + this.#item.observe(this.#item.unique, (unique) => (this._unique = unique ?? '')); + this.#item.observe(this.#item.name, (name) => (this._name = name ?? '')); + this.#item.observe(this.#item.icon, (icon) => (this._icon = icon ?? '')); + this.#item.observe(this.#item.isTrashed, (isTrashed) => (this._isTrashed = isTrashed ?? false)); + this.#item.observe(this.#item.isDraft, (isDraft) => (this._isDraft = isDraft ?? false)); } - #getHref(item: UmbDocumentItemModel) { - return `${this._editPath}/edit/${item.unique}`; + #getHref() { + const path = UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN.generateLocal({ unique: this._unique }); + return `${this._editPath}/${path}`; } override render() { @@ -76,34 +87,30 @@ export class UmbDocumentItemRefElement extends UmbLitElement { return html` - ${this.#renderIcon(this.item)} ${this.#renderIsTrashed(this.item)} + ${this.#renderIcon()}${this.#renderIsDraft()} ${this.#renderIsTrashed()} `; } - #renderIcon(item: UmbDocumentItemModel) { - if (!item.documentType.icon) return; - return html``; + #renderIcon() { + if (!this._icon) return nothing; + return html``; } - #renderIsTrashed(item: UmbDocumentItemModel) { - if (!item.isTrashed) return; + #renderIsTrashed() { + if (!this._isTrashed) return nothing; return html`Trashed`; } - static override styles = [ - css` - .draft { - opacity: 0.6; - } - `, - ]; + #renderIsDraft() { + if (!this._isDraft) return nothing; + return html`Draft`; + } } export { UmbDocumentItemRefElement as element }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/index.ts index f6365499927d..3b64c9668a2b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/index.ts @@ -1 +1,2 @@ export { UmbDocumentItemRepository } from './repository/index.js'; +export * from './document-item-data-resolver.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/types.ts index f1077ccaf85f..142d9e2951d7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/types.ts @@ -7,7 +7,7 @@ export interface UmbDocumentItemModel { documentType: { unique: string; icon: string; - collection: UmbReferenceByUnique | null; + collection?: UmbReferenceByUnique | null; }; entityType: UmbDocumentEntityType; hasChildren: boolean; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts index af7b4f65886e..ee3b089159d5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.context.ts @@ -1,4 +1,5 @@ import type { UmbDocumentTreeItemModel, UmbDocumentTreeRootModel } from '../types.js'; +import { UmbDocumentItemDataResolver } from '../../item/index.js'; import { UmbDefaultTreeItemContext } from '@umbraco-cms/backoffice/tree'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbIsTrashedEntityContext } from '@umbraco-cms/backoffice/recycle-bin'; @@ -9,6 +10,11 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext< > { // TODO: Provide this together with the EntityContext, ideally this takes part via a extension-type [NL] #isTrashedContext = new UmbIsTrashedEntityContext(this); + #item = new UmbDocumentItemDataResolver(this); + + readonly name = this.#item.name; + readonly icon = this.#item.icon; + readonly isDraft = this.#item.isDraft; readonly isTrashed = this._treeItem.asObservablePart((item) => item?.isTrashed ?? false); @@ -19,6 +25,11 @@ export class UmbDocumentTreeItemContext extends UmbDefaultTreeItemContext< this.#isTrashedContext.setIsTrashed(isTrashed); }); } + + public override setTreeItem(treeItem: UmbDocumentTreeItemModel | undefined) { + super.setTreeItem(treeItem); + this.#item.setData(treeItem); + } } export { UmbDocumentTreeItemContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.element.ts index bd122660bfb6..eea98ba21a57 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/tree-item/document-tree-item.element.ts @@ -1,86 +1,46 @@ -import type { UmbDocumentTreeItemModel, UmbDocumentTreeItemVariantModel } from '../types.js'; -import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; -import { css, html, nothing, customElement, state, classMap } from '@umbraco-cms/backoffice/external/lit'; -import type { UmbAppLanguageContext } from '@umbraco-cms/backoffice/language'; -import { UMB_APP_LANGUAGE_CONTEXT } from '@umbraco-cms/backoffice/language'; +import type { UmbDocumentTreeItemModel } from '../types.js'; +import type { UmbDocumentTreeItemContext } from './document-tree-item.context.js'; +import { css, html, nothing, customElement, classMap, state, property } from '@umbraco-cms/backoffice/external/lit'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbTreeItemElementBase } from '@umbraco-cms/backoffice/tree'; @customElement('umb-document-tree-item') -export class UmbDocumentTreeItemElement extends UmbTreeItemElementBase { - #appLanguageContext?: UmbAppLanguageContext; - - @state() - _currentCulture?: string; - - @state() - _defaultCulture?: string; - - @state() - _variant?: UmbDocumentTreeItemVariantModel; - - constructor() { - super(); - - this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (instance) => { - this.#appLanguageContext = instance; - this.#observeAppCulture(); - this.#observeDefaultCulture(); - }); - } - - #observeAppCulture() { - this.observe(this.#appLanguageContext!.appLanguageCulture, (value) => { - this._currentCulture = value; - this._variant = this.#findVariant(value); - }); - } - - #observeDefaultCulture() { - this.observe(this.#appLanguageContext!.appDefaultLanguage, (value) => { - this._defaultCulture = value?.unique; - }); - } - - #findVariant(culture: string | undefined) { - return this.item?.variants.find((x) => x.culture === culture); +export class UmbDocumentTreeItemElement extends UmbTreeItemElementBase< + UmbDocumentTreeItemModel, + UmbDocumentTreeItemContext +> { + #api: UmbDocumentTreeItemContext | undefined; + @property({ type: Object, attribute: false }) + public override get api(): UmbDocumentTreeItemContext | undefined { + return this.#api; } + public override set api(value: UmbDocumentTreeItemContext | undefined) { + this.#api = value; - #isInvariant() { - const firstVariant = this.item?.variants[0]; - return firstVariant?.culture === null && firstVariant?.segment === null; - } - - // TODO: we should move the fallback name logic to a helper class. It will be used in multiple places - #getLabel() { - if (this.#isInvariant()) { - return this._item?.variants[0].name; + if (this.#api) { + this.observe(this.#api.name, (name) => (this._name = name || '')); + this.observe(this.#api.isDraft, (isDraft) => (this._isDraft = isDraft || false)); + this.observe(this.#api.icon, (icon) => (this._icon = icon || '')); } - // ensure we always have the correct variant data - this._variant = this.#findVariant(this._currentCulture); - - const fallbackName = this.#findVariant(this._defaultCulture)?.name ?? this._item?.variants[0].name ?? 'Unknown'; - return this._variant?.name ?? `(${fallbackName})`; + super.api = value; } - #isDraft() { - if (this.#isInvariant()) { - return this._item?.variants[0].state === DocumentVariantStateModel.DRAFT; - } + @state() + private _name = ''; - // ensure we always have the correct variant data - this._variant = this.#findVariant(this._currentCulture); + @state() + private _isDraft = false; - return this._variant?.state === DocumentVariantStateModel.DRAFT; - } + @state() + private _icon = ''; override renderIconContainer() { - const icon = this.item?.documentType.icon; + const icon = this._icon; const iconWithoutColor = icon?.split(' ')[0]; return html` - + ${icon && iconWithoutColor ? html` @@ -92,9 +52,7 @@ export class UmbDocumentTreeItemElement extends UmbTreeItemElementBase${this.#getLabel()} `; + return html`${this._name} `; } #renderStateIcon() { diff --git a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts index 66fb3000df72..60c23df656c7 100644 --- a/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts +++ b/src/Umbraco.Web.UI.Client/utils/all-umb-consts/index.ts @@ -140,7 +140,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/document', - consts: ["UMB_DOCUMENT_COLLECTION_ALIAS","UMB_DOCUMENT_COLLECTION_CONTEXT","UMB_DOCUMENT_COLLECTION_REPOSITORY_ALIAS","UMB_DOCUMENT_GRID_COLLECTION_VIEW_ALIAS","UMB_DOCUMENT_TABLE_COLLECTION_VIEW_ALIAS","UMB_DOCUMENT_CREATE_OPTIONS_MODAL","UMB_CREATE_BLUEPRINT_MODAL","UMB_DOCUMENT_CREATE_BLUEPRINT_REPOSITORY_ALIAS","UMB_CULTURE_AND_HOSTNAMES_MODAL","UMB_DOCUMENT_CULTURE_AND_HOSTNAMES_REPOSITORY_ALIAS","UMB_DUPLICATE_DOCUMENT_MODAL","UMB_DUPLICATE_DOCUMENT_MODAL_ALIAS","UMB_DUPLICATE_DOCUMENT_REPOSITORY_ALIAS","UMB_MOVE_DOCUMENT_REPOSITORY_ALIAS","UMB_DOCUMENT_NOTIFICATIONS_MODAL","UMB_DOCUMENT_NOTIFICATIONS_MODAL_ALIAS","UMB_DOCUMENT_NOTIFICATIONS_REPOSITORY_ALIAS","UMB_PUBLIC_ACCESS_MODAL","UMB_DOCUMENT_PUBLIC_ACCESS_REPOSITORY_ALIAS","UMB_SORT_CHILDREN_OF_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_DUPLICATE_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_MOVE_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_TRASH_DOCUMENT_REPOSITORY_ALIAS","UMB_DOCUMENT_ENTITY_TYPE","UMB_DOCUMENT_ROOT_ENTITY_TYPE","UMB_DOCUMENT_CONFIGURATION_CONTEXT","UMB_CONTENT_MENU_ALIAS","UMB_DOCUMENT_PICKER_MODAL","UMB_DOCUMENT_SAVE_MODAL","UMB_DOCUMENT_SAVE_MODAL_ALIAS","UMB_DOCUMENT_WORKSPACE_PATH","UMB_CREATE_FROM_BLUEPRINT_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_DOCUMENT_PROPERTY_DATASET_CONTEXT","UMB_DOCUMENT_PUBLISH_MODAL_ALIAS","UMB_DOCUMENT_PUBLISH_MODAL","UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL_ALIAS","UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL","UMB_DOCUMENT_PUBLISHING_REPOSITORY_ALIAS","UMB_DOCUMENT_SCHEDULE_MODAL_ALIAS","UMB_DOCUMENT_SCHEDULE_MODAL","UMB_DOCUMENT_UNPUBLISH_MODAL_ALIAS","UMB_DOCUMENT_UNPUBLISH_MODAL","UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT","UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE","UMB_DOCUMENT_RECYCLE_BIN_REPOSITORY_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_CONTEXT","UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS","UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS","UMB_DOCUMENT_DETAIL_STORE_ALIAS","UMB_DOCUMENT_DETAIL_STORE_CONTEXT","UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS","UMB_DOCUMENT_STORE_ALIAS","UMB_DOCUMENT_ITEM_STORE_CONTEXT","UMB_DOCUMENT_VALIDATION_REPOSITORY_ALIAS","UMB_ROLLBACK_MODAL_ALIAS","UMB_ROLLBACK_MODAL","UMB_ROLLBACK_REPOSITORY_ALIAS","UMB_DOCUMENT_SEARCH_PROVIDER_ALIAS","UMB_DOCUMENT_TREE_STORE_CONTEXT","UMB_DOCUMENT_TREE_REPOSITORY_ALIAS","UMB_DOCUMENT_TREE_STORE_ALIAS","UMB_DOCUMENT_TREE_ALIAS","UMB_DOCUMENT_URL_REPOSITORY_ALIAS","UMB_DOCUMENT_URL_STORE_ALIAS","UMB_DOCUMENT_URL_STORE_CONTEXT","UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS","UMB_USER_PERMISSION_DOCUMENT_CREATE","UMB_USER_PERMISSION_DOCUMENT_READ","UMB_USER_PERMISSION_DOCUMENT_UPDATE","UMB_USER_PERMISSION_DOCUMENT_DELETE","UMB_USER_PERMISSION_DOCUMENT_CREATE_BLUEPRINT","UMB_USER_PERMISSION_DOCUMENT_NOTIFICATIONS","UMB_USER_PERMISSION_DOCUMENT_PUBLISH","UMB_USER_PERMISSION_DOCUMENT_PERMISSIONS","UMB_USER_PERMISSION_DOCUMENT_UNPUBLISH","UMB_USER_PERMISSION_DOCUMENT_DUPLICATE","UMB_USER_PERMISSION_DOCUMENT_MOVE","UMB_USER_PERMISSION_DOCUMENT_SORT","UMB_USER_PERMISSION_DOCUMENT_CULTURE_AND_HOSTNAMES","UMB_USER_PERMISSION_DOCUMENT_PUBLIC_ACCESS","UMB_USER_PERMISSION_DOCUMENT_ROLLBACK","UMB_DOCUMENT_PERMISSION_REPOSITORY_ALIAS","UMB_DOCUMENT_IS_NOT_TRASHED_CONDITION_ALIAS","UMB_DOCUMENT_IS_TRASHED_CONDITION_ALIAS","UMB_DOCUMENT_WORKSPACE_ALIAS","UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD","UMB_DOCUMENT_WORKSPACE_CONTEXT"] + consts: ["UMB_DOCUMENT_COLLECTION_ALIAS","UMB_DOCUMENT_COLLECTION_CONTEXT","UMB_DOCUMENT_COLLECTION_REPOSITORY_ALIAS","UMB_DOCUMENT_GRID_COLLECTION_VIEW_ALIAS","UMB_DOCUMENT_TABLE_COLLECTION_VIEW_ALIAS","UMB_DOCUMENT_CREATE_OPTIONS_MODAL","UMB_CREATE_BLUEPRINT_MODAL","UMB_DOCUMENT_CREATE_BLUEPRINT_REPOSITORY_ALIAS","UMB_CULTURE_AND_HOSTNAMES_MODAL","UMB_DOCUMENT_CULTURE_AND_HOSTNAMES_REPOSITORY_ALIAS","UMB_DUPLICATE_DOCUMENT_MODAL","UMB_DUPLICATE_DOCUMENT_MODAL_ALIAS","UMB_DUPLICATE_DOCUMENT_REPOSITORY_ALIAS","UMB_MOVE_DOCUMENT_REPOSITORY_ALIAS","UMB_DOCUMENT_NOTIFICATIONS_MODAL","UMB_DOCUMENT_NOTIFICATIONS_MODAL_ALIAS","UMB_DOCUMENT_NOTIFICATIONS_REPOSITORY_ALIAS","UMB_PUBLIC_ACCESS_MODAL","UMB_DOCUMENT_PUBLIC_ACCESS_REPOSITORY_ALIAS","UMB_SORT_CHILDREN_OF_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_DUPLICATE_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_MOVE_DOCUMENT_REPOSITORY_ALIAS","UMB_BULK_TRASH_DOCUMENT_REPOSITORY_ALIAS","UMB_DOCUMENT_ENTITY_TYPE","UMB_DOCUMENT_ROOT_ENTITY_TYPE","UMB_DOCUMENT_CONFIGURATION_CONTEXT","UMB_DOCUMENT_ITEM_REPOSITORY_ALIAS","UMB_DOCUMENT_STORE_ALIAS","UMB_DOCUMENT_ITEM_STORE_CONTEXT","UMB_CONTENT_MENU_ALIAS","UMB_DOCUMENT_PICKER_MODAL","UMB_DOCUMENT_SAVE_MODAL","UMB_DOCUMENT_SAVE_MODAL_ALIAS","UMB_DOCUMENT_WORKSPACE_PATH","UMB_CREATE_FROM_BLUEPRINT_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_CREATE_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_EDIT_DOCUMENT_WORKSPACE_PATH_PATTERN","UMB_DOCUMENT_PROPERTY_DATASET_CONTEXT","UMB_DOCUMENT_PUBLISH_MODAL_ALIAS","UMB_DOCUMENT_PUBLISH_MODAL","UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL_ALIAS","UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL","UMB_DOCUMENT_PUBLISHING_REPOSITORY_ALIAS","UMB_DOCUMENT_SCHEDULE_MODAL_ALIAS","UMB_DOCUMENT_SCHEDULE_MODAL","UMB_DOCUMENT_UNPUBLISH_MODAL_ALIAS","UMB_DOCUMENT_UNPUBLISH_MODAL","UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT","UMB_DOCUMENT_RECYCLE_BIN_ROOT_ENTITY_TYPE","UMB_DOCUMENT_RECYCLE_BIN_REPOSITORY_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_REPOSITORY_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_ALIAS","UMB_DOCUMENT_RECYCLE_BIN_TREE_STORE_CONTEXT","UMB_DOCUMENT_REFERENCE_REPOSITORY_ALIAS","UMB_DOCUMENT_DETAIL_REPOSITORY_ALIAS","UMB_DOCUMENT_DETAIL_STORE_ALIAS","UMB_DOCUMENT_DETAIL_STORE_CONTEXT","UMB_DOCUMENT_VALIDATION_REPOSITORY_ALIAS","UMB_ROLLBACK_MODAL_ALIAS","UMB_ROLLBACK_MODAL","UMB_ROLLBACK_REPOSITORY_ALIAS","UMB_DOCUMENT_SEARCH_PROVIDER_ALIAS","UMB_DOCUMENT_TREE_STORE_CONTEXT","UMB_DOCUMENT_TREE_REPOSITORY_ALIAS","UMB_DOCUMENT_TREE_STORE_ALIAS","UMB_DOCUMENT_TREE_ALIAS","UMB_DOCUMENT_URL_REPOSITORY_ALIAS","UMB_DOCUMENT_URL_STORE_ALIAS","UMB_DOCUMENT_URL_STORE_CONTEXT","UMB_DOCUMENT_USER_PERMISSION_CONDITION_ALIAS","UMB_USER_PERMISSION_DOCUMENT_CREATE","UMB_USER_PERMISSION_DOCUMENT_READ","UMB_USER_PERMISSION_DOCUMENT_UPDATE","UMB_USER_PERMISSION_DOCUMENT_DELETE","UMB_USER_PERMISSION_DOCUMENT_CREATE_BLUEPRINT","UMB_USER_PERMISSION_DOCUMENT_NOTIFICATIONS","UMB_USER_PERMISSION_DOCUMENT_PUBLISH","UMB_USER_PERMISSION_DOCUMENT_PERMISSIONS","UMB_USER_PERMISSION_DOCUMENT_UNPUBLISH","UMB_USER_PERMISSION_DOCUMENT_DUPLICATE","UMB_USER_PERMISSION_DOCUMENT_MOVE","UMB_USER_PERMISSION_DOCUMENT_SORT","UMB_USER_PERMISSION_DOCUMENT_CULTURE_AND_HOSTNAMES","UMB_USER_PERMISSION_DOCUMENT_PUBLIC_ACCESS","UMB_USER_PERMISSION_DOCUMENT_ROLLBACK","UMB_DOCUMENT_PERMISSION_REPOSITORY_ALIAS","UMB_DOCUMENT_IS_NOT_TRASHED_CONDITION_ALIAS","UMB_DOCUMENT_IS_TRASHED_CONDITION_ALIAS","UMB_DOCUMENT_WORKSPACE_ALIAS","UMB_DOCUMENT_DETAIL_MODEL_VARIANT_SCAFFOLD","UMB_DOCUMENT_WORKSPACE_CONTEXT"] }, { path: '@umbraco-cms/backoffice/entity-action', @@ -224,7 +224,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/member', - consts: ["UMB_MEMBER_COLLECTION_ALIAS","UMB_MEMBER_COLLECTION_CONTEXT","UMB_MEMBER_COLLECTION_REPOSITORY_ALIAS","UMB_MEMBER_TABLE_COLLECTION_VIEW_ALIAS","UMB_MEMBER_PICKER_MODAL","UMB_MEMBER_CREATE_OPTIONS_MODAL","UMB_MEMBER_ENTITY_TYPE","UMB_MEMBER_ROOT_ENTITY_TYPE","UMB_MEMBER_WORKSPACE_PATH","UMB_MEMBER_ROOT_WORKSPACE_PATH","UMB_CREATE_MEMBER_WORKSPACE_PATH_PATTERN","UMB_MEMBER_VARIANT_CONTEXT","UMB_MEMBER_DETAIL_REPOSITORY_ALIAS","UMB_MEMBER_DETAIL_STORE_ALIAS","UMB_MEMBER_DETAIL_STORE_CONTEXT","UMB_MEMBER_ITEM_REPOSITORY_ALIAS","UMB_MEMBER_STORE_ALIAS","UMB_MEMBER_ITEM_STORE_CONTEXT","UMB_MEMBER_VALIDATION_REPOSITORY_ALIAS","UMB_MEMBER_SEARCH_PROVIDER_ALIAS","UMB_MEMBER_DETAIL_MODEL_VARIANT_SCAFFOLD","UMB_MEMBER_WORKSPACE_ALIAS","UMB_MEMBER_WORKSPACE_CONTEXT","UMB_MEMBER_ROOT_WORKSPACE_ALIAS"] + consts: ["UMB_MEMBER_COLLECTION_ALIAS","UMB_MEMBER_COLLECTION_CONTEXT","UMB_MEMBER_COLLECTION_REPOSITORY_ALIAS","UMB_MEMBER_TABLE_COLLECTION_VIEW_ALIAS","UMB_MEMBER_PICKER_MODAL","UMB_MEMBER_CREATE_OPTIONS_MODAL","UMB_MEMBER_ENTITY_TYPE","UMB_MEMBER_ROOT_ENTITY_TYPE","UMB_MEMBER_ITEM_REPOSITORY_ALIAS","UMB_MEMBER_STORE_ALIAS","UMB_MEMBER_ITEM_STORE_CONTEXT","UMB_MEMBER_WORKSPACE_PATH","UMB_MEMBER_ROOT_WORKSPACE_PATH","UMB_CREATE_MEMBER_WORKSPACE_PATH_PATTERN","UMB_MEMBER_VARIANT_CONTEXT","UMB_MEMBER_DETAIL_REPOSITORY_ALIAS","UMB_MEMBER_DETAIL_STORE_ALIAS","UMB_MEMBER_DETAIL_STORE_CONTEXT","UMB_MEMBER_VALIDATION_REPOSITORY_ALIAS","UMB_MEMBER_SEARCH_PROVIDER_ALIAS","UMB_MEMBER_DETAIL_MODEL_VARIANT_SCAFFOLD","UMB_MEMBER_WORKSPACE_ALIAS","UMB_MEMBER_WORKSPACE_CONTEXT","UMB_MEMBER_ROOT_WORKSPACE_ALIAS"] }, { path: '@umbraco-cms/backoffice/menu', @@ -232,7 +232,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/modal', - consts: ["UMB_CONFIRM_MODAL","UMB_DISCARD_CHANGES_MODAL","UMB_ITEM_PICKER_MODAL","UMB_MODAL_MANAGER_CONTEXT","UMB_MODAL_CONTEXT"] + consts: ["UMB_CONFIRM_MODAL","UMB_DISCARD_CHANGES_MODAL","UMB_ERROR_VIEWER_MODAL","UMB_ITEM_PICKER_MODAL","UMB_MODAL_MANAGER_CONTEXT","UMB_MODAL_CONTEXT"] }, { path: '@umbraco-cms/backoffice/models', @@ -260,7 +260,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/picker-input', - consts: [] + consts: ["UMB_PICKER_INPUT_CONTEXT"] }, { path: '@umbraco-cms/backoffice/picker', @@ -384,7 +384,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/tree', - consts: ["UMB_TREE_CONTEXT","UMB_DUPLICATE_TO_MODAL_ALIAS","UMB_DUPLICATE_TO_MODAL","UMB_SORT_CHILDREN_OF_MODAL_ALIAS","UMB_SORT_CHILDREN_OF_MODAL","UMB_FOLDER_CREATE_MODAL","UMB_FOLDER_UPDATE_MODAL","UMB_TREE_ITEM_CONTEXT","UMB_TREE_PICKER_MODAL_ALIAS","UMB_TREE_PICKER_MODAL"] + consts: ["UMB_TREE_CONTEXT","UMB_DUPLICATE_TO_MODAL_ALIAS","UMB_DUPLICATE_TO_MODAL","UMB_SORT_CHILDREN_OF_MODAL_ALIAS","UMB_SORT_CHILDREN_OF_MODAL","UMB_FOLDER_CREATE_MODAL","UMB_FOLDER_UPDATE_MODAL","UMB_TREE_ITEM_CONTEXT","UMB_TREE_ITEM_DEFAULT_KIND_MANIFEST","UMB_TREE_PICKER_MODAL_ALIAS","UMB_TREE_PICKER_MODAL"] }, { path: '@umbraco-cms/backoffice/ufm', @@ -420,7 +420,7 @@ export const foundConsts = [{ }, { path: '@umbraco-cms/backoffice/webhook', - consts: ["UMB_WEBHOOK_WORKSPACE_ALIAS","UMB_WEBHOOK_WORKSPACE","UMB_WEBHOOK_ENTITY_TYPE","UMB_WEBHOOK_ROOT_ENTITY_TYPE","UMB_WEBHOOK_DELIVERY_ENTITY_TYPE","UMB_WEBHOOK_COLLECTION_ALIAS","UMB_WEBHOOK_COLLECTION_REPOSITORY_ALIAS","UMB_WEBHOOK_TABLE_COLLECTION_VIEW_ALIAS","UMB_WEBHOOK_DETAIL_REPOSITORY_ALIAS","UMB_WEBHOOK_DETAIL_STORE_ALIAS","UMB_WEBHOOK_DETAIL_STORE_CONTEXT","UMB_WEBHOOK_ITEM_REPOSITORY_ALIAS","UMB_WEBHOOK_STORE_ALIAS","UMB_WEBHOOK_ITEM_STORE_CONTEXT","UMB_WEBHOOK_WORKSPACE_CONTEXT","UMB_WEBHOOK_DELIVERY_COLLECTION_ALIAS","UMB_WEBHOOK_DELIVERY_COLLECTION_REPOSITORY_ALIAS","UMB_WEBHOOK_EVENTS_MODAL","UMB_WEBHOOK_EVENT_REPOSITORY_ALIAS","UMB_WEBHOOK_EVENT_STORE_ALIAS","UMB_WEBHOOK_EVENT_STORE_CONTEXT","UMB_WEBHOOK_ROOT_WORKSPACE_ALIAS"] + consts: ["UMB_WEBHOOK_WORKSPACE_ALIAS","UMB_WEBHOOK_WORKSPACE","UMB_WEBHOOK_ENTITY_TYPE","UMB_WEBHOOK_ROOT_ENTITY_TYPE","UMB_WEBHOOK_DELIVERY_ENTITY_TYPE","UMB_WEBHOOK_COLLECTION_ALIAS","UMB_WEBHOOK_COLLECTION_REPOSITORY_ALIAS","UMB_WEBHOOK_TABLE_COLLECTION_VIEW_ALIAS","UMB_WEBHOOK_WORKSPACE_PATH","UMB_CREATE_WEBHOOK_WORKSPACE_PATH_PATTERN","UMB_EDIT_WEBHOOK_WORKSPACE_PATH_PATTERN","UMB_WEBHOOK_DETAIL_REPOSITORY_ALIAS","UMB_WEBHOOK_DETAIL_STORE_ALIAS","UMB_WEBHOOK_DETAIL_STORE_CONTEXT","UMB_WEBHOOK_ITEM_REPOSITORY_ALIAS","UMB_WEBHOOK_STORE_ALIAS","UMB_WEBHOOK_ITEM_STORE_CONTEXT","UMB_WEBHOOK_WORKSPACE_CONTEXT","UMB_WEBHOOK_DELIVERY_COLLECTION_ALIAS","UMB_WEBHOOK_DELIVERY_COLLECTION_REPOSITORY_ALIAS","UMB_WEBHOOK_EVENTS_MODAL","UMB_WEBHOOK_EVENT_REPOSITORY_ALIAS","UMB_WEBHOOK_EVENT_STORE_ALIAS","UMB_WEBHOOK_EVENT_STORE_CONTEXT","UMB_WEBHOOK_ROOT_WORKSPACE_PATH","UMB_WEBHOOK_ROOT_WORKSPACE_ALIAS"] }, { path: '@umbraco-cms/backoffice/workspace', From f389cc9aa37417805f061a2d18ca88a8c21e350a Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 19 Feb 2025 09:46:14 +0100 Subject: [PATCH 71/74] Modified AspNetCoreRequestAccessor to gracefully handle the absence of an HttpContext (#18369) --- .../AspNetCore/AspNetCoreRequestAccessor.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs index 6e6de4952133..ca548586a16e 100644 --- a/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs +++ b/src/Umbraco.Web.Common/AspNetCore/AspNetCoreRequestAccessor.cs @@ -34,7 +34,7 @@ public AspNetCoreRequestAccessor( public string? GetRequestValue(string name) => GetFormValue(name) ?? GetQueryStringValue(name); /// - public string? GetQueryStringValue(string name) => _httpContextAccessor.GetRequiredHttpContext().Request.Query[name]; + public string? GetQueryStringValue(string name) => _httpContextAccessor.HttpContext?.Request.Query[name]; /// public Uri? GetRequestUrl() => _httpContextAccessor.HttpContext != null @@ -86,8 +86,8 @@ internal void EnsureApplicationUrl() => private string? GetFormValue(string name) { - HttpRequest request = _httpContextAccessor.GetRequiredHttpContext().Request; - if (!request.HasFormContentType) + HttpRequest? request = _httpContextAccessor.HttpContext?.Request; + if (request?.HasFormContentType is not true) { return null; } From effa40fabbc6909a2d7120c7d04c4af5c9d1abbc Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 18 Feb 2025 16:34:32 +0100 Subject: [PATCH 72/74] build(deps-dev): update npm dependencies --- src/Umbraco.Web.UI.Login/package-lock.json | 320 ++++++++++++--------- src/Umbraco.Web.UI.Login/package.json | 12 +- 2 files changed, 190 insertions(+), 142 deletions(-) diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index 634fcb8e82b8..f0a4be3e77c9 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -6,11 +6,11 @@ "": { "name": "login", "devDependencies": { - "@umbraco-cms/backoffice": "15.2.0", - "msw": "^2.6.4", - "typescript": "^5.6.3", - "vite": "^5.4.14", - "vite-tsconfig-paths": "^5.1.2" + "@umbraco-cms/backoffice": "15.2.1", + "msw": "^2.7.0", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vite-tsconfig-paths": "^5.1.4" }, "engines": { "node": ">=22", @@ -49,9 +49,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", "cpu": [ "ppc64" ], @@ -62,13 +62,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", "cpu": [ "arm" ], @@ -79,13 +79,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", "cpu": [ "arm64" ], @@ -96,13 +96,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", "cpu": [ "x64" ], @@ -113,13 +113,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", "cpu": [ "arm64" ], @@ -130,13 +130,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", "cpu": [ "x64" ], @@ -147,13 +147,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", "cpu": [ "arm64" ], @@ -164,13 +164,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", "cpu": [ "x64" ], @@ -181,13 +181,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", "cpu": [ "arm" ], @@ -198,13 +198,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", "cpu": [ "arm64" ], @@ -215,13 +215,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", "cpu": [ "ia32" ], @@ -232,13 +232,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", "cpu": [ "loong64" ], @@ -249,13 +249,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", "cpu": [ "mips64el" ], @@ -266,13 +266,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", "cpu": [ "ppc64" ], @@ -283,13 +283,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", "cpu": [ "riscv64" ], @@ -300,13 +300,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", "cpu": [ "s390x" ], @@ -317,13 +317,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", "cpu": [ "x64" ], @@ -334,13 +334,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", "cpu": [ "x64" ], @@ -351,13 +368,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", "cpu": [ "x64" ], @@ -368,13 +402,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", "cpu": [ "x64" ], @@ -385,13 +419,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", "cpu": [ "arm64" ], @@ -402,13 +436,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", "cpu": [ "ia32" ], @@ -419,13 +453,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", "cpu": [ "x64" ], @@ -436,7 +470,7 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@inquirer/confirm": { @@ -1469,9 +1503,9 @@ "peer": true }, "node_modules/@umbraco-cms/backoffice": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/@umbraco-cms/backoffice/-/backoffice-15.2.0.tgz", - "integrity": "sha512-jPEZ1eUbWKnhAJo93fkc2fHuTvnfil9YJ2JzcGww58/j0//X9nKuR9rR+HJHRPI+EuaDPVZzVe0WAPjyRMO6Hw==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@umbraco-cms/backoffice/-/backoffice-15.2.1.tgz", + "integrity": "sha512-vIyIfe26mPTN3Tq25BUE2ZLUQ25cjGqT3p+ezkKyfW+gf+RsUh2kVwvFD7PnXjzCJm93OUy/kXYfT8um2Hwe2A==", "dev": true, "license": "MIT", "workspaces": [ @@ -2816,9 +2850,9 @@ } }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2826,32 +2860,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" } }, "node_modules/escalade": { @@ -3736,21 +3772,21 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -3759,19 +3795,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", - "terser": "^5.4.0" + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -3792,6 +3834,12 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, diff --git a/src/Umbraco.Web.UI.Login/package.json b/src/Umbraco.Web.UI.Login/package.json index 05bb6419f8ec..52dd2b553df9 100644 --- a/src/Umbraco.Web.UI.Login/package.json +++ b/src/Umbraco.Web.UI.Login/package.json @@ -13,15 +13,15 @@ "npm": ">=10.9" }, "devDependencies": { - "@umbraco-cms/backoffice": "15.2.0", - "msw": "^2.6.4", - "typescript": "^5.6.3", - "vite": "^5.4.14", - "vite-tsconfig-paths": "^5.1.2" + "@umbraco-cms/backoffice": "15.2.1", + "msw": "^2.7.0", + "typescript": "^5.7.3", + "vite": "^6.1.0", + "vite-tsconfig-paths": "^5.1.4" }, "msw": { "workerDirectory": [ "public" ] } -} +} \ No newline at end of file From 28fb7ad67ef1cfd12036e2658f0c8ad884b651c3 Mon Sep 17 00:00:00 2001 From: leekelleher Date: Wed, 19 Feb 2025 10:22:48 +0000 Subject: [PATCH 73/74] Refactors loading core Tipap extension The unit test doesn't register the manifests, so we'll need to hard code loading the "Rich Text Essentials" extension. --- .../input-tiptap/input-tiptap.element.ts | 20 +++++++++---------- .../core/rich-text-essentials.tiptap-api.ts | 6 +++++- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts index 8ad71780369d..387f511eb1dc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts @@ -66,23 +66,21 @@ export class UmbInputTiptapElement extends UmbFormControlMixin((resolve) => { - this.observe(umbExtensionsRegistry.byType('tiptapExtension'), async (manifests) => { - let enabledExtensions = this.configuration?.getValueByAlias('extensions') ?? []; + const enabledExtensions = this.configuration?.getValueByAlias('extensions') ?? []; - // Ensures that the "Rich Text Essentials" extension is always enabled. [LK] - if (!enabledExtensions.includes(TIPTAP_CORE_EXTENSION_ALIAS)) { - enabledExtensions = [TIPTAP_CORE_EXTENSION_ALIAS, ...enabledExtensions]; - } + // Ensures that the "Rich Text Essentials" extension is always enabled. [LK] + if (!enabledExtensions.includes(TIPTAP_CORE_EXTENSION_ALIAS)) { + const { api } = await import('../../extensions/core/rich-text-essentials.tiptap-api.js'); + this._extensions.push(new api(this)); + } + await new Promise((resolve) => { + this.observe(umbExtensionsRegistry.byTypeAndAliases('tiptapExtension', enabledExtensions), async (manifests) => { for (const manifest of manifests) { if (manifest.api) { const extension = await loadManifestApi(manifest.api); if (extension) { - // Check if the extension is enabled - if (enabledExtensions.includes(manifest.alias)) { - this._extensions.push(new extension(this)); - } + this._extensions.push(new extension(this)); } } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts index bee52bf76dfc..481edecdcede 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/rich-text-essentials.tiptap-api.ts @@ -9,7 +9,7 @@ import { TextStyle, } from '@umbraco-cms/backoffice/external/tiptap'; -export default class UmbTiptapRichTextEssentialsExtensionApi extends UmbTiptapExtensionApiBase { +export class UmbTiptapRichTextEssentialsExtensionApi extends UmbTiptapExtensionApiBase { #localize = new UmbLocalizationController(this); getTiptapExtensions = () => [ @@ -55,3 +55,7 @@ export default class UmbTiptapRichTextEssentialsExtensionApi extends UmbTiptapEx Span, ]; } + +export default UmbTiptapRichTextEssentialsExtensionApi; + +export { UmbTiptapRichTextEssentialsExtensionApi as api }; From 040d4fe363b0a66d3505afe8faa5342af45dc204 Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Wed, 19 Feb 2025 12:00:23 +0000 Subject: [PATCH 74/74] Tiptap RTE: Table extension enhancements (#18365) * Tiptap: Adds custom button for Table Provides a popover with table menu actions. * Moved Table extension to its own folder * Extended the Tiptap `Table` extensions * Amended table toolbar actions to have `focus()` * Markup amends * Fix up Tiptap mock data * [WIP] Table column/row bubble menu * Tiptap, enabled content check for invalid markup * [WIP] Tiptap toolbar cascading menu items * Tiptap: relocated Block extension from "plugins" folder to "extensions" subfolder. * Table toolbar, moved the menu to the API * `UmbTiptapToolbarElementApiBase`, made `execute` concrete so that it becomes optional in extended classes. * Adds "default" toolbar element API which does nothing, but satisfies the extension loader controller. * `@ts-ignore` the `.showPopover()` and `.hidePopover()` calls * Updated Tiptap toolbar mock data-type.data to match TinyMCE toolbar layout. * Implemented "Cascading Menu Popover" as a standalone component * Reworked Table toolbar to use cascading menu * [WIP] Tiptap bubble menu * Adds Tiptap Cascading Menu Popover Example with a Style Select menu, with default (hard coded) options, similar to TinyMCE's unconfigured Style Select menu. * Small code tidy ups * Added "Insert Table" component * Removed Table Bubble Menu feature (temporarily) as its development isn't ready yet. * Removed `umb-tiptap-hover-menu` component as it wasn't been used. I expect this will become the bubble menu feature. --- .../extensions/tiptap-umb-table.extension.ts | 480 ++++++++++++++++++ .../src/external/tiptap/index.ts | 1 + .../cascading-menu-popover.element.ts | 18 +- .../input-tiptap/input-tiptap.element.ts | 5 +- .../input-tiptap/tiptap-hover-menu.element.ts | 53 -- .../src/packages/tiptap/extensions/base.ts | 1 + .../extensions/core/table.tiptap-api.ts | 69 --- .../packages/tiptap/extensions/manifests.ts | 34 +- .../table/components/table-insert.element.ts | 98 ++++ .../tiptap/extensions/table/manifests.ts | 34 ++ .../table-tiptap-toolbar-button.element.ts | 55 ++ .../extensions/table/table.tiptap-api.ts | 133 +++++ .../table/table.tiptap-toolbar-api.ts | 118 +++++ .../toolbar/table.tiptap-toolbar-api.ts | 8 - 14 files changed, 942 insertions(+), 165 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/tiptap-hover-menu.element.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/core/table.tiptap-api.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/components/table-insert.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/manifests.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table-tiptap-toolbar-button.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-api.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/table/table.tiptap-toolbar-api.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/toolbar/table.tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts new file mode 100644 index 000000000000..86794bc96ad0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/extensions/tiptap-umb-table.extension.ts @@ -0,0 +1,480 @@ +import { CellSelection, TableMap } from '@tiptap/pm/tables'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { EditorState } from '@tiptap/pm/state'; +import { EditorView } from '@tiptap/pm/view'; +import { findParentNode, mergeAttributes, Editor, Node } from '@tiptap/core'; +import { Node as PMNode, ResolvedPos } from '@tiptap/pm/model'; +import { Plugin } from '@tiptap/pm/state'; +import { Selection, Transaction } from '@tiptap/pm/state'; +import { Table } from '@tiptap/extension-table'; +import { TableCell } from '@tiptap/extension-table-cell'; +import { TableHeader } from '@tiptap/extension-table-header'; +import { TableRow } from '@tiptap/extension-table-row'; +import type { Rect } from '@tiptap/pm/tables'; + +export const UmbTable = Table.configure({ resizable: true }); + +export const UmbTableRow = TableRow.extend({ + allowGapCursor: false, +}); + +export const UmbTableHeader = TableHeader.extend({ + addAttributes() { + return { + colspan: { + default: 1, + }, + rowspan: { + default: 1, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { isEditable } = this.editor; + + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Array = []; + const cells = getCellsInRow(0)(selection); + + if (cells) { + cells.forEach(({ pos }: { pos: number }, index: number) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const colSelected = isColumnSelected(index)(selection); + const className = colSelected ? 'grip-column selected' : 'grip-column'; + + const grip = document.createElement('a'); + grip.appendChild(document.createElement('uui-symbol-more')); + + grip.className = className; + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr)); + }); + + return grip; + }), + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); + +export const UmbTableCell = TableCell.extend({ + addAttributes() { + return { + colspan: { + default: 1, + parseHTML: (element) => { + const colspan = element.getAttribute('colspan'); + const value = colspan ? parseInt(colspan, 10) : 1; + + return value; + }, + }, + rowspan: { + default: 1, + parseHTML: (element) => { + const rowspan = element.getAttribute('rowspan'); + const value = rowspan ? parseInt(rowspan, 10) : 1; + + return value; + }, + }, + colwidth: { + default: null, + parseHTML: (element) => { + const colwidth = element.getAttribute('colwidth'); + const value = colwidth ? [parseInt(colwidth, 10)] : null; + + return value; + }, + }, + style: { + default: null, + }, + }; + }, + + addProseMirrorPlugins() { + return [ + new Plugin({ + props: { + decorations: (state) => { + const { isEditable } = this.editor; + + if (!isEditable) { + return DecorationSet.empty; + } + + const { doc, selection } = state; + const decorations: Decoration[] = []; + const cells = getCellsInColumn(0)(selection); + + if (cells) { + cells.forEach(({ pos }: { pos: number }, index: number) => { + decorations.push( + Decoration.widget(pos + 1, () => { + const rowSelected = isRowSelected(index)(selection); + const className = rowSelected ? 'grip-row selected' : 'grip-row'; + + const grip = document.createElement('a'); + grip.appendChild(document.createElement('uui-symbol-more')); + + grip.className = className; + grip.addEventListener('mousedown', (event) => { + event.preventDefault(); + event.stopImmediatePropagation(); + + this.editor.view.dispatch(selectRow(index)(this.editor.state.tr)); + }); + + return grip; + }), + ); + }); + } + + return DecorationSet.create(doc, decorations); + }, + }, + }), + ]; + }, +}); + +const isRectSelected = (rect: Rect) => (selection: CellSelection) => { + const map = TableMap.get(selection.$anchorCell.node(-1)); + const start = selection.$anchorCell.start(-1); + const cells = map.cellsInRect(rect); + const selectedCells = map.cellsInRect( + map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start), + ); + + for (let i = 0, count = cells.length; i < count; i += 1) { + if (selectedCells.indexOf(cells[i]) === -1) { + return false; + } + } + + return true; +}; + +const findTable = (selection: Selection) => + findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(selection); + +const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection; + +const isColumnSelected = (columnIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: columnIndex, + right: columnIndex + 1, + top: 0, + bottom: map.height, + })(selection); + } + + return false; +}; + +const isRowSelected = (rowIndex: number) => (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: rowIndex, + bottom: rowIndex + 1, + })(selection); + } + + return false; +}; + +const isTableSelected = (selection: Selection) => { + if (isCellSelection(selection)) { + const map = TableMap.get(selection.$anchorCell.node(-1)); + + return isRectSelected({ + left: 0, + right: map.width, + top: 0, + bottom: map.height, + })(selection); + } + + return false; +}; + +const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.width - 1) { + const cells = map.cellsInRect({ + left: index, + right: index + 1, + top: 0, + bottom: map.height, + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }), + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: PMNode | null | undefined }[], + ); + } + return null; +}; + +const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]); + + return indexes.reduce( + (acc, index) => { + if (index >= 0 && index <= map.height - 1) { + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: index, + bottom: index + 1, + }); + + return acc.concat( + cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + return { pos, start: pos + 1, node }; + }), + ); + } + + return acc; + }, + [] as { pos: number; start: number; node: PMNode | null | undefined }[], + ); + } + + return null; +}; + +const getCellsInTable = (selection: Selection) => { + const table = findTable(selection); + + if (table) { + const map = TableMap.get(table.node); + const cells = map.cellsInRect({ + left: 0, + right: map.width, + top: 0, + bottom: map.height, + }); + + return cells.map((nodePos) => { + const node = table.node.nodeAt(nodePos); + const pos = nodePos + table.start; + + return { pos, start: pos + 1, node }; + }); + } + + return null; +}; + +const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: PMNode) => boolean) => { + for (let i = $pos.depth; i > 0; i -= 1) { + const node = $pos.node(i); + + if (predicate(node)) { + return { + pos: i > 0 ? $pos.before(i) : 0, + start: $pos.start(i), + depth: i, + node, + }; + } + } + + return null; +}; + +const findCellClosestToPos = ($pos: ResolvedPos) => { + const predicate = (node: PMNode) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole); + + return findParentNodeClosestToPos($pos, predicate); +}; + +const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => { + const table = findTable(tr.selection); + const isRowSelection = type === 'row'; + + if (table) { + const map = TableMap.get(table.node); + + // Check if the index is valid + if (index >= 0 && index < (isRowSelection ? map.height : map.width)) { + const left = isRowSelection ? 0 : index; + const top = isRowSelection ? index : 0; + const right = isRowSelection ? map.width : index + 1; + const bottom = isRowSelection ? index + 1 : map.height; + + const cellsInFirstRow = map.cellsInRect({ + left, + top, + right: isRowSelection ? right : left + 1, + bottom: isRowSelection ? top + 1 : bottom, + }); + + const cellsInLastRow = + bottom - top === 1 + ? cellsInFirstRow + : map.cellsInRect({ + left: isRowSelection ? left : right - 1, + top: isRowSelection ? bottom - 1 : top, + right, + bottom, + }); + + const head = table.start + cellsInFirstRow[0]; + const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1]; + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + return tr; +}; + +const selectColumn = select('column'); + +const selectRow = select('row'); + +const selectTable = (tr: Transaction) => { + const table = findTable(tr.selection); + + if (table) { + const { map } = TableMap.get(table.node); + + if (map && map.length) { + const head = table.start + map[0]; + const anchor = table.start + map[map.length - 1]; + const $head = tr.doc.resolve(head); + const $anchor = tr.doc.resolve(anchor); + + return tr.setSelection(new CellSelection($anchor, $head)); + } + } + + return tr; +}; + +const isColumnGripSelected = ({ + editor, + view, + state, + from, +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) { + return false; + } + + let container = node; + + while (container && !['TD', 'TH'].includes(container.tagName)) { + container = container.parentElement!; + } + + const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected'); + + return !!gripColumn; +}; + +export const isRowGripSelected = ({ + editor, + view, + state, + from, +}: { + editor: Editor; + view: EditorView; + state: EditorState; + from: number; +}) => { + const domAtPos = view.domAtPos(from).node as HTMLElement; + const nodeDOM = view.nodeDOM(from) as HTMLElement; + const node = nodeDOM || domAtPos; + + if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) { + return false; + } + + let container = node; + + while (container && !['TD', 'TH'].includes(container.tagName)) { + container = container.parentElement!; + } + + const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected'); + + return !!gripRow; +}; diff --git a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts index 68be91b9fe04..7da80dbf7cc9 100644 --- a/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts +++ b/src/Umbraco.Web.UI.Client/src/external/tiptap/index.ts @@ -36,3 +36,4 @@ export * from './extensions/tiptap-html-global-attributes.extension.js'; export * from './extensions/tiptap-umb-embedded-media.extension.js'; export * from './extensions/tiptap-umb-image.extension.js'; export * from './extensions/tiptap-umb-link.extension.js'; +export * from './extensions/tiptap-umb-table.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts index 3f953367b65f..43e95c192fa7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/cascading-menu-popover/cascading-menu-popover.element.ts @@ -21,8 +21,10 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { return this.shadowRoot?.querySelector(`#${popoverId}`) as UUIPopoverContainerElement; } - #onMouseEnter(popoverId: string) { - const popover = this.#getPopoverById(popoverId); + #onMouseEnter(item: UmbCascadingMenuItem) { + if (!item.items?.length) return; + + const popover = this.#getPopoverById(item.unique); if (!popover) return; // TODO: This ignorer is just neede for JSON SCHEMA TO WORK, As its not updated with latest TS jet. @@ -31,8 +33,8 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { popover.showPopover(); } - #onMouseLeave(popoverId: string) { - const popover = this.#getPopoverById(popoverId); + #onMouseLeave(item: UmbCascadingMenuItem) { + const popover = this.#getPopoverById(item.unique); if (!popover) return; // TODO: This ignorer is just neede for JSON SCHEMA TO WORK, As its not updated with latest TS jet. @@ -45,7 +47,7 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { item.execute?.(); setTimeout(() => { - this.#onMouseLeave(item.unique); + this.#onMouseLeave(item); }, 100); } @@ -74,15 +76,15 @@ export class UmbCascadingMenuPopoverElement extends UUIPopoverContainerElement { element.setAttribute('popovertarget', item.unique); } return html` -
this.#onMouseEnter(item.unique)} @mouseleave=${() => this.#onMouseLeave(item.unique)}> +
this.#onMouseEnter(item)} @mouseleave=${() => this.#onMouseLeave(item)}> ${when( element, () => element, () => html` this.#onClick(item)} - class=${item.separatorAfter ? 'separator' : ''}> + @click-label=${() => this.#onClick(item)}> ${when(item.icon, (icon) => html``)}