From 9dfa110cb8fbdcca86bbe96befeac15bbaa9fdab Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Fri, 28 Feb 2025 13:38:54 +0100 Subject: [PATCH 01/58] 15.3: Hotfix: Content type discard changes (#18490) * wrap in entity detail workspace element * use element on media type and member type * add developer console warning * sync current data with owner content type * Update content-type-workspace-context-base.ts * fix lint error --- .../content-type-workspace-context-base.ts | 49 ++++++++++++++----- .../document-type-workspace-editor.element.ts | 4 +- .../media-type-workspace-editor.element.ts | 4 +- .../member-type-workspace-editor.element.ts | 4 +- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts index a37960f622..74c3f510d1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/content-type/workspace/content-type-workspace-context-base.ts @@ -10,7 +10,7 @@ import { type UmbRoutableWorkspaceContext, } from '@umbraco-cms/backoffice/workspace'; import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; -import { jsonStringComparison, type Observable } from '@umbraco-cms/backoffice/observable-api'; +import type { Observable } from '@umbraco-cms/backoffice/observable-api'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbRequestReloadChildrenOfEntityEvent, @@ -21,6 +21,8 @@ import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UmbContentTypeWorkspaceContextArgs extends UmbEntityDetailWorkspaceContextArgs {} +const LOADING_STATE_UNIQUE = 'umbLoadingContentTypeDetail'; + export abstract class UmbContentTypeWorkspaceContextBase< DetailModelType extends UmbContentTypeDetailModel = UmbContentTypeDetailModel, DetailRepositoryType extends UmbDetailRepository = UmbDetailRepository, @@ -61,6 +63,9 @@ export abstract class UmbContentTypeWorkspaceContextBase< this.allowedContentTypes = this.structure.ownerContentTypeObservablePart((data) => data?.allowedContentTypes); this.compositions = this.structure.ownerContentTypeObservablePart((data) => data?.compositions); this.collection = this.structure.ownerContentTypeObservablePart((data) => data?.collection); + + // Keep current data in sync with the owner content type - This is used for the discard changes feature + this.observe(this.structure.ownerContentType, (data) => this._data.setCurrent(data)); } /** @@ -72,21 +77,27 @@ export abstract class UmbContentTypeWorkspaceContextBase< args: UmbEntityDetailWorkspaceContextCreateArgs, ): Promise { this.resetState(); + this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Creating ${this.getEntityType()} scaffold` }); this.setParent(args.parent); const request = this.structure.createScaffold(args.preset); this._getDataPromise = request; let { data } = await request; - if (!data) return undefined; - this.setUnique(data.unique); + if (data) { + data = await this._scaffoldProcessData(data); - if (this.modalContext) { - data = { ...data, ...this.modalContext.data.preset }; + if (this.modalContext) { + // Notice if the preset comes with values, they will overwrite the scaffolded values... [NL] + data = { ...data, ...this.modalContext.data.preset }; + } + + this.setUnique(data.unique); + this.setIsNew(true); + this._data.setPersisted(data); } - this.setIsNew(true); - this._data.setPersisted(data); + this.loading.removeState(LOADING_STATE_UNIQUE); return data; } @@ -97,8 +108,13 @@ export abstract class UmbContentTypeWorkspaceContextBase< * @returns { Promise } The loaded data */ override async load(unique: string) { + if (unique === this.getUnique() && this._getDataPromise) { + return (await this._getDataPromise) as any; + } + this.resetState(); this.setUnique(unique); + this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Loading ${this.getEntityType()} Details` }); this._getDataPromise = this.structure.loadType(unique); const response = await this._getDataPromise; const data = response.data; @@ -106,11 +122,24 @@ export abstract class UmbContentTypeWorkspaceContextBase< if (data) { this._data.setPersisted(data); this.setIsNew(false); + + this.observe( + response.asObservable(), + (entity: any) => this.#onDetailStoreChange(entity), + 'umbContentTypeDetailStoreObserver', + ); } + this.loading.removeState(LOADING_STATE_UNIQUE); return response; } + #onDetailStoreChange(entity: DetailModelType | undefined) { + if (!entity) { + this._data.clear(); + } + } + /** * Creates the Content Type * @param { DetailModelType } currentData The current data @@ -232,12 +261,6 @@ export abstract class UmbContentTypeWorkspaceContextBase< return this.structure.getOwnerContentType(); } - protected override _getHasUnpersistedChanges(): boolean { - const currentData = this.structure.getOwnerContentType(); - const persistedData = this._data.getPersisted(); - return jsonStringComparison(persistedData, currentData) === false; - } - public override destroy(): void { this.structure.destroy(); super.destroy(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace-editor.element.ts index 6fe2558d9d..be615b0227 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/workspace/document-type/document-type-workspace-editor.element.ts @@ -78,7 +78,7 @@ export class UmbDocumentTypeWorkspaceEditorElement extends UmbLitElement { override render() { return html` - + - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace-editor.element.ts index 4c7fd19afb..10c68fbe84 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/workspace/media-type-workspace-editor.element.ts @@ -82,7 +82,7 @@ export class UmbMediaTypeWorkspaceEditorElement extends UmbLitElement { override render() { return html` - + - + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace-editor.element.ts index c9d8a5f545..521a3a54c5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/workspace/member-type-workspace-editor.element.ts @@ -77,7 +77,7 @@ export class UmbMemberTypeWorkspaceEditorElement extends UmbLitElement { override render() { return html` - + - + `; } From 2ebc6badd701667ee8073fe88de1cd1c000d3f6a Mon Sep 17 00:00:00 2001 From: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:11:07 +0700 Subject: [PATCH 02/58] V15 QA Publish E2E test results in the Azure pipeline (#18498) * Updated playwright config to export the Junit report * Published the Junit report * Fixed * Updated outputDir * Make one test failed * Changed npm command * Fixed outputFile * Updated folder to export test result * Make another tests failed * Updated publish test results job * Changed testSQLite command * Updated testRunTitle * Changed npm command * Updated testRunTitle * Reverted * Fixed comment * Make some tests failed to test * Reverted --- build/azure-pipelines.yml | 26 +++++++++++++++++-- build/nightly-E2E-test-pipelines.yml | 20 ++++++++++++++ .../playwright.config.ts | 3 ++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 966c4b982e..781e2e69fd 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -623,13 +623,24 @@ stages: displayName: Copy Playwright results condition: succeededOrFailed() - # Publish + # Publish test artifacts - task: PublishPipelineArtifact@1 displayName: Publish test artifacts condition: succeededOrFailed() inputs: targetPath: $(Build.ArtifactStagingDirectory) artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)" + + # Publish test results + - task: PublishTestResults@2 + displayName: "Publish test results" + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '*.xml' + searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results" + testRunTitle: "$(Agent.JobName)" + - job: displayName: E2E Tests (SQL Server) variables: @@ -788,13 +799,24 @@ stages: displayName: Copy Playwright results condition: succeededOrFailed() - # Publish + # Publish test artifacts - task: PublishPipelineArtifact@1 displayName: Publish test artifacts condition: succeededOrFailed() inputs: targetPath: $(Build.ArtifactStagingDirectory) artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)" + + # Publish test results + - task: PublishTestResults@2 + displayName: "Publish test results" + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '*.xml' + searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results" + testRunTitle: "$(Agent.JobName)" + ############################################### ## Release ############################################### diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 9435aadfd8..8cbb065cd7 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -248,6 +248,16 @@ stages: targetPath: $(Build.ArtifactStagingDirectory) artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)" + # Publish test results + - task: PublishTestResults@2 + displayName: "Publish test results" + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '*.xml' + searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results" + testRunTitle: "$(Agent.JobName)" + - job: displayName: E2E Tests (SQL Server) timeoutInMinutes: 180 @@ -418,3 +428,13 @@ stages: inputs: targetPath: $(Build.ArtifactStagingDirectory) artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)" + + # Publish test results + - task: PublishTestResults@2 + displayName: "Publish test results" + condition: succeededOrFailed() + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '*.xml' + searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results" + testRunTitle: "$(Agent.JobName)" diff --git a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts index 00483cdc83..5a784172c2 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts @@ -23,7 +23,8 @@ export default defineConfig({ // We don't want to run parallel, as tests might differ in state workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: process.env.CI ? 'line' : 'html', + //reporter: process.env.CI ? 'line' : 'html', + reporter: process.env.CI ? [['line'], ['junit', {outputFile: 'results/results.xml'}]] : 'html', outputDir: "./results", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { From f6b4ddf59896da12f781bb81016a0901d193ad9e Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 3 Mar 2025 13:13:50 +0100 Subject: [PATCH 03/58] Localize the email property editor validation and add tests (#18461) * Localize the email property editor validation and add tests. * Reverted trim to ensure behaviour for whitespace is unchanged. --- .../Validators/EmailValidator.cs | 33 +++++++++--- .../EmailAddressPropertyEditor.cs | 39 +++++++++++---- .../Validators/EmailValidatorTests.cs | 50 +++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/EmailValidatorTests.cs diff --git a/src/Umbraco.Core/PropertyEditors/Validators/EmailValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/EmailValidator.cs index 592b2dc2c7..e8866622b8 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/EmailValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/EmailValidator.cs @@ -1,24 +1,45 @@ using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.Validators; /// -/// A validator that validates an email address +/// A validator that validates an email address. /// public sealed class EmailValidator : IValueValidator { + private readonly ILocalizedTextService _localizedTextService; + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] + public EmailValidator() + : this(StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public EmailValidator(ILocalizedTextService localizedTextService) => _localizedTextService = localizedTextService; + /// public IEnumerable Validate(object? value, string? valueType, object? dataTypeConfiguration, PropertyValidationContext validationContext) { - var asString = value == null ? string.Empty : value.ToString(); + var valueAsString = value?.ToString() ?? string.Empty; - var emailVal = new EmailAddressAttribute(); + var emailAddressAttribute = new EmailAddressAttribute(); - if (asString != string.Empty && emailVal.IsValid(asString) == false) + if (valueAsString != string.Empty && emailAddressAttribute.IsValid(valueAsString) == false) { - // TODO: localize these! - yield return new ValidationResult("Email is invalid", new[] { "value" }); + yield return new ValidationResult( + _localizedTextService.Localize("validation", "invalidEmail", [valueAsString]), + ["value"]); } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs index cbd7ef3a8c..4a2c18b3b5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/EmailAddressPropertyEditor.cs @@ -1,29 +1,50 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors.Validators; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Core.PropertyEditors; +/// +/// Defines an email address property editor. +/// [DataEditor( Constants.PropertyEditors.Aliases.EmailAddress, ValueEditorIsReusable = true)] public class EmailAddressPropertyEditor : DataEditor { - /// - /// The constructor will setup the property editor based on the attribute if one is found - /// - public EmailAddressPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) - : base(dataValueEditorFactory) - => SupportsReadOnly = true; + private readonly ILocalizedTextService _localizedTextService; + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 17.")] + public EmailAddressPropertyEditor(IDataValueEditorFactory dataValueEditorFactory) + : this( + dataValueEditorFactory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public EmailAddressPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, ILocalizedTextService localizedTextService) + : base(dataValueEditorFactory) + { + SupportsReadOnly = true; + _localizedTextService = localizedTextService; + } + + /// protected override IDataValueEditor CreateValueEditor() { IDataValueEditor editor = base.CreateValueEditor(); - - // add an email address validator - editor.Validators.Add(new EmailValidator()); + editor.Validators.Add(new EmailValidator(_localizedTextService)); return editor; } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/EmailValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/EmailValidatorTests.cs new file mode 100644 index 0000000000..24d8bc67a7 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/EmailValidatorTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Globalization; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.Validators; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Validators; + +[TestFixture] +public class EmailValidatorTests +{ + [TestCase(null, true)] + [TestCase("", true)] + [TestCase(" ", false)] + [TestCase("test@test.com", true)] + [TestCase("invalid", false)] + public void Validates_Email_Address(object? email, bool expectedSuccess) + { + var validator = CreateValidator(); + var result = validator.Validate(email, ValueTypes.String, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual("validation_invalidEmail", validationResult.ErrorMessage); + } + } + + private static EmailValidator CreateValidator() + { + var localizedTextServiceMock = new Mock(); + localizedTextServiceMock.Setup(x => x.Localize( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + return new EmailValidator(localizedTextServiceMock.Object); + } +} From 33d83e1491dce0adc0fb1b16dde279ed5d1a31d5 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 3 Mar 2025 13:16:23 +0100 Subject: [PATCH 04/58] Added obsoletion messages for unused interface and implementation for cache rebuilds (#18524) --- .../Migrations/PostMigrations/CacheRebuilder.cs | 1 + .../Migrations/PostMigrations/ICacheRebuilder.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Umbraco.Infrastructure/Migrations/PostMigrations/CacheRebuilder.cs b/src/Umbraco.Infrastructure/Migrations/PostMigrations/CacheRebuilder.cs index 29ba8b3878..618391b4ad 100644 --- a/src/Umbraco.Infrastructure/Migrations/PostMigrations/CacheRebuilder.cs +++ b/src/Umbraco.Infrastructure/Migrations/PostMigrations/CacheRebuilder.cs @@ -7,6 +7,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.PostMigrations; /// /// Implements in Umbraco.Web (rebuilding). /// +[Obsolete("This is no longer used. Scheduled for removal in Umbraco 17.")] public class CacheRebuilder : ICacheRebuilder { private readonly DistributedCache _distributedCache; diff --git a/src/Umbraco.Infrastructure/Migrations/PostMigrations/ICacheRebuilder.cs b/src/Umbraco.Infrastructure/Migrations/PostMigrations/ICacheRebuilder.cs index ee2e72ee52..5e53a11a53 100644 --- a/src/Umbraco.Infrastructure/Migrations/PostMigrations/ICacheRebuilder.cs +++ b/src/Umbraco.Infrastructure/Migrations/PostMigrations/ICacheRebuilder.cs @@ -10,6 +10,7 @@ namespace Umbraco.Cms.Infrastructure.Migrations.PostMigrations; /// be refactored, really. /// /// +[Obsolete("This is no longer used. Scheduled for removal in Umbraco 17.")] public interface ICacheRebuilder { /// From 865a2cd83aa65582dafdf5d31717d04439178626 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 3 Mar 2025 13:23:55 +0100 Subject: [PATCH 05/58] Added tests and localization to radio button validation (#18512) * Added tests and localization to radio button validation. * Remove unnecessary ToString * Add danish translations --------- Co-authored-by: mole --- .../EmbeddedResources/Lang/da.xml | 1 + .../EmbeddedResources/Lang/en.xml | 1 + .../EmbeddedResources/Lang/en_us.xml | 1 + .../PropertyEditors/RadioValueEditor.cs | 3 +- .../Validators/RadioValueValidator.cs | 3 +- .../RadioButtonsPropertyEditor.cs | 77 +++++++++++++++++-- .../RadioButtonsPropertyValueEditorTests.cs | 60 +++++++++++++++ 7 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/RadioButtonsPropertyValueEditorTests.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 1fb0445bb1..7f0c7228e4 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -121,6 +121,7 @@ Mange hilsner fra Umbraco robotten Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%. Værdien %0% forventes ikke at indeholde et spænd. Værdien %0% forventes at have en værdi der er større end fra værdien. + "Værdien '%0%' er ikke en af de tilgængelige valgmuligheder. Slettet indhold med Id: {0} Relateret til original "parent" med id: {1} diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index e1fdba85d4..8925328ee7 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -395,6 +395,7 @@ The chosen media type is invalid. Multiple selected media is not allowed. The selected media is from the wrong folder. + "The value '%0%' is not one of the available options. - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets/logo_light.svg b/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets/logo_light.svg index 01f7260cd3..2cf6f016b5 100644 --- a/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets/logo_light.svg +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/umbraco/assets/logo_light.svg @@ -1,51 +1 @@ - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs index 28c9ad1427..0b2789180e 100644 --- a/src/Umbraco.Core/Configuration/Models/ContentSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/ContentSettings.cs @@ -23,6 +23,7 @@ public class ContentSettings internal const string StaticLoginLogoImage = "assets/logo_light.svg"; internal const string StaticLoginLogoImageAlternative = "assets/logo_dark.svg"; internal const string StaticBackOfficeLogo = "assets/logo.svg"; + internal const string StaticBackOfficeLogoAlternative = "assets/logo_blue.svg"; internal const bool StaticHideBackOfficeLogo = false; internal const bool StaticDisableDeleteWhenReferenced = false; internal const bool StaticDisableUnpublishWhenReferenced = false; @@ -88,9 +89,18 @@ public class ContentSettings /// /// Gets or sets a value for the path to the backoffice logo. /// + /// The alternative version of this logo can be found at . [DefaultValue(StaticBackOfficeLogo)] public string BackOfficeLogo { get; set; } = StaticBackOfficeLogo; + /// + /// Gets or sets a value for the path to the alternative backoffice logo, which can be shown + /// on top of a light background. + /// + /// This is the alternative version to the regular logo found at . + [DefaultValue(StaticBackOfficeLogoAlternative)] + public string BackOfficeLogoAlternative { get; set; } = StaticBackOfficeLogoAlternative; + /// /// Gets or sets a value indicating whether to hide the backoffice umbraco logo or not. /// diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts index 6c885e274f..ee1b7557f7 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app-logo.element.ts @@ -1,17 +1,59 @@ import { UMB_APP_CONTEXT } from './app.context.js'; +import { customElement, html, nothing, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { customElement, html, nothing, state } from '@umbraco-cms/backoffice/external/lit'; +import { UMB_THEME_CONTEXT } from '@umbraco-cms/backoffice/themes'; @customElement('umb-app-logo') export class UmbAppLogoElement extends UmbLitElement { + /** + * The loading attribute of the image. + * @type {'lazy' | 'eager'} + * @default 'eager' + */ + @property() + loading: 'lazy' | 'eager' = 'lazy'; + + /** + * The type of logo to display. Mark will display the mark logo, and logo will display the full logo with text. + * @type {'mark' | 'logo'} + * @default 'mark' + */ + @property({ type: String, attribute: 'logo-type' }) + logoType: 'mark' | 'logo' = 'mark'; + + /** + * Override the application theme, for example if you want to display the dark theme logo on a light theme. + * @example 'umb-dark-theme' + * @type {string} + * @default undefined + */ + @property({ attribute: 'override-theme' }) + overrideTheme?: string; + @state() - private _logoUrl?: string; + private _serverUrl?: string; + + /** + * The theme of the application. + */ + @state() + private _theme?: string; constructor() { super(); this.consumeContext(UMB_APP_CONTEXT, (instance) => { - this._logoUrl = `${instance.getServerUrl()}/umbraco/management/api/v1/security/back-office/graphics/logo`; + this._serverUrl = instance.getServerUrl(); + }); + + this.consumeContext(UMB_THEME_CONTEXT, (context) => { + this.observe( + context.theme, + (theme) => { + this._theme = theme; + }, + '_observeTheme', + ); }); } @@ -24,11 +66,23 @@ export class UmbAppLogoElement extends UmbLitElement { } override render() { - if (!this._logoUrl) { + if (!this._serverUrl) { return nothing; } - return html``; + /** + * This is a temporary solution until we have a better way to define the logo characteristics. + * TODO: The characteristics of the logo are not defined in any theme meta data, so we have to hardcode the logo file names. + */ + let logoFile = (this.overrideTheme ?? this._theme) === 'umb-dark-theme' ? 'logo' : 'logo-alternative'; + + if (this.logoType === 'logo') { + logoFile = `login-${logoFile}`; + } + + const logoUrl = `${this._serverUrl}/umbraco/management/api/v1/security/back-office/graphics/${logoFile}`; + + return html``; } } diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts index fe4c3af09a..03af4fdac5 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-logo.element.ts @@ -1,13 +1,22 @@ import { UMB_BACKOFFICE_CONTEXT } from '../backoffice.context.js'; -import { isCurrentUserAnAdmin } from '@umbraco-cms/backoffice/current-user'; import { css, html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; +import { isCurrentUserAnAdmin } from '@umbraco-cms/backoffice/current-user'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_APP_CONTEXT } from '@umbraco-cms/backoffice/app'; import { UMB_MODAL_MANAGER_CONTEXT } from '@umbraco-cms/backoffice/modal'; import { UMB_NEWVERSION_MODAL, UMB_SYSINFO_MODAL } from '@umbraco-cms/backoffice/sysinfo'; import type { UmbServerUpgradeCheck } from '@umbraco-cms/backoffice/sysinfo'; +/** + * The backoffice header logo element. + * @cssprop --umb-header-logo-display - The display property of the header logo. + * @cssprop --umb-header-logo-margin - The margin of the header logo. + * @cssprop --umb-header-logo-width - The width of the header logo. + * @cssprop --umb-header-logo-height - The height of the header logo. + * @cssprop --umb-logo-display - The display property of the logo. + * @cssprop --umb-logo-width - The width of the logo. + * @cssprop --umb-logo-height - The height of the logo. + */ @customElement('umb-backoffice-header-logo') export class UmbBackofficeHeaderLogoElement extends UmbLitElement { @state() @@ -19,9 +28,6 @@ export class UmbBackofficeHeaderLogoElement extends UmbLitElement { @state() private _serverUpgradeCheck: UmbServerUpgradeCheck | null = null; - @state() - private _serverUrl = ''; - #backofficeContext?: typeof UMB_BACKOFFICE_CONTEXT.TYPE; constructor() { @@ -39,10 +45,6 @@ export class UmbBackofficeHeaderLogoElement extends UmbLitElement { this.#backofficeContext = context; }); - - this.consumeContext(UMB_APP_CONTEXT, (context) => { - this._serverUrl = context.getServerUrl(); - }); } protected override async firstUpdated() { @@ -59,19 +61,13 @@ export class UmbBackofficeHeaderLogoElement extends UmbLitElement { override render() { return html` - /// The string builder. - public static void WriteHeader(StringBuilder sb) => TextHeaderWriter.WriteHeader(sb); + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")] + public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true); + + /// + /// Outputs an "auto-generated" header to a string builder. + /// + /// The string builder. + /// Flag indicating whether the tool version number should be included in the output. + public static void WriteHeader(StringBuilder sb, bool includeVersion) => TextHeaderWriter.WriteHeader(sb, includeVersion); /// /// Outputs a generated model to a string builder. @@ -60,7 +68,7 @@ public class TextBuilder : Builder /// The model to generate. public void Generate(StringBuilder sb, TypeModel typeModel) { - WriteHeader(sb); + WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels); foreach (var t in TypesUsing) { @@ -83,7 +91,7 @@ public class TextBuilder : Builder /// The models to generate. public void Generate(StringBuilder sb, IEnumerable typeModels) { - WriteHeader(sb); + WriteHeader(sb, Config.IncludeVersionNumberInGeneratedModels); foreach (var t in TypesUsing) { diff --git a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextHeaderWriter.cs b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextHeaderWriter.cs index 5a532cbdba..9ab59516d7 100644 --- a/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextHeaderWriter.cs +++ b/src/Umbraco.Infrastructure/ModelsBuilder/Building/TextHeaderWriter.cs @@ -8,13 +8,30 @@ internal static class TextHeaderWriter /// Outputs an "auto-generated" header to a string builder. /// /// The string builder. - public static void WriteHeader(StringBuilder sb) + [Obsolete("Please use the overload taking all parameters. Scheduled for removal in Umbraco 17.")] + public static void WriteHeader(StringBuilder sb) => WriteHeader(sb, true); + + /// + /// Outputs an "auto-generated" header to a string builder. + /// + /// The string builder. + /// Flag indicating whether the tool version number should be included in the output. + public static void WriteHeader(StringBuilder sb, bool includeVersion) { sb.Append("//------------------------------------------------------------------------------\n"); sb.Append("// \n"); sb.Append("// This code was generated by a tool.\n"); sb.Append("//\n"); - sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version); + + if (includeVersion) + { + sb.AppendFormat("// Umbraco.ModelsBuilder.Embedded v{0}\n", ApiVersion.Current.Version); + } + else + { + sb.Append("// Umbraco.ModelsBuilder.Embedded\n"); + } + sb.Append("//\n"); sb.Append("// Changes to this file will be lost if the code is regenerated.\n"); sb.Append("// \n"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.ModelsBuilder.Embedded/BuilderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.ModelsBuilder.Embedded/BuilderTests.cs index 3327e6f27d..86838d921d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.ModelsBuilder.Embedded/BuilderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.ModelsBuilder.Embedded/BuilderTests.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Collections.Generic; -using System.Linq; using System.Text; using NUnit.Framework; using Umbraco.Cms.Core.Configuration.Models; @@ -118,6 +116,102 @@ namespace Umbraco.Cms.Web.Common.PublishedModels Assert.AreEqual(expected.ClearLf(), gen); } + [Test] + public void GenerateSimpleType_WithoutVersion() + { + // Umbraco returns nice, pascal-cased names. + var type1 = new TypeModel + { + Id = 1, + Alias = "type1", + ClrName = "Type1", + Name = "type1Name", + ParentId = 0, + BaseType = null, + ItemType = TypeModel.ItemTypes.Content, + }; + type1.Properties.Add(new PropertyModel + { + Alias = "prop1", + ClrName = "Prop1", + Name = "prop1Name", + ModelClrType = typeof(string), + }); + + TypeModel[] types = { type1 }; + + var modelsBuilderConfig = new ModelsBuilderSettings { IncludeVersionNumberInGeneratedModels = false }; + var builder = new TextBuilder(modelsBuilderConfig, types); + + var sb = new StringBuilder(); + builder.Generate(sb, builder.GetModelsToGenerate().First()); + var gen = sb.ToString(); + + var expected = @"//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Umbraco.ModelsBuilder.Embedded +// +// Changes to this file will be lost if the code is regenerated. +// +//------------------------------------------------------------------------------ + +using System; +using System.Linq.Expressions; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.ModelsBuilder; +using Umbraco.Cms.Core; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Common.PublishedModels +{ + /// type1Name + [PublishedModel(""type1"")] + public partial class Type1 : PublishedContentModel + { + // helpers +#pragma warning disable 0109 // new is redundant + [global::System.CodeDom.Compiler.GeneratedCodeAttribute(""Umbraco.ModelsBuilder.Embedded"", """")] + public new const string ModelTypeAlias = ""type1""; + [global::System.CodeDom.Compiler.GeneratedCodeAttribute(""Umbraco.ModelsBuilder.Embedded"", """")] + public new const PublishedItemType ModelItemType = PublishedItemType.Content; + [global::System.CodeDom.Compiler.GeneratedCodeAttribute(""Umbraco.ModelsBuilder.Embedded"", """")] + [return: global::System.Diagnostics.CodeAnalysis.MaybeNull] + public new static IPublishedContentType GetModelContentType(IPublishedSnapshotAccessor publishedSnapshotAccessor) + => PublishedModelUtility.GetModelContentType(publishedSnapshotAccessor, ModelItemType, ModelTypeAlias); + [global::System.CodeDom.Compiler.GeneratedCodeAttribute(""Umbraco.ModelsBuilder.Embedded"", """")] + [return: global::System.Diagnostics.CodeAnalysis.MaybeNull] + public static IPublishedPropertyType GetModelPropertyType(IPublishedSnapshotAccessor publishedSnapshotAccessor, Expression> selector) + => PublishedModelUtility.GetModelPropertyType(GetModelContentType(publishedSnapshotAccessor), selector); +#pragma warning restore 0109 + + private IPublishedValueFallback _publishedValueFallback; + + // ctor + public Type1(IPublishedContent content, IPublishedValueFallback publishedValueFallback) + : base(content, publishedValueFallback) + { + _publishedValueFallback = publishedValueFallback; + } + + // properties + + /// + /// prop1Name + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute(""Umbraco.ModelsBuilder.Embedded"", """")] + [global::System.Diagnostics.CodeAnalysis.MaybeNull] + [ImplementPropertyType(""prop1"")] + public virtual string Prop1 => this.Value(_publishedValueFallback, ""prop1""); + } +} +"; + Console.WriteLine(gen); + Assert.AreEqual(expected.ClearLf(), gen); + } + [Test] public void GenerateSimpleType_Ambiguous_Issue() { From 3491afefbb19cadb1af112f3fdf64f80a3ddbc82 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 4 Mar 2025 12:09:56 +0100 Subject: [PATCH 22/58] fix block grid custom views (#18558) --- .../components/block-grid-entry/block-grid-entry.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 008acd78ca..80807a87b1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -446,7 +446,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper }; override render() { - return this.contentKey + return this.contentKey && (this._contentTypeAlias || this._unsupported) ? html` ${this.#renderCreateBeforeInlineButton()}
From 7e4dda7bab0fefdb896f896930b14782c89105b1 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 4 Mar 2025 11:23:45 +0100 Subject: [PATCH 23/58] fix block grid custom views --- .../components/block-grid-entry/block-grid-entry.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 008acd78ca..80807a87b1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -446,7 +446,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper }; override render() { - return this.contentKey + return this.contentKey && (this._contentTypeAlias || this._unsupported) ? html` ${this.#renderCreateBeforeInlineButton()}
From 396b5ea21107f2456c36414503b1a3a62ca55087 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 12:12:29 +0100 Subject: [PATCH 24/58] Avoids collection was modified issue when flowing identities to the authenticated user's principal. (#18527) --- .../Extensions/HttpContextExtensions.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs b/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs index fd46ef6903..cf90229513 100644 --- a/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/HttpContextExtensions.cs @@ -68,7 +68,13 @@ public static class HttpContextExtensions // Otherwise we can't log in as both a member and a backoffice user // For instance if you've enabled basic auth. ClaimsPrincipal? authenticatedPrincipal = result.Principal; - IEnumerable existingIdentities = httpContext.User.Identities.Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType); + + // Make sure to copy into a list before attempting to update the authenticated principal, so we don't attempt to modify + // the collection while iterating it. + // See: https://github.com/umbraco/Umbraco-CMS/issues/18509 + var existingIdentities = httpContext.User.Identities + .Where(x => x.IsAuthenticated && x.AuthenticationType != authenticatedPrincipal.Identity.AuthenticationType) + .ToList(); authenticatedPrincipal.AddIdentities(existingIdentities); httpContext.User = authenticatedPrincipal; From 6d5b6a4553c5b14de7c53e2968bbdc24e6c7ef89 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 13:13:37 +0100 Subject: [PATCH 25/58] Server side validation for property editors (colour picker) (#18557) * Added server-side validation for colour picker. * Minor refactor. * Add danish translation --------- Co-authored-by: mole --- .../EmbeddedResources/Lang/da.xml | 1 + .../EmbeddedResources/Lang/en.xml | 3 +- .../EmbeddedResources/Lang/en_us.xml | 3 +- .../ColorPickerPropertyEditor.cs | 81 +++++++++++++++++++ .../PropertyEditors/SliderPropertyEditor.cs | 1 - .../ColorPickerValueEditorTests.cs | 63 +++++++++++++++ 6 files changed, 149 insertions(+), 3 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerValueEditorTests.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 7f0c7228e4..071e0e03f4 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -122,6 +122,7 @@ Mange hilsner fra Umbraco robotten Værdien %0% forventes ikke at indeholde et spænd. Værdien %0% forventes at have en værdi der er større end fra værdien. "Værdien '%0%' er ikke en af de tilgængelige valgmuligheder. + "Den valgte farve '%0%' er ikke en af de tilgængelige valgmuligheder. Slettet indhold med Id: {0} Relateret til original "parent" med id: {1} diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 8925328ee7..46c90d2b4f 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -395,7 +395,8 @@ The chosen media type is invalid. Multiple selected media is not allowed. The selected media is from the wrong folder. - "The value '%0%' is not one of the available options. + The value '%0%' is not one of the available options. + "The selected colour '%0%' is not one of the available options. @@ -46,22 +46,22 @@ - - + + - + - + - - - - + + + + @@ -80,7 +80,7 @@ - + @@ -91,7 +91,7 @@ - + From ca6da751f273574d85fb71ed7984f68aaa2be4de Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:08:08 +0100 Subject: [PATCH 28/58] Handle create and update validation for media picker. (#18537) --- .../MediaPicker3PropertyEditor.cs | 21 +++++++-- .../MediaPicker3ValueEditorValidationTests.cs | 43 ++++++++++++------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 14b6fd7525..c7c71ec9be 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -86,7 +86,7 @@ public class MediaPicker3PropertyEditor : DataEditor var validators = new TypedJsonValidatorRunner, MediaPicker3Configuration>( jsonSerializer, new MinMaxValidator(localizedTextService), - new AllowedTypeValidator(localizedTextService, mediaTypeService), + new AllowedTypeValidator(localizedTextService, mediaTypeService, _mediaService), new StartNodeValidator(localizedTextService, mediaNavigationQueryService)); Validators.Add(validators); @@ -367,11 +367,13 @@ public class MediaPicker3PropertyEditor : DataEditor { private readonly ILocalizedTextService _localizedTextService; private readonly IMediaTypeService _mediaTypeService; + private readonly IMediaService _mediaService; - public AllowedTypeValidator(ILocalizedTextService localizedTextService, IMediaTypeService mediaTypeService) + public AllowedTypeValidator(ILocalizedTextService localizedTextService, IMediaTypeService mediaTypeService, IMediaService mediaService) { _localizedTextService = localizedTextService; _mediaTypeService = mediaTypeService; + _mediaService = mediaService; } public IEnumerable Validate( @@ -393,7 +395,20 @@ public class MediaPicker3PropertyEditor : DataEditor return []; } - IEnumerable distinctTypeAliases = value.DistinctBy(x => x.MediaTypeAlias).Select(x => x.MediaTypeAlias); + // We may or may not have explicit MediaTypeAlias values provided, depending on whether the operation is an update or a + // create. So let's make sure we have them all. + IEnumerable providedTypeAliases = value + .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace() is false) + .Select(x => x.MediaTypeAlias); + + IEnumerable retrievedMediaKeys = value + .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace()) + .Select(x => x.MediaKey); + IEnumerable retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys); + IEnumerable retrievedTypeAliases = retrievedMedia + .Select(x => x.ContentType.Alias); + + IEnumerable distinctTypeAliases = providedTypeAliases.Union(retrievedTypeAliases).Distinct(); foreach (var typeAlias in distinctTypeAliases) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs index 4f6ee99ad4..ad82ce3179 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs @@ -22,7 +22,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase(false, false)] public void Validates_Start_Node_Immediate_Parent(bool shouldSucceed, bool hasValidParentKey) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid? validParentKey = Guid.NewGuid(); var mediaKey = Guid.NewGuid(); @@ -49,7 +49,7 @@ internal class MediaPicker3ValueEditorValidationTests [Test] public void Validates_Start_Node_Parent_Not_Found() { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid? parentKey = null; var mediaKey = Guid.NewGuid(); @@ -71,7 +71,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase(false, true, false)] public void Validates_Start_Node_Ancestor(bool shouldSucceed, bool findsAncestor, bool hasValidAncestorKey) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid ancestorKey = Guid.NewGuid(); Guid? parentKey = Guid.NewGuid(); @@ -90,26 +90,32 @@ internal class MediaPicker3ValueEditorValidationTests ValidateResult(shouldSucceed, result); } - [TestCase(true, true, true)] - [TestCase(false, true, false)] - [TestCase(false, false, true)] - public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool findsMediaType) + [TestCase(true, true, true, false)] + [TestCase(false, true, false, false)] + [TestCase(false, false, true, false)] + [TestCase(true, true, true, true)] + [TestCase(false, true, false, true)] + [TestCase(false, false, true, true)] + public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool findsMediaType, bool valueProvidesMediaTypeAlias) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, mediaServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); var mediaKey = Guid.NewGuid(); var mediaTypeKey = Guid.NewGuid(); var mediaTypeAlias = "Alias"; valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Filter = $"{mediaTypeKey}" }; var mediaTypeMock = new Mock(); + var mediaMock = new Mock(); if (hasAllowedType) { mediaTypeMock.Setup(x => x.Key).Returns(mediaTypeKey); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns(mediaTypeAlias); } else { mediaTypeMock.Setup(x => x.Key).Returns(Guid.NewGuid()); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns("AnotherAlias"); } if (findsMediaType) @@ -121,7 +127,13 @@ internal class MediaPicker3ValueEditorValidationTests mediaTypeServiceMock.Setup(x => x.Get(It.IsAny())).Returns((IMediaType)null); } - var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"" + mediaTypeAlias + "\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; + if (valueProvidesMediaTypeAlias is false) + { + mediaServiceMock.Setup(x => x.GetByIds(It.Is>(y => y.First() == mediaKey))).Returns([mediaMock.Object]); + } + + var providedMediaTypeAlias = valueProvidesMediaTypeAlias ? mediaTypeAlias : string.Empty; + var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"" + providedMediaTypeAlias + "\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty()); ValidateResult(shouldSucceed, result); @@ -134,7 +146,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", false, true)] public void Validates_Multiple(string value, bool multiple, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = multiple }; @@ -150,7 +162,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", 0, true)] public void Validates_Min_Limit(string value, int min, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = true, ValidationLimit = new MediaPicker3Configuration.NumberRange { Min = min } }; @@ -168,7 +180,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", 0, true)] public void Validates_Max_Limit(string value, int max, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = true, ValidationLimit = new MediaPicker3Configuration.NumberRange { Max = max } }; @@ -188,9 +200,10 @@ internal class MediaPicker3ValueEditorValidationTests } } - private static (MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor ValueEditor, Mock MediaTypeServiceMock, Mock MediaNavigationQueryServiceMock) CreateValueEditor() + private static (MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor ValueEditor, Mock MediaTypeServiceMock, Mock MediaServiceMock, Mock MediaNavigationQueryServiceMock) CreateValueEditor() { var mediaTypeServiceMock = new Mock(); + var mediaServiceMock = new Mock(); var mediaNavigationQueryServiceMock = new Mock(); var valueEditor = new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor( Mock.Of(), @@ -198,7 +211,7 @@ internal class MediaPicker3ValueEditorValidationTests Mock.Of(), new DataEditorAttribute("alias"), Mock.Of(), - Mock.Of(), + mediaServiceMock.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -210,6 +223,6 @@ internal class MediaPicker3ValueEditorValidationTests ConfigurationObject = new MediaPicker3Configuration() }; - return (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock); + return (valueEditor, mediaTypeServiceMock, mediaServiceMock, mediaNavigationQueryServiceMock); } } From d16489261979b8e0456990ec2681ce62b45ceb2e Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:08:08 +0100 Subject: [PATCH 29/58] Handle create and update validation for media picker. (#18537) --- .../MediaPicker3PropertyEditor.cs | 21 +++++++-- .../MediaPicker3ValueEditorValidationTests.cs | 43 ++++++++++++------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs index 14b6fd7525..c7c71ec9be 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MediaPicker3PropertyEditor.cs @@ -86,7 +86,7 @@ public class MediaPicker3PropertyEditor : DataEditor var validators = new TypedJsonValidatorRunner, MediaPicker3Configuration>( jsonSerializer, new MinMaxValidator(localizedTextService), - new AllowedTypeValidator(localizedTextService, mediaTypeService), + new AllowedTypeValidator(localizedTextService, mediaTypeService, _mediaService), new StartNodeValidator(localizedTextService, mediaNavigationQueryService)); Validators.Add(validators); @@ -367,11 +367,13 @@ public class MediaPicker3PropertyEditor : DataEditor { private readonly ILocalizedTextService _localizedTextService; private readonly IMediaTypeService _mediaTypeService; + private readonly IMediaService _mediaService; - public AllowedTypeValidator(ILocalizedTextService localizedTextService, IMediaTypeService mediaTypeService) + public AllowedTypeValidator(ILocalizedTextService localizedTextService, IMediaTypeService mediaTypeService, IMediaService mediaService) { _localizedTextService = localizedTextService; _mediaTypeService = mediaTypeService; + _mediaService = mediaService; } public IEnumerable Validate( @@ -393,7 +395,20 @@ public class MediaPicker3PropertyEditor : DataEditor return []; } - IEnumerable distinctTypeAliases = value.DistinctBy(x => x.MediaTypeAlias).Select(x => x.MediaTypeAlias); + // We may or may not have explicit MediaTypeAlias values provided, depending on whether the operation is an update or a + // create. So let's make sure we have them all. + IEnumerable providedTypeAliases = value + .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace() is false) + .Select(x => x.MediaTypeAlias); + + IEnumerable retrievedMediaKeys = value + .Where(x => x.MediaTypeAlias.IsNullOrWhiteSpace()) + .Select(x => x.MediaKey); + IEnumerable retrievedMedia = _mediaService.GetByIds(retrievedMediaKeys); + IEnumerable retrievedTypeAliases = retrievedMedia + .Select(x => x.ContentType.Alias); + + IEnumerable distinctTypeAliases = providedTypeAliases.Union(retrievedTypeAliases).Distinct(); foreach (var typeAlias in distinctTypeAliases) { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs index 4f6ee99ad4..ad82ce3179 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MediaPicker3ValueEditorValidationTests.cs @@ -22,7 +22,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase(false, false)] public void Validates_Start_Node_Immediate_Parent(bool shouldSucceed, bool hasValidParentKey) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid? validParentKey = Guid.NewGuid(); var mediaKey = Guid.NewGuid(); @@ -49,7 +49,7 @@ internal class MediaPicker3ValueEditorValidationTests [Test] public void Validates_Start_Node_Parent_Not_Found() { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid? parentKey = null; var mediaKey = Guid.NewGuid(); @@ -71,7 +71,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase(false, true, false)] public void Validates_Start_Node_Ancestor(bool shouldSucceed, bool findsAncestor, bool hasValidAncestorKey) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); Guid ancestorKey = Guid.NewGuid(); Guid? parentKey = Guid.NewGuid(); @@ -90,26 +90,32 @@ internal class MediaPicker3ValueEditorValidationTests ValidateResult(shouldSucceed, result); } - [TestCase(true, true, true)] - [TestCase(false, true, false)] - [TestCase(false, false, true)] - public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool findsMediaType) + [TestCase(true, true, true, false)] + [TestCase(false, true, false, false)] + [TestCase(false, false, true, false)] + [TestCase(true, true, true, true)] + [TestCase(false, true, false, true)] + [TestCase(false, false, true, true)] + public void Validates_Allowed_Type(bool shouldSucceed, bool hasAllowedType, bool findsMediaType, bool valueProvidesMediaTypeAlias) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, mediaServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); var mediaKey = Guid.NewGuid(); var mediaTypeKey = Guid.NewGuid(); var mediaTypeAlias = "Alias"; valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Filter = $"{mediaTypeKey}" }; var mediaTypeMock = new Mock(); + var mediaMock = new Mock(); if (hasAllowedType) { mediaTypeMock.Setup(x => x.Key).Returns(mediaTypeKey); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns(mediaTypeAlias); } else { mediaTypeMock.Setup(x => x.Key).Returns(Guid.NewGuid()); + mediaMock.SetupGet(x => x.ContentType.Alias).Returns("AnotherAlias"); } if (findsMediaType) @@ -121,7 +127,13 @@ internal class MediaPicker3ValueEditorValidationTests mediaTypeServiceMock.Setup(x => x.Get(It.IsAny())).Returns((IMediaType)null); } - var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"" + mediaTypeAlias + "\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; + if (valueProvidesMediaTypeAlias is false) + { + mediaServiceMock.Setup(x => x.GetByIds(It.Is>(y => y.First() == mediaKey))).Returns([mediaMock.Object]); + } + + var providedMediaTypeAlias = valueProvidesMediaTypeAlias ? mediaTypeAlias : string.Empty; + var value = "[ {\n \" key\" : \"20266ebe-1f7e-4cf3-a694-7a5fb210223b\",\n \"mediaKey\" : \"" + mediaKey + "\",\n \"mediaTypeAlias\" : \"" + providedMediaTypeAlias + "\",\n \"crops\" : [ ],\n \"focalPoint\" : null\n} ]"; var result = valueEditor.Validate(value, false, null, PropertyValidationContext.Empty()); ValidateResult(shouldSucceed, result); @@ -134,7 +146,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", false, true)] public void Validates_Multiple(string value, bool multiple, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = multiple }; @@ -150,7 +162,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", 0, true)] public void Validates_Min_Limit(string value, int min, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = true, ValidationLimit = new MediaPicker3Configuration.NumberRange { Min = min } }; @@ -168,7 +180,7 @@ internal class MediaPicker3ValueEditorValidationTests [TestCase("[]", 0, true)] public void Validates_Max_Limit(string value, int max, bool succeed) { - var (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock) = CreateValueEditor(); + var (valueEditor, mediaTypeServiceMock, _, mediaNavigationQueryServiceMock) = CreateValueEditor(); valueEditor.ConfigurationObject = new MediaPicker3Configuration() { Multiple = true, ValidationLimit = new MediaPicker3Configuration.NumberRange { Max = max } }; @@ -188,9 +200,10 @@ internal class MediaPicker3ValueEditorValidationTests } } - private static (MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor ValueEditor, Mock MediaTypeServiceMock, Mock MediaNavigationQueryServiceMock) CreateValueEditor() + private static (MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor ValueEditor, Mock MediaTypeServiceMock, Mock MediaServiceMock, Mock MediaNavigationQueryServiceMock) CreateValueEditor() { var mediaTypeServiceMock = new Mock(); + var mediaServiceMock = new Mock(); var mediaNavigationQueryServiceMock = new Mock(); var valueEditor = new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor( Mock.Of(), @@ -198,7 +211,7 @@ internal class MediaPicker3ValueEditorValidationTests Mock.Of(), new DataEditorAttribute("alias"), Mock.Of(), - Mock.Of(), + mediaServiceMock.Object, Mock.Of(), Mock.Of(), Mock.Of(), @@ -210,6 +223,6 @@ internal class MediaPicker3ValueEditorValidationTests ConfigurationObject = new MediaPicker3Configuration() }; - return (valueEditor, mediaTypeServiceMock, mediaNavigationQueryServiceMock); + return (valueEditor, mediaTypeServiceMock, mediaServiceMock, mediaNavigationQueryServiceMock); } } From a7f5b142e93d28f522f8ab94ce6b80a8621eac4d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:46:54 +0100 Subject: [PATCH 30/58] Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints (#18508) * Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints. * Fixed typo in comment. --------- Co-authored-by: Mole --- .../DictionaryConfigurationValidatorBase.cs | 22 ++++++- .../DecimalValueEditorTests.cs | 59 ++++++++++++++++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs index 0ed0af57e2..ce9dc4dff0 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs @@ -21,7 +21,7 @@ public abstract class DictionaryConfigurationValidatorBase return false; } - if (configuration.TryGetValue(key, out object? obj) && obj is TValue castValue) + if (configuration.TryGetValue(key, out object? obj) && TryCastValue(obj, out TValue? castValue)) { value = castValue; return true; @@ -30,4 +30,24 @@ public abstract class DictionaryConfigurationValidatorBase value = default; return false; } + + private static bool TryCastValue(object? value, [NotNullWhen(true)] out TValue? castValue) + { + if (value is TValue valueAsType) + { + castValue = valueAsType; + return true; + } + + // Special case for floating point numbers - when deserialized these will be integers if whole numbers rather + // than double. + if (typeof(TValue) == typeof(double) && value is int valueAsInt) + { + castValue = (TValue)(object)Convert.ToDouble(valueAsInt); + return true; + } + + castValue = default; + return false; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs index 95d92c511b..c50c1b1301 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs @@ -132,6 +132,25 @@ public class DecimalValueEditorTests } } + [TestCase(1.8, true)] + [TestCase(2.2, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max_With_Configured_Whole_Numbers(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(min: 1, max: 2); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + } + } + [TestCase(0.2, 1.4, false)] [TestCase(0.2, 1.5, true)] [TestCase(0.0, 1.4, true)] // A step of zero would trigger a divide by zero error in evaluating. So we always pass validation for zero, as effectively any step value is valid. @@ -165,7 +184,7 @@ public class DecimalValueEditorTests return CreateValueEditor().ToEditor(property.Object); } - private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double step = 0.2) + private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double min = 1.1, double max = 1.9, double step = 0.2) { var localizedTextServiceMock = new Mock(); localizedTextServiceMock.Setup(x => x.Localize( @@ -174,6 +193,37 @@ public class DecimalValueEditorTests It.IsAny(), It.IsAny>())) .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + + // When configuration is populated from the deserialized JSON, whole number values are deserialized as integers. + // So we want to replicate that in our tests. + var configuration = new Dictionary(); + if (min % 1 == 0) + { + configuration.Add("min", (int)min); + } + else + { + configuration.Add("min", min); + } + + if (max % 1 == 0) + { + configuration.Add("max", (int)max); + } + else + { + configuration.Add("max", max); + } + + if (step % 1 == 0) + { + configuration.Add("step", (int)step); + } + else + { + configuration.Add("step", step); + } + return new DecimalPropertyEditor.DecimalPropertyValueEditor( Mock.Of(), Mock.Of(), @@ -181,12 +231,7 @@ public class DecimalValueEditorTests new DataEditorAttribute("alias"), localizedTextServiceMock.Object) { - ConfigurationObject = new Dictionary - { - { "min", 1.1 }, - { "max", 1.9 }, - { "step", step } - } + ConfigurationObject = configuration }; } } From 7629b8586166c69c47902f98493c16f1e4c5c81d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:46:54 +0100 Subject: [PATCH 31/58] Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints (#18508) * Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints. * Fixed typo in comment. --------- Co-authored-by: Mole --- .../DictionaryConfigurationValidatorBase.cs | 22 ++++++- .../DecimalValueEditorTests.cs | 59 ++++++++++++++++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs index 0ed0af57e2..ce9dc4dff0 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs @@ -21,7 +21,7 @@ public abstract class DictionaryConfigurationValidatorBase return false; } - if (configuration.TryGetValue(key, out object? obj) && obj is TValue castValue) + if (configuration.TryGetValue(key, out object? obj) && TryCastValue(obj, out TValue? castValue)) { value = castValue; return true; @@ -30,4 +30,24 @@ public abstract class DictionaryConfigurationValidatorBase value = default; return false; } + + private static bool TryCastValue(object? value, [NotNullWhen(true)] out TValue? castValue) + { + if (value is TValue valueAsType) + { + castValue = valueAsType; + return true; + } + + // Special case for floating point numbers - when deserialized these will be integers if whole numbers rather + // than double. + if (typeof(TValue) == typeof(double) && value is int valueAsInt) + { + castValue = (TValue)(object)Convert.ToDouble(valueAsInt); + return true; + } + + castValue = default; + return false; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs index 95d92c511b..c50c1b1301 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs @@ -132,6 +132,25 @@ public class DecimalValueEditorTests } } + [TestCase(1.8, true)] + [TestCase(2.2, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max_With_Configured_Whole_Numbers(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(min: 1, max: 2); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + } + } + [TestCase(0.2, 1.4, false)] [TestCase(0.2, 1.5, true)] [TestCase(0.0, 1.4, true)] // A step of zero would trigger a divide by zero error in evaluating. So we always pass validation for zero, as effectively any step value is valid. @@ -165,7 +184,7 @@ public class DecimalValueEditorTests return CreateValueEditor().ToEditor(property.Object); } - private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double step = 0.2) + private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double min = 1.1, double max = 1.9, double step = 0.2) { var localizedTextServiceMock = new Mock(); localizedTextServiceMock.Setup(x => x.Localize( @@ -174,6 +193,37 @@ public class DecimalValueEditorTests It.IsAny(), It.IsAny>())) .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + + // When configuration is populated from the deserialized JSON, whole number values are deserialized as integers. + // So we want to replicate that in our tests. + var configuration = new Dictionary(); + if (min % 1 == 0) + { + configuration.Add("min", (int)min); + } + else + { + configuration.Add("min", min); + } + + if (max % 1 == 0) + { + configuration.Add("max", (int)max); + } + else + { + configuration.Add("max", max); + } + + if (step % 1 == 0) + { + configuration.Add("step", (int)step); + } + else + { + configuration.Add("step", step); + } + return new DecimalPropertyEditor.DecimalPropertyValueEditor( Mock.Of(), Mock.Of(), @@ -181,12 +231,7 @@ public class DecimalValueEditorTests new DataEditorAttribute("alias"), localizedTextServiceMock.Object) { - ConfigurationObject = new Dictionary - { - { "min", 1.1 }, - { "max", 1.9 }, - { "step", step } - } + ConfigurationObject = configuration }; } } From 0f9c2332e0a94f1d6bd90f759a2502a2cb72501b Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:49:37 +0100 Subject: [PATCH 32/58] Adds unit tests for RequiredValidator. (#18471) Co-authored-by: Mole --- .../Validators/RequiredValidatorTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/RequiredValidatorTests.cs diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/RequiredValidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/RequiredValidatorTests.cs new file mode 100644 index 0000000000..d2071472ab --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Validators/RequiredValidatorTests.cs @@ -0,0 +1,66 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.ComponentModel.DataAnnotations; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.Validators; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Validators; + +[TestFixture] +public class RequiredValidatorTests +{ + [Test] + public void Validates_Null() + { + var validator = new RequiredValidator(); + var result = validator.ValidateRequired(null, ValueTypes.String); + AssertValidationFailed(result, expectedMessage: Constants.Validation.ErrorMessages.Properties.Missing); + } + + [TestCase("", false)] + [TestCase(" ", false)] + [TestCase("a", true)] + public void Validates_Strings(string value, bool expectedSuccess) + { + var validator = new RequiredValidator(); + var result = validator.ValidateRequired(value, ValueTypes.String); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + AssertValidationFailed(result); + } + } + + [TestCase("{}", false)] + [TestCase("[]", false)] + [TestCase("{ }", false)] + [TestCase("[ ]", false)] + [TestCase(" { } ", false)] + [TestCase(" [ ] ", false)] + [TestCase(" { \"foo\": \"bar\" } ", true)] + public void Validates_Json(string value, bool expectedSuccess) + { + var validator = new RequiredValidator(); + var result = validator.ValidateRequired(value, ValueTypes.Json); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + AssertValidationFailed(result); + } + } + + private static void AssertValidationFailed(IEnumerable result, string expectedMessage = Constants.Validation.ErrorMessages.Properties.Empty) + { + Assert.AreEqual(1, result.Count()); + Assert.AreEqual(expectedMessage, result.First().ErrorMessage); + } +} From 51223f44fff7b620715cc5a98c4e6988df9d7d81 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 4 Mar 2025 14:46:54 +0100 Subject: [PATCH 33/58] Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints (#18508) * Fix issue with server validation from dictionary configuration where floating point values can be be accessed as doubles or ints. * Fixed typo in comment. --------- Co-authored-by: Mole --- .../DictionaryConfigurationValidatorBase.cs | 22 ++++++- .../DecimalValueEditorTests.cs | 59 ++++++++++++++++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs index 0ed0af57e2..ce9dc4dff0 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/DictionaryConfigurationValidatorBase.cs @@ -21,7 +21,7 @@ public abstract class DictionaryConfigurationValidatorBase return false; } - if (configuration.TryGetValue(key, out object? obj) && obj is TValue castValue) + if (configuration.TryGetValue(key, out object? obj) && TryCastValue(obj, out TValue? castValue)) { value = castValue; return true; @@ -30,4 +30,24 @@ public abstract class DictionaryConfigurationValidatorBase value = default; return false; } + + private static bool TryCastValue(object? value, [NotNullWhen(true)] out TValue? castValue) + { + if (value is TValue valueAsType) + { + castValue = valueAsType; + return true; + } + + // Special case for floating point numbers - when deserialized these will be integers if whole numbers rather + // than double. + if (typeof(TValue) == typeof(double) && value is int valueAsInt) + { + castValue = (TValue)(object)Convert.ToDouble(valueAsInt); + return true; + } + + castValue = default; + return false; + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs index 95d92c511b..c50c1b1301 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalValueEditorTests.cs @@ -132,6 +132,25 @@ public class DecimalValueEditorTests } } + [TestCase(1.8, true)] + [TestCase(2.2, false)] + public void Validates_Is_Less_Than_Or_Equal_To_Configured_Max_With_Configured_Whole_Numbers(object value, bool expectedSuccess) + { + var editor = CreateValueEditor(min: 1, max: 2); + var result = editor.Validate(value, false, null, PropertyValidationContext.Empty()); + if (expectedSuccess) + { + Assert.IsEmpty(result); + } + else + { + Assert.AreEqual(1, result.Count()); + + var validationResult = result.First(); + Assert.AreEqual(validationResult.ErrorMessage, "validation_outOfRangeMaximum"); + } + } + [TestCase(0.2, 1.4, false)] [TestCase(0.2, 1.5, true)] [TestCase(0.0, 1.4, true)] // A step of zero would trigger a divide by zero error in evaluating. So we always pass validation for zero, as effectively any step value is valid. @@ -165,7 +184,7 @@ public class DecimalValueEditorTests return CreateValueEditor().ToEditor(property.Object); } - private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double step = 0.2) + private static DecimalPropertyEditor.DecimalPropertyValueEditor CreateValueEditor(double min = 1.1, double max = 1.9, double step = 0.2) { var localizedTextServiceMock = new Mock(); localizedTextServiceMock.Setup(x => x.Localize( @@ -174,6 +193,37 @@ public class DecimalValueEditorTests It.IsAny(), It.IsAny>())) .Returns((string key, string alias, CultureInfo culture, IDictionary args) => $"{key}_{alias}"); + + // When configuration is populated from the deserialized JSON, whole number values are deserialized as integers. + // So we want to replicate that in our tests. + var configuration = new Dictionary(); + if (min % 1 == 0) + { + configuration.Add("min", (int)min); + } + else + { + configuration.Add("min", min); + } + + if (max % 1 == 0) + { + configuration.Add("max", (int)max); + } + else + { + configuration.Add("max", max); + } + + if (step % 1 == 0) + { + configuration.Add("step", (int)step); + } + else + { + configuration.Add("step", step); + } + return new DecimalPropertyEditor.DecimalPropertyValueEditor( Mock.Of(), Mock.Of(), @@ -181,12 +231,7 @@ public class DecimalValueEditorTests new DataEditorAttribute("alias"), localizedTextServiceMock.Object) { - ConfigurationObject = new Dictionary - { - { "min", 1.1 }, - { "max", 1.9 }, - { "step", step } - } + ConfigurationObject = configuration }; } } From 85883cee8587c1a13d25c8f414fc8556085b37d1 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Tue, 4 Mar 2025 15:22:22 +0100 Subject: [PATCH 34/58] Feature: Has Children Condition (#18161) * add children to reload translation * add has children condition + context * export * register manifests * set hasChildren value for tree items * add condition to document tree item * add has children condition to sort children of document * add conditions for media * add entity trashed event * dispatch event when entity is trashed * remove double event listeners * export action class * Update default-tree.context.ts * wip reload tree when entity is trashed * move into data folder * clean up listeners * move manifest * wip reload root * clean up * add recycle bin tree item * use for media * pass entity types through manifest + add recycle bin tree item kind * remove custom implementation for document recycle bin * use kind * rename to supportedEntityTypes * Update recycle-bin-tree-item.context.ts * clean up * remove condition * only show empty recycle bin if it has children * remove of sort children --- .../src/assets/lang/en-us.ts | 2 +- .../src/assets/lang/en.ts | 2 +- .../packages/core/entity-action/constants.ts | 1 + .../has-children/condition/constants.ts | 1 + .../entity-has-children.condition-config.ts | 12 +++++++ .../entity-has-children.condition.manifest.ts | 9 ++++++ .../entity-has-children.condition.ts | 25 +++++++++++++++ .../entity-action/has-children/constants.ts | 2 ++ .../has-children/context/constants.ts | 1 + .../context/has-children.context-token.ts | 6 ++++ .../context/has-children.entity-context.ts | 31 +++++++++++++++++++ .../has-children/context/index.ts | 1 + .../core/entity-action/has-children/index.ts | 1 + .../entity-action/has-children/manifests.ts | 4 +++ .../src/packages/core/entity-action/index.ts | 2 ++ .../packages/core/entity-action/manifests.ts | 2 ++ .../recycle-bin-tree-item.context.ts | 2 +- .../tree-item-base/tree-item-context-base.ts | 13 ++++++-- .../recycle-bin/entity-action/manifests.ts | 4 +++ .../recycle-bin/entity-action/manifests.ts | 6 ++++ .../package-lock.json | 8 ++--- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../Content/ChildrenContent.spec.ts | 2 +- .../ContentWithAllowedChildNodes.spec.ts | 2 +- .../ContentWithCollections.spec.ts | 4 +-- 25 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition-config.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.manifest.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.entity-context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/manifests.ts 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 9a188a97bb..43b4c99226 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 @@ -48,7 +48,7 @@ export default { notify: 'Notifications', protect: 'Public Access', publish: 'Publish', - refreshNode: 'Reload', + refreshNode: 'Reload children', remove: 'Remove', rename: 'Rename', republish: 'Republish entire site', 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 c5079d0d76..93d8a67909 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -49,7 +49,7 @@ export default { protect: 'Public Access', publish: 'Publish', readOnly: 'Read-only', - refreshNode: 'Reload', + refreshNode: 'Reload children', remove: 'Remove', rename: 'Rename', republish: 'Republish entire site', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/constants.ts index d60c068006..c6a840d160 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/constants.ts @@ -1 +1,2 @@ export * from './common/constants.js'; +export * from './has-children/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/constants.ts new file mode 100644 index 0000000000..cdfbe5b0b3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/constants.ts @@ -0,0 +1 @@ +export const UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS = 'Umb.Condition.EntityHasChildren'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition-config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition-config.ts new file mode 100644 index 0000000000..2c08118a07 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition-config.ts @@ -0,0 +1,12 @@ +import type { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from './constants.js'; +import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UmbEntityHasChildrenConditionConfig + extends UmbConditionConfigBase {} + +declare global { + interface UmbExtensionConditionConfigMap { + UmbEntityHasChildrenConditionConfig: UmbEntityHasChildrenConditionConfig; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.manifest.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.manifest.ts new file mode 100644 index 0000000000..ddfde4f2a9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.manifest.ts @@ -0,0 +1,9 @@ +import { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from './constants.js'; +import type { ManifestCondition } from '@umbraco-cms/backoffice/extension-api'; + +export const manifest: ManifestCondition = { + type: 'condition', + name: 'Entity Has Children Condition', + alias: UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS, + api: () => import('./entity-has-children.condition.js'), +}; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.ts new file mode 100644 index 0000000000..cc930c4cf8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/condition/entity-has-children.condition.ts @@ -0,0 +1,25 @@ +import { UMB_HAS_CHILDREN_ENTITY_CONTEXT } from '../context/has-children.context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { + UmbConditionConfigBase, + UmbConditionControllerArguments, + UmbExtensionCondition, +} from '@umbraco-cms/backoffice/extension-api'; +import { UmbConditionBase } from '@umbraco-cms/backoffice/extension-registry'; + +export class UmbEntityHasChildrenCondition + extends UmbConditionBase + implements UmbExtensionCondition +{ + constructor(host: UmbControllerHost, args: UmbConditionControllerArguments) { + super(host, args); + + this.consumeContext(UMB_HAS_CHILDREN_ENTITY_CONTEXT, (context) => { + this.observe(context.hasChildren, (hasChildren) => { + this.permitted = hasChildren === true; + }); + }); + } +} + +export { UmbEntityHasChildrenCondition as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/constants.ts new file mode 100644 index 0000000000..853290d6b2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/constants.ts @@ -0,0 +1,2 @@ +export * from './condition/constants.js'; +export * from './context/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/constants.ts new file mode 100644 index 0000000000..137e79f843 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/constants.ts @@ -0,0 +1 @@ +export * from './has-children.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.context-token.ts new file mode 100644 index 0000000000..a2bb77d5e2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.context-token.ts @@ -0,0 +1,6 @@ +import type { UmbHasChildrenEntityContext } from './has-children.entity-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_HAS_CHILDREN_ENTITY_CONTEXT = new UmbContextToken( + 'UmbHasChildrenEntityContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.entity-context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.entity-context.ts new file mode 100644 index 0000000000..ed8d1c0de9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/has-children.entity-context.ts @@ -0,0 +1,31 @@ +import { UMB_HAS_CHILDREN_ENTITY_CONTEXT } from './has-children.context-token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbBooleanState } from '@umbraco-cms/backoffice/observable-api'; + +export class UmbHasChildrenEntityContext extends UmbContextBase { + #hasChildren = new UmbBooleanState(undefined); + public readonly hasChildren = this.#hasChildren.asObservable(); + + constructor(host: UmbControllerHost) { + super(host, UMB_HAS_CHILDREN_ENTITY_CONTEXT); + } + + /** + * Gets the hasChildren state + * @returns {boolean} - The hasChildren state + * @memberof UmbHasChildrenEntityContext + */ + public getHasChildren(): boolean | undefined { + return this.#hasChildren.getValue(); + } + + /** + * Sets the hasChildren state + * @param {boolean} hasChildren - The hasChildren state + * @memberof UmbHasChildrenEntityContext + */ + public setHasChildren(hasChildren: boolean) { + this.#hasChildren.setValue(hasChildren); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/index.ts new file mode 100644 index 0000000000..0b119e565c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/context/index.ts @@ -0,0 +1 @@ +export * from './has-children.entity-context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/index.ts new file mode 100644 index 0000000000..00c55032bc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/index.ts @@ -0,0 +1 @@ +export * from './context/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/manifests.ts new file mode 100644 index 0000000000..e3b60432b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/has-children/manifests.ts @@ -0,0 +1,4 @@ +import { manifest as conditionManifest } from './condition/entity-has-children.condition.manifest.js'; +import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; + +export const manifests: Array = [conditionManifest]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/index.ts index 886f167a42..2104b5fe36 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/index.ts @@ -5,8 +5,10 @@ export * from './constants.js'; export * from './entity-action-base.js'; export * from './entity-action-list.element.js'; export * from './entity-action.event.js'; +export * from './has-children/index.js'; export * from './entity-updated.event.js'; export * from './entity-deleted.event.js'; + export type * from './types.js'; export { UmbRequestReloadStructureForEntityEvent } from './request-reload-structure-for-entity.event.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/manifests.ts index 7d6813e49a..66347b90e6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-action/manifests.ts @@ -2,6 +2,7 @@ import { manifests as createEntityActionManifests } from './common/create/manife import { manifests as defaultEntityActionManifests } from './default/manifests.js'; import { manifests as deleteEntityActionManifests } from './common/delete/manifests.js'; import { manifests as duplicateEntityActionManifests } from './common/duplicate/manifests.js'; +import { manifests as hasChildrenManifests } from './has-children/manifests.js'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; @@ -10,4 +11,5 @@ export const manifests: Array = ...defaultEntityActionManifests, ...deleteEntityActionManifests, ...duplicateEntityActionManifests, + ...hasChildrenManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/tree/tree-item/recycle-bin-tree-item.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/tree/tree-item/recycle-bin-tree-item.context.ts index ff1156ed0b..c0091158e8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/tree/tree-item/recycle-bin-tree-item.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/recycle-bin/tree/tree-item/recycle-bin-tree-item.context.ts @@ -35,7 +35,7 @@ export class UmbRecycleBinTreeItemContext< const supportedEntityTypes = this.getManifest()?.meta.supportedEntityTypes; if (!supportedEntityTypes) { - throw new Error('Supported entity types are missing from the manifest. (manifest.meta.supportedEntityTypes)'); + throw new Error('Entity types are missing from the manifest (manifest.meta.supportedEntityTypes).'); } if (supportedEntityTypes.includes(entityType)) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts index cd3e085fa2..93844ec277 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-context-base.ts @@ -12,6 +12,7 @@ import { UMB_SECTION_CONTEXT, UMB_SECTION_SIDEBAR_CONTEXT } from '@umbraco-cms/b import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { + UmbHasChildrenEntityContext, UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; @@ -75,6 +76,8 @@ export abstract class UmbTreeItemContextBase< #sectionSidebarContext?: typeof UMB_SECTION_SIDEBAR_CONTEXT.TYPE; #actionEventContext?: typeof UMB_ACTION_EVENT_CONTEXT.TYPE; + #hasChildrenContext = new UmbHasChildrenEntityContext(this); + // TODO: get this from the tree context #paging = { skip: 0, @@ -128,7 +131,10 @@ export abstract class UmbTreeItemContextBase< if (!treeItem.entityType) throw new Error('Could not create tree item context, tree item type is missing'); this.entityType = treeItem.entityType; - this.#hasChildren.setValue(treeItem.hasChildren || false); + const hasChildren = treeItem.hasChildren || false; + this.#hasChildren.setValue(hasChildren); + this.#hasChildrenContext.setHasChildren(hasChildren); + this._treeItem.setValue(treeItem); // Update observers: @@ -184,7 +190,10 @@ export abstract class UmbTreeItemContextBase< this.#childItems.setValue(data.items); } - this.#hasChildren.setValue(data.total > 0); + const hasChildren = data.total > 0; + this.#hasChildren.setValue(hasChildren); + this.#hasChildrenContext.setHasChildren(hasChildren); + this.pagination.setTotalItems(data.total); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/entity-action/manifests.ts index 6c4cf7b4b2..cc11edeb22 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/entity-action/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/entity-action/manifests.ts @@ -12,6 +12,7 @@ import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, } from '@umbraco-cms/backoffice/recycle-bin'; +import { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from '@umbraco-cms/backoffice/entity-action'; export const manifests: Array = [ { @@ -70,6 +71,9 @@ export const manifests: Array = [ alias: 'Umb.Condition.UserPermission.Document', allOf: [UMB_USER_PERMISSION_DOCUMENT_DELETE], }, + { + alias: UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS, + }, ], }, ...bulkTrashManifests, diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts index 65b3693975..144684b99c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/entity-action/manifests.ts @@ -6,6 +6,7 @@ import { import { UMB_MEDIA_RECYCLE_BIN_ROOT_ENTITY_TYPE, UMB_MEDIA_RECYCLE_BIN_REPOSITORY_ALIAS } from '../constants.js'; import { UMB_MEDIA_REFERENCE_REPOSITORY_ALIAS } from '../../reference/constants.js'; import { manifests as bulkTrashManifests } from './bulk-trash/manifests.js'; +import { UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS } from '@umbraco-cms/backoffice/entity-action'; import { UMB_ENTITY_IS_NOT_TRASHED_CONDITION_ALIAS, UMB_ENTITY_IS_TRASHED_CONDITION_ALIAS, @@ -55,6 +56,11 @@ export const manifests: Array = [ meta: { recycleBinRepositoryAlias: UMB_MEDIA_RECYCLE_BIN_REPOSITORY_ALIAS, }, + conditions: [ + { + alias: UMB_ENTITY_HAS_CHILDREN_CONDITION_ALIAS, + }, + ], }, ...bulkTrashManifests, ]; diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 43ad766350..46b9e661d8 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.29", - "@umbraco/playwright-testhelpers": "^15.0.27", + "@umbraco/playwright-testhelpers": "^15.0.29", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -67,9 +67,9 @@ } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "15.0.27", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-15.0.27.tgz", - "integrity": "sha512-PH1KDAN3Eo0eqKh5mmcZdNWPPuEWUr8RY/ZcGApZyTt5Sys2ES8aGFEReJV1fRyHsTT7Y2Q1qQfCES8p43v0dQ==", + "version": "15.0.29", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-15.0.29.tgz", + "integrity": "sha512-AHXmHkpB2fEYzjX5zyUjPArKhJyLi5CjzHuU/l5Bs9kwmn6QAIAdCpMt4LGoEizvBkDc+EiuH1JeMAHaV2Hhuw==", "license": "MIT", "dependencies": { "@umbraco/json-models-builders": "2.0.30", diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 3d39c24048..42b975e74b 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.29", - "@umbraco/playwright-testhelpers": "^15.0.27", + "@umbraco/playwright-testhelpers": "^15.0.29", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts index f36fb09883..78cc3db04d 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ChildrenContent.spec.ts @@ -43,7 +43,7 @@ test('can create child node', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) = expect(childData[0].variants[0].name).toBe(childContentName); // verify that the child content displays in the tree after reloading children await umbracoUi.content.clickActionsMenuForContent(contentName); - await umbracoUi.content.clickReloadButton(); + await umbracoUi.content.clickReloadChildrenButton(); await umbracoUi.content.clickCaretButtonForContentName(contentName); await umbracoUi.content.doesContentTreeHaveName(childContentName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts index 766b26cdf1..d567a0984f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithAllowedChildNodes.spec.ts @@ -83,7 +83,7 @@ test('can create multiple child nodes with different document types', async ({um expect(childData[1].variants[0].name).toBe(secondChildContentName); // verify that the child content displays in the tree after reloading children await umbracoUi.content.clickActionsMenuForContent(contentName); - await umbracoUi.content.clickReloadButton(); + await umbracoUi.content.clickReloadChildrenButton(); await umbracoUi.content.clickCaretButtonForContentName(contentName); await umbracoUi.content.doesContentTreeHaveName(firstChildContentName); await umbracoUi.content.doesContentTreeHaveName(secondChildContentName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithCollections.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithCollections.spec.ts index 17f17d6dab..98868fc1c1 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithCollections.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithDocumentTypeProperties/ContentWithCollections.spec.ts @@ -62,7 +62,7 @@ test('can create child content in a collection', async ({umbracoApi, umbracoUi}) expect(childData[0].variants[0].name).toBe(firstChildContentName); // verify that the child content displays in collection list after reloading tree await umbracoUi.content.clickActionsMenuForContent(contentName); - await umbracoUi.content.clickReloadButton(); + await umbracoUi.content.clickReloadChildrenButton(); await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.doesDocumentTableColumnNameValuesMatch(expectedNames); @@ -95,7 +95,7 @@ test('can create multiple child nodes in a collection', async ({umbracoApi, umbr expect(childData[1].variants[0].name).toBe(secondChildContentName); // verify that the child content displays in collection list after reloading tree await umbracoUi.content.clickActionsMenuForContent(contentName); - await umbracoUi.content.clickReloadButton(); + await umbracoUi.content.clickReloadChildrenButton(); await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.doesDocumentTableColumnNameValuesMatch(expectedNames); From a99c581ab5bdaab3252eb52f7404a20d0c0fad1c Mon Sep 17 00:00:00 2001 From: Mole Date: Tue, 4 Mar 2025 16:06:05 +0100 Subject: [PATCH 35/58] V15: Add MNTP serverside validation (#18526) * Add amount validator * Add ObjectTypeValidator to MNTP * Move validate startnode to helper method * Validate allowed type * Fix tests * Added some XML header comments and resolved nit-picky warnings. * Further XML comments. * Fix null validation case --------- Co-authored-by: Andy Butland --- .../EmbeddedResources/Lang/da.xml | 5 +- .../EmbeddedResources/Lang/en.xml | 6 +- .../EmbeddedResources/Lang/en_us.xml | 7 +- .../MultiNodePickerConfiguration.cs | 3 + .../Validation/TypedJsonValidatorRunner.cs | 14 +- .../Validation/ValidationHelper.cs | 47 +++ .../MediaPicker3PropertyEditor.cs | 116 +++--- .../MultiNodeTreePickerPropertyEditor.cs | 337 +++++++++++++++++- .../MultiNodeTreePickerTests.cs | 28 +- .../MultiNodeTreePickerValidationTests.cs | 217 +++++++++++ 10 files changed, 709 insertions(+), 71 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultiNodeTreePickerValidationTests.cs diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 071e0e03f4..032a7913a7 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -114,13 +114,16 @@ Mange hilsner fra Umbraco robotten %1% for mange.]]> Ét eller flere områder lever ikke op til kravene for antal indholdselementer. Den valgte medie type er ugyldig. + Det valgte indhold er af en ugyldig type. + Det valgte indhold eksistere ikke. Det er kun tilladt at vælge ét medie. - Valgt medie kommer fra en ugyldig mappe. + Valgt indhold kommer fra en ugyldig mappe. Værdien %0% er mindre end det tilladte minimum af %1%. Værdien %0% er større end det tilladte maksimum af %1%. Værdien %0% passer ikke med den konfigureret trin værdi af %1% og mindste værdi af %2%. Værdien %0% forventes ikke at indeholde et spænd. Værdien %0% forventes at have en værdi der er større end fra værdien. + Det valgte indhold er af den forkerte type. "Værdien '%0%' er ikke en af de tilgængelige valgmuligheder. "Den valgte farve '%0%' er ikke en af de tilgængelige valgmuligheder. diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 46c90d2b4f..4c0fffe9cb 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -393,10 +393,14 @@ The value %0% is not expected to contain a range The value %0% is not expected to have a to value less than the from value The chosen media type is invalid. + The chosen content is of invalid type. + The chosen content does not exist. Multiple selected media is not allowed. - The selected media is from the wrong folder. The value '%0%' is not one of the available options. "The selected colour '%0%' is not one of the available options. + The selected item is from the wrong folder. + The selected item is of the wrong type. + "The value '%0%' is not one of the available options.