From e415ad22e9eeb1fc1299725affeddd67b58bb949 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 18 Sep 2025 06:44:03 +0200 Subject: [PATCH 01/56] Updated obsoletion messages on IPublishedContent Parent and Children properties. --- src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs index 6fefac9040..fa3586a065 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs @@ -100,7 +100,7 @@ public interface IPublishedContent : IPublishedElement /// Gets the parent of the content item. /// /// The parent of root content is null. - [Obsolete("Please use either the IPublishedContent.Parent<>() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")] + [Obsolete("Please use either the IPublishedContent.Parent<>() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")] IPublishedContent? Parent { get; } /// @@ -142,6 +142,6 @@ public interface IPublishedContent : IPublishedElement /// /// Gets the children of the content item that are available for the current culture. /// - [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")] + [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")] IEnumerable Children { get; } } From 061be01e89da395fe342dac63f0336c817f8d02d Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Thu, 18 Sep 2025 07:51:09 +0100 Subject: [PATCH 02/56] Extension Insights: Fixes CSS alignment (fixes #20170) (#20174) Extension Insights: Fixes CSS alignment Fixes #20170. --- .../components/collection-filter-field.element.ts | 4 ++++ .../collection/extension-collection.element.ts | 13 ++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts index 923f5cc01a..7ddb228c63 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts @@ -33,6 +33,10 @@ export class UmbCollectionFilterFieldElement extends UmbLitElement { static override readonly styles = [ css` + :host { + display: flex; + } + uui-input { width: 100%; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts b/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts index 375daeb232..84f8a9c371 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts @@ -49,19 +49,18 @@ export class UmbExtensionCollectionElement extends UmbCollectionDefaultElement { static override styles = [ css` #toolbar { - flex: 1; display: flex; gap: var(--uui-size-space-5); justify-content: space-between; align-items: center; - } - umb-collection-filter-field { - width: 100%; - } + umb-collection-filter-field { + flex: 1; + } - uui-select { - width: 100%; + uui-select { + flex: 1; + } } `, ]; From fd0ccc529b528a3747cc9a2bb2678d214f8a9c96 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Thu, 18 Sep 2025 08:55:58 +0200 Subject: [PATCH 03/56] Property Editors: Improve Missing Property Editor UI and allow save/publish (#20104) * Initial implementation of non existing property editor * Adjust `MissingPropertyEditor` to not require registering in PropertyEditorCollection * Add `MissingPropertyEditor.name` back * Remove unused dependencies from DataTypeService * Removed reference to non existing property * Add parameterless constructor back to MissingPropertyEditor * Add validation error on document open to property with missing editor * Update labels * Removed public editor alias const * Update src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts * Add test that checks whether the new MissingPropertyEditor is returned when an editor is not found * Also check if the editor UI alias is correct in the test * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Share property editor instances between properties * Only store missing property editors in memory in `ContentMapDefinition.MapValueViewModels()` * Add value converter for the missing property editor to always return a string (same as the Label did previously) * Small improvements to code block * Adjust property validation to accept missing property editors * Return the current value when trying to update a property with a missing editor Same logic as for when the property is readonly. * Fix failing unit tests * Small fix * Add unit test * Remove client validation * UI adjustments * Adjustments from code review * Adjust test --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Services/ContentEditingServiceBase.cs | 8 +- .../Services/PropertyValidationService.cs | 7 +- .../src/assets/lang/en.ts | 11 +- .../src/assets/lang/pt.ts | 11 +- .../property-editors/missing/manifests.ts | 3 - .../missing/modal/constants.ts | 1 - .../missing/modal/manifests.ts | 8 -- .../modal/missing-editor-modal.element.ts | 47 ------ .../modal/missing-editor-modal.token.ts | 17 --- .../property-editor-ui-missing.element.ts | 135 ++++++++++++++---- .../PropertyValidationServiceTests.cs | 42 ++++-- 11 files changed, 167 insertions(+), 123 deletions(-) delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts delete mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index edfde776e2..15a4b9670e 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -526,8 +526,12 @@ internal abstract class ContentEditingServiceBaseError! This property type is no longer available. Please reach out to your administrator.

', + "Don't worry, your content is safe and publishing this document won't overwrite it or remove it.
Please contact your site administrator to resolve this issue.", + detailsTitle: 'Additional details', detailsDescription: - '

This property type is no longer available.
Please contact your administrator so they can either delete this property or restore the property type.

Data:

', + "To resolve this you should either restore the property editor, change the property to use a supported data type or remove the property if it's no longer needed.", + detailsDataType: 'Data type', + detailsPropertyEditor: 'Property editor', + detailsData: 'Data', + detailsHide: 'Hide details', + detailsShow: 'Show details', }, uiCulture: { ar: 'العربية', diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts index cd63947ae4..36ce12d20d 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts @@ -2832,9 +2832,16 @@ export default { resetUrlLabel: 'Redefinir', }, missingEditor: { + title: 'Este tipo de propriedade já não se encontra disponível.', description: - '

Erro! Este tipo de propriedade já não se encontra disponível. Por favor, contacte o administrador.

', + 'Não se preocupe, o seu conteúdo está seguro e a publicação deste documento não o substituirá nem removerá.
Entre em contacto com o administrador do site para resolver o problema.', + detailsTitle: 'Detalhes adicionais', detailsDescription: - '

Este tipo de propriedade já não se encontra disponível.
Por favor, contacte o administrador para que ele possa apagar a propriedade ou restaurar o tipo de propriedade.

Dados:

', + 'Para resolver o problema, deverá ou restaurar o editor de propriedades, ou alterar a propriedade para usar um tipo de dados compatível ou remover a propriedade se ela não for mais necessária.', + detailsDataType: 'Tipo de dados', + detailsPropertyEditor: 'Editor de propriedades', + detailsData: 'Dados', + detailsHide: 'Esconder detalhes', + detailsShow: 'Mostrar detalhes', }, } as UmbLocalizationDictionary; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts index 0575dfc63a..932e48b20f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts @@ -1,5 +1,3 @@ -import { manifests as modalManifests } from './modal/manifests.js'; - export const manifests: Array = [ { type: 'propertyEditorUi', @@ -14,5 +12,4 @@ export const manifests: Array = [ supportsReadOnly: true, }, }, - ...modalManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts deleted file mode 100644 index fb0853adfa..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './missing-editor-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts deleted file mode 100644 index 3ef10f367f..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const manifests: Array = [ - { - type: 'modal', - alias: 'Umb.Modal.MissingPropertyEditor', - name: 'Missing Property Editor Modal', - element: () => import('./missing-editor-modal.element.js'), - }, -]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts deleted file mode 100644 index f71d9769aa..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UmbMissingPropertyModalData, UmbMissingPropertyModalResult } from './missing-editor-modal.token.js'; -import { html, customElement, css } from '@umbraco-cms/backoffice/external/lit'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; - -@customElement('umb-missing-property-editor-modal') -export class UmbMissingPropertyEditorModalElement extends UmbModalBaseElement< - UmbMissingPropertyModalData, - UmbMissingPropertyModalResult -> { - override render() { - return html` - - - ${this.data?.value} - - - `; - } - - static override styles = [ - UmbTextStyles, - css` - uui-dialog-layout { - max-inline-size: 60ch; - } - #codeblock { - max-height: 300px; - overflow: auto; - } - `, - ]; -} - -export { UmbMissingPropertyEditorModalElement as element }; - -declare global { - interface HTMLElementTagNameMap { - 'umb-missing-property-editor-modal': UmbMissingPropertyEditorModalElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts deleted file mode 100644 index 9792759058..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; - -export interface UmbMissingPropertyModalData { - value: string | undefined; -} - -export type UmbMissingPropertyModalResult = undefined; - -export const UMB_MISSING_PROPERTY_EDITOR_MODAL = new UmbModalToken< - UmbMissingPropertyModalData, - UmbMissingPropertyModalResult ->('Umb.Modal.MissingPropertyEditor', { - modal: { - type: 'dialog', - size: 'small', - }, -}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts index 5ec66cbf83..337dd9fe9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts @@ -1,50 +1,129 @@ -import { UMB_MISSING_PROPERTY_EDITOR_MODAL } from './modal/missing-editor-modal.token.js'; -import { customElement, html } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing, property, query, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; +import { UmbDataTypeDetailRepository, type UmbDataTypeDetailModel } from '@umbraco-cms/backoffice/data-type'; +import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; /** * @element umb-property-editor-ui-missing */ @customElement('umb-property-editor-ui-missing') -export class UmbPropertyEditorUIMissingElement - extends UmbFormControlMixin(UmbLitElement, undefined) - implements UmbPropertyEditorUiElement -{ +export class UmbPropertyEditorUIMissingElement extends UmbLitElement implements UmbPropertyEditorUiElement { + @property() + value = ''; + + @state() + private _expanded = false; + + @query('#details') + focalPointElement!: HTMLElement; + + private _dataTypeDetailModel?: UmbDataTypeDetailModel | undefined; + private _dataTypeDetailRepository = new UmbDataTypeDetailRepository(this); + constructor() { super(); - this.addValidator( - 'customError', - () => this.localize.term('errors_propertyHasErrors'), - () => true, - ); - - this.pristine = false; + this.consumeContext(UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT, (propertyContext) => { + if (!propertyContext?.dataType) return; + this.observe(propertyContext.dataType, (dt) => { + if (!dt?.unique) return; + this._updateEditorAlias(dt); + }); + }); } - async #onDetails(event: Event) { - event.stopPropagation(); + private async _updateEditorAlias(dataType: UmbPropertyTypeModel['dataType']) { + this.observe(await this._dataTypeDetailRepository.byUnique(dataType.unique), (dataType) => { + this._dataTypeDetailModel = dataType; + }); + } - await umbOpenModal(this, UMB_MISSING_PROPERTY_EDITOR_MODAL, { - data: { - // If the value is an object, we stringify it to make sure we can display it properly. - // If it's a primitive value, we just convert it to string. - value: typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value), - }, - }).catch(() => undefined); + async #onDetails() { + this._expanded = !this._expanded; + if (this._expanded) { + await this.updateComplete; + this.focalPointElement?.focus(); + } } override render() { - return html` + return html` +
+ ${this.localize.term('missingEditor_title')} +
+
+ + ${this._expanded ? this._renderDetails() : nothing} +
+ `; + compact + label="${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')}" + @click=${this.#onDetails}> + ${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')} + +
`; } + + private _renderDetails() { + return html`
+ +

+ +

+

+ : + ${this._dataTypeDetailModel?.name}
+ : + ${this._dataTypeDetailModel?.editorAlias} +

+ ${typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value)} +
`; + } + + static override styles = [ + css` + :host { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-3); + --uui-box-default-padding: 0; + } + #content { + padding: var(--uui-size-space-5); + padding-bottom: var(--uui-size-space-3); + } + #alert { + padding-right: var(--uui-size-space-2); + } + #details-button { + float: right; + } + #details { + margin-top: var(--uui-size-space-5); + } + #details-title { + font-weight: 800; + } + #expand-symbol { + transform: rotate(90deg); + } + #expand-symbol[open] { + transform: rotate(180deg); + } + #codeblock { + max-height: 400px; + display: flex; + flex-direction: column; + } + `, + ]; } export default UmbPropertyEditorUIMissingElement; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs index 3457ba7719..3d5eacb428 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Dictionary; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Validation; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.Validators; using Umbraco.Cms.Core.Serialization; @@ -27,16 +28,14 @@ public class PropertyValidationServiceTests private void MockObjects(out PropertyValidationService validationService, out IDataType dt) { var dataTypeService = new Mock(); - var dataType = Mock.Of( - x => x.ConfigurationObject == string.Empty // irrelevant but needs a value - && x.DatabaseType == ValueStorageType.Nvarchar - && x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox); + var dataType = Mock.Of(x => x.ConfigurationObject == string.Empty // irrelevant but needs a value + && x.DatabaseType == ValueStorageType.Nvarchar + && x.EditorAlias == Constants.PropertyEditors.Aliases.TextBox); dataTypeService.Setup(x => x.GetDataType(It.IsAny())).Returns(() => dataType); dt = dataType; // new data editor that returns a TextOnlyValueEditor which will do the validation for the properties - var dataEditor = Mock.Of( - x => x.Alias == Constants.PropertyEditors.Aliases.TextBox); + var dataEditor = Mock.Of(x => x.Alias == Constants.PropertyEditors.Aliases.TextBox); Mock.Get(dataEditor).Setup(x => x.GetValueEditor(It.IsAny())) .Returns(new CustomTextOnlyValueEditor( new DataEditorAttribute(Constants.PropertyEditors.Aliases.TextBox), @@ -44,7 +43,15 @@ public class PropertyValidationServiceTests new SystemTextJsonSerializer(new DefaultJsonSerializerEncoderFactory()), Mock.Of())); - var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => new[] { dataEditor })); + var languageService = new Mock(); + languageService + .Setup(s => s.GetDefaultIsoCodeAsync()) + .ReturnsAsync(() => "en-US"); + + var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => [dataEditor])); + + var contentSettings = new Mock>(); + contentSettings.Setup(x => x.Value).Returns(new ContentSettings()); validationService = new PropertyValidationService( propEditors, @@ -52,8 +59,8 @@ public class PropertyValidationServiceTests Mock.Of(), new ValueEditorCache(), Mock.Of(), - Mock.Of(), - Mock.Of>()); + languageService.Object, + contentSettings.Object); } [Test] @@ -279,6 +286,23 @@ public class PropertyValidationServiceTests Assert.AreEqual(4, invalid.Length); } + [TestCase(null)] + [TestCase(24)] + [TestCase("test")] + [TestCase("{\"test\": true}")] + public void ValidatePropertyValue_Always_Returns_No_Validation_Errors_For_Missing_Editor(object? value) + { + MockObjects(out var validationService, out _); + + var p1 = new PropertyType(ShortStringHelper, "Missing.Alias", ValueStorageType.Ntext) + { + Variations = ContentVariation.Nothing, + }; + + var result = validationService.ValidatePropertyValue(p1, value, PropertyValidationContext.Empty()); + Assert.AreEqual(0, result.Count()); + } + // used so we can inject a mock - we should fix the base class DataValueEditor to be able to have the ILocalizedTextField passed // in to create the Requried and Regex validators so we aren't using singletons private class CustomTextOnlyValueEditor : TextOnlyValueEditor From f23050d5c630a7233cd414e448da50227928bca8 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:58:54 +0200 Subject: [PATCH 04/56] V16 QA cherry picked acceptance tests with different configuration (#20106) * Added appsettings * Added test setup for different config * Added appsettings for external login * Added acceptance tests * Updated pipelines * Updated solution file * V15 QA Added external login provider tests and split pipeline into templates (#20049) * Added setup for external login * Started on yaml * Added test file * Updated pipeline * Use env vars * Added env variables and commented out test we don't need to run * Removed list from matrix * Updated condition * Updated package path * Updated testFolder * double slash * Updated condition * Updated condition again * Added port * Removed redundant values * Set as env vars * Added env vars beneath matrix * Get env * Updated naming * Updated usage of values * Added a check for client id, to see if value set * Moved env out of pool * Tried moving env * Trying to fix the env being empty * Removed env * Updated name of variable * Fixed A cyclical reference * Updated typo * More logging * Reverted change * Added env * Added env to tests * Cleaned up * Added yaml template files * Updated nighly pipepline to use templates * Updated sln * Split yaml into templates for e2e setup * Updated pipeline * Updated solution file * Set value * Added if statement * Added variables * Set default values * Updated values * Updated condition * Run multiple tests * Added env * Updated from parameter to variable * Fixed condition * Fixed condition to use actual value * Updated npx wait on command * Updated pwsh * Updated port again * Updated port value * Updated wait on * Updated condition * Restructured * Updated var * Updated run application steps * Added echo * Updated to boolean * Updated conditions * Updated test template usage * Added databaseType * Added another databaseType * Split up templates * Fixed indentation * Updated condition * updated path * removed build from path * Updated conditions for azureAd * Fixed indentation * Updated to single qoutes * Cleaned up * Removed unused file * Clarified namin * Moved * Updated pipeline, not done * Updated locator * Updated pipelines * Updated test helpers package * Skipped build stage for default app settings tests * Updated password var * Updated locators * Updated defaultconfig build setup * Split E2E stage in two * Added parameter for skipping integration tests * Cleaned up * Added ASPNETCORE_URLS * V15 QA acceptance tests with appsettings (#19550) * Start of appsetting * Updated setup of playwright * Adjusted the pipeline * Updated appsetting * Added install test * Added comments * Updated pipeline * Updated development app settings * Commented tests out * comment * Added if statement * Updated pipeline * Fixed condition * Changed to production * Added a log * Updated copy item * Added * Updated app settings * Updated pipeline * Moved playwright login * Updated pipeline * Updated app setting * Updated nightly * Updated appsettings * Updated get * Updated wait on * Updated appsettings * Updated connection string * Updates * Skips code * Updated variable * Updated pipeline * We want to always retain the trace, to see if the test runs as expected on the pipeline * Added a temporary wait till port is open * Fixed condition * Added missing tcp for wait on * Updated URL env * Updated setup * Fixed string * Updated locator * Split tests into SQLite and SQLServer * Updated pipeline to run all tests * Retain trace on failure * Added testFolder var * Added appsettings and program for delivery api tests * Updated playwright config * Split test runners into defaultconfig and different app settings * Added delivery api tests * Cleaned up tests * Bumped version * Updated pipeline * Small fixes * Added password * Updated connection string * Fixed * Removed quotes * Removed unnecessary connection string * Added missing password * Cleaned up * Cleaned up * Cleaned up * Updated to use helpers * Bumped version * Updated helper usage * Added password to variables and a condition * Added check * Indented value * Fixed condition * More updates * Updated variable * Removed settings * Updated delivery api tests * Bumped version * Updated test * Removed unnecessary variables * Updates based on copilot comments * Fixed merge conflict * Fixed env creation step * Bumped version * Updated tests to use new helper * Updated helper * Updated to string * Moved logic to conditions * bumped version * Use new name for helper * Remove echo * Added variable --- build/azure-pipelines.yml | 506 ++----------- build/nightly-E2E-build-template.yml | 74 ++ .../nightly-E2E-run-application-template.yml | 45 ++ build/nightly-E2E-run-tests-template.yml | 106 +++ build/nightly-E2E-setup-template.yml | 70 ++ build/nightly-E2E-test-pipelines.yml | 677 +++++++++++------- .../package-lock.json | 16 +- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../playwright.config.ts | 32 +- .../Block/BlockGridBlockAdvanced.spec.ts | 2 +- .../BlockListEditor/BlockListBlocks.spec.ts | 2 +- .../tests/DefaultConfig/Media/Media.spec.ts | 2 +- .../tests/DefaultConfig/appsettings.json | 1 - .../DeliveryApi/AdditionalSetup/Program.cs | 27 + .../AdditionalSetup/appsettings.json | 64 ++ .../tests/DeliveryApi/DeliveryApi.spec.ts | 42 ++ .../tests/DeliveryApi/appsettings.json | 64 ++ .../App_Plugins/Login/umbraco-package.json | 25 + .../AzureB2CAuthenticationExtensions.cs | 73 ++ .../AdditionalSetup/AzureB2CComposer.cs | 14 + .../AdditionalSetup/AzureB2COptions.cs | 31 + .../AdditionalSetup/AzureB2CSettings.cs | 11 + .../AdditionalSetup/appsettings.json | 58 ++ .../ExternalLogin/AzureADB2C/Login.spec.ts | 20 + .../AdditionalSetup/appsettings.json | 54 ++ .../Install/InstallSQLServer.spec.ts | 28 + .../Install/InstallSQLite.spec.ts | 27 + umbraco.sln | 9 + 28 files changed, 1357 insertions(+), 725 deletions(-) create mode 100644 build/nightly-E2E-build-template.yml create mode 100644 build/nightly-E2E-run-application-template.yml create mode 100644 build/nightly-E2E-run-tests-template.yml create mode 100644 build/nightly-E2E-setup-template.yml delete mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/appsettings.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/Program.cs create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/appsettings.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/DeliveryApi.spec.ts create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/appsettings.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/App_Plugins/Login/umbraco-package.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CAuthenticationExtensions.cs create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CComposer.cs create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2COptions.cs create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CSettings.cs create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/appsettings.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/Login.spec.ts create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/AdditionalSetup/appsettings.json create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLServer.spec.ts create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLite.spec.ts diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index c1cec3b15a..b53a2d0ad2 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -522,160 +522,72 @@ stages: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.Sqlite + DatabaseType: SQLite + additionalEnvironmentVariables: false strategy: matrix: LinuxPart1Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=1/3" LinuxPart2Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=2/3" LinuxPart3Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=3/3" WindowsPart1Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=1/3" WindowsPart2Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=2/3" WindowsPart3Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTestSqlite -- --shard=3/3" pool: vmImage: $(vmImage) steps: - # Setup test environment - - task: DownloadPipelineArtifact@2 - displayName: Download NuGet artifacts - inputs: - artifact: nupkg - path: $(Agent.BuildDirectory)/app/nupkg - - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - retryCountOnTaskFailure: 3 - inputs: - versionSpec: $(nodeVersion) - - - task: UseDotNet@2 - displayName: Use .NET SDK from global.json - inputs: - useGlobalJson: true + # Setup test environment Template + - template: nightly-E2E-setup-template.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }} + PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }} + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + npm_config_cache: ${{ variables.npm_config_cache }} - pwsh: | - "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL) - UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - URL=$(ASPNETCORE_URLS) - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: $(npm_config_cache) - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages - - # Build application - - pwsh: | - $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" - dotnet new nugetconfig - dotnet nuget add source ./nupkg --name Local - dotnet new install Umbraco.Templates::$cmsVersion - dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check dotnet restore UmbracoProject cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject - dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore + displayName: Restore project + workingDirectory: $(Agent.BuildDirectory)/app + + - pwsh: | + dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore dotnet dev-certs https displayName: Build application workingDirectory: $(Agent.BuildDirectory)/app + condition: succeeded() - # Run application - - bash: | - nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & - echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" - displayName: Run application (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - workingDirectory: $(Agent.BuildDirectory)/app + # Run application Template + - template: nightly-E2E-run-application-template.yml + parameters: + DatabaseType: ${{ variables.DatabaseType }} + buildConfiguration: ${{ variables.buildConfiguration }} + additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }} - - pwsh: | - $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log - Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" - displayName: Run application (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - workingDirectory: $(Agent.BuildDirectory)/app - - # Wait for application to start responding to requests - - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS) - displayName: Wait for application - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Install Playwright and dependencies - - pwsh: npx playwright install chromium - displayName: Install Playwright only with Chromium browser - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Test - - pwsh: $(testCommand) - displayName: Run Playwright tests - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - env: - CI: true - CommitId: $(Build.SourceVersion) - AgentOs: $(Agent.OS) - - # Stop application - - bash: kill -15 $(AcceptanceTestProcessId) - displayName: Stop application (Linux) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) - displayName: Stop application (Windows) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) - - # Copy artifacts - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse - } - displayName: Copy Playwright results - condition: succeededOrFailed() - - # Copy console error log - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory) - } - displayName: Copy console error log - condition: succeededOrFailed() - - # 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)" + # Run tests Template + - template: nightly-E2E-run-tests-template.yml + parameters: + testCommand: $(testCommand) + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + DatabaseType: ${{ variables.DatabaseType }} - job: displayName: E2E Smoke Tests (SQL Server) @@ -683,354 +595,78 @@ stages: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) + DatabaseType: SQLServer + additionalEnvironmentVariables: false strategy: matrix: ${{ if eq(parameters.sqlServerLinuxAcceptanceTests, True) }}: LinuxPart1Of3: testCommand: "npm run smokeTest -- --shard=1/3" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) + testFolder: "DefaultConfig" CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" LinuxPart2Of3: testCommand: "npm run smokeTest -- --shard=2/3" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) + testFolder: "DefaultConfig" CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" LinuxPart3Of3: testCommand: "npm run smokeTest -- --shard=3/3" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) + testFolder: "DefaultConfig" CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" WindowsPart1Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTest -- --shard=1/3" WindowsPart2Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTest -- --shard=2/3" WindowsPart3Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run smokeTest -- --shard=3/3" pool: vmImage: $(vmImage) steps: - # Setup test environment - - task: DownloadPipelineArtifact@2 - displayName: Download NuGet artifacts - inputs: - artifact: nupkg - path: $(Agent.BuildDirectory)/app/nupkg - - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - inputs: - versionSpec: $(nodeVersion) - - - task: UseDotNet@2 - displayName: Use .NET SDK from global.json - inputs: - useGlobalJson: true + # Setup test environment Template + - template: nightly-E2E-setup-template.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }} + PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }} + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + npm_config_cache: ${{ variables.npm_config_cache }} - pwsh: | - "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL) - UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - URL=$(ASPNETCORE_URLS) - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: $(npm_config_cache) - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages - - # Build application - - pwsh: | - $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" - dotnet new nugetconfig - dotnet nuget add source ./nupkg --name Local - dotnet new install Umbraco.Templates::$cmsVersion - dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check dotnet restore UmbracoProject cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject - dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore + displayName: Restore project + workingDirectory: $(Agent.BuildDirectory)/app + + - pwsh: | + dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore dotnet dev-certs https displayName: Build application workingDirectory: $(Agent.BuildDirectory)/app + condition: succeeded() - # Start SQL Server - - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest - displayName: Start SQL Server Docker image (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + # Run application Template + - template: nightly-E2E-run-application-template.yml + parameters: + SA_PASSWORD: ${{ variables.SA_PASSWORD }} + buildConfiguration: ${{ variables.buildConfiguration }} + DatabaseType: ${{ variables.DatabaseType }} + additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }} - - pwsh: SqlLocalDB start MSSQLLocalDB - displayName: Start SQL Server LocalDB (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - - # Run application - - bash: | - nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & - echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" - displayName: Run application (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - workingDirectory: $(Agent.BuildDirectory)/app - - - pwsh: | - $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log - Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" - displayName: Run application (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - workingDirectory: $(Agent.BuildDirectory)/app - - # Wait for application to start responding to requests - - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS) - displayName: Wait for application - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Install Playwright and dependencies - - pwsh: npx playwright install chromium - displayName: Install Playwright only with Chromium browser - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Test - - pwsh: $(testCommand) - displayName: Run Playwright tests - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - env: - CI: true - CommitId: $(Build.SourceVersion) - AgentOs: $(Agent.OS) - - # Stop application - - bash: kill -15 $(AcceptanceTestProcessId) - displayName: Stop application (Linux) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) - displayName: Stop application (Windows) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) - - # Stop SQL Server - - pwsh: docker stop mssql - displayName: Stop SQL Server Docker image (Linux) - condition: eq(variables['Agent.OS'], 'Linux') - - - pwsh: SqlLocalDB stop MSSQLLocalDB - displayName: Stop SQL Server LocalDB (Windows) - condition: eq(variables['Agent.OS'], 'Windows_NT') - - # Copy artifacts - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse - } - displayName: Copy Playwright results - condition: succeededOrFailed() - - # Copy console error log - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory) - } - displayName: Copy console error log - condition: succeededOrFailed() - - # 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 Release Tests (SQL Server) - variables: - # Connection string - CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True - CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient - condition: eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True') - strategy: - matrix: - WindowsPart1Of3: - vmImage: "windows-latest" - testCommand: "npm run releaseTest -- --shard=1/3" - WindowsPart2Of3: - vmImage: "windows-latest" - testCommand: "npm run releaseTest -- --shard=2/3" - WindowsPart3Of3: - vmImage: "windows-latest" - testCommand: "npm run releaseTest -- --shard=3/3" - pool: - vmImage: $(vmImage) - steps: - # Setup test environment - - task: DownloadPipelineArtifact@2 - displayName: Download NuGet artifacts - inputs: - artifact: nupkg - path: $(Agent.BuildDirectory)/app/nupkg - - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - inputs: - versionSpec: $(nodeVersion) - - - task: UseDotNet@2 - displayName: Use .NET SDK from global.json - inputs: - useGlobalJson: true - - - pwsh: | - "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL) - UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - URL=$(ASPNETCORE_URLS) - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: $(npm_config_cache) - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages - - # Build application - - pwsh: | - $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" - dotnet new nugetconfig - dotnet nuget add source ./nupkg --name Local - dotnet new install Umbraco.Templates::$cmsVersion - dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check - dotnet restore UmbracoProject - cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject - dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore - dotnet dev-certs https - displayName: Build application - workingDirectory: $(Agent.BuildDirectory)/app - - # Start SQL Server - - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest - displayName: Start SQL Server Docker image (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: SqlLocalDB start MSSQLLocalDB - displayName: Start SQL Server LocalDB (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - - # Run application - - bash: | - nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & - echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" - displayName: Run application (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - workingDirectory: $(Agent.BuildDirectory)/app - - - pwsh: | - $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log - Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" - displayName: Run application (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - workingDirectory: $(Agent.BuildDirectory)/app - - # Wait for application to start responding to requests - - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS) - displayName: Wait for application - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Install Playwright and dependencies - - pwsh: npx playwright install chromium - displayName: Install Playwright only with Chromium browser - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Test - - pwsh: $(testCommand) - displayName: Run Playwright tests - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - env: - CI: true - CommitId: $(Build.SourceVersion) - AgentOs: $(Agent.OS) - - # Stop application - - bash: kill -15 $(AcceptanceTestProcessId) - displayName: Stop application (Linux) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) - displayName: Stop application (Windows) - condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) - - # Stop SQL Server - - pwsh: docker stop mssql - displayName: Stop SQL Server Docker image (Linux) - condition: eq(variables['Agent.OS'], 'Linux') - - - pwsh: SqlLocalDB stop MSSQLLocalDB - displayName: Stop SQL Server LocalDB (Windows) - condition: eq(variables['Agent.OS'], 'Windows_NT') - - # Copy artifacts - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse - } - displayName: Copy Playwright results - condition: succeededOrFailed() - - # Copy console error log - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory) - } - displayName: Copy console error log - condition: succeededOrFailed() - - # 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)" + # Run tests Template + - template: nightly-E2E-run-tests-template.yml + parameters: + testCommand: $(testCommand) + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + DatabaseType: ${{ variables.DatabaseType }} ############################################### ## Release @@ -1237,4 +873,4 @@ stages: storage: umbracoapidocs ContainerName: "$web" BlobPrefix: v$(umbracoMajorVersion)/ui-api - CleanTargetBeforeCopy: true \ No newline at end of file + CleanTargetBeforeCopy: true diff --git a/build/nightly-E2E-build-template.yml b/build/nightly-E2E-build-template.yml new file mode 100644 index 0000000000..4ec5f299be --- /dev/null +++ b/build/nightly-E2E-build-template.yml @@ -0,0 +1,74 @@ +parameters: + - name: testFolder + type: string + default: '' + + - name: buildConfiguration + type: string + default: '' + + - name: additionalEnvironmentVariables + type: string + default: 'false' + +steps: + - pwsh: | + dotnet restore UmbracoProject + cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject + displayName: Restore project + workingDirectory: $(Agent.BuildDirectory)/app + + # Update application to use necessary app settings + - pwsh: | + $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup" + $destinationPath = "UmbracoProject" + $jsonFiles = Get-ChildItem -Path $sourcePath -Filter "*.json" + if ($jsonFiles) { + $jsonFiles | ForEach-Object { + Write-Host "Copying: $($_.FullName)" + Copy-Item -Path $_.FullName -Destination $destinationPath -Force + } + } else { + Write-Host "No JSON files found." + } + displayName: Update application to use necessary app settings + workingDirectory: $(Agent.BuildDirectory)/app + + # Update application to use necessary App_Plugins + - pwsh: | + $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup" + $destinationPath = "UmbracoProject" + $appPluginsFolders = Get-ChildItem -Path $sourcePath -Directory -Filter "App_Plugins" + if ($appPluginsFolders) { + foreach ($folder in $appPluginsFolders) { + Write-Host "Copying folder: $($folder.FullName)" + Copy-Item -Path $folder.FullName -Destination $destinationPath -Recurse -Force + } + } else { + Write-Host "No App_Plugins found." + } + displayName: Update application to use necessary app plugins + workingDirectory: $(Agent.BuildDirectory)/app + + # Update application to use necessary classes + - pwsh: | + $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup" + $destinationPath = "UmbracoProject" + $csharpFiles = Get-ChildItem -Path $sourcePath -Filter "*.cs" + if ($csharpFiles) { + $csharpFiles | ForEach-Object { + Write-Host "Copying: $($_.FullName)" + Copy-Item -Path $_.FullName -Destination $destinationPath -Force + } + } else { + Write-Host "No C# files found." + } + displayName: Update application to use necessary classes + workingDirectory: $(Agent.BuildDirectory)/app + + - pwsh: | + dotnet build UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-restore + dotnet dev-certs https + displayName: Build application + workingDirectory: $(Agent.BuildDirectory)/app + condition: and(succeeded(), eq(variables['additionalEnvironmentVariables'], 'false')) diff --git a/build/nightly-E2E-run-application-template.yml b/build/nightly-E2E-run-application-template.yml new file mode 100644 index 0000000000..5301ddeaf1 --- /dev/null +++ b/build/nightly-E2E-run-application-template.yml @@ -0,0 +1,45 @@ +parameters: + - name: SA_PASSWORD + type: string + default: '' + + - name: buildConfiguration + type: string + default: '' + + - name: additionalEnvironmentVariables + type: string + default: 'false' + + - name: DatabaseType + type: string + default: '' + +steps: + # Skips the SQLServer setup if the databaseType does not match + - ${{ if eq(parameters.DatabaseType, 'SQLServer') }}: + # Start SQL Server Linux + - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=${{ parameters.SA_PASSWORD }}" mcr.microsoft.com/mssql/server:2022-latest + displayName: Start SQL Server Docker image (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + # Start SQL Server LocalDB Windows + - pwsh: SqlLocalDB start MSSQLLocalDB + displayName: Start SQL Server LocalDB (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + + # Run application for Linux + - bash: | + nohup dotnet run --project UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & + echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" + displayName: Run application (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), eq(variables['additionalEnvironmentVariables'], 'false')) + workingDirectory: $(Agent.BuildDirectory)/app + + # Run application for Windows + - pwsh: | + $process = Start-Process dotnet "run --project UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log + Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" + displayName: Run application (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['additionalEnvironmentVariables'], 'false')) + workingDirectory: $(Agent.BuildDirectory)/app diff --git a/build/nightly-E2E-run-tests-template.yml b/build/nightly-E2E-run-tests-template.yml new file mode 100644 index 0000000000..c424931d8a --- /dev/null +++ b/build/nightly-E2E-run-tests-template.yml @@ -0,0 +1,106 @@ +parameters: + - name: ASPNETCORE_URLS + type: string + default: '' + + - name: testCommand + type: string + default: '' + + - name: port + type: string + default: '' + + - name: AZUREB2CTESTUSEREMAIL + type: string + default: '' + + - name: AZUREB2CTESTUSERPASSWORD + type: string + default: '' + + - name: DatabaseType + type: string + default: '' + +steps: + # Ensures we have the package wait-on installed + - pwsh: npm install wait-on + displayName: Install wait-on package + + # Wait for either the port of the aspnetcore url + - pwsh: | + $Port = "${{ parameters.port }}" + $Url = "${{ parameters.ASPNETCORE_URLS }}" + + if ($Port -ne "") { + Write-Host "Waiting on TCP port $Port" + npx wait-on -v --interval 1000 --timeout 120000 "tcp:$Port" + } else { + Write-Host "Waiting on URL $Url" + npx wait-on -v --interval 1000 --timeout 120000 "$Url" + } + displayName: Wait for application + workingDirectory: tests/Umbraco.Tests.AcceptanceTest + + # Install Playwright and dependencies + - pwsh: npx playwright install chromium + displayName: Install Playwright only with Chromium browser + workingDirectory: tests/Umbraco.Tests.AcceptanceTest + + # Test + - pwsh: ${{ parameters.testCommand }} + displayName: Run Playwright tests + continueOnError: true + workingDirectory: tests/Umbraco.Tests.AcceptanceTest + env: + CI: true + CommitId: $(Build.SourceVersion) + AgentOs: $(Agent.OS) + AZUREADB2CTESTUSEREMAIL: ${{ parameters.AZUREB2CTESTUSEREMAIL }} + AZUREADB2CTESTUSERPASSWORD: ${{ parameters.AZUREB2CTESTUSERPASSWORD }} + + # Stop application + - bash: kill -15 $(AcceptanceTestProcessId) + displayName: Stop application (Linux) + condition: and(succeededOrFailed(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) + displayName: Stop application (Windows) + condition: and(succeededOrFailed(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) + + - ${{ if eq(parameters.DatabaseType, 'SQLServer') }}: + # Stop SQL Server + - pwsh: docker stop mssql + displayName: Stop SQL Server Docker image (Linux) + condition: and(succeededOrFailed(), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: SqlLocalDB stop MSSQLLocalDB + displayName: Stop SQL Server LocalDB (Windows) + condition: and(succeededOrFailed(), eq(variables['Agent.OS'], 'Windows_NT')) + + # Copy artifacts + - pwsh: | + if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { + Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse + } + displayName: Copy Playwright results + condition: succeededOrFailed() + + # Publish + - 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)" diff --git a/build/nightly-E2E-setup-template.yml b/build/nightly-E2E-setup-template.yml new file mode 100644 index 0000000000..8085561900 --- /dev/null +++ b/build/nightly-E2E-setup-template.yml @@ -0,0 +1,70 @@ +parameters: + - name: nodeVersion + type: string + default: '' + + - name: PlaywrightUserEmail + type: string + default: '' + + - name: PlaywrightPassword + type: string + default: '' + + - name: ASPNETCORE_URLS + type: string + default: '' + + - name: npm_config_cache + type: string + default: '' + +steps: + - task: DownloadPipelineArtifact@2 + displayName: Download NuGet artifacts + inputs: + artifact: nupkg + path: $(Agent.BuildDirectory)/app/nupkg + + - task: NodeTool@0 + displayName: Use Node.js $(nodeVersion) + inputs: + versionSpec: $(nodeVersion) + + - task: UseDotNet@2 + displayName: Use .NET SDK from global.json + inputs: + useGlobalJson: true + + - pwsh: | + "UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }} + UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }} + URL=${{ parameters.ASPNETCORE_URLS }} + STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json + CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env + displayName: Generate .env + workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest + + # Cache and restore NPM packages + - task: Cache@2 + displayName: Cache NPM packages + inputs: + key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' + restoreKeys: | + npm_e2e | "$(Agent.OS)" + npm_e2e + path: ${{ parameters.npm_config_cache }} + + - script: npm ci --no-fund --no-audit --prefer-offline + workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest + displayName: Restore NPM packages + + # Install Template + - pwsh: | + $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" + dotnet new nugetconfig + dotnet nuget add source ./nupkg --name Local + dotnet new install Umbraco.Templates::$cmsVersion + dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check + displayName: Install Template + workingDirectory: $(Agent.BuildDirectory)/app diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 73034b6d7c..7bbdec8e14 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -12,9 +12,18 @@ schedules: - main parameters: - # Skipped due to DB locks - - name: sqliteAcceptanceTests - displayName: Run SQLite Acceptance Tests + - name: skipIntegrationTests + displayName: Skip integration tests + type: boolean + default: false + + - name: differentAppSettingsAcceptanceTests + displayName: Run acceptance tests with different app settings + type: boolean + default: false + + - name: skipDefaultConfigAcceptanceTests + displayName: Skip tests with DefaultConfig type: boolean default: false @@ -100,8 +109,191 @@ stages: targetPath: $(Build.ArtifactStagingDirectory)/npm artifactName: npm - - stage: E2E - displayName: E2E Tests + - stage: Integration + displayName: Integration Tests + dependsOn: Build + condition: ${{ eq(parameters.skipIntegrationTests, false) }} + jobs: + # Integration Tests (SQLite) + - job: + timeoutInMinutes: 180 + displayName: Integration Tests (SQLite) + strategy: + matrix: + # Windows: + # vmImage: 'windows-latest' + # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline + LinuxPart1Of3: + vmImage: "ubuntu-latest" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + LinuxPart2Of3: + vmImage: "ubuntu-latest" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + LinuxPart3Of3: + vmImage: "ubuntu-latest" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + macOSPart1Of3: + vmImage: "macOS-latest" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + macOSPart2Of3: + vmImage: "macOS-latest" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + macOSPart3Of3: + vmImage: "macOS-latest" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + pool: + vmImage: $(vmImage) + variables: + Tests__Database__DatabaseType: "Sqlite" + steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 + # Setup test environment + - task: DownloadPipelineArtifact@2 + displayName: Download build artifacts + inputs: + artifact: build_output + path: $(Build.SourcesDirectory) + + - task: UseDotNet@2 + displayName: Use .NET SDK from global.json + inputs: + useGlobalJson: true + + # Test + - task: DotNetCoreCLI@2 + displayName: Run dotnet test + inputs: + command: test + projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj" + testRunTitle: Integration Tests SQLite - $(Agent.OS) + arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build' + + # Integration Tests (SQL Server) + - job: + timeoutInMinutes: 180 + displayName: Integration Tests (SQL Server) + variables: + SA_PASSWORD: UmbracoAcceptance123! + strategy: + matrix: + # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline + WindowsPart1Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + WindowsPart2Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + WindowsPart3Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + LinuxPart1Of3: + vmImage: "ubuntu-latest" + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + LinuxPart2Of3: + vmImage: "ubuntu-latest" + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + LinuxPart3Of3: + vmImage: "ubuntu-latest" + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + pool: + vmImage: $(vmImage) + steps: + # Setup test environment + - task: DownloadPipelineArtifact@2 + displayName: Download build artifacts + inputs: + artifact: build_output + path: $(Build.SourcesDirectory) + + - task: UseDotNet@2 + displayName: Use .NET SDK from global.json + inputs: + useGlobalJson: true + + # Start SQL Server + - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest + displayName: Start SQL Server Docker image (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - powershell: | + $maxAttempts = 12 + $attempt = 0 + $status = "" + + while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) { + Start-Sleep -Seconds 5 + # We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build. + $status = docker inspect -f '{{.State.Status}}' mssql + + if ($status -ne 'running') { + Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)" + $attempt++ + } + } + + if ($status -eq 'running') { + Write-Host "SQL Server container is running" + docker ps -a + } else { + Write-Host "SQL Server did not become ready in time. Last known status: $status" + docker logs mssql + exit 1 + } + displayName: Wait for SQL Server to be ready (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: SqlLocalDB start MSSQLLocalDB + displayName: Start SQL Server LocalDB (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + + # Test + - task: DotNetCoreCLI@2 + displayName: Run dotnet test + inputs: + command: test + projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj" + testRunTitle: Integration Tests SQL Server - $(Agent.OS) + arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build' + + # Stop SQL Server + - pwsh: docker stop mssql + displayName: Stop SQL Server Docker image (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: SqlLocalDB stop MSSQLLocalDB + displayName: Stop SQL Server LocalDB (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + + - stage: DefaultConfigE2E + displayName: Default Config E2E Tests dependsOn: Build variables: npm_config_cache: $(Pipeline.Workspace)/.npm_e2e @@ -128,369 +320,304 @@ stages: # E2E Tests - job: displayName: E2E Tests (SQLite) - condition: eq(${{parameters.sqliteAcceptanceTests}}, True) timeoutInMinutes: 180 + condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }} variables: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.Sqlite + DatabaseType: SQLite + additionalEnvironmentVariables: false strategy: matrix: LinuxPart1Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=1/3" LinuxPart2Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=2/3" LinuxPart3Of3: vmImage: "ubuntu-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=3/3" WindowsPart1Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=1/3" WindowsPart2Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=2/3" WindowsPart3Of3: vmImage: "windows-latest" + testFolder: "DefaultConfig" testCommand: "npm run testSqlite -- --shard=3/3" pool: vmImage: $(vmImage) steps: - # Setup test environment - - task: DownloadPipelineArtifact@2 - displayName: Download NuGet artifacts - inputs: - artifact: nupkg - path: $(Agent.BuildDirectory)/app/nupkg - - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - retryCountOnTaskFailure: 3 - inputs: - versionSpec: $(nodeVersion) - - - task: UseDotNet@2 - displayName: Use .NET SDK from global.json - inputs: - useGlobalJson: true + # Setup test environment Template + - template: nightly-E2E-setup-template.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }} + PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }} + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + npm_config_cache: ${{ variables.npm_config_cache }} - pwsh: | - "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL) - UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - URL=$(ASPNETCORE_URLS) - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: $(npm_config_cache) - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages - - # Build application - - pwsh: | - $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" - dotnet new nugetconfig - dotnet nuget add source ./nupkg --name Local - dotnet new install Umbraco.Templates::$cmsVersion - dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check dotnet restore UmbracoProject cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject - dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore + displayName: Restore project + workingDirectory: $(Agent.BuildDirectory)/app + + - pwsh: | + dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore dotnet dev-certs https displayName: Build application workingDirectory: $(Agent.BuildDirectory)/app + condition: succeeded() - # Run application - - bash: | - nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & - echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" - displayName: Run application (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - workingDirectory: $(Agent.BuildDirectory)/app + # Run application Template + - template: nightly-E2E-run-application-template.yml + parameters: + DatabaseType: ${{ variables.DatabaseType }} + buildConfiguration: ${{ variables.buildConfiguration }} + additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }} - - pwsh: | - $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log - Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" - displayName: Run application (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - workingDirectory: $(Agent.BuildDirectory)/app - - # Ensures we have the package wait-on installed - - pwsh: npm install wait-on - displayName: Install wait-on package - - # Wait for application to start responding to requests - - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS) - displayName: Wait for application - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Install Playwright and dependencies - - pwsh: npx playwright install chromium - displayName: Install Playwright only with Chromium browser - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Test - - pwsh: $(testCommand) - displayName: Run Playwright tests - continueOnError: true - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - env: - CI: true - CommitId: $(Build.SourceVersion) - AgentOs: $(Agent.OS) - - # Stop application - - bash: kill -15 $(AcceptanceTestProcessId) - displayName: Stop application (Linux) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) - displayName: Stop application (Windows) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) - - # Copy artifacts - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse - } - displayName: Copy Playwright results - condition: succeededOrFailed() - - # Copy console error log - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory) - } - displayName: Copy console error log - condition: succeededOrFailed() - - # Publish - - 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)" + # Run tests Template + - template: nightly-E2E-run-tests-template.yml + parameters: + testCommand: $(testCommand) + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + DatabaseType: ${{ variables.DatabaseType }} - job: displayName: E2E Tests (SQL Server) timeoutInMinutes: 180 + condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }} variables: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + DatabaseType: SQLServer + SA_PASSWORD: UmbracoAcceptance123! + additionalEnvironmentVariables: false strategy: matrix: LinuxPart1Of3: testCommand: "npm run test -- --shard=1/3" + testFolder: "DefaultConfig" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" LinuxPart2Of3: testCommand: "npm run test -- --shard=2/3" + testFolder: "DefaultConfig" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" LinuxPart3Of3: testCommand: "npm run test -- --shard=3/3" + testFolder: "DefaultConfig" vmImage: "ubuntu-latest" - SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" WindowsPart1Of3: - vmImage: "windows-latest" testCommand: "npm run test -- --shard=1/3" + testFolder: "DefaultConfig" + vmImage: "windows-latest" WindowsPart2Of3: - vmImage: "windows-latest" testCommand: "npm run test -- --shard=2/3" - WindowsPart3Of3: + testFolder: "DefaultConfig" vmImage: "windows-latest" + WindowsPart3Of3: testCommand: "npm run test -- --shard=3/3" + testFolder: "DefaultConfig" + vmImage: "windows-latest" pool: vmImage: $(vmImage) steps: - # Setup test environment - - task: DownloadPipelineArtifact@2 - displayName: Download NuGet artifacts - inputs: - artifact: nupkg - path: $(Agent.BuildDirectory)/app/nupkg - - - task: NodeTool@0 - displayName: Use Node.js $(nodeVersion) - inputs: - versionSpec: $(nodeVersion) - - - task: UseDotNet@2 - displayName: Use .NET SDK from global.json - inputs: - useGlobalJson: true + # Setup test environment Template + - template: nightly-E2E-setup-template.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }} + PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }} + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + npm_config_cache: ${{ variables.npm_config_cache }} - pwsh: | - "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL) - UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - URL=$(ASPNETCORE_URLS) - STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json - CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env - displayName: Generate .env - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - - # Cache and restore NPM packages - - task: Cache@2 - displayName: Cache NPM packages - inputs: - key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json' - restoreKeys: | - npm_e2e | "$(Agent.OS)" - npm_e2e - path: $(npm_config_cache) - - - script: npm ci --no-fund --no-audit --prefer-offline - workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest - displayName: Restore NPM packages - - # Build application - - pwsh: | - $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g" - dotnet new nugetconfig - dotnet nuget add source ./nupkg --name Local - dotnet new install Umbraco.Templates::$cmsVersion - dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check dotnet restore UmbracoProject cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject - dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore + displayName: Restore project + workingDirectory: $(Agent.BuildDirectory)/app + + - pwsh: | + dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore dotnet dev-certs https displayName: Build application workingDirectory: $(Agent.BuildDirectory)/app + condition: succeeded() - # Start SQL Server - - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest - displayName: Start SQL Server Docker image (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + # Run application Template + - template: nightly-E2E-run-application-template.yml + parameters: + SA_PASSWORD: ${{ variables.SA_PASSWORD }} + buildConfiguration: ${{ variables.buildConfiguration }} + DatabaseType: ${{ variables.DatabaseType }} + additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }} - - pwsh: SqlLocalDB start MSSQLLocalDB - displayName: Start SQL Server LocalDB (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + # Run tests Template + - template: nightly-E2E-run-tests-template.yml + parameters: + testCommand: $(testCommand) + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + DatabaseType: ${{ variables.DatabaseType }} - # Run application + - stage: AdditionalConfigE2E + displayName: Additional Config E2E Tests + dependsOn: Build + variables: + npm_config_cache: $(Pipeline.Workspace)/.npm_e2e + ASPNETCORE_URLS: https://localhost:44331 + PlaywrightPassword: UmbracoAcceptance123! + PlaywrightUserEmail: playwright@umbraco.com + jobs: + - job: + displayName: E2E Tests with Different App settings (SQL Server) + condition: ${{ or(eq(parameters.differentAppSettingsAcceptanceTests, true), eq(parameters.skipDefaultConfigAcceptanceTests, true)) }} + timeoutInMinutes: 180 + variables: + SA_PASSWORD: UmbracoAcceptance123! + DatabaseType: SQLServer + strategy: + matrix: + # UnattendedInstallConfig + WindowsUnattendedInstallConfig: + vmImage: "windows-latest" + testFolder: "UnattendedInstallConfig" + testCommand: "npx playwright test --project=unattendedInstallConfig --grep=InstallSQLServer" + port: 44331 + additionalEnvironmentVariables: false + # DeliveryApiConfig + WindowsDeliveryApiConfig: + vmImage: "windows-latest" + testFolder: "DeliveryApi" + port: '' + testCommand: "npx playwright test --project=deliveryApi" + CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True + CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + additionalEnvironmentVariables: false + LinuxDeliveryApiConfig: + vmImage: "ubuntu-latest" + testFolder: "DeliveryApi" + port: '' + testCommand: "npx playwright test --project=deliveryApi" + CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True + CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + additionalEnvironmentVariables: false + # ExternalLogin AzureADB2C + WindowsExternalLoginAzureADB2C: + vmImage: "windows-latest" + testFolder: "ExternalLogin\\AzureADB2C" + testCommand: "npx playwright test --project=externalLoginAzureADB2C" + port: 44331 + packageName: "Microsoft.AspNetCore.Authentication.OpenIdConnect" + packageVersion: "9.0.8" + CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True + CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient + additionalEnvironmentVariables: true + pool: + vmImage: $(vmImage) + steps: + # Setup test environment Template + - template: nightly-E2E-setup-template.yml + parameters: + nodeVersion: ${{ variables.nodeVersion }} + PlaywrightUserEmail: ${{ variables.PlaywrightUserEmail }} + PlaywrightPassword: ${{ variables.PlaywrightPassword }} + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + npm_config_cache: ${{ variables.npm_config_cache }} + + # Install NuGet package if specified in the matrix + - pwsh: | + Write-Host "Installing package $(packageName) version $(packageVersion)" + dotnet add package $(packageName) --version $(packageVersion) + displayName: "Install NuGet package: $(packageName)" + workingDirectory: $(Agent.BuildDirectory)/app/UmbracoProject + condition: and(succeeded(), ne(variables['packageName'], ''), ne(variables['packageVersion'], '')) + + # Build application Template + - template: nightly-E2E-build-template.yml + parameters: + testFolder: $(testFolder) + buildConfiguration: ${{ variables.buildConfiguration }} + additionalEnvironmentVariables: $(additionalEnvironmentVariables) + + # Build application for AzureADB2C + - pwsh: | + dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore + dotnet dev-certs https + displayName: Build application for AzureADB2C + workingDirectory: $(Agent.BuildDirectory)/app + env: + AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN) + AZUREADB2CTENANT: $(AZUREB2CTENANT) + AZUREADB2CPOLICY: $(AZUREB2CPOLICY) + AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID) + AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET) + condition: and(succeeded(), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C')) + + # Run application Template + - template: nightly-E2E-run-application-template.yml + parameters: + SA_PASSWORD: ${{ variables.SA_PASSWORD }} + additionalEnvironmentVariables: $(additionalEnvironmentVariables) + buildConfiguration: ${{ variables.buildConfiguration }} + DatabaseType: ${{ variables.DatabaseType }} + + # Run application for Linux with additional Environment Variables for Azure AD - bash: | - nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & + nohup dotnet run --project UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 & echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!" displayName: Run application (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C')) workingDirectory: $(Agent.BuildDirectory)/app + env: + AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN) + AZUREADB2CTENANT: $(AZUREB2CTENANT) + AZUREADB2CPOLICY: $(AZUREB2CPOLICY) + AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID) + AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET) + # Run application for Windows with additional Environment Variables for Azure AD - pwsh: | - $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log + $process = Start-Process dotnet "run --project UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)" displayName: Run application (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C')) workingDirectory: $(Agent.BuildDirectory)/app - - # Ensures we have the package wait-on installed - - pwsh: npm install wait-on - displayName: Install wait-on package - - # Wait for application to start responding to requests - - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS) - displayName: Wait for application - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Install Playwright and dependencies - - pwsh: npx playwright install chromium - displayName: Install Playwright only with Chromium browser - workingDirectory: tests/Umbraco.Tests.AcceptanceTest - - # Test - - pwsh: $(testCommand) - displayName: Run Playwright tests - continueOnError: true - workingDirectory: tests/Umbraco.Tests.AcceptanceTest env: - CI: true - CommitId: $(Build.SourceVersion) - AgentOs: $(Agent.OS) + AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN) + AZUREADB2CTENANT: $(AZUREB2CTENANT) + AZUREADB2CPOLICY: $(AZUREB2CPOLICY) + AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID) + AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET) - # Stop application - - bash: kill -15 $(AcceptanceTestProcessId) - displayName: Stop application (Linux) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: Stop-Process -Id $(AcceptanceTestProcessId) - displayName: Stop application (Windows) - condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT')) - - # Stop SQL Server - - pwsh: docker stop mssql - displayName: Stop SQL Server Docker image (Linux) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) - - - pwsh: SqlLocalDB stop MSSQLLocalDB - displayName: Stop SQL Server LocalDB (Windows) - condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) - - # Copy artifacts - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse - } - displayName: Copy Playwright results - condition: succeededOrFailed() - - # Copy console error log - - pwsh: | - if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) { - Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory) - } - displayName: Copy console error log - condition: succeededOrFailed() - - # Publish - - 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)" + # Run tests Template + - template: nightly-E2E-run-tests-template.yml + parameters: + testCommand: $(testCommand) + ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }} + port: $(port) + AZUREB2CTESTUSEREMAIL: $(AZUREB2CTESTUSEREMAIL) + AZUREB2CTESTUSERPASSWORD: $(AZUREB2CTESTUSERPASSWORD) + DatabaseType: ${{ variables.DatabaseType }} - stage: NotifySlackBot displayName: Notify Slack on Failure - dependsOn: E2E + dependsOn: DefaultConfigE2E # This stage will only run if the E2E tests fail or succeed with issues - condition: or( - eq(dependencies.E2E.result, 'failed'), - eq(dependencies.E2E.result, 'succeededWithIssues')) + condition: or(eq(dependencies.DefaultConfigE2E.result, 'failed'), eq(dependencies.DefaultConfigE2E.result, 'succeededWithIssues')) jobs: - job: PostToSlack displayName: Send Slack Notification diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 478655791d..fbeda87922 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.38", - "@umbraco/playwright-testhelpers": "^16.0.42", + "@umbraco/playwright-testhelpers": "^16.0.46", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -58,21 +58,21 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.38.tgz", - "integrity": "sha512-6nC1Y1xn+8zyqU3iqHubRo18L53TdZkhHIY4z68VSLcA6YoAzdxtjw+zx7yDIMV+epoQ4NCG2ooAa0gBhHqQgg==", + "version": "2.0.39", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.39.tgz", + "integrity": "sha512-YcgZ+WJ3HANBUaffSzZVRlJNLjXOaWOQNIuGf/A0lGH1khd5Kkv2JGln1bq2bNzIbIYQM+f2vYAnmYXmJFN7Vg==", "license": "MIT", "dependencies": { "camelize": "^1.0.1" } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "16.0.42", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.42.tgz", - "integrity": "sha512-ePKl8gtELoIMEV57E3N4VumfKNkuOTFo/LYH7ePhseCcm5oUh1Cc/RVqvlXYsdfBTiMfZ7x7Nu4lOSv15D2Z3Q==", + "version": "16.0.46", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.46.tgz", + "integrity": "sha512-2C76pXp8ixbrOj4kcSzwyXCPSXMsubPcR6wClBdVx6ZiR4LgkAzQ8WwRca/K5pKVm2Uh6HogdRE6bg+qv6klxQ==", "license": "MIT", "dependencies": { - "@umbraco/json-models-builders": "2.0.38", + "@umbraco/json-models-builders": "2.0.39", "node-fetch": "^2.6.7" } }, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index adda209951..abeb17447e 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.38", - "@umbraco/playwright-testhelpers": "^16.0.42", + "@umbraco/playwright-testhelpers": "^16.0.46", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts index b7f9fc6ac8..c09d132505 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/playwright.config.ts @@ -44,14 +44,42 @@ export default defineConfig({ testMatch: '**/*.setup.ts', }, { - name: 'chromium', + name: 'defaultConfig', + testMatch: 'DefaultConfig/**', dependencies: ['setup'], use: { ...devices['Desktop Chrome'], // Use prepared auth state. ignoreHTTPSErrors: true, - storageState: STORAGE_STATE, + storageState: STORAGE_STATE + } + }, + { + name: 'deliveryApi', + testMatch: 'DeliveryApi/**', + dependencies: ['setup'], + use: { + ...devices['Desktop Chrome'], + // Use prepared auth state. + ignoreHTTPSErrors: true, + storageState: STORAGE_STATE }, }, + { + name: 'externalLoginAzureADB2C', + testMatch: 'ExternalLogin/AzureADB2C/**', + use: { + ...devices['Desktop Chrome'], + ignoreHTTPSErrors: true, + } + }, + // This project is used to test the install steps, for that we do not need to authenticate. + { + name: 'unattendedInstallConfig', + testMatch: 'UnattendedInstallConfig/**', + use: { + ...devices['Desktop Chrome'] + } + } ], }); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockGrid/Block/BlockGridBlockAdvanced.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockGrid/Block/BlockGridBlockAdvanced.spec.ts index da485d8777..7803ce7e83 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockGrid/Block/BlockGridBlockAdvanced.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockGrid/Block/BlockGridBlockAdvanced.spec.ts @@ -248,7 +248,7 @@ test('can add a thumbnail to a block', {tag: '@smoke'}, async ({umbracoApi, umbr const textStringData = await umbracoApi.dataType.getByName(dataTypeName); const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); await umbracoApi.dataType.createBlockGridWithABlock(blockGridEditorName, contentElementTypeId); - const mediaUrl = await umbracoApi.media.getMediaUrl(mediaId); + const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaId); // Act await umbracoUi.dataType.goToDataType(blockGridEditorName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts index 49f502c462..2196ef194b 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/BlockListEditor/BlockListBlocks.spec.ts @@ -422,7 +422,7 @@ test('can add a thumbnail to a block', {tag: '@release'}, async ({umbracoApi, um const textStringData = await umbracoApi.dataType.getByName(dataTypeName); const contentElementTypeId = await umbracoApi.documentType.createDefaultElementType(elementTypeName, groupName, dataTypeName, textStringData.id); await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListEditorName, contentElementTypeId); - const mediaUrl = await umbracoApi.media.getMediaUrl(mediaId); + const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaId); // Act await umbracoUi.dataType.goToDataType(blockListEditorName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Media/Media.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Media/Media.spec.ts index 251f455072..fba1dd167c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Media/Media.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Media/Media.spec.ts @@ -73,7 +73,7 @@ for (const mediaFileType of mediaFileTypes) { // Assert await umbracoUi.media.waitForMediaItemToBeCreated(); const mediaData = await umbracoApi.media.getByName(mediaFileType.fileName); - const mediaUrl = await umbracoApi.media.getMediaUrl(mediaData.id); + const mediaUrl = await umbracoApi.media.getFullMediaUrl(mediaData.id); await umbracoUi.media.doesMediaHaveThumbnail(mediaData.id, mediaFileType.thumbnail, mediaUrl); await umbracoUi.media.isMediaTreeItemVisible(mediaFileType.fileName); expect(await umbracoApi.media.doesNameExist(mediaFileType.fileName)).toBeTruthy(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/appsettings.json deleted file mode 100644 index 9e26dfeeb6..0000000000 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/appsettings.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/Program.cs b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/Program.cs new file mode 100644 index 0000000000..854d98c154 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/Program.cs @@ -0,0 +1,27 @@ +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.CreateUmbracoBuilder() + .AddBackOffice() + .AddWebsite() + .AddDeliveryApi() + .AddComposers() + .Build(); + +WebApplication app = builder.Build(); + +await app.BootUmbracoAsync(); + + +app.UseUmbraco() + .WithMiddleware(u => + { + u.UseBackOffice(); + u.UseWebsite(); + }) + .WithEndpoints(u => + { + u.UseBackOfficeEndpoints(); + u.UseWebsiteEndpoints(); + }); + +await app.RunAsync(); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/appsettings.json new file mode 100644 index 0000000000..44dc8b93d2 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/AdditionalSetup/appsettings.json @@ -0,0 +1,64 @@ +{ + "$schema": "appsettings-schema.json", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "Configure": [ + { + "Name": "Console" + } + ] + } + } + ] + }, + "Umbraco": { + "CMS": { + "DeliveryApi": { + "Enabled": true, + "Media": { + "Enabled": true + } + }, + "Unattended": { + "InstallUnattended": true, + "UnattendedUserName": "Playwright Test", + "UnattendedUserEmail": "playwright@umbraco.com", + "UnattendedUserPassword": "UmbracoAcceptance123!" + }, + "Content": { + "ContentVersionCleanupPolicy": { + "EnableCleanup": false + } + }, + "Global": { + "DisableElectionForSingleServer": true, + "InstallMissingDatabase": true, + "Id": "00000000-0000-0000-0000-000000000042", + "VersionCheckPeriod": 0, + "UseHttps": true + }, + "HealthChecks": { + "Notification": { + "Enabled": false + } + }, + "KeepAlive": { + "DisableKeepAliveTask": true + }, + "WebRouting": { + "UmbracoApplicationUrl": "https://localhost:44331/" + } + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/DeliveryApi.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/DeliveryApi.spec.ts new file mode 100644 index 0000000000..5d3ae153eb --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/DeliveryApi.spec.ts @@ -0,0 +1,42 @@ +import {expect} from '@playwright/test'; +import {AliasHelper, test} from '@umbraco/playwright-testhelpers'; + +test('can get content from delivery api', async ({umbracoApi}) => { + // Arrange + const documentTypeName = 'TestDocumentType'; + const contentName = 'TestContent'; + const dataTypeName = 'Textstring'; + const textStringValue = 'This is a test text string value'; + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + const dataType = await umbracoApi.dataType.getByName(dataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, dataTypeName, dataType.id, 'TestGroup'); + const documentId = await umbracoApi.document.createDocumentWithTextContent(contentName, documentTypeId, textStringValue, dataTypeName); + const propertyValue = { + dataTypeName: AliasHelper.toAlias(dataTypeName), + dataTypeValue: textStringValue + } + + // Act + await umbracoApi.document.publish(documentId); + + // Assert + expect(await umbracoApi.contentDeliveryApi.doesContentItemWithIdContainValues(documentId, contentName, AliasHelper.toAlias(documentTypeName), [propertyValue])).toBeTruthy(); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); +}); + +test('can get media image from delivery api', async ({umbracoApi}) => { + // Arrange + const mediaName = 'TestMedia'; + const mediaTypeName = 'File'; + await umbracoApi.media.ensureNameNotExists(mediaName); + const mediaId = await umbracoApi.media.createDefaultMediaFile(mediaName); + const mediaUrl = await umbracoApi.media.getMediaUrlWithoutBaseUrl(mediaId); + + // Assert + expect(await umbracoApi.mediaDeliveryApi.doesMediaItemWithIdContainValues(mediaId, mediaName, mediaTypeName, mediaUrl)).toBeTruthy(); + + // Clean + await umbracoApi.media.ensureNameNotExists(mediaName); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/appsettings.json new file mode 100644 index 0000000000..44dc8b93d2 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DeliveryApi/appsettings.json @@ -0,0 +1,64 @@ +{ + "$schema": "appsettings-schema.json", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "Configure": [ + { + "Name": "Console" + } + ] + } + } + ] + }, + "Umbraco": { + "CMS": { + "DeliveryApi": { + "Enabled": true, + "Media": { + "Enabled": true + } + }, + "Unattended": { + "InstallUnattended": true, + "UnattendedUserName": "Playwright Test", + "UnattendedUserEmail": "playwright@umbraco.com", + "UnattendedUserPassword": "UmbracoAcceptance123!" + }, + "Content": { + "ContentVersionCleanupPolicy": { + "EnableCleanup": false + } + }, + "Global": { + "DisableElectionForSingleServer": true, + "InstallMissingDatabase": true, + "Id": "00000000-0000-0000-0000-000000000042", + "VersionCheckPeriod": 0, + "UseHttps": true + }, + "HealthChecks": { + "Notification": { + "Enabled": false + } + }, + "KeepAlive": { + "DisableKeepAliveTask": true + }, + "WebRouting": { + "UmbracoApplicationUrl": "https://localhost:44331/" + } + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/App_Plugins/Login/umbraco-package.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/App_Plugins/Login/umbraco-package.json new file mode 100644 index 0000000000..929a1446ac --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/App_Plugins/Login/umbraco-package.json @@ -0,0 +1,25 @@ +{ + "$schema": "../../umbraco-package-schema.json", + "name": "Azure B2C Login", + "allowPublicAccess": true, + "extensions": [ + { + "type": "authProvider", + "alias": "Test.AzureB2C", + "name": "Azure AD B2C", + "forProviderName": "Umbraco.AzureB2C", + "meta": { + "label": "Sign in with Azure AD B2C", + "defaultView": { + "icon": "icon-cloud" + }, + "behavior": { + "autoRedirect": false + }, + "linking": { + "allowManualLinking": true + } + } + } + ] +} \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CAuthenticationExtensions.cs b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CAuthenticationExtensions.cs new file mode 100644 index 0000000000..53db531b52 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CAuthenticationExtensions.cs @@ -0,0 +1,73 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C +{ + public static class AzureB2CAuthenticationExtensions + { + public static IUmbracoBuilder ConfigureAuthentication(this IUmbracoBuilder builder, + IConfiguration configuration) + { + var b2cSettings = new AzureB2CSettings(); + + builder.AddBackOfficeExternalLogins(logins => + { + const string schemeName = AzureB2COptions.SchemeName; + var backOfficeScheme = Constants.Security.BackOfficeExternalAuthenticationTypePrefix + schemeName; + + logins.AddBackOfficeLogin(backOfficeAuth => + { + backOfficeAuth.AddOpenIdConnect(backOfficeScheme, options => + { + options.RequireHttpsMetadata = true; + options.SaveTokens = true; + options.ClientId = b2cSettings.ClientId; + options.ClientSecret = b2cSettings.ClientSecret; + options.CallbackPath = "/umbraco-b2c-users-signin"; + options.MetadataAddress = + $"https://{b2cSettings.Domain}/{b2cSettings.Tenant}/{b2cSettings.Policy}/v2.0/.well-known/openid-configuration"; + + options.ResponseType = OpenIdConnectResponseType.Code; + options.TokenValidationParameters.SaveSigninToken = true; + options.GetClaimsFromUserInfoEndpoint = true; + options.TokenValidationParameters.NameClaimType = "name"; + options.TokenValidationParameters.RoleClaimType = "role"; + + options.Events = new OpenIdConnectEvents + { + OnTokenResponseReceived = context => + { + if (string.IsNullOrEmpty(context.TokenEndpointResponse.AccessToken)) + { + context.TokenEndpointResponse.AccessToken = "empty_access_token"; + } + + return Task.CompletedTask; + }, + + OnTokenValidated = context => + { + var identity = context.Principal!.Identities.First(); + + var email = identity.FindFirst("emails")?.Value + ?? identity.FindFirst(ClaimTypes.Email)?.Value; + + if (!string.IsNullOrWhiteSpace(email)) + { + identity.AddClaim(new Claim(ClaimTypes.Email, email)); + identity.AddClaim(new Claim("email", email)); + } + + return Task.CompletedTask; + } + }; + }); + }); + }); + + return builder; + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CComposer.cs b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CComposer.cs new file mode 100644 index 0000000000..ee4787143f --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CComposer.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C +{ + public class AzureB2CComposer : IComposer + { + public void Compose(IUmbracoBuilder builder) + { + builder.Services.ConfigureOptions(); + + builder.ConfigureAuthentication(builder.Config); + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2COptions.cs b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2COptions.cs new file mode 100644 index 0000000000..3464b73cc4 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2COptions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Options; +using Umbraco.Cms.Api.Management.Security; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C +{ + public class AzureB2COptions : IConfigureNamedOptions + { + public const string SchemeName = "AzureB2C"; + + public void Configure(string? name, BackOfficeExternalLoginProviderOptions options) + { + if (name != Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName) + return; + + options.AutoLinkOptions = new ExternalSignInAutoLinkOptions( + autoLinkExternalAccount: true, + defaultUserGroups: [Constants.Security.AdminGroupAlias], + defaultCulture: "en-US", + allowManualLinking: true + ) + { + OnAutoLinking = (user, loginInfo) => { user.IsApproved = true; }, + OnExternalLogin = (user, loginInfo) => { return true; } + }; + } + + public void Configure(BackOfficeExternalLoginProviderOptions options) => + Configure(Constants.Security.BackOfficeExternalAuthenticationTypePrefix + SchemeName, options); + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CSettings.cs b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CSettings.cs new file mode 100644 index 0000000000..48aa5a476b --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/AzureB2CSettings.cs @@ -0,0 +1,11 @@ +namespace Umbraco.Cms.Tests.AcceptanceTest.ExternalLogin.AzureADB2C +{ + public class AzureB2CSettings + { + public string Domain { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CDOMAIN") ?? string.Empty; + public string Tenant { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CTENANT") ?? string.Empty; + public string Policy { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CPOLICY") ?? string.Empty; + public string ClientId { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CCLIENTID") ?? string.Empty; + public string ClientSecret { get; set; } = Environment.GetEnvironmentVariable("AZUREADB2CCLIENTSECRET") ?? string.Empty; + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/appsettings.json new file mode 100644 index 0000000000..e978258b9a --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/AdditionalSetup/appsettings.json @@ -0,0 +1,58 @@ +{ + "$schema": "appsettings-schema.json", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "Configure": [ + { + "Name": "Console" + } + ] + } + } + ] + }, + "Umbraco": { + "CMS": { + "Unattended": { + "InstallUnattended": true, + "UnattendedUserName": "Playwright Test", + "UnattendedUserEmail": "playwright@umbraco.com", + "UnattendedUserPassword": "UmbracoAcceptance123!" + }, + "Content": { + "ContentVersionCleanupPolicy": { + "EnableCleanup": false + } + }, + "Global": { + "DisableElectionForSingleServer": true, + "InstallMissingDatabase": true, + "Id": "00000000-0000-0000-0000-000000000042", + "VersionCheckPeriod": 0, + "UseHttps": true + }, + "HealthChecks": { + "Notification": { + "Enabled": false + } + }, + "KeepAlive": { + "DisableKeepAliveTask": true + }, + "WebRouting": { + "UmbracoApplicationUrl": "https://localhost:44331/" + } + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/Login.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/Login.spec.ts new file mode 100644 index 0000000000..385dfbccab --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/ExternalLogin/AzureADB2C/Login.spec.ts @@ -0,0 +1,20 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +const azureEmail = process.env.AZUREADB2CTESTUSEREMAIL; +const azurePassword = process.env.AZUREADB2CTESTUSERPASSWORD; + +// Really simple test to check if we can log in using Azure AD B2C +test('Log in to Umbraco using Azure AD B2C', async ({umbracoUi}) => { + test.slow(); + // Arrange + await umbracoUi.goToBackOffice(); + + // Act + await umbracoUi.externalLogin.clickSignInWithAzureADB2CButton(); + await umbracoUi.externalLogin.enterAzureADB2CEmail(azureEmail); + await umbracoUi.externalLogin.enterAzureADB2CPassword(azurePassword); + await umbracoUi.externalLogin.clickSignInButton(); + + // Assert + await umbracoUi.content.goToSection(ConstantHelper.sections.content); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/AdditionalSetup/appsettings.json b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/AdditionalSetup/appsettings.json new file mode 100644 index 0000000000..3aa310742c --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/AdditionalSetup/appsettings.json @@ -0,0 +1,54 @@ +{ + "$schema": "appsettings-schema.json", + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Async", + "Args": { + "Configure": [ + { + "Name": "Console" + } + ] + } + } + ] + }, + "Umbraco": { + "CMS": { + "Unattended": { + "InstallUnattended": false + }, + "Content": { + "ContentVersionCleanupPolicy": { + "EnableCleanup": false + } + }, + "Global": { + "DisableElectionForSingleServer": true, + "Id": "00000000-0000-0000-0000-000000000042", + "VersionCheckPeriod": 0, + "UseHttps": true + }, + "HealthChecks": { + "Notification": { + "Enabled": false + } + }, + "KeepAlive": { + "DisableKeepAliveTask": true + }, + "WebRouting": { + "UmbracoApplicationUrl": "https://localhost:44331/" + } + } + } +} diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLServer.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLServer.spec.ts new file mode 100644 index 0000000000..1ae0714e21 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLServer.spec.ts @@ -0,0 +1,28 @@ +// To be able to test different databases, we need to set an additional UnattendedInstallConfig up because we would have to start from scratch, otherwise we would be using the same database. +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +const name = 'TestName'; +const email = process.env.UMBRACO_USER_LOGIN; +const password = process.env.UMBRACO_USER_PASSWORD; + +test('Install Umbraco using SQLServer Express', async ({umbracoUi}) => { + test.slow(); + // Arrange + await umbracoUi.install.goToInstallPage(); + + // Act + await umbracoUi.install.enterName(name); + await umbracoUi.install.enterEmail(email); + await umbracoUi.install.enterPassword(password); + await umbracoUi.install.clickNextButton(); + await umbracoUi.install.clickNextButton(); + await umbracoUi.install.setDatabaseType('SQL Server Express LocalDB'); + await umbracoUi.install.doesDatabaseHaveType('SQL Server Express LocalDB'); + await umbracoUi.install.clickInstallButton(); + + // Assert + await umbracoUi.login.enterEmail(email); + await umbracoUi.login.enterPassword(password); + await umbracoUi.login.clickLoginButton(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); +}); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLite.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLite.spec.ts new file mode 100644 index 0000000000..69bc110782 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/UnattendedInstallConfig/Install/InstallSQLite.spec.ts @@ -0,0 +1,27 @@ +// To be able to test different databases, we need to set an additional UnattendedInstallConfig up because we would have to start from scratch, otherwise we would be using the same database. +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +const name = 'TestName'; +const email = process.env.UMBRACO_USER_LOGIN; +const password = process.env.UMBRACO_USER_PASSWORD; + +test('Install Umbraco using SQLite', async ({umbracoUi}) => { + test.slow(); + // Arrange + await umbracoUi.install.goToInstallPage(); + + // Act + await umbracoUi.install.enterName(name); + await umbracoUi.install.enterEmail(email); + await umbracoUi.install.enterPassword(password); + await umbracoUi.install.clickNextButton(); + await umbracoUi.install.clickNextButton(); + await umbracoUi.install.doesDatabaseHaveType('SQLite'); + await umbracoUi.install.clickInstallButton(); + + // Assert + await umbracoUi.login.enterEmail(email); + await umbracoUi.login.enterPassword(password); + await umbracoUi.login.clickLoginButton(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); +}); diff --git a/umbraco.sln b/umbraco.sln index 74fcc8dc3f..0568efabec 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -189,6 +189,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.Cms.Api.Management" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Umbraco.PublishedCache.HybridCache", "src\Umbraco.PublishedCache.HybridCache\Umbraco.PublishedCache.HybridCache.csproj", "{CB0B9817-EDBC-4D6D-B4D2-969019C4606D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nightly-e2e-templates", "nightly-e2e-templates", "{E90531F6-E32D-40DA-BCB2-55FA94D5AB19}" + ProjectSection(SolutionItems) = preProject + build\nightly-E2E-build-template.yml = build\nightly-E2E-build-template.yml + build\nightly-E2E-run-application-template.yml = build\nightly-E2E-run-application-template.yml + build\nightly-E2E-run-tests-template.yml = build\nightly-E2E-run-tests-template.yml + build\nightly-E2E-setup-template.yml = build\nightly-E2E-setup-template.yml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -382,6 +390,7 @@ Global {25AECCB5-B187-4406-844B-91B8FF0FCB37} = {2B47AD9F-FFF1-448A-88F1-D4F568811738} {EA628ABD-624E-4AF3-B548-6710D4D66531} = {2B47AD9F-FFF1-448A-88F1-D4F568811738} {A13FF0A0-69FA-468A-9F79-565401D5C341} = {B5BD12C1-A454-435E-8A46-FF4A364C0382} + {E90531F6-E32D-40DA-BCB2-55FA94D5AB19} = {20CE9C97-9314-4A19-BCF1-D12CF49B7205} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {7A0F2E34-D2AF-4DAB-86A0-7D7764B3D0EC} From 1660e69681dc61730a39f82566cc85a054357e69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 18 Sep 2025 13:34:36 +0200 Subject: [PATCH 05/56] Workspace Actions: set attributes on the right element for label to work (#20178) set attributes on the right element --- .../workspace-entity-action-menu.element.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts index 06252f0472..7a9ba0caa6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts @@ -31,11 +31,10 @@ export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { if (!this._entityType) return nothing; if (this._unique === undefined) return nothing; - return html` - + return html` + `; } From 3025dcdf319d591089f47ee15fbe15c8e093cfc0 Mon Sep 17 00:00:00 2001 From: Engiber Lozada <89547469+engijlr@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:56:11 +0200 Subject: [PATCH 06/56] Tags Property Editor: Remove tags with keyboard backspace/delete (#19892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Remove tags with backspace * Unused varible * Manage focusable tag and tabindex updates * `import`s tidy-up * Adds `tabindex` and focus outline for each tag * Removed the tag wrapper container No longer required. * Adds support for "Delete" key * Disables `autocomplete` for new tag input This conflicts with the suggestions prompt. * Reverted removal of the tag wrapper container Required as a "skip tags" tabbing feature * Uses `UmbChangeEvent` --------- Co-authored-by: Mads Rasmussen Co-authored-by: Niels Lyngsø Co-authored-by: leekelleher --- .../tags-input/tags-input.element.ts | 149 ++++++++++++++---- 1 file changed, 117 insertions(+), 32 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts index 9efb1b6962..5a84f467c6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts @@ -1,19 +1,20 @@ import { UmbTagRepository } from '../../repository/tag.repository.js'; import { css, + customElement, html, nothing, - customElement, property, query, queryAll, - state, repeat, + state, } from '@umbraco-cms/backoffice/external/lit'; -import type { UUIInputElement, UUIInputEvent, UUITagElement } from '@umbraco-cms/backoffice/external/uui'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; import type { TagResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UUIInputElement, UUIInputEvent, UUITagElement } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-tags-input') export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') { @@ -61,6 +62,9 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') @queryAll('.options') private _optionCollection?: HTMLCollectionOf; + @queryAll('.tag') + private _tagEls?: NodeListOf; + #repository = new UmbTagRepository(this); public override focus() { @@ -78,18 +82,29 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this._matches = data.items; } - #onKeydown(e: KeyboardEvent) { - //Prevent tab away if there is a input. - if (e.key === 'Tab' && (this._tagInput.value as string).trim().length && !this._matches.length) { + #onInputKeydown(e: KeyboardEvent) { + const inputLength = (this._tagInput.value as string).trim().length; + + //Prevent tab away if there is a text in the input. + if (e.key === 'Tab' && inputLength && !this._matches.length) { e.preventDefault(); this.#createTag(); return; } + + //If the input is empty we can navigate out of it using tab + if (e.key === 'Tab' && !inputLength) { + return; + } + + //Create a new tag when enter to the input if (e.key === 'Enter') { this.#createTag(); return; } - if (e.key === 'ArrowDown' || e.key === 'Tab') { + + //This one to show option collection if there is any + if (e.key === 'ArrowDown') { e.preventDefault(); this._currentInput = this._optionCollection?.item(0)?.value ?? this._currentInput; this._optionCollection?.item(0)?.focus(); @@ -98,6 +113,54 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this.#inputError(false); } + #focusTag(index: number) { + const tag = this._tagEls?.[index]; + if (!tag) return; + + // Find the current element with the class .tab and tabindex=0 (will be the previous tag) + const active = this.renderRoot.querySelector('.tag[tabindex="0"]'); + + // Return it is tabindex to -1 + active?.setAttribute('tabindex', '-1'); + + // Set the tabindex to 0 in the current target + tag.setAttribute('tabindex', '0'); + + tag.focus(); + } + + #onTagsWrapperKeydown(e: KeyboardEvent) { + if ((e.key === 'Enter' || e.key === 'ArrowDown') && this.items.length) { + e.preventDefault(); + this.#focusTag(0); + } + } + + #onTagKeydown(e: KeyboardEvent, idx: number) { + if (e.key === 'ArrowRight') { + e.preventDefault(); + if (idx < this.items.length - 1) { + this.#focusTag(idx + 1); + } + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (idx > 0) { + this.#focusTag(idx - 1); + } + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + if (this.#items.length - 1 === idx) { + this.#focusTag(idx - 1); + } + this.#delete(this.#items[idx]); + this.#focusTag(idx + 1); + } + } + #onInput(e: UUIInputEvent) { this._currentInput = e.target.value as string; if (!this._currentInput || !this._currentInput.length) { @@ -128,7 +191,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this.items = [...this.items, newTag]; this._tagInput.value = ''; this._currentInput = ''; - this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + this.dispatchEvent(new UmbChangeEvent()); } #inputError(error: boolean) { @@ -150,7 +213,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') } else { this.items = []; } - this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + this.dispatchEvent(new UmbChangeEvent()); } /** Dropdown */ @@ -196,7 +259,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') override render() { return html`
- ${this.#enteredTags()} + ${this.#renderTags()}
@@ -390,6 +434,13 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< `; } + #renderHintBadge(hint?: UmbVariantHint) { + if (!hint) return nothing; + return html`
+ ${hint.text} +
`; + } + #isCreated(variantOption: VariantOptionModelType) { return ( variantOption.variant?.state && From 611db112058c2619a29740d47f8b6419e8bdc527 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 18 Sep 2025 18:31:02 +0200 Subject: [PATCH 08/56] Content Variant: Preserve additional URL path in split view navigation (closes #17412) (#20177) * Preserve additional URL path in split view navigation Enhances the split view manager to retain any additional pathname segments when updating the browser history, ensuring that navigation state beyond the variant part is preserved. * Update src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * format --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ...workspace-split-view-manager.controller.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts index 215eecc03f..f98f542817 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts @@ -67,7 +67,8 @@ export class UmbWorkspaceSplitViewManager { .map((v) => UmbVariantId.Create(v).toString()) .join(UBM_VARIANT_DELIMITER); - history.pushState(null, '', `${workspaceRoute}/${variantPart}`); + const additionalPathname = this.#getAdditionalPathname(); + history.pushState(null, '', `${workspaceRoute}/${variantPart}${additionalPathname}`); return true; } } @@ -119,4 +120,27 @@ export class UmbWorkspaceSplitViewManager { const variantId = UmbVariantId.FromString(folderPart); this.setActiveVariant(index, variantId.culture, variantId.segment); } + + #getCurrentVariantPathname() { + const workspaceRoute = this.getWorkspaceRoute(); + const activeVariants = this.getActiveVariants(); + const currentVariantPart: string = activeVariants + .map((v) => UmbVariantId.Create(v).toString()) + .join(UBM_VARIANT_DELIMITER); + + return `${workspaceRoute}/${currentVariantPart}`; + } + + #getAdditionalPathname() { + const currentUrl = new URL(window.location.href); + const currentFullPathname = currentUrl.pathname; + const currentVariantPathname = this.#getCurrentVariantPathname(); + + if (currentVariantPathname && currentFullPathname.startsWith(currentVariantPathname)) { + return currentFullPathname.substring(currentVariantPathname.length); + } + + // If the currentVariantPathname is not a prefix, return empty string + return ''; + } } From 014e95c2c4e6e98af4e70a73adccf4806c22bdd6 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 18 Sep 2025 18:32:12 +0200 Subject: [PATCH 09/56] Dynamic Root: Fix missing dynamicRootQueryStep types (closes #19612) (#20183) * expose content-picker types in a module * update path * clean up module * Update entry-point.ts * Whitespace tweak (spaces to tabs) --------- Co-authored-by: leekelleher --- src/Umbraco.Web.UI.Client/package.json | 1 + .../property-editors/content-picker/components/index.ts | 2 ++ .../property-editors/content-picker/dynamic-root/types.ts | 1 + .../src/packages/property-editors/content-picker/types.ts | 2 ++ .../src/packages/property-editors/vite.config.ts | 1 + src/Umbraco.Web.UI.Client/tsconfig.json | 1 + 6 files changed, 8 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 2eb75aa399..65c0f92ede 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -33,6 +33,7 @@ "./const": "./dist-cms/packages/core/const/index.js", "./content-type": "./dist-cms/packages/content/content-type/index.js", "./content": "./dist-cms/packages/content/content/index.js", + "./content-picker": "./dist-cms/packages/property-editors/content-picker/index.js", "./culture": "./dist-cms/packages/core/culture/index.js", "./current-user": "./dist-cms/packages/user/current-user/index.js", "./dashboard": "./dist-cms/packages/core/dashboard/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts index cb519d7b9d..08c3309c59 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts @@ -1 +1,3 @@ +import './input-content/input-content.element.js'; + export * from './input-content/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts new file mode 100644 index 0000000000..d88e7115f5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts @@ -0,0 +1 @@ +export type * from './dynamic-root.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts index e13c1ac857..1ba3ccf670 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts @@ -1,3 +1,5 @@ +export type * from './dynamic-root/types.js'; + export type UmbContentPickerSourceType = 'content' | 'member' | 'media'; export type UmbContentPickerSource = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts index 8ace36f856..d628cd8325 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'entry-point': 'entry-point.ts', 'umbraco-package': 'umbraco-package.ts', manifests: 'manifests.ts', + 'content-picker/index': './content-picker/index.ts', }, }), }); diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index c2ec31a43d..a14602106a 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -60,6 +60,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/const": ["./src/packages/core/const/index.ts"], "@umbraco-cms/backoffice/content-type": ["./src/packages/content/content-type/index.ts"], "@umbraco-cms/backoffice/content": ["./src/packages/content/content/index.ts"], + "@umbraco-cms/backoffice/content-picker": ["./src/packages/property-editors/content-picker/index.ts"], "@umbraco-cms/backoffice/culture": ["./src/packages/core/culture/index.ts"], "@umbraco-cms/backoffice/current-user": ["./src/packages/user/current-user/index.ts"], "@umbraco-cms/backoffice/dashboard": ["./src/packages/core/dashboard/index.ts"], From 8b6b2fcdfda533d64067afc472863f141cc26982 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 18 Sep 2025 18:46:30 +0200 Subject: [PATCH 10/56] fix missing const exports --- .../src/packages/property-editors/content-picker/constants.ts | 1 + .../property-editors/content-picker/dynamic-root/constants.ts | 1 + .../src/packages/property-editors/content-picker/index.ts | 1 + 3 files changed, 3 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts new file mode 100644 index 0000000000..d6d9a8e5e3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts @@ -0,0 +1 @@ +export * from './dynamic-root/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts new file mode 100644 index 0000000000..ea358318ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts @@ -0,0 +1 @@ +export * from './modals/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts index ea811ee51c..753539c814 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts @@ -1,4 +1,5 @@ export * from './components/index.js'; export * from './config/source-content/index.js'; +export * from './constants.js'; export * from './dynamic-root/index.js'; export type * from './types.js'; From ec23f7008e0bd4712b7f1a3f12ce78a6ed1fa912 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 18 Sep 2025 19:56:31 +0200 Subject: [PATCH 11/56] Interaction Memory: Feature for picker modal state retention (#18305) (#20159) * set property type unique on context * set the value * observe property type unique from content picker property editor * remove unused * observe data type unique * wip picker memories * append memory option to the picker data model * split into methods * initialize memory context * rename arg * make memory module * export constants * allow nested memories * pass memory from input document to picker context * Update property-editor-ui-content-picker.element.ts * fix import * prefix with interaction * clean up * fix import * rename module * Update vite.config.ts * update module name * observe after search is initialized * use memory manager in all places * make picker modal base element * update types * add memory for document picker property editor * store tree item picker expansion state in interaction memory * Update picker-modal-base.element.ts * remove the memory if we have no expansion state * delete memory if it doesn't include anything * clear picker input memories if nothing comes from the modal * Refactor interaction memory handling in picker input Moved the passing of interaction memories from the document picker input context to the core picker input context. Renamed the method for setting memories from the modal for clarity and consistency. * only dispatch an event if the value changes * remove unused * observe to support close on escape * add comments * fix type error * fix typings * Replaces data type-based memory keys with config hash-based keys * dont store picker search in interaction memory * Rename interaction memory key in picker modal base * Remove error throw for missing interaction memory * Refactor interaction memory handling in content picker Replaces the single 'memory' property with an 'interactionMemories' array and updates event handling to support multiple interaction memories. Adjusts property types, event listeners, and child component bindings to accommodate this change. * Refactor content picker to use interaction memories Replaces the previous memory handling with a new approach using interaction memories, including unique hash generation based on config. Updates event handling and property names to align with the new interaction memory model, improving state management and consistency. * remove debugger * rename const * wip media picker memories * remove args * simplify memory model * update internal value before dispatching event * remove unused * Update property-type-based-property.element.ts * rename method * simplify types * implement location memory for media picker * temp type cast * set location memory when using the breadcrumb * remove code duplication * bubble memories from input media to input content * Update src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix import * remove unused method * Refactor content picker interaction memory management Introduced UmbPropertyEditorUiInteractionMemoryManager to encapsulate interaction memory logic for property editors. Updated the content picker property editor to use this new manager, removing duplicated memory management code and improving maintainability. * Refactor interaction memory management in pickers Replaces custom interaction memory logic in document and media picker property editors with the shared UmbPropertyEditorUiInteractionMemoryManager. Updates unique memory key prefixes for consistency and simplifies related event handling. This improves maintainability and standardizes memory management across property editors. * export context token * add js docs * remove timestamp * add tests for interaction memory manager * Added tests for the property editor ui interaction memory manager * Rename memories to memoriesForPropertyEditor Renamed the 'memories' property to 'memoriesForPropertyEditor' in the interaction memory manager and updated all references in related property editor components and tests for clarity and consistency. * Separated out `import type`s + ordering * remove interaction memory implementation in modal context * remove interactionMemories from modal interface * revert to using the umbOpenModal helper * align property and event name --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: leekelleher --- src/Umbraco.Web.UI.Client/package.json | 1 + .../src/packages/core/entry-point.ts | 13 +- .../core/interaction-memory/constants.ts | 1 + .../interaction-memories-change.event.ts | 8 ++ .../packages/core/interaction-memory/index.ts | 6 + .../interaction-memory.context.token.ts | 6 + .../interaction-memory.context.ts | 12 ++ .../interaction-memory.manager.test.ts | 103 ++++++++++++++ .../interaction-memory.manager.ts | 71 ++++++++++ .../packages/core/interaction-memory/types.ts | 5 + .../core/modal/context/modal.context.ts | 8 +- .../src/packages/core/modal/types.ts | 2 +- .../core/picker-input/picker-input.context.ts | 18 ++- .../src/packages/core/picker/index.ts | 3 +- .../src/packages/core/picker/modal/index.ts | 1 + .../picker/modal/picker-modal-base.element.ts | 64 +++++++++ .../packages/core/picker/picker.context.ts | 7 +- .../search/manager/picker-search.manager.ts | 17 +-- .../packages/core/property-editor/index.ts | 1 + .../interaction-memory/index.ts | 1 + ...itor-ui-interaction-memory.manager.test.ts | 128 ++++++++++++++++++ ...ty-editor-ui-interaction-memory.manager.ts | 93 +++++++++++++ .../tree-item-picker-expansion.manager.ts | 94 +++++++++++++ .../tree-item-picker.context.ts | 8 +- .../tree-picker-modal.element.ts | 63 ++++++--- .../src/packages/core/vite.config.ts | 1 + .../input-document/input-document.element.ts | 61 +++++++-- ...perty-editor-ui-document-picker.element.ts | 38 +++++- .../input-media/input-media.element.ts | 56 ++++++-- .../input-rich-media.element.ts | 40 +++++- .../packages/media/media/modals/constants.ts | 3 +- .../media/modals/media-picker/constants.ts | 1 + .../media-picker-modal.element.ts | 99 +++++++++++--- .../media-picker.context.token.ts | 8 ++ .../media-picker/media-picker.context.ts | 18 +++ ...property-editor-ui-media-picker.element.ts | 43 ++++-- .../input-content/input-content.element.ts | 33 ++++- ...operty-editor-ui-content-picker.element.ts | 34 ++++- src/Umbraco.Web.UI.Client/tsconfig.json | 1 + 39 files changed, 1048 insertions(+), 122 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 65c0f92ede..d3e356ad7b 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -58,6 +58,7 @@ "./icon": "./dist-cms/packages/core/icon-registry/index.js", "./id": "./dist-cms/packages/core/id/index.js", "./imaging": "./dist-cms/packages/media/imaging/index.js", + "./interaction-memory": "./dist-cms/packages/core/interaction-memory/index.js", "./language": "./dist-cms/packages/language/index.js", "./lit-element": "./dist-cms/packages/core/lit-element/index.js", "./localization": "./dist-cms/packages/core/localization/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts index d1236bc97b..89763284e8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts @@ -1,10 +1,12 @@ -import { UMB_AUTH_CONTEXT } from './auth/auth.context.token.js'; -import { UmbBackofficeNotificationContainerElement, UmbBackofficeModalContainerElement } from './components/index.js'; -import { UmbActionEventContext } from './action/action-event.context.js'; import { manifests as coreManifests } from './manifests.js'; -import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import { UMB_AUTH_CONTEXT } from './auth/auth.context.token.js'; +import { UmbActionEventContext } from './action/action-event.context.js'; +import { UmbBackofficeNotificationContainerElement, UmbBackofficeModalContainerElement } from './components/index.js'; +import { UmbInteractionMemoryContext } from './interaction-memory/index.js'; +import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal'; -import { UmbExtensionsApiInitializer, type UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api'; +import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification'; +import type { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api'; import './property-action/components/index.js'; import './menu/components/index.js'; @@ -31,6 +33,7 @@ export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => { new UmbNotificationContext(host); new UmbModalManagerContext(host); new UmbActionEventContext(host); + new UmbInteractionMemoryContext(host); host.consumeContext(UMB_AUTH_CONTEXT, (authContext) => { // Initialize the auth context to let the app context know that the core module is ready diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts new file mode 100644 index 0000000000..9641d5013b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts @@ -0,0 +1 @@ +export * from './interaction-memory.context.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts new file mode 100644 index 0000000000..e9d65ff065 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts @@ -0,0 +1,8 @@ +export class UmbInteractionMemoriesChangeEvent extends Event { + public static readonly TYPE = 'interaction-memories-change'; + + public constructor() { + // mimics the native change event + super(UmbInteractionMemoriesChangeEvent.TYPE, { bubbles: true, composed: false, cancelable: false }); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts new file mode 100644 index 0000000000..175b537a06 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts @@ -0,0 +1,6 @@ +export * from './constants.js'; +export * from './event/interaction-memories-change.event.js'; +export * from './interaction-memory.context.js'; +export * from './interaction-memory.manager.js'; + +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts new file mode 100644 index 0000000000..d37a64fe99 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts @@ -0,0 +1,6 @@ +import type { UmbInteractionMemoryContext } from './interaction-memory.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_INTERACTION_MEMORY_CONTEXT = new UmbContextToken( + 'UmbInteractionMemoryContext', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts new file mode 100644 index 0000000000..32e267129a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts @@ -0,0 +1,12 @@ +import { UMB_INTERACTION_MEMORY_CONTEXT } from './interaction-memory.context.token.js'; +import { UmbInteractionMemoryManager } from './interaction-memory.manager.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbInteractionMemoryContext extends UmbContextBase { + public readonly memory = new UmbInteractionMemoryManager(this); + + constructor(host: UmbControllerHost) { + super(host, UMB_INTERACTION_MEMORY_CONTEXT); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts new file mode 100644 index 0000000000..6c506732d6 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts @@ -0,0 +1,103 @@ +import { UmbInteractionMemoryManager } from './interaction-memory.manager.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { expect } from '@open-wc/testing'; +import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; + +@customElement('test-my-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {} + +describe('UmbInteractionMemoryManager', () => { + let manager: UmbInteractionMemoryManager; + const nestedMemory1 = { unique: 'nestedMemory1', value: 'Nested Memory 1' }; + const nestedMemory2 = { unique: 'nestedMemory2', value: 'Nested Memory 2' }; + const memory1 = { unique: '1', value: 'Memory 1' }; + const memory2 = { unique: '2', value: 'Memory 2', memories: [nestedMemory1, nestedMemory2] }; + + beforeEach(() => { + const hostElement = new UmbTestControllerHostElement(); + manager = new UmbInteractionMemoryManager(hostElement); + manager.setMemory(memory1); + manager.setMemory(memory2); + }); + + describe('Public API', () => { + describe('properties', () => { + it('has a memories property', () => { + expect(manager).to.have.property('memories').to.be.an.instanceOf(Observable); + }); + }); + + describe('methods', () => { + it('has a memory method', () => { + expect(manager).to.have.property('memory').that.is.a('function'); + }); + + it('has a getMemory method', () => { + expect(manager).to.have.property('getMemory').that.is.a('function'); + }); + + it('has a setMemory method', () => { + expect(manager).to.have.property('setMemory').that.is.a('function'); + }); + + it('has a deleteMemory method', () => { + expect(manager).to.have.property('deleteMemory').that.is.a('function'); + }); + + it('has a getAllMemories method', () => { + expect(manager).to.have.property('getAllMemories').that.is.a('function'); + }); + + it('has a clear method', () => { + expect(manager).to.have.property('clear').that.is.a('function'); + }); + }); + }); + + describe('getMemory()', () => { + it('returns the correct memory item by unique identifier', () => { + const result = manager.getMemory('1'); + expect(result).to.deep.equal(memory1); + }); + }); + + describe('setMemory()', () => { + it('create a new memory unique identifier', () => { + const newMemory = { unique: 'newMemory', value: 'New Memory' }; + manager.setMemory(newMemory); + const result = manager.getMemory('newMemory'); + expect(result).to.deep.equal(newMemory); + }); + + it('update an existing memory item by unique identifier', () => { + const updatedMemory = { unique: '1', value: 'Updated Memory 1' }; + manager.setMemory(updatedMemory); + const result = manager.getMemory('1'); + expect(result).to.deep.equal(updatedMemory); + }); + }); + + describe('deleteMemory()', () => { + it('deletes an existing memory item by unique identifier', () => { + manager.deleteMemory('1'); + const result = manager.getMemory('1'); + expect(result).to.be.undefined; + }); + }); + + describe('getAllMemories()', () => { + it('returns all memory items', () => { + const result = manager.getAllMemories(); + expect(result).to.deep.equal([memory1, memory2]); + }); + }); + + describe('clear()', () => { + it('clears all memory items', () => { + manager.clear(); + const result = manager.getAllMemories(); + expect(result.length).to.equal(0); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts new file mode 100644 index 0000000000..a385e4a59c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts @@ -0,0 +1,71 @@ +import type { UmbInteractionMemoryModel } from './types.js'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { Observable } from '@umbraco-cms/backoffice/observable-api'; + +/** + * A manager for handling interaction memory items. + * @exports + * @class UmbInteractionMemoryManager + * @augments {UmbControllerBase} + */ +export class UmbInteractionMemoryManager extends UmbControllerBase { + #memories = new UmbArrayState([], (x) => x.unique); + /** Observable for all memory items. */ + memories = this.#memories.asObservable(); + + /** + * Observable for a specific memory item by its unique identifier. + * @param {string} unique - The unique identifier of the memory item. + * @returns {(Observable)} An observable that emits the memory item or undefined if not found. + * @memberof UmbInteractionMemoryManager + */ + memory(unique: string): Observable { + return this.#memories.asObservablePart((items) => items.find((item) => item.unique === unique)); + } + + /** + * Get a specific memory item by its unique identifier. + * @param {string} unique - The unique identifier of the memory item. + * @returns {(UmbInteractionMemoryModel | undefined)} The memory item or undefined if not found. + * @memberof UmbInteractionMemoryManager + */ + getMemory(unique: string): UmbInteractionMemoryModel | undefined { + return this.#memories.getValue().find((item) => item.unique === unique); + } + + /** + * Add or update a memory item. + * @param {UmbInteractionMemoryModel} memory - The memory item to add or update. + * @memberof UmbInteractionMemoryManager + */ + setMemory(memory: UmbInteractionMemoryModel) { + this.#memories.appendOne(memory); + } + + /** + * Delete a memory item by its unique identifier. + * @param {string} unique - The unique identifier of the memory item. + * @memberof UmbInteractionMemoryManager + */ + deleteMemory(unique: string) { + this.#memories.removeOne(unique); + } + + /** + * Get all memory items from the manager. + * @returns {Array} An array of all memory items. + * @memberof UmbInteractionMemoryManager + */ + getAllMemories(): Array { + return this.#memories.getValue(); + } + + /** + * Clear all memory items from the manager. + * @memberof UmbInteractionMemoryManager + */ + clear() { + this.#memories.clear(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts new file mode 100644 index 0000000000..7f3d18c531 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts @@ -0,0 +1,5 @@ +export interface UmbInteractionMemoryModel { + unique: string; + value?: any; + memories?: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts index 3271d035d4..b27388d406 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts @@ -2,12 +2,14 @@ import { UmbModalToken } from '../token/modal-token.js'; import type { UmbModalConfig, UmbModalType } from '../types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; +import { umbDeepMerge } from '@umbraco-cms/backoffice/utils'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -import { type UmbDeepPartialObject, umbDeepMerge } from '@umbraco-cms/backoffice/utils'; +import { UMB_ROUTE_CONTEXT } from '@umbraco-cms/backoffice/router'; import type { ElementLoaderProperty } from '@umbraco-cms/backoffice/extension-api'; -import { UMB_ROUTE_CONTEXT, type IRouterSlot } from '@umbraco-cms/backoffice/router'; +import type { IRouterSlot } from '@umbraco-cms/backoffice/router'; +import type { UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils'; export interface UmbModalRejectReason { type: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts index 97b3ebb81e..3fe7068550 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts @@ -1,5 +1,5 @@ -import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; import type { ElementLoaderProperty } from '@umbraco-cms/backoffice/extension-api'; +import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; export type * from './extensions/types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts index c6407d1c54..11d3bdeeb2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts @@ -1,12 +1,18 @@ import { UMB_PICKER_INPUT_CONTEXT } from './picker-input.context-token.js'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; +import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; -import { umbConfirmModal, umbOpenModal } from '@umbraco-cms/backoffice/modal'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbItemRepository } from '@umbraco-cms/backoffice/repository'; -import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal'; -import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; +import { + umbConfirmModal, + umbOpenModal, + type UmbModalToken, + type UmbPickerModalData, + type UmbPickerModalValue, +} from '@umbraco-cms/backoffice/modal'; type PickerItemBaseType = { name: string; unique: string }; export class UmbPickerInputContext< @@ -21,8 +27,9 @@ export class UmbPickerInputContext< #itemManager; - selection; - selectedItems; + public readonly selection; + public readonly selectedItems; + public readonly interactionMemory = new UmbInteractionMemoryManager(this); /** * Define a minimum amount of selected items in this input, for this input to be valid. @@ -100,6 +107,7 @@ export class UmbPickerInputContext< selection: this.getSelection(), } as PickerModalValueType, }).catch(() => undefined); + if (!modalValue) return; this.setSelection(modalValue.selection); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts index d38d1310d8..fb3be7e155 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts @@ -1,5 +1,6 @@ export * from './constants.js'; -export * from './search/index.js'; +export * from './modal/index.js'; export * from './picker.context.js'; export * from './picker.context.token.js'; +export * from './search/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts new file mode 100644 index 0000000000..762dc5e74c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts @@ -0,0 +1 @@ +export * from './picker-modal-base.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts new file mode 100644 index 0000000000..85aa28e480 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts @@ -0,0 +1,64 @@ +import type { UmbPickerContext } from '../picker.context.js'; +import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { ManifestModal, UmbPickerModalData } from '@umbraco-cms/backoffice/modal'; +import { UMB_PICKER_INPUT_CONTEXT } from '@umbraco-cms/backoffice/picker-input'; + +export abstract class UmbPickerModalBaseElement< + ItemType = UmbEntityModel, + ModalDataType extends UmbPickerModalData = UmbPickerModalData, + ModalValueType = unknown, + ModalManifestType extends ManifestModal = ManifestModal, +> extends UmbModalBaseElement { + protected abstract _pickerContext: UmbPickerContext; + + #pickerInputContext?: typeof UMB_PICKER_INPUT_CONTEXT.TYPE; + + constructor() { + super(); + this.consumeContext(UMB_PICKER_INPUT_CONTEXT, (pickerInputContext) => { + this.#pickerInputContext = pickerInputContext; + this.#observeMemoriesFromInputContext(); + }); + } + + override connectedCallback(): void { + super.connectedCallback(); + this.#observeMemoriesFromPicker(); + } + + #observeMemoriesFromPicker() { + this.observe(this._pickerContext.interactionMemory.memories, (memories) => { + this.#setMemoriesOnInputContext(memories); + }); + } + + #getInteractionMemoryUnique() { + // TODO: consider appending with a unique when we have that implemented. + return `UmbPickerModal`; + } + + #observeMemoriesFromInputContext() { + this.observe( + this.#pickerInputContext?.interactionMemory.memory(this.#getInteractionMemoryUnique()), + (memory) => { + memory?.memories?.forEach((memory) => this._pickerContext.interactionMemory.setMemory(memory)); + }, + 'umbModalInteractionMemoryObserver', + ); + } + + #setMemoriesOnInputContext(pickerMemories: Array) { + if (pickerMemories?.length > 0) { + const pickerModalMemory: UmbInteractionMemoryModel = { + unique: this.#getInteractionMemoryUnique(), + memories: pickerMemories, + }; + + this.#pickerInputContext?.interactionMemory.setMemory(pickerModalMemory); + } else { + this.#pickerInputContext?.interactionMemory.deleteMemory(this.#getInteractionMemoryUnique()); + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts index e606e79b0f..63a16f123a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts @@ -1,18 +1,23 @@ import { UMB_PICKER_CONTEXT } from './picker.context.token.js'; import { UmbPickerSearchManager } from './search/manager/picker-search.manager.js'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory'; +import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; export class UmbPickerContext extends UmbContextBase { + public readonly interactionMemory = new UmbInteractionMemoryManager(this); public readonly selection = new UmbSelectionManager(this); public readonly search = new UmbPickerSearchManager(this); + public dataType?: { unique: string }; constructor(host: UmbControllerHost) { super(host, UMB_PICKER_CONTEXT); + /* TODO: Move this implementation to another place. The generic picker context shouldn't be aware of property and data types. + It also gives an illegal import of content module */ this.consumeContext(UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT, (context) => { this.observe(context?.dataType, (dataType) => { this.dataType = dataType; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts index 9d73c0fdba..35a4405364 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts @@ -1,10 +1,9 @@ import type { UmbPickerSearchManagerConfig } from './types.js'; -import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; -import { UmbArrayState, UmbBooleanState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; -import type { UmbSearchProvider, UmbSearchRequestArgs, UmbSearchResultItemModel } from '@umbraco-cms/backoffice/search'; import { debounce } from '@umbraco-cms/backoffice/utils'; +import { UmbArrayState, UmbBooleanState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import type { UmbSearchProvider, UmbSearchRequestArgs, UmbSearchResultItemModel } from '@umbraco-cms/backoffice/search'; /** * A manager for searching items in a picker. @@ -36,15 +35,6 @@ export class UmbPickerSearchManager< #config?: UmbPickerSearchManagerConfig; #searchProvider?: UmbSearchProvider; - /** - * Creates an instance of UmbPickerSearchManager. - * @param {UmbControllerHost} host The controller host for the search manager. - * @memberof UmbPickerSearchManager - */ - constructor(host: UmbControllerHost) { - super(host); - } - /** * Set the configuration for the search manager. * @param {UmbPickerSearchManagerConfig} config The configuration for the search manager. @@ -187,6 +177,7 @@ export class UmbPickerSearchManager< // ensure that config params are always included ...this.#config?.queryParams, searchFrom: this.#config?.searchFrom, + // TODO: Move this implementation to another place. The generic picker search manager shouldn't be aware of data types. dataTypeUnique: this.#config?.dataTypeUnique, }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts index 6f36bd6037..016483412b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts @@ -2,5 +2,6 @@ export * from './components/index.js'; export * from './config/index.js'; export * from './constants.js'; export * from './events/index.js'; +export * from './interaction-memory/index.js'; export * from './ui-picker-modal/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts new file mode 100644 index 0000000000..835e4df198 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts @@ -0,0 +1 @@ +export * from './property-editor-ui-interaction-memory.manager.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts new file mode 100644 index 0000000000..4f775c8578 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts @@ -0,0 +1,128 @@ +import { UmbPropertyEditorUiInteractionMemoryManager } from './property-editor-ui-interaction-memory.manager.js'; +import { UmbPropertyEditorConfigCollection } from '../config/index.js'; +import { customElement } from '@umbraco-cms/backoffice/external/lit'; +import { expect } from '@open-wc/testing'; +import { Observable } from '@umbraco-cms/backoffice/external/rxjs'; +import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api'; +import { UmbInteractionMemoryContext } from '@umbraco-cms/backoffice/interaction-memory'; + +@customElement('test-my-controller-host') +class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) { + constructor() { + super(); + new UmbInteractionMemoryContext(this); + } +} + +describe('UmbPropertyEditorUiInteractionMemoryManager', () => { + let manager: UmbPropertyEditorUiInteractionMemoryManager; + let childMemories = [ + { unique: '1', value: 'Value 1' }, + { unique: '2', value: 'Value 2' }, + ]; + + beforeEach(() => { + const hostElement = new UmbTestControllerHostElement(); + document.body.appendChild(hostElement); + + manager = new UmbPropertyEditorUiInteractionMemoryManager(hostElement, { + memoryUniquePrefix: 'TestPrefix', + }); + + // A random config to generate a hash code from + const config = new UmbPropertyEditorConfigCollection([ + { + alias: 'someAlias', + value: 'someValue', + }, + ]); + + manager.setPropertyEditorConfig(config); + }); + + describe('Public API', () => { + describe('properties', () => { + it('has a memoriesForPropertyEditor property', () => { + expect(manager).to.have.property('memoriesForPropertyEditor').to.be.an.instanceOf(Observable); + }); + }); + + describe('methods', () => { + it('has a setPropertyEditorConfig method', () => { + expect(manager).to.have.property('setPropertyEditorConfig').that.is.a('function'); + }); + + it('has a saveMemoriesForPropertyEditor method', () => { + expect(manager).to.have.property('saveMemoriesForPropertyEditor').that.is.a('function'); + }); + + it('has a deleteMemoriesForPropertyEditor method', () => { + expect(manager).to.have.property('deleteMemoriesForPropertyEditor').that.is.a('function'); + }); + }); + + describe('saveMemoriesForPropertyEditor', () => { + it('creates a property editor memory based on the provided data', (done) => { + manager.memoriesForPropertyEditor.subscribe((memories) => { + if (memories.length > 0) { + expect(memories).to.have.lengthOf(2); + expect(memories).to.deep.equal(childMemories); + done(); + } + }); + + manager.saveMemoriesForPropertyEditor(childMemories); + }); + + it('updates the property editor memory based on the provided data', (done) => { + const updatedChildMemories = [ + { unique: '1', value: 'Updated Value 1' }, + { unique: '2', value: 'Updated Value 2' }, + { unique: '3', value: 'New Value 3' }, + ]; + + // We start at -1 because the first call is the initial empty array + let callCount = -1; + manager.memoriesForPropertyEditor.subscribe((memories) => { + callCount++; + if (callCount === 1) { + // First call, after initial save + expect(memories).to.have.lengthOf(2); + expect(memories).to.deep.equal(childMemories); + } else if (callCount === 2) { + // Second call, after update + expect(memories).to.have.lengthOf(3); + expect(memories).to.deep.equal(updatedChildMemories); + done(); + } + }); + + manager.saveMemoriesForPropertyEditor(childMemories); + manager.saveMemoriesForPropertyEditor(updatedChildMemories); + }); + }); + + describe('deleteMemoriesForPropertyEditor', () => { + it('deletes all memories for this property editor', (done) => { + // We start at -1 because the first call is the initial empty array + let callCount = -1; + manager.memoriesForPropertyEditor.subscribe((memories) => { + callCount++; + if (callCount === 1) { + // First call, after initial save + expect(memories).to.have.lengthOf(2); + expect(memories).to.deep.equal(childMemories); + } else if (callCount === 2) { + // Second call, after delete + expect(memories).to.have.lengthOf(0); + expect(memories).to.deep.equal([]); + done(); + } + }); + + manager.saveMemoriesForPropertyEditor(childMemories); + manager.deleteMemoriesForPropertyEditor(); + }); + }); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts new file mode 100644 index 0000000000..8d86e21b76 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts @@ -0,0 +1,93 @@ +import type { UmbPropertyEditorConfigCollection } from '../config/index.js'; +import { simpleHashCode, UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UMB_INTERACTION_MEMORY_CONTEXT } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; + +export interface UmbPropertyEditorUiInteractionMemoryManagerArgs { + memoryUniquePrefix: string; +} + +export class UmbPropertyEditorUiInteractionMemoryManager extends UmbControllerBase { + #memories = new UmbArrayState([], (x) => x.unique); + memoriesForPropertyEditor = this.#memories.asObservable(); + + #interactionMemoryContext?: typeof UMB_INTERACTION_MEMORY_CONTEXT.TYPE; + #configHashCode?: number; + #memoryUniquePrefix: string; + #init?: Promise; + + constructor(host: UmbControllerHost, args: UmbPropertyEditorUiInteractionMemoryManagerArgs) { + super(host); + + this.#memoryUniquePrefix = args.memoryUniquePrefix; + + this.#init = Promise.all([ + this.consumeContext(UMB_INTERACTION_MEMORY_CONTEXT, (context) => { + this.#interactionMemoryContext = context; + }).asPromise(), + ]); + } + + /** + * Sets the property editor config, used to create a unique hash for the interaction memory. + * @param {(UmbPropertyEditorConfigCollection | undefined)} config + * @memberof UmbPropertyEditorUiInteractionMemoryManager + */ + setPropertyEditorConfig(config: UmbPropertyEditorConfigCollection | undefined) { + this.#setConfigHash(config); + this.#getInteractionMemory(); + } + + /** + * Creates or updates an interaction memory for this property editor based on the provided memories. + * @param {Array} memories - The memories to include for this property editor. + * @returns {Promise} + * @memberof UmbPropertyEditorUiInteractionMemoryManager + */ + async saveMemoriesForPropertyEditor(memories: Array): Promise { + await this.#init; + const memoryUnique = this.#getInteractionMemoryUnique(); + if (!this.#interactionMemoryContext) return; + + const propertyEditorMemory: UmbInteractionMemoryModel = { + unique: memoryUnique, + memories, + }; + + this.#interactionMemoryContext.memory.setMemory(propertyEditorMemory); + this.#memories.setValue(memories); + } + + /** + * Deletes the interaction memory for this property editor. + * @memberof UmbPropertyEditorUiInteractionMemoryManager + */ + async deleteMemoriesForPropertyEditor(): Promise { + await this.#init; + const unique = this.#getInteractionMemoryUnique(); + this.#interactionMemoryContext?.memory.deleteMemory(unique); + this.#memories.setValue([]); + } + + #getInteractionMemoryUnique() { + return `${this.#memoryUniquePrefix}PropertyEditorUi${this.#configHashCode ? '-' + this.#configHashCode : ''}`; + } + + async #getInteractionMemory() { + await this.#init; + const memoryUnique = this.#getInteractionMemoryUnique(); + if (!memoryUnique) return; + if (!this.#interactionMemoryContext) return; + + const memory = this.#interactionMemoryContext.memory.getMemory(memoryUnique); + this.#memories.setValue(memory?.memories ?? []); + } + + #setConfigHash(config: UmbPropertyEditorConfigCollection | undefined) { + const configString = config ? JSON.stringify(config.toObject()) : ''; + const hashCode = simpleHashCode(configString); + this.#configHashCode = hashCode; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts new file mode 100644 index 0000000000..7d4d3f7070 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts @@ -0,0 +1,94 @@ +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbEntityExpansionManager } from '@umbraco-cms/backoffice/utils'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils'; +import type { + UmbInteractionMemoryManager, + UmbInteractionMemoryModel, +} from '@umbraco-cms/backoffice/interaction-memory'; + +export interface UmbTreeItemPickerExpansionManagerArgs { + interactionMemoryManager?: UmbInteractionMemoryManager; +} + +export class UmbTreeItemPickerExpansionManager extends UmbControllerBase { + #manager = new UmbEntityExpansionManager(this); + public readonly expansion = this.#manager.expansion; + + #interactionMemoryManager?: UmbInteractionMemoryManager; + #interactionMemoryUnique: string = 'UmbTreeItemPickerExpansion'; + #muteMemoryObservation = false; + + constructor(host: UmbControllerHost, args?: UmbTreeItemPickerExpansionManagerArgs) { + super(host); + this.#interactionMemoryManager = args?.interactionMemoryManager; + + if (this.#interactionMemoryManager) { + this.#observeInteractionMemory(); + } + } + + /** + * Sets the full expansion state + * @param {UmbEntityExpansionModel} expansion - The full expansion state to set + * @memberof UmbTreeItemPickerExpansionManager + */ + setExpansion(expansion: UmbEntityExpansionModel): void { + this.#manager.setExpansion(expansion); + + // Store the latest expansion state in interaction memory + if (expansion.length > 0) { + this.#setExpansionMemory(); + } else { + this.#removeExpansionMemory(); + } + } + + /** + * Gets the current expansion state + * @returns {UmbEntityExpansionModel} The full expansion state + * @memberof UmbTreeItemPickerExpansionManager + */ + getExpansion(): UmbEntityExpansionModel { + return this.#manager.getExpansion(); + } + + #observeInteractionMemory() { + this.observe(this.#interactionMemoryManager?.memory(this.#interactionMemoryUnique), (memory) => { + if (this.#muteMemoryObservation) return; + + if (memory) { + this.#applyExpansionInteractionMemory(memory); + } + }); + } + + #setExpansionMemory() { + if (!this.#interactionMemoryManager) return; + + // Add a memory entry with the latest expansion state + const memory: UmbInteractionMemoryModel = { + unique: this.#interactionMemoryUnique, + value: { + expansion: this.getExpansion(), + }, + }; + + this.#muteMemoryObservation = true; + this.#interactionMemoryManager?.setMemory(memory); + this.#muteMemoryObservation = false; + } + + #removeExpansionMemory() { + if (!this.#interactionMemoryManager) return; + this.#interactionMemoryManager.deleteMemory(this.#interactionMemoryUnique); + } + + #applyExpansionInteractionMemory(memory: UmbInteractionMemoryModel) { + const memoryExpansion = memory?.value?.expansion as UmbEntityExpansionModel | undefined; + + if (memoryExpansion) { + this.#manager.setExpansion(memoryExpansion); + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts index 99f163c25c..bd30dcf638 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts @@ -1,10 +1,10 @@ +import { UmbTreeItemPickerExpansionManager } from './tree-item-picker-expansion.manager.js'; import { UmbPickerContext } from '@umbraco-cms/backoffice/picker'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbTreeItemPickerContext extends UmbPickerContext { - constructor(host: UmbControllerHost) { - super(host); - } + public readonly expansion = new UmbTreeItemPickerExpansionManager(this, { + interactionMemoryManager: this.interactionMemory, + }); } export { UmbTreeItemPickerContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts index bbd95cd19c..fb71dd349d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts @@ -1,15 +1,18 @@ -import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration } from '../types.js'; import { UmbTreeItemPickerContext } from '../tree-item-picker/index.js'; +import type { UmbTreeElement } from '../tree.element.js'; +import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration } from '../types.js'; import type { UmbTreePickerModalData, UmbTreePickerModalValue } from './tree-picker-modal.token.js'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { html, customElement, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { customElement, html, ifDefined, nothing, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbDeselectedEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UmbPickerModalBaseElement } from '@umbraco-cms/backoffice/picker'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbEntityExpansionModel, UmbExpansionChangeEvent } from '@umbraco-cms/backoffice/utils'; @customElement('umb-tree-picker-modal') -export class UmbTreePickerModalElement extends UmbModalBaseElement< +export class UmbTreePickerModalElement extends UmbPickerModalBaseElement< + TreeItemType, UmbTreePickerModalData, UmbTreePickerModalValue > { @@ -32,16 +35,20 @@ export class UmbTreePickerModalElement { + this._pickerContext.selection.setSelectable(true); + this.observe(this._pickerContext.selection.hasSelection, (hasSelection) => { this._hasSelection = hasSelection; }); this.#observePickerSelection(); this.#observeSearch(); + this.#observeExpansion(); } override connectedCallback(): void { @@ -54,15 +61,15 @@ export class UmbTreePickerModalElement { this.updateValue({ selection }); this.requestUpdate(); @@ -93,7 +100,7 @@ export class UmbTreePickerModalElement { this._searchQuery = query?.query; }, @@ -101,16 +108,26 @@ export class UmbTreePickerModalElement { + this._treeExpansion = value; + }, + 'umbTreeItemPickerExpansionObserver', + ); + } + // Tree Selection #onTreeItemSelected(event: UmbSelectedEvent) { event.stopPropagation(); - this.#pickerContext.selection.select(event.unique); + this._pickerContext.selection.select(event.unique); this.modalContext?.dispatchEvent(new UmbSelectedEvent(event.unique)); } #onTreeItemDeselected(event: UmbDeselectedEvent) { event.stopPropagation(); - this.#pickerContext.selection.deselect(event.unique); + this._pickerContext.selection.deselect(event.unique); this.modalContext?.dispatchEvent(new UmbDeselectedEvent(event.unique)); } @@ -149,6 +166,12 @@ export class UmbTreePickerModalElement @@ -181,9 +204,11 @@ export class UmbTreePickerModalElement + @deselected=${this.#onTreeItemDeselected} + @expansion-change=${this.#onTreeItemExpansionChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 6df35d3cd1..35aa800b95 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ 'id/index': './id/index.ts', 'lit-element/index': './lit-element/index.ts', 'localization/index': './localization/index.ts', + 'interaction-memory/index': './interaction-memory/index.ts', 'menu/index': './menu/index.ts', 'modal/index': './modal/index.ts', 'models/index': './models/index.ts', diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts index 52406deee8..8f890d7cdb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts @@ -1,13 +1,16 @@ import type { UmbDocumentItemModel } from '../../item/types.js'; import { UmbDocumentPickerInputContext } from './input-document.context.js'; import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type'; +import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; @customElement('umb-input-document') export class UmbInputDocumentElement extends UmbFormControlMixin( @@ -37,10 +40,10 @@ export class UmbInputDocumentElement extends UmbFormControlMixin) { - this.#pickerContext.setSelection(ids); + this.#pickerInputContext.setSelection(ids); this.#sorter.setModel(ids); } public get selection(): Array { - return this.#pickerContext.getSelection(); + return this.#pickerInputContext.getSelection(); } @property({ type: Object, attribute: false }) @@ -122,10 +125,21 @@ export class UmbInputDocumentElement extends UmbFormControlMixin | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _items?: Array; - #pickerContext = new UmbDocumentPickerInputContext(this); + #pickerInputContext = new UmbDocumentPickerInputContext(this); constructor() { super(); @@ -142,12 +156,35 @@ export class UmbInputDocumentElement extends UmbFormControlMixin !!this.max && this.selection.length > this.max, ); - this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')), '_observeSelection'); - this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems), '_observerItems'); + this.observe( + this.#pickerInputContext.selection, + (selection) => (this.value = selection.join(',')), + '_observeSelection', + ); + + this.observe( + this.#pickerInputContext.selectedItems, + (selectedItems) => (this._items = selectedItems), + '_observerItems', + ); + + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); } #openPicker() { - this.#pickerContext.openPicker( + this.#pickerInputContext.openPicker( { hideTreeRoot: true, startNode: this.startNode, @@ -163,7 +200,7 @@ export class UmbInputDocumentElement extends UmbFormControlMixin('validationLimit'); @@ -47,11 +51,37 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl @state() private _startNodeId?: string; + @state() + private _interactionMemories: Array = []; + + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbDocumentPicker', + }); + + constructor() { + super(); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); + } + #onChange(event: CustomEvent & { target: UmbInputDocumentElement }) { this.value = event.target.value; this.dispatchEvent(new UmbChangeEvent()); } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputDocumentElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { const startNode: UmbTreeStartNode | undefined = this._startNodeId ? { unique: this._startNodeId, entityType: UMB_DOCUMENT_ENTITY_TYPE } @@ -64,7 +94,9 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl .startNode=${startNode} .value=${this.value} @change=${this.#onChange} - ?readonly=${this.readonly}> + ?readonly=${this.readonly} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts index dc2bfcbf1b..177fd5e022 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts @@ -10,15 +10,18 @@ import { repeat, state, } from '@umbraco-cms/backoffice/external/lit'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; -import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/media-type'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import '@umbraco-cms/backoffice/imaging'; @@ -61,10 +64,10 @@ export class UmbInputMediaElement extends UmbFormControlMixin) { - this.#pickerContext.setSelection(ids); + this.#pickerInputContext.setSelection(ids); this.#sorter.setModel(ids); } public get selection(): Array { - return this.#pickerContext.getSelection(); + return this.#pickerInputContext.getSelection(); } @property({ type: Array }) @@ -146,13 +149,24 @@ export class UmbInputMediaElement extends UmbFormControlMixin | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _editMediaPath = ''; @state() private _cards: Array = []; - #pickerContext = new UmbMediaPickerInputContext(this); + #pickerInputContext = new UmbMediaPickerInputContext(this); constructor() { super(); @@ -166,15 +180,29 @@ export class UmbInputMediaElement extends UmbFormControlMixin (this.value = selection.join(','))); + this.observe(this.#pickerInputContext.selection, (selection) => (this.value = selection.join(','))); - this.observe(this.#pickerContext.selectedItems, async (selectedItems) => { + this.observe(this.#pickerInputContext.selectedItems, async (selectedItems) => { const missingCards = selectedItems.filter((item) => !this._cards.find((card) => card.unique === item.unique)); if (selectedItems?.length && !missingCards.length) return; this._cards = selectedItems ?? []; }); + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); + this.addValidator( 'rangeUnderflow', () => this.minMessage, @@ -188,7 +216,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin 1, startNode: this.startNode, @@ -204,7 +232,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin x.unique !== item.unique); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts index 353400e686..d11cd168be 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts @@ -18,6 +18,11 @@ import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/media-type'; import '@umbraco-cms/backoffice/imaging'; +import { + UmbInteractionMemoriesChangeEvent, + type UmbInteractionMemoryModel, +} from '@umbraco-cms/backoffice/interaction-memory'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; type UmbRichMediaCardModel = { unique: string; @@ -102,7 +107,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< public override set value(value: Array | undefined) { super.value = value; this.#sorter.setModel(value); - this.#pickerContext.setSelection(value?.map((item) => item.mediaKey) ?? []); + this.#pickerInputContext.setSelection(value?.map((item) => item.mediaKey) ?? []); this.#itemManager.setUniques(value?.map((x) => x.mediaKey)); // Maybe the new value is using an existing media, and there we need to update the cards despite no repository update. this.#populateCards(); @@ -171,6 +176,17 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< } #readonly = false; + @property({ type: Array, attribute: false }) + public get interactionMemories(): Array | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _cards: Array = []; @@ -179,7 +195,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< readonly #itemManager = new UmbRepositoryItemsManager(this, UMB_MEDIA_ITEM_REPOSITORY_ALIAS); - readonly #pickerContext = new UmbMediaPickerInputContext(this); + readonly #pickerInputContext = new UmbMediaPickerInputContext(this); constructor() { super(); @@ -234,10 +250,24 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< this._routeBuilder = routeBuilder; }); - this.observe(this.#pickerContext.selection, (selection) => { + this.observe(this.#pickerInputContext.selection, (selection) => { this.#addItems(selection); }); + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); + this.addValidator( 'valueMissing', () => this.requiredMessage ?? UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, @@ -312,7 +342,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< } #openPicker() { - this.#pickerContext.openPicker( + this.#pickerInputContext.openPicker( { multiple: this.multiple, startNode: this.startNode, @@ -330,7 +360,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< async #onRemove(item: UmbRichMediaCardModel) { try { - await this.#pickerContext.requestRemoveItem(item.media); + await this.#pickerInputContext.requestRemoveItem(item.media); this.value = this.value?.filter((x) => x.key !== item.unique); this.dispatchEvent(new UmbChangeEvent()); } catch { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts index 7ad378394b..4274263cda 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts @@ -1,3 +1,4 @@ export { UMB_IMAGE_CROPPER_EDITOR_MODAL } from './image-cropper-editor/index.js'; -export * from './media-caption-alt-text/constants.js'; export { UMB_MEDIA_PICKER_MODAL } from './media-picker/index.js'; +export * from './media-caption-alt-text/constants.js'; +export * from './media-picker/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts new file mode 100644 index 0000000000..252317b059 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts @@ -0,0 +1 @@ +export * from './media-picker.context.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts index 3ac92136f9..a9a70f8f33 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts @@ -1,31 +1,34 @@ import { UmbMediaItemRepository } from '../../repository/index.js'; import { UmbMediaTreeRepository } from '../../tree/media-tree.repository.js'; import { UMB_MEDIA_ROOT_ENTITY_TYPE } from '../../entity.js'; -import type { UmbMediaTreeItemModel, UmbMediaSearchItemModel, UmbMediaItemModel } from '../../types.js'; import { UmbMediaSearchProvider } from '../../search/index.js'; import type { UmbDropzoneMediaElement } from '../../dropzone/index.js'; +import type { UmbMediaTreeItemModel, UmbMediaSearchItemModel, UmbMediaItemModel } from '../../types.js'; +import { UmbMediaPickerContext } from './media-picker.context.js'; import type { UmbMediaPathModel } from './types.js'; import type { UmbMediaPickerFolderPathElement } from './components/media-picker-folder-path.element.js'; import type { UmbMediaPickerModalData, UmbMediaPickerModalValue } from './media-picker-modal.token.js'; -import type { UmbDropzoneChangeEvent, UmbUploadableItem } from '@umbraco-cms/backoffice/dropzone'; import { css, - html, customElement, - state, - repeat, + html, ifDefined, - query, - type PropertyValues, nothing, + query, + repeat, + state, } from '@umbraco-cms/backoffice/external/lit'; import { debounce, UmbPaginationManager } from '@umbraco-cms/backoffice/utils'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; -import type { UUIInputEvent, UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import { isUmbracoFolder } from '@umbraco-cms/backoffice/media-type'; -import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { UmbPickerModalBaseElement } from '@umbraco-cms/backoffice/picker'; +import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; import { UMB_VARIANT_CONTEXT } from '@umbraco-cms/backoffice/variant'; +import type { PropertyValues } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbDropzoneChangeEvent, UmbUploadableItem } from '@umbraco-cms/backoffice/dropzone'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbPickerContext } from '@umbraco-cms/backoffice/picker'; +import type { UUIInputEvent, UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import '@umbraco-cms/backoffice/imaging'; @@ -33,11 +36,19 @@ const root: UmbMediaPathModel = { name: 'Media', unique: null, entityType: UMB_M // TODO: investigate how we can reuse the picker-search-field element, picker context etc. @customElement('umb-media-picker-modal') -export class UmbMediaPickerModalElement extends UmbModalBaseElement { +export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< + UmbMediaItemModel, + UmbMediaPickerModalData, + UmbMediaPickerModalValue +> { #mediaTreeRepository = new UmbMediaTreeRepository(this); #mediaItemRepository = new UmbMediaItemRepository(this); #mediaSearchProvider = new UmbMediaSearchProvider(this); + /* TODO: We currently only rely on the interactionMemory manager in the picker interface which is correctly implemented in the Media Picker + Remove this type cast when MediaPicker has implemented the full PickerContext interface */ + protected override _pickerContext = new UmbMediaPickerContext(this) as unknown as UmbPickerContext; + #dataType?: { unique: string }; @state() @@ -78,6 +89,7 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement(); #contextCulture?: string | null; + #locationInteractionMemoryUnique: string = 'UmbMediaItemPickerLocation'; constructor() { super(); @@ -106,25 +118,36 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement x !== null && x !== undefined, + ); - if (this._startNode) { + if (uniquesToRequest.length > 0) { + const { data } = await this.#mediaItemRepository.requestItems(uniquesToRequest); + + this._startNode = data?.find((x) => x.unique === startNode?.unique); + const locationMemoryItem = data?.find((x) => x.unique === locationFromMemory?.entity.unique); + + // TODO: We probably need to check if the location item is within the start node. If not then fall back to start node. + const source = locationMemoryItem || this._startNode; + + if (source) { this._currentMediaEntity = { - name: this._startNode.name, - unique: this._startNode.unique, - entityType: this._startNode.entityType, + name: source.name, + unique: source.unique, + entityType: source.entityType, }; - this._searchFrom = { unique: this._startNode.unique, entityType: this._startNode.entityType }; + this._searchFrom = { unique: source.unique, entityType: source.entityType }; } } this.#loadChildrenOfCurrentMediaItem(); } + // TODO: move to location manager in context async #loadChildrenOfCurrentMediaItem(selectedItems?: Array) { const key = this._currentMediaEntity.entityType + this._currentMediaEntity.unique; let paginationManager = this.#pagingMap.get(key); @@ -166,13 +189,14 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement { this.#searchMedia(); }, 500); + // TODO: move to search manager in context #onSearch(e: UUIInputEvent) { this._searchQuery = (e.target.value as string).toLocaleLowerCase(); this._searching = true; @@ -260,6 +290,8 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts new file mode 100644 index 0000000000..f1bf040da9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts @@ -0,0 +1,8 @@ +import type { UmbMediaPickerContext } from './media-picker.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_MEDIA_PICKER_CONTEXT = new UmbContextToken( + 'UmbPickerContext', + undefined, + (context): context is UmbMediaPickerContext => context.IS_MEDIA_PICKER_CONTEXT, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts new file mode 100644 index 0000000000..88ef119fdc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts @@ -0,0 +1,18 @@ +import { UMB_MEDIA_PICKER_CONTEXT } from './media-picker.context.token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +// TODO: extend UmbTreeItemPickerContext +export class UmbMediaPickerContext extends UmbContextBase { + // For context token safety: + public readonly IS_MEDIA_PICKER_CONTEXT = true; + + public readonly interactionMemory = new UmbInteractionMemoryManager(this); + + constructor(host: UmbControllerHost) { + super(host, UMB_MEDIA_PICKER_CONTEXT); + } +} + +export { UmbMediaPickerContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts index d2e5d0a576..29e8c9c72d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts @@ -2,30 +2,31 @@ import type { UmbInputRichMediaElement } from '../../components/input-rich-media import type { UmbCropModel, UmbMediaPickerValueModel } from '../types.js'; import { UMB_MEDIA_ENTITY_TYPE } from '../../entity.js'; import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbPropertyEditorUiInteractionMemoryManager } from '@umbraco-cms/backoffice/property-editor'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; -import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; -import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import '../../components/input-rich-media/input-rich-media.element.js'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; - -const elementName = 'umb-property-editor-ui-media-picker'; - /** * @element umb-property-editor-ui-media-picker */ -@customElement(elementName) +@customElement('umb-property-editor-ui-media-picker') export class UmbPropertyEditorUIMediaPickerElement extends UmbFormControlMixin(UmbLitElement) implements UmbPropertyEditorUiElement { public set config(config: UmbPropertyEditorConfigCollection | undefined) { + this.#interactionMemoryManager.setPropertyEditorConfig(config); + if (!config) return; this._allowedMediaTypes = config.getValueByAlias('filter')?.split(',') ?? []; @@ -87,6 +88,13 @@ export class UmbPropertyEditorUIMediaPickerElement @state() private _variantId?: string; + @state() + private _interactionMemories: Array = []; + + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbMediaPicker', + }); + constructor() { super(); @@ -94,6 +102,10 @@ export class UmbPropertyEditorUIMediaPickerElement this.observe(context?.alias, (alias) => (this._alias = alias)); this.observe(context?.variantId, (variantId) => (this._variantId = variantId?.toString() || 'invariant')); }); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); } override firstUpdated() { @@ -110,6 +122,17 @@ export class UmbPropertyEditorUIMediaPickerElement this.dispatchEvent(new UmbChangeEvent()); } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputRichMediaElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { return html` + ?readonly=${this.readonly} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> `; } @@ -136,6 +161,6 @@ export { UmbPropertyEditorUIMediaPickerElement as element }; declare global { interface HTMLElementTagNameMap { - [elementName]: UmbPropertyEditorUIMediaPickerElement; + ['umb-property-editor-ui-media-picker']: UmbPropertyEditorUIMediaPickerElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts index 91e8f177d5..a557488c1e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts @@ -1,9 +1,11 @@ import type { UmbContentPickerSource } from '../../types.js'; -import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbReferenceByUniqueAndType } from '@umbraco-cms/backoffice/models'; import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; @@ -72,7 +74,17 @@ export class UmbInputContentElement extends UmbFormControlMixin | undefined { + return this.#interactionMemories; + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + } + + #interactionMemories: Array | undefined; #entityTypeLookup = { content: 'document', media: 'media', member: 'member' }; @@ -88,6 +100,15 @@ export class UmbInputContentElement extends UmbFormControlMixin + @change=${this.#onChange} + .interactionMemories=${this.#interactionMemories} + @interaction-memories-change=${this.#onInteractionMemoriesChange}> `; } @@ -126,7 +149,9 @@ export class UmbInputContentElement extends UmbFormControlMixin + @change=${this.#onChange} + .interactionMemories=${this.#interactionMemories} + @interaction-memories-change=${this.#onInteractionMemoriesChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts index f34caa2ca1..a805823960 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts @@ -10,6 +10,8 @@ import { UMB_ANCESTORS_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; import { UMB_DOCUMENT_ENTITY_TYPE } from '@umbraco-cms/backoffice/document'; import { UMB_MEDIA_ENTITY_TYPE } from '@umbraco-cms/backoffice/media'; import { UMB_MEMBER_ENTITY_TYPE } from '@umbraco-cms/backoffice/member'; +import { UmbPropertyEditorUiInteractionMemoryManager } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, @@ -65,6 +67,9 @@ export class UmbPropertyEditorUIContentPickerElement @state() private _invalidData?: UmbContentPickerValueType; + @state() + private _interactionMemories: Array = []; + #dynamicRoot?: UmbContentPickerSource['dynamicRoot']; #dynamicRootRepository = new UmbContentPickerDynamicRootRepository(this); @@ -74,7 +79,21 @@ export class UmbPropertyEditorUIContentPickerElement member: UMB_MEMBER_ENTITY_TYPE, }; + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbContentPicker', + }); + + constructor() { + super(); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); + } + public set config(config: UmbPropertyEditorConfigCollection | undefined) { + this.#interactionMemoryManager.setPropertyEditorConfig(config); + if (!config) return; const startNode = config.getValueByAlias('startNode'); @@ -160,6 +179,17 @@ export class UmbPropertyEditorUIContentPickerElement this.readonly = false; } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputContentElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { const startNode: UmbTreeStartNode | undefined = this._rootUnique && this._rootEntityType @@ -177,7 +207,9 @@ export class UmbPropertyEditorUIContentPickerElement .startNode=${startNode} .allowedContentTypeIds=${this._allowedContentTypeUniques ?? ''} ?readonly=${this.readonly} - @change=${this.#onChange}> + @change=${this.#onChange} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> ${this.#renderInvalidData()} `; diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index a14602106a..010ad94033 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -87,6 +87,7 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/icon": ["./src/packages/core/icon-registry/index.ts"], "@umbraco-cms/backoffice/id": ["./src/packages/core/id/index.ts"], "@umbraco-cms/backoffice/imaging": ["./src/packages/media/imaging/index.ts"], + "@umbraco-cms/backoffice/interaction-memory": ["./src/packages/core/interaction-memory/index.ts"], "@umbraco-cms/backoffice/language": ["./src/packages/language/index.ts"], "@umbraco-cms/backoffice/lit-element": ["./src/packages/core/lit-element/index.ts"], "@umbraco-cms/backoffice/localization": ["./src/packages/core/localization/index.ts"], From eb193464326548f10e88c5b13ed4daa167c941c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 18 Sep 2025 20:50:03 +0200 Subject: [PATCH 12/56] View Context: Implement browser title (#20145) * POC browser title * support view-alias as null * provide tab view contexts * refactor view context * refactor workspace implementation of view context * clean up context + revert title order * view context for section context * update type * disable and re-active parent views * remove unused import * remove log * Implementation of Browser Title * implement more browser titles * sort imports * remove unused imports * use _internal_ * lint updates * reactive titles * fix hints for root tab * implement use of UmbEntityDetailWorkspaceContextBase --- .../hint-workspace-view.ts | 6 +- .../content-type-workspace-context-base.ts | 7 +- ...ontent-type-workspace-context.interface.ts | 9 +- .../content-detail-workspace-base.ts | 27 +- .../views/edit/content-editor.element.ts | 68 ++-- .../entity-item-ref.element.ts | 3 +- .../core/hint/context/hints.controller.ts | 43 ++- .../packages/core/section/section.context.ts | 11 +- .../core/view/context/view.context.ts | 83 +---- .../core/view/context/view.controller.ts | 309 ++++++++++++++++++ .../workspace-editor.context.ts | 21 +- .../workspace-editor.element.ts | 5 + .../workspace-view.context.ts | 5 +- .../entity-named-detail-workspace-base.ts | 14 +- .../input-document/input-document.element.ts | 1 - .../member/member-workspace.context.ts | 4 +- .../user-group-workspace.context.ts | 9 +- .../workspace/user/user-workspace.context.ts | 21 +- .../webhook-collection.server.data-source.ts | 2 +- .../webhook-detail.server.data-source.ts | 2 +- .../src/packages/webhook/webhook/types.ts | 2 +- .../workspace/webhook-workspace.context.ts | 17 +- 22 files changed, 491 insertions(+), 178 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts index 8678114981..6f5b7775c2 100644 --- a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts +++ b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts @@ -41,10 +41,10 @@ export class ExampleHintWorkspaceView extends UmbElementMixin(LitElement) { throw new Error('Could not find the workspace'); } - if (workspace.hints.has('exampleHintFromToggleAction')) { - workspace.hints.removeOne('exampleHintFromToggleAction'); + if (workspace.view.hints.has('exampleHintFromToggleAction')) { + workspace.view.hints.removeOne('exampleHintFromToggleAction'); } else { - workspace.hints.addOne({ + workspace.view.hints.addOne({ unique: 'exampleHintFromToggleAction', path: ['Umb.WorkspaceView.Document.Edit'], text: 'Hi', diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index 4661ae1b41..9f72381411 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -7,6 +7,7 @@ import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import type { Observable } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -52,6 +53,8 @@ export abstract class UmbContentTypeWorkspaceContextBase< public readonly structure: UmbContentTypeStructureManager; + public readonly view = new UmbViewContext(this, null); + constructor(host: UmbControllerHost, args: UmbContentTypeWorkspaceContextArgs) { super(host, args); @@ -70,7 +73,9 @@ export abstract class UmbContentTypeWorkspaceContextBase< 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)); + this.observe(this.structure.ownerContentType, (data) => this._data.setCurrent(data), null); + this.observe(this.name, (name) => this.view.setBrowserTitle(name), null); + // TODO: sometimes the browserTitle for a parent view is set later than the child is updating. We need to fix this as well enable a parent browser title to be updating on the go. [NL] } /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts index dc529f7980..c45e72c363 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts @@ -1,13 +1,13 @@ import type { UmbContentTypeCompositionModel, UmbContentTypeModel, UmbContentTypeSortModel } from '../types.js'; import type { UmbContentTypeStructureManager } from '../structure/index.js'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; -import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; +import type { UmbNamableWorkspaceContext, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; export interface UmbContentTypeWorkspaceContext - extends UmbSubmittableWorkspaceContext { + extends UmbSubmittableWorkspaceContext, + UmbNamableWorkspaceContext { readonly IS_CONTENT_TYPE_WORKSPACE_CONTEXT: true; - readonly name: Observable; readonly alias: Observable; readonly description: Observable; readonly icon: Observable; @@ -32,7 +32,4 @@ export interface UmbContentTypeWorkspaceContext, @@ -141,8 +145,8 @@ export abstract class UmbContentDetailWorkspaceContextBase< readonly collection: UmbContentCollectionManager; - /* Hints */ - readonly hints = new UmbHintContext(this); + /* View */ + readonly view = new UmbViewContext(this, null); /* Variant Options */ // TODO: Optimize this so it uses either a App Language Context? [NL] @@ -221,7 +225,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< this, this.structure, this.validationContext, - this.hints, + this.view.hints, ); this.variantOptions = mergeObservables( @@ -334,6 +338,17 @@ export abstract class UmbContentDetailWorkspaceContextBase< null, ); + this.observe( + observeMultiple([this.splitView.activeVariantByIndex(0), this.variants]), + ([activeVariant, variants]) => { + const variantName = variants.find( + (v) => v.culture === activeVariant?.culture && v.segment === activeVariant?.segment, + )?.name; + this.view.setBrowserTitle(variantName); + }, + null, + ); + this.observe( this.varies, (varies) => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index 45c8d3b817..5b531ae552 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -1,22 +1,28 @@ import type { UmbContentWorkspaceViewEditTabElement } from './content-editor-tab.element.js'; import { css, html, customElement, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { encodeFolderName } from '@umbraco-cms/backoffice/router'; +import { + UmbContentTypeContainerStructureHelper, + UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT, +} from '@umbraco-cms/backoffice/content-type'; +import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { UMB_VIEW_CONTEXT, UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { + PageComponent, + UmbRoute, + UmbRouterSlotChangeEvent, + UmbRouterSlotInitEvent, +} from '@umbraco-cms/backoffice/router'; import type { UmbContentTypeModel, UmbContentTypeStructureManager, UmbPropertyTypeContainerMergedModel, } from '@umbraco-cms/backoffice/content-type'; -import { - UmbContentTypeContainerStructureHelper, - UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT, -} from '@umbraco-cms/backoffice/content-type'; -import type { UmbRoute, UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router'; -import { encodeFolderName } from '@umbraco-cms/backoffice/router'; -import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; -import './content-editor-tab.element.js'; import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; -import { UMB_VIEW_CONTEXT, UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace'; + +import './content-editor-tab.element.js'; @customElement('umb-content-workspace-view-edit') export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements UmbWorkspaceViewElement { @@ -43,7 +49,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements private _activePath = ''; @state() - private _hintMap: Map = new Map(); + private _hintMap: Map = new Map(); #tabViewContexts: Array = []; @@ -104,9 +110,10 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements component: () => import('./content-editor-tab.element.js'), setup: (component) => { (component as UmbContentWorkspaceViewEditTabElement).containerId = null; + this.#provideViewContext(null, component); }, }); - this.#createViewContext('root'); + this.#createViewContext(null, '#general_generic'); } if (this._tabs.length > 0) { @@ -118,9 +125,10 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements component: () => import('./content-editor-tab.element.js'), setup: (component) => { (component as UmbContentWorkspaceViewEditTabElement).containerId = tab.ownerId ?? tab.ids[0]; + this.#provideViewContext(path, component); }, }); - this.#createViewContext(path); + this.#createViewContext(path, tabName); }); } @@ -140,11 +148,17 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements this._routes = routes; } - #createViewContext(viewAlias: string) { + #createViewContext(viewAlias: string | null, tabName: string) { if (!this.#tabViewContexts.find((context) => context.viewAlias === viewAlias)) { const view = new UmbViewContext(this, viewAlias); this.#tabViewContexts.push(view); + if (viewAlias === null) { + // for the root tab, we need to filter hints, so in this case we do accept everything that is not in a tab: [NL] + view.hints.setPathFilter((paths) => paths[0].includes('tab/') === false); + } + + view.setBrowserTitle(tabName); view.inheritFrom(this.#viewContext); this.observe( @@ -162,13 +176,28 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements } } + #currentProvidedView?: UmbViewContext; + + #provideViewContext(viewAlias: string | null, component: PageComponent) { + const view = this.#tabViewContexts.find((context) => context.viewAlias === viewAlias); + if (this.#currentProvidedView === view) { + return; + } + this.#currentProvidedView?.unprovide(); + if (!view) { + throw new Error(`View context with alias ${viewAlias} not found`); + } + this.#currentProvidedView = view; + view.provideAt(component as any); + } + override render() { if (!this._routes || !this._tabs) return; return html` ${this._routerPath && (this._tabs.length > 1 || (this._tabs.length === 1 && this._hasRootGroups)) ? html` - ${this._hasRootGroups && this._tabs.length > 0 ? this.#renderTab('root', '#general_generic') : nothing} + ${this._hasRootGroups && this._tabs.length > 0 ? this.#renderTab(null, '#general_generic') : nothing} ${repeat( this._tabs, (tab) => tab.name, @@ -194,17 +223,18 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements `; } - #renderTab(path: string, name: string, index = 0) { + #renderTab(path: string | null, name: string, index = 0) { const hint = this._hintMap.get(path); - const fullPath = this._routerPath + '/' + path; + const fullPath = this._routerPath + '/' + (path ? path : 'root'); const active = fullPath === this._activePath || - (!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath); + (!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath) || + (this._hasRootGroups && index === 0 && path === null && this._routerPath + '/' === this._activePath); return html`${hint && !active ? html`${hint.text} { - viewAlias?: string; + viewAlias?: string | null; scaffold?: Partial; } @@ -16,10 +16,15 @@ export class UmbHintController< IncomingHintType extends UmbIncomingHintBase = UmbPartialSome, > extends UmbControllerBase { // - #viewAlias?: string; - getViewAlias(): string | undefined { + #viewAlias: string | null; + getViewAlias(): string | null { return this.#viewAlias; } + #pathFilter?: (path: Array) => boolean; + setPathFilter(filter: (path: Array) => boolean) { + this.#pathFilter = filter; + } + #scaffold = new UmbObjectState>({}); readonly scaffold = this.#scaffold.asObservable(); #inUnprovidingState?: boolean; @@ -43,7 +48,7 @@ export class UmbHintController< constructor(host: UmbControllerHost, args?: UmbHintControllerArgs) { super(host); - this.#viewAlias = args?.viewAlias; + this.#viewAlias = args?.viewAlias ?? null; if (args?.scaffold) { this.#scaffold.setValue(args?.scaffold); } @@ -82,7 +87,7 @@ export class UmbHintController< return this.#hints.asObservablePart(fn); } - descendingHints(viewAlias?: string): Observable | undefined> { + descendingHints(viewAlias?: string | null): Observable | undefined> { if (viewAlias) { return this.#hints.asObservablePart((hints) => { return hints.filter((hint) => hint.path[0] === viewAlias); @@ -92,7 +97,22 @@ export class UmbHintController< } } + /** + * @internal + * @param {(path: Array) => boolean} filter - A filter function to filter the hints by their path. + * @returns {Observable | undefined>} An observable of an array of hints that match the filter. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + _internal_descendingHintsByFilter(filter: (path: Array) => boolean): Observable | undefined> { + return this.#hints.asObservablePart((hints) => { + return hints.filter((hint) => filter(hint.path)); + }); + } + inherit(): void { + if (this.#viewAlias === null && this.#pathFilter === undefined) { + throw new Error('A Hint Controller needs a view alias or pathFilter to be able to inherit from a parent.'); + } this.consumeContext(UMB_HINT_CONTEXT, (parent) => { this.inheritFrom(parent); }).skipHost(); @@ -101,13 +121,24 @@ export class UmbHintController< inheritFrom(parent: UmbHintController | undefined): void { if (this.#parent === parent) return; + if (this.#viewAlias === null && this.#pathFilter === undefined) { + throw new Error('A Hint Controller needs a view alias or pathFilter to be able to inherit from a parent.'); + } this.#parent = parent; this.observe(this.#parent?.scaffold, (scaffold) => { if (scaffold) { this.#scaffold.update(scaffold as any); } }); - this.observe(parent?.descendingHints(this.#viewAlias), this.#receiveHints, 'observeParentHints'); + if (this.#viewAlias) { + this.observe(parent?.descendingHints(this.#viewAlias), this.#receiveHints, 'observeParentHints'); + } else if (this.#pathFilter) { + this.observe( + parent?._internal_descendingHintsByFilter(this.#pathFilter), + this.#receiveHints, + 'observeParentHints', + ); + } this.observe(this.hints, this.#propagateHints, 'observeLocalMessages'); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts index 510697e146..85fc30489d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts @@ -1,10 +1,11 @@ import type { ManifestSection } from './extensions/section.extension.js'; import { UMB_SECTION_CONTEXT } from './section.context.token.js'; -import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbSectionContext extends UmbContextBase { #manifestAlias = new UmbStringState(undefined); @@ -14,17 +15,21 @@ export class UmbSectionContext extends UmbContextBase { public readonly pathname = this.#manifestPathname.asObservable(); public readonly label = this.#manifestLabel.asObservable(); + #viewContext = new UmbViewContext(this, null); #sectionContextExtensionController?: UmbExtensionsApiInitializer; constructor(host: UmbControllerHost) { super(host, UMB_SECTION_CONTEXT); + this.#createSectionContextExtensions(); } public setManifest(manifest?: ManifestSection) { this.#manifestAlias.setValue(manifest?.alias); this.#manifestPathname.setValue(manifest?.meta?.pathname); - this.#manifestLabel.setValue(manifest ? manifest.meta?.label || manifest.name : undefined); + const sectionLabel = manifest ? manifest.meta?.label || manifest.name : undefined; + this.#manifestLabel.setValue(sectionLabel); + this.#viewContext.setBrowserTitle(sectionLabel); } getPathname() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts index 6930c4fa94..d2833afe12 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts @@ -1,80 +1,9 @@ -import { UMB_VIEW_CONTEXT } from './view.context-token.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; -import { UmbClassState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; -import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -import { UmbHintController, type UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import { UmbViewController } from './view.controller.js'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; -/** - * - * TODO: - * Include Shortcuts - * - * Browser Title? - * - */ -export class UmbViewContext extends UmbControllerBase { - // - #providerCtrl: any; - #currentProvideHost?: UmbClassInterface; - - public readonly viewAlias: string; - #variantId = new UmbClassState(undefined); - protected readonly variantId = this.#variantId.asObservable(); - - public hints; - - readonly firstHintOfVariant; - - constructor(host: UmbControllerHost, viewAlias: string) { - super(host); - this.viewAlias = viewAlias; - this.hints = new UmbHintController(this, { - viewAlias: viewAlias, - }); - this.firstHintOfVariant = mergeObservables([this.variantId, this.hints.hints], ([variantId, hints]) => { - // Notice, because we in UI have invariant fields on Variants, then we will accept invariant hints on variants. - if (variantId) { - return hints.find((hint) => - hint.variantId ? hint.variantId.equal(variantId!) || hint.variantId.isInvariant() : true, - ); - } else { - return hints[0]; - } - }); - } - - setVariantId(variantId: UmbVariantId | undefined): void { - this.#variantId.setValue(variantId); - this.hints.updateScaffold({ variantId: variantId }); - } - - provideAt(controllerHost: UmbClassInterface): void { - if (this.#currentProvideHost === controllerHost) return; - - this.unprovide(); - - this.#currentProvideHost = controllerHost; - this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); - this.hints.provideAt(controllerHost); - } - - unprovide(): void { - if (this.#providerCtrl) { - this.#providerCtrl.destroy(); - this.#providerCtrl = undefined; - } - this.hints.unprovide(); - } - - inheritFrom(context?: UmbViewContext): void { - this.observe( - context?.variantId, - (variantId) => { - this.setVariantId(variantId); - }, - 'observeParentVariantId', - ); - this.hints.inheritFrom(context?.hints); +export class UmbViewContext extends UmbViewController { + constructor(host: UmbClassInterface, viewAlias: string | null) { + super(host, viewAlias); + this.provideAt(host); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts new file mode 100644 index 0000000000..af2328240b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -0,0 +1,309 @@ +import { UMB_VIEW_CONTEXT } from './view.context-token.js'; +import { UmbClassState, UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbHintController } from '@umbraco-cms/backoffice/hint'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextConsumerController, UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +/** + * + * TODO: + * Include Shortcuts + * + * The View Context handles the aspects of three Features: + * Browser Titles — Provide a title for this view and it will be set or joint with parent views depending on the inheritance setting. + * Hints — Holds Hints for this view, depending on the inheritance setting it will propagate the hints to be displayed at parent views. + * Shortcuts — Not implemented yet. + * + */ +export class UmbViewController extends UmbControllerBase { + // + #attached = false; + #providerCtrl?: UmbContextProviderController; + #consumeParentCtrl?: UmbContextConsumerController; + #currentProvideHost?: UmbClassInterface; + #localize = new UmbLocalizationController(this); + + // State used to know if the context can be auto activated when attached. + #autoActivate = true; + #active = false; + #hasActiveChild = false; + #inherit?: boolean; + #explicitInheritance?: boolean; + #parentView?: UmbViewController; + #title?: string; + #computedTitle = new UmbStringState(undefined); + readonly computedTitle = this.#computedTitle.asObservable(); + + public readonly viewAlias: string | null; + + #variantId = new UmbClassState(undefined); + protected readonly variantId = this.#variantId.asObservable(); + + public hints; + + readonly firstHintOfVariant; + + constructor(host: UmbControllerHost, viewAlias: string | null) { + super(host); + this.viewAlias = viewAlias; + this.hints = new UmbHintController(this, { + viewAlias: viewAlias, + }); + this.firstHintOfVariant = mergeObservables([this.variantId, this.hints.hints], ([variantId, hints]) => { + // Notice, because we in UI have invariant fields on Variants, then we will accept invariant hints on variants. + if (variantId) { + return hints.find((hint) => + hint.variantId ? hint.variantId.equal(variantId!) || hint.variantId.isInvariant() : true, + ); + } else { + return hints[0]; + } + }); + + this.#consumeParentCtrl = this.consumeContext(UMB_VIEW_CONTEXT, (parentView) => { + // In case of explicit inheritance we do not want to overview the parent view. + if (this.#explicitInheritance) return; + if (this.#active && !this.#hasActiveChild) { + // If we were active we will react as if we got deactivated and then activated again below if state allows. [NL] + this.#propagateActivation(); + } + this.#active = false; + if (parentView) { + this.#parentView = parentView; + } + if (this.#inherit) { + this.#inheritFromParent(); + } + // only activate if we had an incoming parentView, cause if not we are in a disassembling state. [NL] + if (parentView && this.#attached && this.#autoActivate) { + this._internal_activate(); + } + }).skipHost(); + } + + public setVariantId(variantId: UmbVariantId | undefined): void { + this.#variantId.setValue(variantId); + this.hints.updateScaffold({ variantId: variantId }); + } + + public setBrowserTitle(title: string | undefined): void { + if (this.#title === title) return; + this.#title = title; + // TODO: This check should be if its the most child being active, but again think about how the parents in the active chain should work. + this.#computeTitle(); + this.#updateTitle(); + } + + public provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#autoActivate = true; + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); + this.hints.provideAt(controllerHost); + + if (this.#attached && this.#autoActivate) { + this._internal_activate(); + } + } + + public unprovide(): void { + if (this.#providerCtrl) { + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + } + this.hints.unprovide(); + + this._internal_deactivate(); + } + + override hostConnected(): void { + this.#attached = true; + super.hostConnected(); + // CHeck that we have a providerController, otherwise this is not provided. [NL] + if (this.#autoActivate) { + this._internal_activate(); + } + } + + override hostDisconnected(): void { + const wasAttached = this.#attached; + const wasActive = this.#active; + this.#attached = false; + this.#active = false; + super.hostDisconnected(); + if (wasAttached === true && wasActive) { + // CHeck that we have a providerController, otherwise this is not provided. [NL] + this.#propagateActivation(); + } + } + + public inherit() { + this.#inherit = true; + } + + public inheritFrom(context?: UmbViewController): void { + this.#inherit = true; + this.#explicitInheritance = true; + this.#consumeParentCtrl?.destroy(); + this.#consumeParentCtrl = undefined; + this.#parentView = context; + this.#inheritFromParent(); + this.#propagateActivation(); + } + + #inheritFromParent(): void { + this.observe( + this.#parentView?.variantId, + (variantId) => { + this.setVariantId(variantId); + }, + 'observeParentVariantId', + ); + this.observe( + this.#parentView?.computedTitle, + () => { + this.#computeTitle(); + // Check for parent view as it is undefined in a disassembling state and we do not want to update the title in that situation. [NL] + if (this.#providerCtrl && this.#parentView && this.#active) { + console.log('ttt', this.viewAlias, this); + this.#updateTitle(); + } + }, + 'observeParentTitle', + ); + this.hints.inheritFrom(this.#parentView?.hints); + } + + #propagateActivation() { + if (!this.#parentView) return; + if (this.#inherit) { + if (this.#active) { + this.#parentView._internal_childActivated(); + } else { + this.#parentView._internal_childDeactivated(); + } + } else { + if (this.#active) { + this.#parentView._internal_deactivate(); + } else { + this.#parentView._internal_activate(); + } + } + } + + /** + * @internal + * Notify that a view context has been activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_activate() { + if (!this.#providerCtrl) { + // If we are not provided we should not be activated. [NL] + return; + } + this.#autoActivate = true; + if (this.#active === true) { + return; + } + // If not attached then propagate the activation to the parent. [NL] + if (this.#attached === false) { + if (!this.#parentView) { + throw new Error('Cannot activate a view that is not attached to the DOM.'); + } + this.#propagateActivation(); + } else { + this.#active = true; + this.#propagateActivation(); + this.#updateTitle(); + // TODO: Start shortcuts. [NL] + } + } + + /** + * @internal + * Notify that a child has been activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_childActivated() { + if (this.#hasActiveChild) return; + this.#hasActiveChild = true; + this._internal_activate(); + } + + /** + * @internal + * Notify that a child is no longer activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_childDeactivated() { + this.#hasActiveChild = false; + if (this.#attached === false) { + if (this.#parentView) { + return; + } else { + throw new Error('Cannot re-activate(_childDeactivated) a view that is not attached to the DOM.'); + } + } + if (this.#autoActivate) { + this._internal_activate(); + } else { + this.#propagateActivation(); + } + } + + /** + * @internal + * Deactivate the view context. + * We cannot conclude that this means the parent should be activated, it can be because of a child being activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_deactivate() { + this.#autoActivate = false; + if (!this.#active) return; + this.#active = false; + // TODO: Stop shortcuts. [NL] + // Deactivate parents: + this.#propagateActivation(); + } + + #updateTitle() { + if (!this.#active || this.#hasActiveChild) { + return; + } + const localTitle = this.getComputedTitle(); + document.title = (localTitle ? localTitle + ' | ' : '') + 'Umbraco'; + } + + #computeTitle() { + const titles = []; + if (this.#inherit && this.#parentView) { + titles.push(this.#parentView.getComputedTitle()); + } + if (this.#title) { + titles.push(this.#localize.string(this.#title)); + } + this.#computedTitle.setValue(titles.length > 0 ? titles.join(' | ') : undefined); + } + + public getComputedTitle(): string | undefined { + return this.#computedTitle.getValue(); + } + + override destroy(): void { + this.#inherit = false; + this.#active = false; + this.#autoActivate = false; + (this as any).provideAt = undefined; + this.unprovide(); + super.destroy(); + this.#consumeParentCtrl = undefined; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts index 73ac95d563..7efdb59688 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts @@ -1,14 +1,12 @@ import type { ManifestWorkspaceView } from '../../types.js'; import { UmbWorkspaceViewContext } from './workspace-view.context.js'; import { UMB_WORKSPACE_EDITOR_CONTEXT } from './workspace-editor.context-token.js'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbBasicState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { UmbHintController } from '@umbraco-cms/backoffice/hint'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils'; -import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; export class UmbWorkspaceEditorContext extends UmbContextBase { @@ -26,9 +24,13 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { let contexts = this.#contexts; // remove ones that are no longer contained in the workspaceViews (And thereby make the new array): - const contextsToKeep = contexts.filter( - (view) => !manifests.some((manifest) => manifest.alias === view.manifest.alias), - ); + const contextsToKeep = contexts.filter((view) => { + const keep = manifests.some((manifest) => manifest.alias === view.manifest.alias); + if (!keep) { + view.destroy(); + } + return keep; + }); const hasDiff = contextsToKeep.length !== manifests.length; if (hasDiff) { @@ -40,7 +42,8 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { .forEach((manifest) => { const context = new UmbWorkspaceViewContext(this, manifest); context.setVariantId(this.#variantId); - context.hints.inheritFrom(this.#hints); + context.setBrowserTitle(manifest.meta.label); + context.inherit(); contexts.push(context); }); } @@ -82,13 +85,10 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { #contexts = new Array(); #variantId?: UmbVariantId; - #hints = new UmbHintController(this, {}); constructor(host: UmbControllerHost) { super(host, UMB_WORKSPACE_EDITOR_CONTEXT); - this.#hints.inherit(); - this.#init = new UmbExtensionsManifestInitializer( this, umbExtensionsRegistry, @@ -102,7 +102,6 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { setVariantId(variantId: UmbVariantId | undefined): void { this.#variantId = variantId; - this.#hints.updateScaffold({ variantId }); this.#contexts.forEach((view) => { view.hints.updateScaffold({ variantId }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 300a8fdc15..46f7f14c46 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -110,6 +110,7 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { ); } + #currentProvidedView?: UmbWorkspaceViewContext; #createRoutes() { let newRoutes: UmbRoute[] = []; @@ -120,7 +121,11 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { path: UMB_WORKSPACE_VIEW_PATH_PATTERN.generateLocal({ viewPathname: manifest.meta.pathname }), component: () => createExtensionElement(manifest), setup: (component?: any) => { + if (this.#currentProvidedView !== context) { + this.#currentProvidedView?.unprovide(); + } if (component) { + this.#currentProvidedView = context; context.provideAt(component); component.manifest = manifest; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts index eafe506578..bf84734cd8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts @@ -1,13 +1,14 @@ import type { ManifestWorkspaceView } from '../../types.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; + export class UmbWorkspaceViewContext extends UmbViewContext { public readonly IS_WORKSPACE_VIEW_CONTEXT = true as const; // Note: manifest can change later, but because we currently only use the alias from it, it's not something we need to handle. [NL] public manifest: ManifestWorkspaceView; - constructor(host: UmbControllerHost, manifest: ManifestWorkspaceView) { + constructor(host: UmbClassInterface, manifest: ManifestWorkspaceView) { super(host, manifest.alias); this.manifest = manifest; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts index 0cf1573409..4d14510056 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts @@ -2,9 +2,10 @@ import type { UmbNamableWorkspaceContext } from '../types.js'; import { UmbNameWriteGuardManager } from '../namable/index.js'; import { UmbEntityDetailWorkspaceContextBase } from './entity-detail-workspace-base.js'; import type { UmbEntityDetailWorkspaceContextArgs, UmbEntityDetailWorkspaceContextCreateArgs } from './types.js'; -import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; +import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; export abstract class UmbEntityNamedDetailWorkspaceContextBase< NamedDetailModelType extends UmbNamedEntityModel = UmbNamedEntityModel, @@ -23,9 +24,18 @@ export abstract class UmbEntityNamedDetailWorkspaceContextBase< public readonly nameWriteGuard = new UmbNameWriteGuardManager(this); + public readonly view = new UmbViewContext(this, null); + constructor(host: UmbControllerHost, args: UmbEntityDetailWorkspaceContextArgs) { super(host, args); this.nameWriteGuard.fallbackToPermitted(); + this.observe( + this.name, + (name) => { + this.view.setBrowserTitle(name); + }, + null, + ); } getName() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts index 8f890d7cdb..6d21582800 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts @@ -257,7 +257,6 @@ export class UmbInputDocumentElement extends UmbFormControlMixin { if (this.#hintedMsgs.has(message.key)) return; - this.hints.addOne({ + this.view.hints.addOne({ unique: message.key, path: [UMB_MEMBER_WORKSPACE_VIEW_MEMBER_ALIAS], text: '!', @@ -158,7 +158,7 @@ export class UmbMemberWorkspaceContext this.#hintedMsgs.forEach((key) => { if (!messages.some((msg) => msg.key === key)) { this.#hintedMsgs.delete(key); - this.hints.removeOne(key); + this.view.hints.removeOne(key); } }); }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts index 0e2d78241b..1923edb9d0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/workspace/user-group/user-group-workspace.context.ts @@ -3,19 +3,18 @@ import { UMB_USER_GROUP_DETAIL_REPOSITORY_ALIAS, type UmbUserGroupDetailReposito import { UMB_USER_GROUP_ENTITY_TYPE, UMB_USER_GROUP_ROOT_ENTITY_TYPE } from '../../entity.js'; import { UmbUserGroupWorkspaceEditorElement } from './user-group-workspace-editor.element.js'; import { UMB_USER_GROUP_WORKSPACE_ALIAS } from './constants.js'; -import type { UmbUserPermissionModel } from '@umbraco-cms/backoffice/user-permission'; -import type { UmbRoutableWorkspaceContext, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import { - UmbEntityDetailWorkspaceContextBase, + UmbEntityNamedDetailWorkspaceContextBase, UmbWorkspaceIsNewRedirectController, } from '@umbraco-cms/backoffice/workspace'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbRoutableWorkspaceContext, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; +import type { UmbUserPermissionModel } from '@umbraco-cms/backoffice/user-permission'; export class UmbUserGroupWorkspaceContext - extends UmbEntityDetailWorkspaceContextBase + extends UmbEntityNamedDetailWorkspaceContextBase implements UmbSubmittableWorkspaceContext, UmbRoutableWorkspaceContext { - readonly name = this._data.createObservablePartOfCurrent((data) => data?.name || ''); readonly alias = this._data.createObservablePartOfCurrent((data) => data?.alias || ''); readonly aliasCanBeChanged = this._data.createObservablePartOfCurrent((data) => data?.aliasCanBeChanged); readonly icon = this._data.createObservablePartOfCurrent((data) => data?.icon || null); diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts index cc7f872c1d..8541cf7657 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/workspace/user/user-workspace.context.ts @@ -1,27 +1,26 @@ import type { UmbUserDetailModel, UmbUserStartNodesModel, UmbUserStateEnum } from '../../types.js'; -import { UMB_USER_ENTITY_TYPE } from '../../entity.js'; import type { UmbUserDetailRepository } from '../../repository/index.js'; import { UMB_USER_DETAIL_REPOSITORY_ALIAS } from '../../repository/index.js'; +import { UMB_USER_ENTITY_TYPE } from '../../entity.js'; import { UmbUserAvatarRepository } from '../../repository/avatar/index.js'; import { UmbUserConfigRepository } from '../../repository/config/index.js'; -import { UMB_USER_WORKSPACE_ALIAS } from './constants.js'; import { UmbUserWorkspaceEditorElement } from './user-workspace-editor.element.js'; -import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; -import { UmbEntityDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/workspace'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UMB_USER_WORKSPACE_ALIAS } from './constants.js'; +import { UmbEntityNamedDetailWorkspaceContextBase } from '@umbraco-cms/backoffice/workspace'; import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbRepositoryResponseWithAsObservable } from '@umbraco-cms/backoffice/repository'; +import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; type EntityType = UmbUserDetailModel; export class UmbUserWorkspaceContext - extends UmbEntityDetailWorkspaceContextBase + extends UmbEntityNamedDetailWorkspaceContextBase implements UmbSubmittableWorkspaceContext { public readonly avatarRepository: UmbUserAvatarRepository = new UmbUserAvatarRepository(this); public readonly configRepository = new UmbUserConfigRepository(this); - readonly name = this._data.createObservablePartOfCurrent((x) => x?.name); readonly state = this._data.createObservablePartOfCurrent((x) => x?.state); readonly kind = this._data.createObservablePartOfCurrent((x) => x?.kind); readonly userGroupUniques = this._data.createObservablePartOfCurrent((x) => x?.userGroupUniques || []); @@ -116,14 +115,6 @@ export class UmbUserWorkspaceContext return this.avatarRepository.deleteAvatar(unique); } - getName(): string { - return this._data.getCurrent()?.name || ''; - } - - setName(name: string) { - this._data.updateCurrent({ name }); - } - override destroy(): void { this.avatarRepository.destroy(); super.destroy(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/collection/repository/webhook-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/collection/repository/webhook-collection.server.data-source.ts index f3aecf9d40..1ce283b864 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/collection/repository/webhook-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/collection/repository/webhook-collection.server.data-source.ts @@ -41,7 +41,7 @@ export class UmbWebhookCollectionServerDataSource implements UmbWebhookCollectio entityType: UMB_WEBHOOK_ENTITY_TYPE, unique: item.id, url: item.url, - name: item.name, + name: item.name ?? '', description: item.description, enabled: item.enabled, headers: item.headers, diff --git a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/detail/webhook-detail.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/detail/webhook-detail.server.data-source.ts index fedd488ff8..34d72ec12e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/detail/webhook-detail.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/detail/webhook-detail.server.data-source.ts @@ -73,7 +73,7 @@ export class UmbWebhookDetailServerDataSource implements UmbDetailDataSource; contentTypes: Array; diff --git a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/webhook-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/webhook-workspace.context.ts index d2d2d46231..8878f584be 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/webhook-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/webhook-workspace.context.ts @@ -5,23 +5,21 @@ import type { UmbWebhookDetailModel } from '../types.js'; import type { UmbWebhookEventModel } from '../../webhook-event/types.js'; import { UmbWebhookWorkspaceEditorElement } from './webhook-workspace-editor.element.js'; import { - type UmbSubmittableWorkspaceContext, + UmbEntityNamedDetailWorkspaceContextBase, UmbWorkspaceIsNewRedirectController, - type UmbRoutableWorkspaceContext, - UmbEntityDetailWorkspaceContextBase, UmbWorkspaceIsNewRedirectControllerAlias, } from '@umbraco-cms/backoffice/workspace'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbSubmittableWorkspaceContext, UmbRoutableWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; export class UmbWebhookWorkspaceContext - extends UmbEntityDetailWorkspaceContextBase + extends UmbEntityNamedDetailWorkspaceContextBase implements UmbSubmittableWorkspaceContext, UmbRoutableWorkspaceContext { // Observable states readonly headers = this._data.createObservablePartOfCurrent((data) => data?.headers); readonly enabled = this._data.createObservablePartOfCurrent((data) => data?.enabled); readonly url = this._data.createObservablePartOfCurrent((data) => data?.url); - readonly name = this._data.createObservablePartOfCurrent((data) => data?.name); readonly description = this._data.createObservablePartOfCurrent((data) => data?.description); readonly events = this._data.createObservablePartOfCurrent((data) => data?.events); readonly contentTypes = this._data.createObservablePartOfCurrent((data) => data?.contentTypes); @@ -121,15 +119,6 @@ export class UmbWebhookWorkspaceContext this._data.updateCurrent({ url }); } - /** - * Sets the name - * @param {string} name - The name - * @memberof UmbWebhookWorkspaceContext - */ - setName(name: string) { - this._data.updateCurrent({ name }); - } - /** * Sets the description * @param {string} description - The description From 0f11ee335744b3d0cf8cd31cfeb0704ea73f5ec2 Mon Sep 17 00:00:00 2001 From: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com> Date: Fri, 19 Sep 2025 16:00:43 +0700 Subject: [PATCH 13/56] E2E nightly pipeline: Fix the Tiptap failing tests (#20194) * Updated default value of tiptap toolbar and tiptap extensions * Bumped version * Updated default extension value of Tiptap --- .../package-lock.json | 18 ++++++------- .../Umbraco.Tests.AcceptanceTest/package.json | 4 +-- .../DataType/RichTextEditor.spec.ts | 27 ++++++++++++++++--- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index fbeda87922..ab48c0f50a 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -7,8 +7,8 @@ "name": "acceptancetest", "hasInstallScript": true, "dependencies": { - "@umbraco/json-models-builders": "^2.0.38", - "@umbraco/playwright-testhelpers": "^16.0.46", + "@umbraco/json-models-builders": "^2.0.40", + "@umbraco/playwright-testhelpers": "^16.0.47", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -58,21 +58,21 @@ } }, "node_modules/@umbraco/json-models-builders": { - "version": "2.0.39", - "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.39.tgz", - "integrity": "sha512-YcgZ+WJ3HANBUaffSzZVRlJNLjXOaWOQNIuGf/A0lGH1khd5Kkv2JGln1bq2bNzIbIYQM+f2vYAnmYXmJFN7Vg==", + "version": "2.0.40", + "resolved": "https://registry.npmjs.org/@umbraco/json-models-builders/-/json-models-builders-2.0.40.tgz", + "integrity": "sha512-Yqojp/0akRgXsnjg18+MjMdkRvFrmlUNbfITgZ3d1h/PIRbWXPNKY1YAfZmdUv+g1SRSHrbIRpPPtSy+gNOjHw==", "license": "MIT", "dependencies": { "camelize": "^1.0.1" } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "16.0.46", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.46.tgz", - "integrity": "sha512-2C76pXp8ixbrOj4kcSzwyXCPSXMsubPcR6wClBdVx6ZiR4LgkAzQ8WwRca/K5pKVm2Uh6HogdRE6bg+qv6klxQ==", + "version": "16.0.47", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-16.0.47.tgz", + "integrity": "sha512-N88UCvjqCwJMRCu5wUmW2xxPVqEMR0sKGDlUsko9EejvyyJBFSE00PRGyWo6lPuYxAy4LkkONwIWBATWiry7xg==", "license": "MIT", "dependencies": { - "@umbraco/json-models-builders": "2.0.39", + "@umbraco/json-models-builders": "2.0.40", "node-fetch": "^2.6.7" } }, diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index abeb17447e..3a3c6971a5 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,8 +21,8 @@ "typescript": "^4.8.3" }, "dependencies": { - "@umbraco/json-models-builders": "^2.0.38", - "@umbraco/playwright-testhelpers": "^16.0.46", + "@umbraco/json-models-builders": "^2.0.40", + "@umbraco/playwright-testhelpers": "^16.0.47", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts index 2660e106d0..5abb31dfda 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataType/RichTextEditor.spec.ts @@ -6,16 +6,37 @@ const tipTapPropertyEditorName = 'Rich Text Editor [Tiptap] Property Editor UI'; const tipTapAlias = 'Umbraco.RichText'; const tipTapUiAlias = 'Umb.PropertyEditorUi.Tiptap'; const extensionsDefaultValue = [ + "Umb.Tiptap.RichTextEssentials", + "Umb.Tiptap.Anchor", + "Umb.Tiptap.Blockquote", + "Umb.Tiptap.Bold", + "Umb.Tiptap.BulletList", + "Umb.Tiptap.CodeBlock", "Umb.Tiptap.Embed", - "Umb.Tiptap.Link", "Umb.Tiptap.Figure", + "Umb.Tiptap.Heading", + "Umb.Tiptap.HorizontalRule", + "Umb.Tiptap.HtmlAttributeClass", + "Umb.Tiptap.HtmlAttributeDataset", + "Umb.Tiptap.HtmlAttributeId", + "Umb.Tiptap.HtmlAttributeStyle", + "Umb.Tiptap.HtmlTagDiv", + "Umb.Tiptap.HtmlTagSpan", "Umb.Tiptap.Image", + "Umb.Tiptap.Italic", + "Umb.Tiptap.Link", + "Umb.Tiptap.MediaUpload", + "Umb.Tiptap.OrderedList", + "Umb.Tiptap.Strike", "Umb.Tiptap.Subscript", "Umb.Tiptap.Superscript", "Umb.Tiptap.Table", - "Umb.Tiptap.Underline", "Umb.Tiptap.TextAlign", - "Umb.Tiptap.MediaUpload" + "Umb.Tiptap.TextDirection", + "Umb.Tiptap.TextIndent", + "Umb.Tiptap.TrailingNode", + "Umb.Tiptap.Underline", + "Umb.Tiptap.WordCount" ]; const toolbarDefaultValue = [ From 5921950ea0371aceb51fc3d8adb2cfebb49c3866 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Fri, 19 Sep 2025 12:53:25 +0200 Subject: [PATCH 14/56] remove console.log --- .../src/packages/core/view/context/view.controller.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts index af2328240b..0727d18a6b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -173,7 +173,6 @@ export class UmbViewController extends UmbControllerBase { this.#computeTitle(); // Check for parent view as it is undefined in a disassembling state and we do not want to update the title in that situation. [NL] if (this.#providerCtrl && this.#parentView && this.#active) { - console.log('ttt', this.viewAlias, this); this.#updateTitle(); } }, From c1b74b6883b4f59b6aa8c92cc3f9089bc6eeebde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 19 Sep 2025 13:18:35 +0200 Subject: [PATCH 15/56] Badge: Make badge go on top (#20196) * umb badge and implementation * only show variant selector hint if hint on none active variant --- .../views/edit/content-editor.element.ts | 4 +- .../core/components/badge/badge.element.ts | 72 +++++++++++++++++++ .../packages/core/components/badge/index.ts | 1 + .../src/packages/core/components/index.ts | 3 +- .../workspace-editor.element.ts | 11 ++- ...ace-split-view-variant-selector.element.ts | 13 ++-- 6 files changed, 87 insertions(+), 17 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index 5b531ae552..731389f7d0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -236,8 +236,8 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements href=${fullPath} data-mark="content-tab:${path ?? 'root'}" >${hint && !active - ? html`${hint.text}${hint.text}` : nothing}`; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts new file mode 100644 index 0000000000..13bb1e67ad --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts @@ -0,0 +1,72 @@ +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; +import { html, customElement, property, css, LitElement, ifDefined } from '@umbraco-cms/backoffice/external/lit'; +import type { UUIInterfaceColor, UUIInterfaceLook } from '@umbraco-cms/backoffice/external/uui'; + +/** + * @element umb-badge + * @description A wrapper for the uui-badge component with position fixed support to go on top of other elements. + * @augments {LitElement} + */ +@customElement('umb-badge') +export class UmbBadgeElement extends LitElement { + /** + * Changes the look of the button to one of the predefined, symbolic looks. + * @type {"default" | "positive" | "warning" | "danger"} + * @attr + * @default "default" + */ + @property({ type: String }) + color?: UUIInterfaceColor; + + /** + * Changes the look of the button to one of the predefined, symbolic looks. + * @type {"default" | "primary" | "secondary" | "outline" | "placeholder"} + * @attr + * @default "default" + */ + @property({ type: String }) + look?: UUIInterfaceLook; + + /** + * Bring attention to this badge by applying a bounce animation. + * @type boolean + * @attr + * @default false + */ + @property({ type: Boolean }) + attention?: boolean; + + override render() { + return html``; + } + + static override styles = [ + UmbTextStyles, + css` + :host { + position: absolute; + anchor-name: --umb-badge-anchor; + /** because inset has no effect on uui-badge in this case, we then apply it here: */ + inset: var(--uui-badge-inset, -8px -8px auto auto); + } + + @supports (position-anchor: --my-name) { + uui-badge { + position: fixed; + position-anchor: --umb-badge-anchor; + z-index: 1; + top: anchor(top); + right: anchor(right); + } + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + 'umb-badge': UmbBadgeElement; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts new file mode 100644 index 0000000000..4094fdef5a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts @@ -0,0 +1 @@ +export * from './badge.element.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts index a6494c5c3b..f1d63a722e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts @@ -2,6 +2,7 @@ // TODO: we need to move these files into their respective folders/silos. We then need a way for a silo to globally register a component export * from './backoffice-modal-container/backoffice-modal-container.element.js'; export * from './backoffice-notification-container/backoffice-notification-container.element.js'; +export * from './badge/index.js'; export * from './body-layout/body-layout.element.js'; export * from './code-block/index.js'; export * from './dropdown/index.js'; @@ -25,6 +26,6 @@ export * from './multiple-color-picker-input/index.js'; export * from './multiple-text-string-input/index.js'; export * from './popover-layout/index.js'; export * from './ref-item/index.js'; -export * from './stack/index.js'; export * from './split-panel/index.js'; +export * from './stack/index.js'; export * from './table/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 46f7f14c46..df17e89301 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -193,8 +193,8 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { data-mark="workspace:view-link:${manifest.alias}">
${hint && !active - ? html`${hint.text}${hint.text}` : nothing}
@@ -275,12 +275,9 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { position: relative; } - uui-badge { - position: absolute; + umb-badge { font-size: var(--uui-type-small-size); - top: -0.5em; - right: auto; - left: calc(50% + 0.8em); + right: -1.5em; } umb-extension-slot[slot='actions'] { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index 7cb20c7228..8c0b26eea3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -337,7 +337,7 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< const hintsOrderedByWeight = Array.from(this._hintMap.values()).sort((a, b) => (b.weight || 0) - (a.weight || 0)); firstHintOnInactiveVariant = hintsOrderedByWeight.find((hint) => { if (!hint.variantId) return false; - return !hint.variantId.isInvariant() && hint.variantId.compare(this._activeVariant!) === false; + return !hint.variantId.isInvariant() && this.#isVariantActive(hint.variantId) === false; }); } @@ -369,8 +369,8 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< ${this.#getVariantSpecInfo(this._activeVariant)} ${this.#renderReadOnlyTag(this._activeVariant?.culture)} - ${this.#renderHintBadge(firstHintOnInactiveVariant)} + ${!this._variantSelectorOpen ? this.#renderHintBadge(firstHintOnInactiveVariant) : nothing} ${this._activeVariants.length > 1 ? html` @@ -424,9 +424,8 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
${this.#getVariantSpecInfo(variantOption)}
- ${this.#renderHintBadge(!active ? hint : undefined)} - ${this.#renderSplitViewButton(variantOption)} + ${this.#renderHintBadge(!active ? hint : undefined)} ${this.#renderSplitViewButton(variantOption)} ${this.#isVariantExpanded(variantId) ? html` ${subVariantOptions.map((option) => this.#renderSegmentVariantOption(option))} ` @@ -436,9 +435,9 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< #renderHintBadge(hint?: UmbVariantHint) { if (!hint) return nothing; - return html`
- ${hint.text} -
`; + return html` ${hint.text}`; } #isCreated(variantOption: VariantOptionModelType) { From 3a196ef9962b32692b7668dd13a5e2ed5e637c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 19 Sep 2025 19:01:19 +0200 Subject: [PATCH 16/56] Icons: add badge icon (#20201) add badge icon --- .../src/packages/core/icon-registry/icon-dictionary.json | 4 ++++ .../src/packages/core/icon-registry/icons.ts | 3 +++ .../src/packages/core/icon-registry/icons/icon-badge.ts | 1 + 3 files changed, 8 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index d095317698..d4534b46de 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -100,6 +100,10 @@ "name": "icon-backspace", "file": "delete.svg" }, + { + "name": "icon-badge", + "file": "badge.svg" + }, { "name": "icon-badge-add", "file": "circle-plus.svg" diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index 653bf6a54e..0f83d37184 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -74,6 +74,9 @@ path: () => import("./icons/icon-axis-rotation.js"), name: "icon-backspace", path: () => import("./icons/icon-backspace.js"), },{ +name: "icon-badge", +path: () => import("./icons/icon-badge.js"), +},{ name: "icon-badge-add", path: () => import("./icons/icon-badge-add.js"), },{ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts new file mode 100644 index 0000000000..7238c1faba --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file From 12ae51d67e6e92d4e5421aa224dbe54e022021a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 19 Sep 2025 19:22:16 +0200 Subject: [PATCH 17/56] Hints: Chore, just renaming files to fit name (#20203) rename files to fit name --- .../src/packages/core/hint/context/hint.context-token.ts | 2 +- .../core/hint/context/{hints.context.ts => hint.context.ts} | 2 +- .../hint/context/{hints.controller.ts => hint.controller.ts} | 0 .../src/packages/core/hint/context/index.ts | 4 ++-- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/Umbraco.Web.UI.Client/src/packages/core/hint/context/{hints.context.ts => hint.context.ts} (97%) rename src/Umbraco.Web.UI.Client/src/packages/core/hint/context/{hints.controller.ts => hint.controller.ts} (100%) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts index 5649e78018..f85498b378 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts @@ -1,4 +1,4 @@ -import type { UmbHintController } from './hints.controller.js'; +import type { UmbHintController } from './hint.controller.js'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; export const UMB_HINT_CONTEXT = new UmbContextToken('UmbHintContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts similarity index 97% rename from src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts index 3a9875d47c..7d381dcecd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts @@ -1,6 +1,6 @@ import type { UmbHint, UmbIncomingHintBase } from '../types.js'; import { UMB_HINT_CONTEXT } from './hint.context-token.js'; -import { UmbHintController, type UmbHintControllerArgs } from './hints.controller.js'; +import { UmbHintController, type UmbHintControllerArgs } from './hint.controller.js'; import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.controller.ts similarity index 100% rename from src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.controller.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts index 9523595c70..df3deea0fd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts @@ -1,3 +1,3 @@ export * from './hint.context-token.js'; -export * from './hints.context.js'; -export * from './hints.controller.js'; +export * from './hint.context.js'; +export * from './hint.controller.js'; From 07f0b7c6ae3a79dd01d1845c294a8d7aada8d8ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Fri, 19 Sep 2025 19:45:46 +0200 Subject: [PATCH 18/56] Content/Document Picker: make not existing items appear as not found items (#20198) make not existing items appear as not found items --- .../entity-item-ref.element.ts | 27 +++++++++++++++- .../core/picker-input/picker-input.context.ts | 8 +++-- .../repository/repository-items.manager.ts | 9 +----- .../src/packages/core/repository/types.ts | 8 +++++ .../input-document/input-document.element.ts | 31 +++++++++++++------ 5 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts index 0f2222bc84..97b469d8a5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts @@ -120,6 +120,12 @@ export class UmbEntityItemRefElement extends UmbLitElement { } } + @property({ type: Boolean }) + error?: boolean; + + @property({ type: String, attribute: 'error-message', reflect: false }) + errorMessage?: string; + #pathAddendum = new UmbRoutePathAddendumContext(this); #onSelected(event: UmbSelectedEvent) { @@ -155,6 +161,7 @@ export class UmbEntityItemRefElement extends UmbLitElement { this._component?.remove(); const component = extensionControllers[0]?.component || document.createElement('umb-default-item-ref'); + // TODO: I would say this code can use feature of the UmbExtensionsElementInitializer, to set properties and get a fallback element. [NL] // assign the properties to the component component.item = this.#item; component.readonly = this.readonly; @@ -182,7 +189,25 @@ export class UmbEntityItemRefElement extends UmbLitElement { } override render() { - return html`${this._component}`; + if (this._component) { + return html`${this._component}`; + } + // Error: + if (this.error) { + return html` + + + `; + } + // Loading: + return html``; } override destroy(): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts index 11d3bdeeb2..c345fd22d4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts @@ -29,6 +29,7 @@ export class UmbPickerInputContext< public readonly selection; public readonly selectedItems; + public readonly statuses; public readonly interactionMemory = new UmbInteractionMemoryManager(this); /** @@ -84,6 +85,7 @@ export class UmbPickerInputContext< this.#itemManager = new UmbRepositoryItemsManager(this, repositoryAlias, getUniqueMethod); this.selection = this.#itemManager.uniques; + this.statuses = this.#itemManager.statuses; this.selectedItems = this.#itemManager.items; } @@ -116,12 +118,12 @@ export class UmbPickerInputContext< async requestRemoveItem(unique: string) { const item = this.#itemManager.getItems().find((item) => this.#getUnique(item) === unique); - if (!item) throw new Error('Could not find item with unique: ' + unique); + const name = item?.name ?? '#general_notFound'; await umbConfirmModal(this, { color: 'danger', - headline: `#actions_remove ${item.name}?`, - content: `#defaultdialogs_confirmremove ${item.name}?`, + headline: `#actions_remove ${name}?`, + content: `#defaultdialogs_confirmremove ${name}?`, confirmLabel: '#actions_remove', }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts index bc3536a3e9..fc9f7fd124 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts @@ -7,17 +7,10 @@ import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-ap import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbEntityUpdatedEvent } from '@umbraco-cms/backoffice/entity-action'; +import type { UmbRepositoryItemsStatus } from './types.js'; const ObserveRepositoryAlias = Symbol(); -interface UmbRepositoryItemsStatus { - state: { - type: 'success' | 'error' | 'loading'; - error?: string; - }; - unique: string; -} - export class UmbRepositoryItemsManager extends UmbControllerBase { // repository?: UmbItemRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts index 32d7a55020..7cd8329ff1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts @@ -11,6 +11,14 @@ export interface UmbRepositoryResponse extends UmbDataSourceResponse {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UmbRepositoryErrorResponse extends UmbDataSourceErrorResponse {} +export interface UmbRepositoryItemsStatus { + state: { + type: 'success' | 'error' | 'loading'; + error?: string; + }; + unique: string; +} + /** * Interface for a repository that can return a paged model. * @template T - The type of items in the paged model. diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts index 6d21582800..52161c7974 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts @@ -11,6 +11,7 @@ import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type'; import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbRepositoryItemsStatus } from '@umbraco-cms/backoffice/repository'; @customElement('umb-input-document') export class UmbInputDocumentElement extends UmbFormControlMixin( @@ -139,6 +140,9 @@ export class UmbInputDocumentElement extends UmbFormControlMixin; + @state() + private _statuses?: Array; + #pickerInputContext = new UmbDocumentPickerInputContext(this); constructor() { @@ -168,6 +172,8 @@ export class UmbInputDocumentElement extends UmbFormControlMixin (this._statuses = statuses), '_observerStatuses'); + this.observe( this.#pickerInputContext.interactionMemory.memories, (memories) => { @@ -199,8 +205,8 @@ export class UmbInputDocumentElement extends UmbFormControlMixin ${repeat( - this._items, - (item) => item.unique, - (item) => - html` status.unique, + (status) => { + const unique = status.unique; + const item = this._items?.find((x) => x.unique === unique); + return html` ${when( @@ -242,11 +252,12 @@ export class UmbInputDocumentElement extends UmbFormControlMixin this.#onRemove(item)}> + @click=${() => this.#onRemove(unique)}>
`, )} - `, + `; + }, )} `; From 61c0ab6759d832c58c10bfcbd850e6c9999d6f03 Mon Sep 17 00:00:00 2001 From: Mehmet <36473707+ustadstar@users.noreply.github.com> Date: Sat, 20 Sep 2025 12:45:14 +0200 Subject: [PATCH 19/56] Management API: Add user data delete endpoint (closes #19793) (#20040) * Add user data delete endpoint to the management API * Fix typo and remove unused umbracoMapper * Applied changes from code review. --------- Co-authored-by: Andy Butland --- .../UserData/DeleteUserDataController.cs | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs new file mode 100644 index 0000000000..e2bb3e8849 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs @@ -0,0 +1,52 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.UserData; + +[ApiVersion("1.0")] +public class DeleteUserDataController : UserDataControllerBase +{ + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IUserDataService _userDataService; + + public DeleteUserDataController( + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IUserDataService userDataService) + { + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _userDataService = userDataService; + } + + [HttpDelete("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(UserDataOperationStatus), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(UserDataOperationStatus), StatusCodes.Status404NotFound)] + public async Task Delete(CancellationToken cancellationToken, Guid id) + { + IUserData? data = await _userDataService.GetAsync(id); + if (data is null) + { + return NotFound(); + } + + Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor); + + if (data.UserKey != currentUserKey) + { + return Unauthorized(); + } + + Attempt attempt = await _userDataService.DeleteAsync(id); + + return attempt.Success + ? Ok() + : UserDataOperationStatusResult(attempt.Result); + } +} From 37f9dea25987ce7303e614c5a4ca28a01d9e4833 Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Sat, 20 Sep 2025 13:57:38 +0200 Subject: [PATCH 20/56] Tree: Optimize tree root total children calls (#20192) * Remove redundant call to #loadTreeRoot in tree context * Update tree root requests to use take: 0 Changed all tree repository requestTreeRoot methods to call getRootItems with { take: 0 } instead of { take: 1 }. This ensures that no items are fetched when only the total count is needed to determine if children exist, improving efficiency. --- .../src/packages/core/tree/default/default-tree.context.ts | 2 -- .../src/packages/data-type/tree/data-type-tree.repository.ts | 2 +- .../src/packages/dictionary/tree/dictionary-tree.repository.ts | 2 +- .../tree/document-blueprint-tree.repository.ts | 2 +- .../document-types/tree/document-type-tree.repository.ts | 2 +- .../tree/data/document-recycle-bin-tree.repository.ts | 2 +- .../documents/documents/tree/document-tree.repository.ts | 2 +- .../media/media-types/tree/media-type-tree.repository.ts | 2 +- .../media/recycle-bin/tree/media-recycle-bin-tree.repository.ts | 2 +- .../src/packages/media/media/tree/media-tree.repository.ts | 2 +- .../members/member-type/tree/member-type-tree.repository.ts | 2 +- .../packages/static-file/tree/static-file-tree.repository.ts | 2 +- .../partial-views/tree/partial-view-tree.repository.ts | 2 +- .../packages/templating/scripts/tree/script-tree.repository.ts | 2 +- .../templating/stylesheets/tree/stylesheet-tree.repository.ts | 2 +- .../templating/templates/tree/template-tree.repository.ts | 2 +- 16 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts index bb60697a09..fb5f032436 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts @@ -156,8 +156,6 @@ export class UmbDefaultTreeContext< this.#loadRootItems(reload); return; } - - this.#loadTreeRoot(); } async #loadTreeRoot() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts index 18bc88fd11..52e5cf6cec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDataTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDataTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts index 457af615cd..5d19c10733 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDictionaryTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDictionaryTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts index 9d973193b9..c20d8a4646 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts @@ -18,7 +18,7 @@ export class UmbDocumentBlueprintTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentBlueprintTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts index fdab000953..5a96ff5d78 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts index 23a40ffe6b..f4c1339dc2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentRecycleBinTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts index b64b7584ca..6d6e6eb48f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts index e9b0c2482f..3922ef9d31 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMediaTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMediaTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts index a15f5cd2ea..0347877cb9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMediaRecycleBinTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts index f5e04d6ef9..f8c8812829 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts @@ -25,7 +25,7 @@ export class UmbMediaTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMediaTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts index 0140e663b7..a53f5a914d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMemberTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMemberTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts index 0161ca254a..86eac34b70 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbStaticFileTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbStaticFileTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/partial-view-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/partial-view-tree.repository.ts index b2592fcf89..50be41d4af 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/partial-view-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/partial-views/tree/partial-view-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbPartialViewTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbPartialViewTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/tree/script-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/tree/script-tree.repository.ts index 79c3c7f4de..95e557e433 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/tree/script-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/scripts/tree/script-tree.repository.ts @@ -12,7 +12,7 @@ export class UmbScriptTreeRepository extends UmbTreeRepositoryBase 0 : false; const data: UmbScriptTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/tree/stylesheet-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/tree/stylesheet-tree.repository.ts index d2f10d7f50..6d2fdce84a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/tree/stylesheet-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/stylesheets/tree/stylesheet-tree.repository.ts @@ -14,7 +14,7 @@ export class UmbStylesheetTreeRepository extends UmbTreeRepositoryBase< } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbStylesheetTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/tree/template-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/tree/template-tree.repository.ts index c4205baacf..92ec24796e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/tree/template-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/tree/template-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbTemplateTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbTemplateTreeRootModel = { From fa575d1f84d7d4b6aa6d770e3206e16f4c3fcadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Sat, 20 Sep 2025 14:03:41 +0200 Subject: [PATCH 21/56] View Context: observe parent activation to make sure children follows along. (#20206) observe parent activation to make sure children follows along. --- .../core/view/context/view.controller.ts | 58 +++++++++++++------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts index 0727d18a6b..0375dcdfb4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -1,5 +1,10 @@ import { UMB_VIEW_CONTEXT } from './view.context-token.js'; -import { UmbClassState, UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import { + UmbBooleanState, + UmbClassState, + UmbStringState, + mergeObservables, +} from '@umbraco-cms/backoffice/observable-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbHintController } from '@umbraco-cms/backoffice/hint'; import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; @@ -9,6 +14,8 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +const ObserveParentActiveCtrlAlias = Symbol(); + /** * * TODO: @@ -30,7 +37,11 @@ export class UmbViewController extends UmbControllerBase { // State used to know if the context can be auto activated when attached. #autoActivate = true; - #active = false; + #active = new UmbBooleanState(false); + public readonly active = this.#active.asObservable(); + get isActive() { + return this.#active.getValue(); + } #hasActiveChild = false; #inherit?: boolean; #explicitInheritance?: boolean; @@ -68,11 +79,11 @@ export class UmbViewController extends UmbControllerBase { this.#consumeParentCtrl = this.consumeContext(UMB_VIEW_CONTEXT, (parentView) => { // In case of explicit inheritance we do not want to overview the parent view. if (this.#explicitInheritance) return; - if (this.#active && !this.#hasActiveChild) { + if (this.isActive && !this.#hasActiveChild) { // If we were active we will react as if we got deactivated and then activated again below if state allows. [NL] this.#propagateActivation(); } - this.#active = false; + this.#active.setValue(false); if (parentView) { this.#parentView = parentView; } @@ -125,22 +136,23 @@ export class UmbViewController extends UmbControllerBase { } override hostConnected(): void { + const wasActive = this.isActive; this.#attached = true; super.hostConnected(); - // CHeck that we have a providerController, otherwise this is not provided. [NL] - if (this.#autoActivate) { + // Check that we have a providerController, otherwise this is not provided. [NL] + if (this.#autoActivate && !wasActive) { this._internal_activate(); } } override hostDisconnected(): void { const wasAttached = this.#attached; - const wasActive = this.#active; + const wasActive = this.isActive; this.#attached = false; - this.#active = false; + this.#active.setValue(false); super.hostDisconnected(); if (wasAttached === true && wasActive) { - // CHeck that we have a providerController, otherwise this is not provided. [NL] + // Check that we have a providerController, otherwise this is not provided. [NL] this.#propagateActivation(); } } @@ -155,6 +167,18 @@ export class UmbViewController extends UmbControllerBase { this.#consumeParentCtrl?.destroy(); this.#consumeParentCtrl = undefined; this.#parentView = context; + // Notice because we cannot break the inheritance, we do not need to stop this observation in any of the logic. [NL] + this.observe( + this.#parentView?.active, + (isActive) => { + if (isActive) { + this._internal_activate(); + } else { + this._internal_deactivate(); + } + }, + ObserveParentActiveCtrlAlias, + ); this.#inheritFromParent(); this.#propagateActivation(); } @@ -172,7 +196,7 @@ export class UmbViewController extends UmbControllerBase { () => { this.#computeTitle(); // Check for parent view as it is undefined in a disassembling state and we do not want to update the title in that situation. [NL] - if (this.#providerCtrl && this.#parentView && this.#active) { + if (this.#providerCtrl && this.#parentView && this.isActive) { this.#updateTitle(); } }, @@ -184,13 +208,13 @@ export class UmbViewController extends UmbControllerBase { #propagateActivation() { if (!this.#parentView) return; if (this.#inherit) { - if (this.#active) { + if (this.isActive) { this.#parentView._internal_childActivated(); } else { this.#parentView._internal_childDeactivated(); } } else { - if (this.#active) { + if (this.isActive) { this.#parentView._internal_deactivate(); } else { this.#parentView._internal_activate(); @@ -209,7 +233,7 @@ export class UmbViewController extends UmbControllerBase { return; } this.#autoActivate = true; - if (this.#active === true) { + if (this.isActive) { return; } // If not attached then propagate the activation to the parent. [NL] @@ -219,7 +243,7 @@ export class UmbViewController extends UmbControllerBase { } this.#propagateActivation(); } else { - this.#active = true; + this.#active.setValue(true); this.#propagateActivation(); this.#updateTitle(); // TODO: Start shortcuts. [NL] @@ -266,8 +290,8 @@ export class UmbViewController extends UmbControllerBase { // eslint-disable-next-line @typescript-eslint/naming-convention public _internal_deactivate() { this.#autoActivate = false; - if (!this.#active) return; - this.#active = false; + if (!this.isActive) return; + this.#active.setValue(false); // TODO: Stop shortcuts. [NL] // Deactivate parents: this.#propagateActivation(); @@ -298,7 +322,7 @@ export class UmbViewController extends UmbControllerBase { override destroy(): void { this.#inherit = false; - this.#active = false; + this.#active.setValue(false); this.#autoActivate = false; (this as any).provideAt = undefined; this.unprovide(); From 96b37889ae51e234725102edbc64f5b053fcd95b Mon Sep 17 00:00:00 2001 From: BerglindV Date: Sat, 20 Sep 2025 15:15:53 +0200 Subject: [PATCH 22/56] Docs: Updated contribution guidelines to include PR naming best practices (#20180) * Add naming PR guide to Update contributing-creating-a-pr.md * Add PR naming guide to contributing-creating-a-pr.md * Update contributing-creating-a-pr.md * Updates note on versions and default branch Updated default branch reference and contribution guidelines. --------- Co-authored-by: Andy Butland --- .github/contributing-creating-a-pr.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/contributing-creating-a-pr.md b/.github/contributing-creating-a-pr.md index dc1d67ea65..d28c815bdc 100644 --- a/.github/contributing-creating-a-pr.md +++ b/.github/contributing-creating-a-pr.md @@ -7,9 +7,26 @@ We recommend you to [sync with our repository][sync fork] before you submit your GitHub will have picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go. ![Create a pull request](img/createpullrequest.png) -We like to use [git flow][git flow] as much as possible, but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to `contrib`. This is the branch you should be targeting. +We like to use [git flow][git flow] as much as possible, but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to `main`. This is the branch you should be targeting. -Please note: we are no longer accepting features for v8 and below but will continue to merge security fixes as and when they arise. +We welcome PRs for features and bugfixes for different versions according to the [published support and EOL schedule][support-and-eol]. + +We don't have rules for naming PRs - so name them as you prefer. At HQ we do have a best practice on clear and concise PR naming, so if you would like to use the format feel free to do so. + +Our convention of doing it is: + +_Area: Description (closes #IssueID)_ + +1. Start by specifying the area. Fx the feature name(UFM, Tiptap etc.) or specific section (migrations, relations, segmentation). + +2. In your description, where applicable, mention type of PR (Build, Bump, Fix, Refactor etc.). + +4. Good practise is to make sure you describe specifically the change and/or impact of change.
+ Example: Writing "Extension Insights: Fixes CSS alignment" instead of "Fixed issue". + +6. Add (closes #IssueID) behind description, if your PR resolves an issue. + +That's it! ## The review process [review process]: #the-review-process @@ -48,4 +65,5 @@ There will be times that we really like your proposed changes and we’ll finish [making larger changes]: contributing-before-you-start.md#making-large-changes [pr or package]: contributing-before-you-start.md#pull-request-or-package -[Core collabs]: contributing-core-collabs-team.md \ No newline at end of file +[Core collabs]: contributing-core-collabs-team.md +[support-and-eol]: https://umbraco.com/products/knowledge-center/long-term-support-and-end-of-life/ From 182b7e0cfa5903ace313657294f15d32a82efbb0 Mon Sep 17 00:00:00 2001 From: Abdulaziz <60339265+Abdjulaziz@users.noreply.github.com> Date: Mon, 22 Sep 2025 10:33:58 +0200 Subject: [PATCH 23/56] Accessibility changes for the top navbar tabs and settings tabs (#20107) * Accessibility changes for the top navbar tabs and settings tabs * fix indention --------- Co-authored-by: Mads Rasmussen --- .../backoffice-header-sections.element.ts | 14 ++++++++------ .../section-main-views.element.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts index 89c0ffbb51..117867336b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts @@ -54,6 +54,10 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement { ); } + #getSectionName(section: UmbExtensionManifestInitializer) { + return section.manifest?.meta.label ? this.localize.string(section.manifest?.meta.label) : section.manifest?.name; + } + #getSectionPath(manifest: ManifestSection | undefined) { return `section/${manifest?.meta.pathname}`; } @@ -108,12 +112,10 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement { ?active="${this._currentSectionAlias === section.alias}" @click=${(event: PointerEvent) => this.#onSectionClick(event, section.manifest)} href="${this.#getSectionPath(section.manifest)}" - label="${ifDefined( - section.manifest?.meta.label - ? this.localize.string(section.manifest?.meta.label) - : section.manifest?.name, - )}" - data-mark="section-link:${section.alias}"> + label="${ifDefined(this.#getSectionName(section))}" + data-mark="section-link:${section.alias}" + >${this.#getSectionName(section)} `, )}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts index 685fff6ed0..a9bcb5a78b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts @@ -111,6 +111,10 @@ export class UmbSectionMainViewElement extends UmbLitElement { : nothing; } + #getDashboardName(dashboard: ManifestDashboard) { + return dashboard.meta?.label ? this.localize.string(dashboard.meta.label) : (dashboard.name ?? dashboard.alias); + } + #renderDashboards() { // Only show dashboards if there are more than one dashboard or if there are both dashboards and views return (this._dashboards.length > 0 && this._views.length > 0) || this._dashboards.length > 1 @@ -124,10 +128,10 @@ export class UmbSectionMainViewElement extends UmbLitElement { return html` + label="${this.#getDashboardName(dashboard)}" + ?active="${isActive}" + >${this.#getDashboardName(dashboard)} `; })} From 5dd75c0877ee95f5e1de0a0bcfdce11eceb41914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= <93977820+OskarKruger@users.noreply.github.com> Date: Mon, 22 Sep 2025 11:09:54 +0200 Subject: [PATCH 24/56] Update label for tree item caret to identify if open or closed (#20199) * added hovering and focus border to RTE * fix main to OG * fix to main again * I'm going to cry * added dynamic label to expand/collapse button on parent/child treeitems --------- Co-authored-by: Oskar kruger --- src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/da.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/en.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts | 1 + src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts | 1 + .../tree/tree-item/tree-item-base/tree-item-element-base.ts | 2 +- 8 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts index ac62461746..c76993903f 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts @@ -2211,6 +2211,7 @@ export default { searchContentTree: "Chwilio'r coeden cynnwys", maxAmount: 'Uchafswm', expandChildItems: 'Ehangu eitemau plentyn ar gyfer', + collapseChildItems: 'Cuddio eitemau plant ar gyfer', openContextNode: 'Agor nod cyd-destun ar gyfer', }, references: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts index 95be59b6f8..b8f00070f2 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts @@ -2350,6 +2350,7 @@ export default { maxAmount: 'Maximum antal', contextDialogDescription: 'Perform action %0% on the %1% node', expandChildItems: 'Udvid underordnede elementer for', + collapseChildItems: 'Skjul underordnede elementer for', openContextNode: 'Åbn kontekstnode for', }, references: { 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 0179093334..ebeebc9445 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -2408,6 +2408,7 @@ export default { searchContentTree: 'Search content tree', maxAmount: 'Maximum amount', expandChildItems: 'Expand child items for', + collapseChildItems: 'Collapse child items for', openContextNode: 'Open context node for', }, references: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts index dcbc5655b7..7c7bcf58f5 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts @@ -1873,6 +1873,7 @@ export default { searchContentTree: "Chercher dans l'arborescence de contenu", maxAmount: 'Quantité maximum', expandChildItems: 'Afficher les éléments enfant pour', + collapseChildItems: 'Cacher les éléments enfant pour', openContextNode: 'Ouvrir le noeud de contexte pour', }, references: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts index 36ce12d20d..68201301c8 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts @@ -2407,6 +2407,7 @@ export default { searchContentTree: 'Pesquisar Árvore de Conteúdo', maxAmount: 'Quantidade máxima', expandChildItems: 'Expandir itens filhos para', + collapseChildItems: 'Fechar itens filhos para', openContextNode: 'Abrir nó de contexto para', }, references: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts index 86a9f82544..57f308e160 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts @@ -318,6 +318,7 @@ export default { searchContentTree: 'Sök i innehållsträdet', maxAmount: 'Maximalt värde', expandChildItems: 'Visa underliggande noder för', + collapseChildItems: 'Dölj underliggande noder för', openContextNode: 'Öppna kontext för', }, prompt: { diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts index 1058aa0909..65be353739 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts @@ -2410,6 +2410,7 @@ export default { searchContentTree: 'Tìm kiếm cây nội dung', maxAmount: 'Số lượng tối đa', expandChildItems: 'Mở rộng các mục con cho', + collapseChildItems: 'Thu gọn các mục con cho', openContextNode: 'Mở nút ngữ cảnh cho %0%', }, references: { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts index b325590d1f..aaced243ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts @@ -136,7 +136,7 @@ export abstract class UmbTreeItemElementBase< .loading=${this._isLoading} .hasChildren=${this._hasChildren} .showChildren=${this._isOpen} - .caretLabel=${this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + this._label} + .caretLabel=${this._isOpen ? this.localize.term('visuallyHiddenTexts_collapseChildItems') + ' ' + this._label: this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + this._label} label=${this._label} href="${ifDefined(this._isSelectableContext ? undefined : this._href)}"> ${this.renderIconContainer()} ${this.renderLabel()} ${this.#renderActions()} ${this.#renderChildItems()} From 2c3a2e2b2da9fd03cc0a1473961157fa5b082bba Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 22 Sep 2025 11:26:25 +0200 Subject: [PATCH 25/56] Cherry-pick of #20129 to 16. --- src/Umbraco.Infrastructure/IPublishedContentQuery.cs | 2 ++ src/Umbraco.Infrastructure/PublishedContentQuery.cs | 7 +++++-- .../Extensions/FriendlyPublishedContentExtensions.cs | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Infrastructure/IPublishedContentQuery.cs b/src/Umbraco.Infrastructure/IPublishedContentQuery.cs index 9a5da442c1..0b2eac8752 100644 --- a/src/Umbraco.Infrastructure/IPublishedContentQuery.cs +++ b/src/Umbraco.Infrastructure/IPublishedContentQuery.cs @@ -24,6 +24,8 @@ public interface IPublishedContentQuery IEnumerable ContentAtRoot(); + IEnumerable ContentAtRoot(string? culture) => culture is null ? ContentAtRoot() : throw new NotSupportedException(); + IPublishedContent? Media(int id); IPublishedContent? Media(Guid id); diff --git a/src/Umbraco.Infrastructure/PublishedContentQuery.cs b/src/Umbraco.Infrastructure/PublishedContentQuery.cs index 221a7545ba..0d8b230114 100644 --- a/src/Umbraco.Infrastructure/PublishedContentQuery.cs +++ b/src/Umbraco.Infrastructure/PublishedContentQuery.cs @@ -144,6 +144,9 @@ public class PublishedContentQuery : IPublishedContentQuery public IEnumerable ContentAtRoot() => ItemsAtRoot(_publishedContent); + public IEnumerable ContentAtRoot(string? culture) + => ItemsAtRoot(_publishedContent, culture); + #endregion #region Media @@ -212,8 +215,8 @@ public class PublishedContentQuery : IPublishedContentQuery private IEnumerable ItemsByIds(IPublishedCache? cache, IEnumerable ids) => ids.Select(eachId => ItemById(eachId, cache)).WhereNotNull(); - private static IEnumerable ItemsAtRoot(IPublishedCache? cache) - => cache?.GetAtRoot() ?? Array.Empty(); + private static IEnumerable ItemsAtRoot(IPublishedCache? cache, string? culture = null) + => cache?.GetAtRoot(culture) ?? Array.Empty(); #endregion diff --git a/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs b/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs index 921c815e2f..15c0a7bd6f 100644 --- a/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs @@ -347,7 +347,7 @@ public static class FriendlyPublishedContentExtensions /// /// /// - /// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot + /// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot. /// public static IEnumerable DescendantsOrSelfOfType( this IEnumerable parentNodes, string docTypeAlias, string? culture = null) @@ -375,7 +375,7 @@ public static class FriendlyPublishedContentExtensions /// /// /// - /// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot + /// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot. /// public static IEnumerable DescendantsOrSelf( this IEnumerable parentNodes, From 8ff11e7c6457b7a462bba8b373122d0a8bb409ac Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 22 Sep 2025 11:34:08 +0200 Subject: [PATCH 26/56] Link rendering: Add support for `UrlMode` parameter in `HtmlLocalLinkParser` (port to 16) (#20207) * Add support for UrlMode parameter in HtmlLocalLinkParser (port of #20200 from 13 to 16). * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Kenn Jacobsen --- .../TextStringValueConverter.cs | 2 +- .../Templates/HtmlLocalLinkParser.cs | 20 +- .../MarkdownEditorValueConverter.cs | 2 +- .../RteBlockRenderingValueConverter.cs | 2 +- .../Templates/HtmlLocalLinkParserTests.cs | 241 ++++++++++++++++-- 5 files changed, 233 insertions(+), 34 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs index 8fe15645e1..0a290e7492 100644 --- a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs @@ -40,7 +40,7 @@ public class TextStringValueConverter : PropertyValueConverterBase, IDeliveryApi var sourceString = source.ToString(); // ensures string is parsed for {localLink} and URLs are resolved correctly - sourceString = _linkParser.EnsureInternalLinks(sourceString!, preview); + sourceString = _linkParser.EnsureInternalLinks(sourceString!); sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs index 4714ebcd2e..73aec2e74d 100644 --- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs +++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.RegularExpressions; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; namespace Umbraco.Cms.Core.Templates; @@ -45,17 +46,18 @@ public sealed class HtmlLocalLinkParser /// /// Parses the string looking for the {localLink} syntax and updates them to their correct links. /// - /// - /// - /// + [Obsolete("This method overload is no longer used in Umbraco and delegates to the overload without the preview parameter. Scheduled for removal in Umbraco 18.")] public string EnsureInternalLinks(string text, bool preview) => EnsureInternalLinks(text); /// /// Parses the string looking for the {localLink} syntax and updates them to their correct links. /// - /// - /// - public string EnsureInternalLinks(string text) + public string EnsureInternalLinks(string text) => EnsureInternalLinks(text, UrlMode.Default); + + /// + /// Parses the string looking for the {localLink} syntax and updates them to their correct links. + /// + public string EnsureInternalLinks(string text, UrlMode urlMode) { foreach (LocalLinkTag tagData in FindLocalLinkIds(text)) { @@ -63,8 +65,8 @@ public sealed class HtmlLocalLinkParser { var newLink = tagData.Udi?.EntityType switch { - Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid), - Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid), + Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid, urlMode), + Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid, urlMode), _ => string.Empty, }; @@ -73,7 +75,7 @@ public sealed class HtmlLocalLinkParser } else if (tagData.IntId.HasValue) { - var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value); + var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value, urlMode); text = text.Replace(tagData.TagHref, newLink); } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs index 05c6a8a4f1..ff0962a827 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs @@ -41,7 +41,7 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver var sourceString = source.ToString()!; // ensures string is parsed for {localLink} and URLs are resolved correctly - sourceString = _localLinkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _localLinkParser.EnsureInternalLinks(sourceString); sourceString = _urlParser.EnsureUrls(sourceString); return sourceString; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs index b2c47fc3cb..d39d13e243 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs @@ -135,7 +135,7 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe var sourceString = intermediateValue.Markup; // ensures string is parsed for {localLink} and URLs and media are resolved correctly - sourceString = _linkParser.EnsureInternalLinks(sourceString, preview); + sourceString = _linkParser.EnsureInternalLinks(sourceString); sourceString = _urlParser.EnsureUrls(sourceString); sourceString = _imageSourceParser.EnsureImageSources(sourceString); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs index d1e5e0f494..0aa00b48d6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Templates/HtmlLocalLinkParserTests.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Core.Templates; +using Umbraco.Cms.Core.Web; using Umbraco.Cms.Tests.Common; using Umbraco.Cms.Tests.UnitTests.TestHelpers.Objects; @@ -216,18 +217,204 @@ public class HtmlLocalLinkParserTests var umbracoContextAccessor = new TestUmbracoContextAccessor(); + var umbracoContextFactory = TestUmbracoContextFactory.Create( + umbracoContextAccessor: umbracoContextAccessor); + + using (var reference = umbracoContextFactory.EnsureUmbracoContext()) + { + var contentCache = Mock.Get(reference.UmbracoContext.Content); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + + var mediaCache = Mock.Get(reference.UmbracoContext.Media); + mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); + mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); + + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + mediaUrlProvider, + umbracoContextAccessor); + + var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); + + var output = linkParser.EnsureInternalLinks(input); + + Assert.AreEqual(result, output); + } + } + + [Test] + public void ParseLocalLinks_WithUrlMode_RespectsUrlMode() + { + // Arrange + var input = "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world"; + + // Setup content URL provider that returns different URLs based on UrlMode + var contentUrlProvider = new Mock(); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("http://example.com/absolute-url")); + + var contentType = new PublishedContentType( + Guid.NewGuid(), + 666, + "alias", + PublishedItemType.Content, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var publishedContent = new Mock(); + publishedContent.Setup(x => x.Id).Returns(1234); + publishedContent.Setup(x => x.ContentType).Returns(contentType); + + var umbracoContextAccessor = new TestUmbracoContextAccessor(); var umbracoContextFactory = TestUmbracoContextFactory.Create( umbracoContextAccessor: umbracoContextAccessor); var webRoutingSettings = new WebRoutingSettings(); - var navigationQueryService = new Mock(); - // Guid? parentKey = null; - // navigationQueryService.Setup(x => x.TryGetParentKey(It.IsAny(), out parentKey)).Returns(true); - IEnumerable ancestorKeys = []; - navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny(), out ancestorKeys)).Returns(true); + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + new Mock(), + umbracoContextAccessor); - var publishedContentStatusFilteringService = new Mock(); + using (var reference = umbracoContextFactory.EnsureUmbracoContext()) + { + var contentCache = Mock.Get(reference.UmbracoContext.Content); + contentCache.Setup(x => x.GetById(It.IsAny())).Returns(publishedContent.Object); + + var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); + + // Act + var relativeOutput = linkParser.EnsureInternalLinks(input, UrlMode.Relative); + var absoluteOutput = linkParser.EnsureInternalLinks(input, UrlMode.Absolute); + + // Assert + Assert.AreEqual("hello href=\"/relative-url\" world", relativeOutput); + Assert.AreEqual("hello href=\"http://example.com/absolute-url\" world", absoluteOutput); + } + } + + [TestCase(UrlMode.Default, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:1234}\" world ", "hello href=\"https://example.com/absolute-url\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:1234}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Default, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/absolute-url\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://document/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/relative-url\" world ")] + [TestCase(UrlMode.Default, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + [TestCase(UrlMode.Relative, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + [TestCase(UrlMode.Absolute, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"https://example.com/media/absolute/image.jpg\" world ")] + [TestCase(UrlMode.Auto, "hello href=\"{localLink:umb://media/9931BDE0AAC34BABB838909A7B47570E}\" world ", "hello href=\"/media/relative/image.jpg\" world ")] + public void ParseLocalLinks_WithVariousUrlModes_ReturnsCorrectUrls(UrlMode urlMode, string input, string expectedResult) + { + // Setup content URL provider that returns different URLs based on UrlMode + var contentUrlProvider = new Mock(); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Default, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("https://example.com/absolute-url")); + contentUrlProvider + .Setup(x => x.GetUrl( + It.IsAny(), + UrlMode.Auto, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/relative-url")); + + var contentType = new PublishedContentType( + Guid.NewGuid(), + 666, + "alias", + PublishedItemType.Content, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var publishedContent = new Mock(); + publishedContent.Setup(x => x.Id).Returns(1234); + publishedContent.Setup(x => x.ContentType).Returns(contentType); + + // Setup media URL provider that returns different URLs based on UrlMode + var mediaUrlProvider = new Mock(); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Default, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Relative, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Absolute, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("https://example.com/media/absolute/image.jpg")); + mediaUrlProvider.Setup(x => x.GetMediaUrl( + It.IsAny(), + It.IsAny(), + UrlMode.Auto, + It.IsAny(), + It.IsAny())) + .Returns(UrlInfo.Url("/media/relative/image.jpg")); + + var mediaType = new PublishedContentType( + Guid.NewGuid(), + 777, + "image", + PublishedItemType.Media, + Enumerable.Empty(), + Enumerable.Empty(), + ContentVariation.Nothing); + var media = new Mock(); + media.Setup(x => x.ContentType).Returns(mediaType); + + var umbracoContextAccessor = new TestUmbracoContextAccessor(); + var umbracoContextFactory = TestUmbracoContextFactory.Create( + umbracoContextAccessor: umbracoContextAccessor); + + var webRoutingSettings = new WebRoutingSettings(); + + var publishedUrlProvider = CreatePublishedUrlProvider( + contentUrlProvider, + mediaUrlProvider, + umbracoContextAccessor); using (var reference = umbracoContextFactory.EnsureUmbracoContext()) { @@ -239,25 +426,35 @@ public class HtmlLocalLinkParserTests mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); mediaCache.Setup(x => x.GetById(It.IsAny())).Returns(media.Object); - var publishStatusQueryService = new Mock(); - publishStatusQueryService - .Setup(x => x.IsDocumentPublished(It.IsAny(), It.IsAny())) - .Returns(true); - - var publishedUrlProvider = new UrlProvider( - umbracoContextAccessor, - Options.Create(webRoutingSettings), - new UrlProviderCollection(() => new[] { contentUrlProvider.Object }), - new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }), - Mock.Of(), - navigationQueryService.Object, - publishedContentStatusFilteringService.Object); - var linkParser = new HtmlLocalLinkParser(publishedUrlProvider); - var output = linkParser.EnsureInternalLinks(input); + var output = linkParser.EnsureInternalLinks(input, urlMode); - Assert.AreEqual(result, output); + Assert.AreEqual(expectedResult, output); } } + + private static UrlProvider CreatePublishedUrlProvider( + Mock contentUrlProvider, + Mock mediaUrlProvider, + TestUmbracoContextAccessor umbracoContextAccessor) + { + var navigationQueryService = new Mock(); + IEnumerable ancestorKeys = []; + navigationQueryService.Setup(x => x.TryGetAncestorsKeys(It.IsAny(), out ancestorKeys)).Returns(true); + + var publishStatusQueryService = new Mock(); + publishStatusQueryService + .Setup(x => x.IsDocumentPublished(It.IsAny(), It.IsAny())) + .Returns(true); + + return new UrlProvider( + umbracoContextAccessor, + Options.Create(new WebRoutingSettings()), + new UrlProviderCollection(() => new[] { contentUrlProvider.Object }), + new MediaUrlProviderCollection(() => new[] { mediaUrlProvider.Object }), + Mock.Of(), + navigationQueryService.Object, + new Mock().Object); + } } From 79de4e3871f7efbe96a9093313e9040d12d922fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kr=C3=BCger?= <93977820+OskarKruger@users.noreply.github.com> Date: Mon, 22 Sep 2025 12:35:52 +0200 Subject: [PATCH 27/56] Tiptap RTE: Adds hover and focus border input states (#20172) * added hovering and focus border to RTE * fix main to OG * fix to main again * I'm going to cry * added hovering and focus border to RTE * fix indentation * Refactored to set `--umb-tiptap-edge-border-color` variable so that the toolbar and statusbar can pick up the state changes. * Applies `transition` to the toolbar/statusbar components --------- Co-authored-by: Oskar kruger Co-authored-by: leekelleher --- .../input-tiptap/input-tiptap.element.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts index 2e898bf7e8..0e6132cefc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/input-tiptap/input-tiptap.element.ts @@ -266,6 +266,15 @@ export class UmbInputTiptapElement extends UmbFormControlMixin Date: Mon, 22 Sep 2025 12:51:27 +0200 Subject: [PATCH 28/56] Permissions: Fix removal of check on removal the final admin user (closes #19917) (#19921) Reworks update of user groups on a user by updating in place rather than deleting and re-adding. Ensure user groups affected by the update are invalidated in the repository cache. Co-authored-by: Kenn Jacobsen --- .../Repositories/Implement/UserRepository.cs | 85 ++++++++++++++++--- .../Services/UserServiceCrudTests.Update.cs | 31 +++++++ .../Services/UserServiceTests.cs | 36 ++++++++ 3 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs index 88f9540a4f..882b678a2e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs @@ -671,6 +671,15 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 protected override void PersistDeletedItem(IUser entity) { + // Clear user group caches for any user groups associated with the deleted user. + // We need to do this because the count of the number of users in the user group is cached + // along with the user group, and if we've made changes to the user groups assigned to the user, + // the count for the groups need to be refreshed. + foreach (IReadOnlyUserGroup group in entity.Groups) + { + ClearRepositoryCacheForUserGroup(group.Id); + } + IEnumerable deletes = GetDeleteClauses(); foreach (var delete in deletes) { @@ -713,8 +722,8 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 if (entity.IsPropertyDirty("Groups")) { // lookup all assigned - List? assigned = entity.Groups == null || entity.Groups.Any() == false - ? new List() + List? assigned = entity.Groups.Any() is false + ? [] : Database.Fetch( "SELECT * FROM umbracoUserGroup WHERE userGroupAlias IN (@aliases)", new { aliases = entity.Groups.Select(x => x.Alias) }); @@ -724,6 +733,15 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id }; Database.Insert(dto); } + + // Clear user group caches for the user groups associated with the new user. + // We need to do this because the count of the number of users in the user group is cached + // along with the user group, and if we've made changes to the user groups assigned to the user, + // the count for the groups need to be refreshed. + foreach (IReadOnlyUserGroup group in entity.Groups) + { + ClearRepositoryCacheForUserGroup(group.Id); + } } entity.ResetDirtyProperties(); @@ -836,27 +854,66 @@ SELECT 4 AS [Key], COUNT(id) AS [Value] FROM umbracoUser WHERE userDisabled = 0 if (entity.IsPropertyDirty("Groups")) { - //lookup all assigned - List? assigned = entity.Groups == null || entity.Groups.Any() == false - ? new List() - : Database.Fetch( - "SELECT * FROM umbracoUserGroup WHERE userGroupAlias IN (@aliases)", - new { aliases = entity.Groups.Select(x => x.Alias) }); + // Get all user groups Ids currently assigned to the user. + var existingUserGroupIds = Database.Fetch( + "WHERE UserId = @UserId", + new { UserId = entity.Id }) + .Select(x => x.UserGroupId) + .ToList(); - //first delete all - // TODO: We could do this a nicer way instead of "Nuke and Pave" - Database.Delete("WHERE UserId = @UserId", new { UserId = entity.Id }); + // Get the user groups Ids that need to be removed and added. + var userGroupsIdsToRemove = existingUserGroupIds + .Except(entity.Groups.Select(x => x.Id)) + .ToList(); + var userGroupIdsToAdd = entity.Groups + .Select(x => x.Id) + .Except(existingUserGroupIds) + .ToList(); - foreach (UserGroupDto? groupDto in assigned) + // Remove user groups that are no longer assigned to the user. + if (userGroupsIdsToRemove.Count > 0) { - var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id }; - Database.Insert(dto); + Database.Delete( + "WHERE UserId = @UserId AND UserGroupId IN (@userGroupIds)", + new { UserId = entity.Id, userGroupIds = userGroupsIdsToRemove }); + } + + // Add user groups that are newly assigned to the user. + if (userGroupIdsToAdd.Count > 0) + { + IEnumerable user2UserGroupDtos = userGroupIdsToAdd + .Select(userGroupId => new User2UserGroupDto + { + UserGroupId = userGroupId, + UserId = entity.Id, + }); + Database.InsertBulk(user2UserGroupDtos); + } + + // Clear user group caches for any user group that have been removed or added. + // We need to do this because the count of the number of users in the user group is cached + // along with the user group, and if we've made changes to the user groups assigned to the user, + // the count for the groups need to be refreshed. + var userGroupIdsToRefresh = userGroupsIdsToRemove + .Union(userGroupIdsToAdd) + .ToList(); + foreach (int userGroupIdToRefresh in userGroupIdsToRefresh) + { + ClearRepositoryCacheForUserGroup(userGroupIdToRefresh); } } entity.ResetDirtyProperties(); } + private void ClearRepositoryCacheForUserGroup(int id) + { + IAppPolicyCache userGroupCache = AppCaches.IsolatedCaches.GetOrCreate(); + + string cacheKey = RepositoryCacheKeys.GetKey(id); + userGroupCache.Clear(cacheKey); + } + private void AddingOrUpdateStartNodes(IEntity entity, IEnumerable current, UserStartNodeDto.StartNodeTypeValue startNodeType, int[]? entityStartIds) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs index f2d8ab68b8..d9a1fed95c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/UserServiceCrudTests.Update.cs @@ -398,4 +398,35 @@ internal sealed partial class UserServiceCrudTests Assert.IsNotNull(updatedUser.StartMediaIds); Assert.IsEmpty(updatedUser.StartMediaIds); } + + [TestCase(false, false)] + [TestCase(true, true)] + public async Task Cannot_Remove_Admin_Group_From_Only_Admin_User(bool createAdditionalAdminUser, bool expectSuccess) + { + var userService = CreateUserService(securitySettings: new SecuritySettings { UsernameIsEmail = false }); + + if (createAdditionalAdminUser) + { + var (updateModel, _) = await CreateUserForUpdate(userService); + updateModel.UserGroupKeys = new HashSet { Constants.Security.AdminGroupKey }; + var updateResult = await userService.UpdateAsync(Constants.Security.SuperUserKey, updateModel); + Assert.IsTrue(updateResult.Success); + } + + var adminUser = await userService.GetAsync(Constants.Security.SuperUserKey); + var adminUserUpdateModel = await MapUserToUpdateModel(adminUser); + adminUserUpdateModel.Email = "admin@test.com"; + adminUserUpdateModel.UserGroupKeys = new HashSet { Constants.Security.EditorGroupKey }; + var adminUserUpdateResult = await userService.UpdateAsync(Constants.Security.SuperUserKey, adminUserUpdateModel); + + if (expectSuccess) + { + Assert.IsTrue(adminUserUpdateResult.Success); + } + else + { + Assert.IsFalse(adminUserUpdateResult.Success); + Assert.AreEqual(UserOperationStatus.AdminUserGroupMustNotBeEmpty, adminUserUpdateResult.Status); + } + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserServiceTests.cs index d5ef54ece6..6c8e737493 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/UserServiceTests.cs @@ -1019,6 +1019,42 @@ internal sealed class UserServiceTests : UmbracoIntegrationTest } } + [Test] + public async Task Can_Assign_And_Get_Groups_For_User() + { + // Arrange + var (user, userGroup1) = await CreateTestUserAndGroup(); + var userGroup2 = await CreateTestUserGroup("testGroup2", "Test Group 2"); + + // Act & Assert + user = UserService.GetByUsername(user.Username); + + Assert.IsNotNull(user); + Assert.AreEqual(1, user.Groups.Count()); + Assert.AreEqual(userGroup1.Alias, user.Groups.First().Alias); + + // - add second group + user.AddGroup(userGroup2); + UserService.Save(user); + user = UserService.GetByUsername(user.Username); + Assert.AreEqual(2, user.Groups.Count()); + + // - remove first group + user.RemoveGroup(userGroup1.Alias); + UserService.Save(user); + user = UserService.GetByUsername(user.Username); + Assert.AreEqual(1, user.Groups.Count()); + Assert.AreEqual(userGroup2.Alias, user.Groups.First().Alias); + + // - remove second group and add first + user.RemoveGroup(userGroup2.Alias); + user.AddGroup(userGroup1.ToReadOnlyGroup()); + UserService.Save(user); + user = UserService.GetByUsername(user.Username); + Assert.AreEqual(1, user.Groups.Count()); + Assert.AreEqual(userGroup1.Alias, user.Groups.First().Alias); + } + [TestCase(UserKind.Default, UserClientCredentialsOperationStatus.InvalidUser)] [TestCase(UserKind.Api, UserClientCredentialsOperationStatus.Success)] public async Task Can_Assign_ClientId_To_Api_User(UserKind userKind, UserClientCredentialsOperationStatus expectedResult) From 410fc1900d198144acc4a5d1e288389ae577f4b4 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Mon, 22 Sep 2025 13:34:44 +0200 Subject: [PATCH 29/56] Cherry-pick of #20129 to 16 (part 2). --- src/Umbraco.Web.Common/UmbracoHelper.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Umbraco.Web.Common/UmbracoHelper.cs b/src/Umbraco.Web.Common/UmbracoHelper.cs index 9b1a9c6275..e37080e9a5 100644 --- a/src/Umbraco.Web.Common/UmbracoHelper.cs +++ b/src/Umbraco.Web.Common/UmbracoHelper.cs @@ -309,8 +309,19 @@ public class UmbracoHelper /// If an identifier does not match an existing content, it will be missing in the returned value. public IEnumerable Content(IEnumerable ids) => _publishedContentQuery.Content(ids); + /// + /// Gets the documents at root. + /// + /// A collection of found at the root. public IEnumerable ContentAtRoot() => _publishedContentQuery.ContentAtRoot(); + /// + /// Gets the documents at root. + /// + /// The requested culture. + /// A collection of found at the root. + public IEnumerable ContentAtRoot(string? culture) => _publishedContentQuery.ContentAtRoot(culture); + #endregion #region Media From 3c592ad2cd944cd1cbfc89b15d77e58113f8da0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 09:00:06 +0000 Subject: [PATCH 30/56] Bump vite from 7.1.3 to 7.1.5 in /src/Umbraco.Web.UI.Login Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.3 to 7.1.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.1.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Login/package-lock.json | 20 ++++++++++---------- src/Umbraco.Web.UI.Login/package.json | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Web.UI.Login/package-lock.json b/src/Umbraco.Web.UI.Login/package-lock.json index 130dc369c4..0c752d9313 100644 --- a/src/Umbraco.Web.UI.Login/package-lock.json +++ b/src/Umbraco.Web.UI.Login/package-lock.json @@ -10,7 +10,7 @@ "@umbraco-cms/backoffice": "16.2.0-rc", "msw": "^2.7.0", "typescript": "^5.9.2", - "vite": "^7.1.3" + "vite": "^7.1.5" }, "engines": { "node": ">=22", @@ -4380,14 +4380,14 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -4513,9 +4513,9 @@ } }, "node_modules/vite": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", - "integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", + "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4524,7 +4524,7 @@ "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", - "tinyglobby": "^0.2.14" + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" diff --git a/src/Umbraco.Web.UI.Login/package.json b/src/Umbraco.Web.UI.Login/package.json index 017961f872..684b8b2892 100644 --- a/src/Umbraco.Web.UI.Login/package.json +++ b/src/Umbraco.Web.UI.Login/package.json @@ -18,7 +18,7 @@ "@umbraco-cms/backoffice": "16.2.0-rc", "msw": "^2.7.0", "typescript": "^5.9.2", - "vite": "^7.1.3" + "vite": "^7.1.5" }, "msw": { "workerDirectory": [ From e29e612e4658ac53428cb2da132e832e548ed5dc Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 22 Sep 2025 16:13:00 +0200 Subject: [PATCH 31/56] Caching: Don't remove null values from hybrid cache to avoid broken content references repeatedly requiring a database hit (closes #18892) (#20209) * Implement initial fix * Revert "Implement initial fix" This reverts commit 05e5803ebaa6330979e9a4ff6a4b343e74957ca0. * Don't remove null cache values, they can always get removed when clearing cache --- .../Services/DocumentCacheService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index 6457773e31..2d41bc0a12 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -132,10 +132,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService GetEntryOptions(key, preview), GenerateTags(key)); - // We don't want to cache removed items, this may cause issues if the L2 serializer changes. if (contentCacheNode is null) { - await _hybridCache.RemoveAsync(cacheKey); return null; } From fb0f719c7df9da96c514f1ed5bafd511e7218d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Mon, 22 Sep 2025 17:19:28 +0200 Subject: [PATCH 32/56] Shortcuts: Implement context and a few shortcuts of interest (#20204) * setup files * allow Unproviding as a valid word * setup context * declare new module * clean up on destroy * implement keydown listener * rename to all * Revert "rename to all" This reverts commit 5384408d5f70111b63a5e07b9b20d6536c530c00. * revert shortcuts revert * move view initialization to submittable workspace base * comment on destroy thingy * submit workspace shortcut * rename to action * observe parent activation to make sure children follows along. * fix comment to make AI happy * implement modal view and titles * fix getting title from token * rename context alias * use controller not context here * provide modal view at modal element * implement view context at app level * Refactor view inheritance logic * reverse children to be activated loop * note on global shortcuts * additional note --- .vscode/settings.json | 5 +- src/Umbraco.Web.UI.Client/package.json | 9 +- .../src/apps/app/app.element.ts | 3 + .../content-type-workspace-context-base.ts | 3 - .../content-detail-workspace-base.ts | 4 - .../views/edit/content-editor.element.ts | 15 +- .../property-type-workspace.context.ts | 24 +- .../core/modal/component/modal.element.ts | 1 + .../core/modal/context/modal.context.ts | 11 +- .../src/packages/core/modal/types.ts | 5 + .../repository/repository-items.manager.ts | 2 +- .../packages/core/shortcut/context/index.ts | 3 + .../context/shortcut.context-token.ts | 4 + .../core/shortcut/context/shortcut.context.ts | 10 + .../shortcut/context/shortcut.controller.ts | 189 +++++++++++++ .../src/packages/core/shortcut/index.ts | 2 + .../src/packages/core/shortcut/types.ts | 12 + .../src/packages/core/view/context/index.ts | 1 + .../core/view/context/view.controller.ts | 249 ++++++++++-------- .../src/packages/core/vite.config.ts | 9 +- .../entity-named-detail-workspace-base.ts | 3 - .../default/default-workspace.context.ts | 8 +- .../submittable-workspace-context-base.ts | 10 + .../save-modal/document-save-modal.element.ts | 35 +-- .../modal/document-publish-modal.element.ts | 54 ++-- .../publishing/workspace-context/constants.ts | 2 + .../document-publishing.workspace-context.ts | 26 +- .../workspace/logviewer-workspace.context.ts | 5 + .../current-user/current-user-modal.token.ts | 1 + .../webhook/webhook/workspace/manifests.ts | 2 +- src/Umbraco.Web.UI.Client/tsconfig.json | 9 +- 31 files changed, 517 insertions(+), 199 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d4441fffc..662f47d2d0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { - "cSpell.words": ["unprovide"], + "cSpell.words": [ + "unprovide", + "Unproviding" + ], "eslint.useFlatConfig": true, "eslint.workingDirectories": [ "./src/Umbraco.Web.UI.Client/", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index d3e356ad7b..3c831031ac 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -31,9 +31,9 @@ "./collection": "./dist-cms/packages/core/collection/index.js", "./components": "./dist-cms/packages/core/components/index.js", "./const": "./dist-cms/packages/core/const/index.js", + "./content-picker": "./dist-cms/packages/property-editors/content-picker/index.js", "./content-type": "./dist-cms/packages/content/content-type/index.js", "./content": "./dist-cms/packages/content/content/index.js", - "./content-picker": "./dist-cms/packages/property-editors/content-picker/index.js", "./culture": "./dist-cms/packages/core/culture/index.js", "./current-user": "./dist-cms/packages/user/current-user/index.js", "./dashboard": "./dist-cms/packages/core/dashboard/index.js", @@ -47,8 +47,8 @@ "./entity-action": "./dist-cms/packages/core/entity-action/index.js", "./entity-bulk-action": "./dist-cms/packages/core/entity-bulk-action/index.js", "./entity-create-option-action": "./dist-cms/packages/core/entity-create-option-action/index.js", - "./entity": "./dist-cms/packages/core/entity/index.js", "./entity-item": "./dist-cms/packages/core/entity-item/index.js", + "./entity": "./dist-cms/packages/core/entity/index.js", "./event": "./dist-cms/packages/core/event/index.js", "./extension-registry": "./dist-cms/packages/core/extension-registry/index.js", "./health-check": "./dist-cms/packages/health-check/index.js", @@ -68,9 +68,9 @@ "./media-type": "./dist-cms/packages/media/media-types/index.js", "./media": "./dist-cms/packages/media/media/index.js", "./member-group": "./dist-cms/packages/members/member-group/index.js", + "./member-public-access": "./dist-cms/packages/members/member-public-access/index.js", "./member-type": "./dist-cms/packages/members/member-type/index.js", "./member": "./dist-cms/packages/members/member/index.js", - "./member-public-access": "./dist-cms/packages/members/member-public-access/index.js", "./menu": "./dist-cms/packages/core/menu/index.js", "./modal": "./dist-cms/packages/core/modal/index.js", "./models": "./dist-cms/packages/core/models/index.js", @@ -96,9 +96,10 @@ "./search": "./dist-cms/packages/search/index.js", "./section": "./dist-cms/packages/core/section/index.js", "./segment": "./dist-cms/packages/segment/index.js", - "./server": "./dist-cms/packages/core/server/index.js", "./server-file-system": "./dist-cms/packages/core/server-file-system/index.js", + "./server": "./dist-cms/packages/core/server/index.js", "./settings": "./dist-cms/packages/settings/index.js", + "./shortcut": "./dist-cms/packages/core/shortcut/index.js", "./sorter": "./dist-cms/packages/core/sorter/index.js", "./static-file": "./dist-cms/packages/static-file/index.js", "./store": "./dist-cms/packages/core/store/index.js", diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts index 7a17520579..bbe8d7b98e 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts @@ -22,6 +22,7 @@ import { filter, first, firstValueFrom } from '@umbraco-cms/backoffice/external/ import { hasOwnOpener, redirectToStoredPath } from '@umbraco-cms/backoffice/utils'; import { UmbApiInterceptorController } from '@umbraco-cms/backoffice/resources'; import { umbHttpClient } from '@umbraco-cms/backoffice/http-client'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import './app-logo.element.js'; import './app-oauth.element.js'; @@ -159,6 +160,8 @@ export class UmbAppElement extends UmbLitElement { new UmbContextDebugController(this); new UmbNetworkConnectionStatusManager(this); + + new UmbViewContext(this, null); } override connectedCallback(): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index 9f72381411..3f46cbe18f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -7,7 +7,6 @@ import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; -import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import type { Observable } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; @@ -53,8 +52,6 @@ export abstract class UmbContentTypeWorkspaceContextBase< public readonly structure: UmbContentTypeStructureManager; - public readonly view = new UmbViewContext(this, null); - constructor(host: UmbControllerHost, args: UmbContentTypeWorkspaceContextArgs) { super(host, args); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index ee60251f62..2714250e54 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -33,7 +33,6 @@ import { } from '@umbraco-cms/backoffice/property'; import { UmbSegmentCollectionRepository } from '@umbraco-cms/backoffice/segment'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UMB_VALIDATION_CONTEXT, @@ -145,9 +144,6 @@ export abstract class UmbContentDetailWorkspaceContextBase< readonly collection: UmbContentCollectionManager; - /* View */ - readonly view = new UmbViewContext(this, null); - /* Variant Options */ // TODO: Optimize this so it uses either a App Language Context? [NL] #languageRepository = new UmbLanguageCollectionRepository(this); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index 731389f7d0..85e79ba0fa 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -7,7 +7,7 @@ import { } from '@umbraco-cms/backoffice/content-type'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UMB_VIEW_CONTEXT, UmbViewContext } from '@umbraco-cms/backoffice/view'; +import { UMB_VIEW_CONTEXT, UmbViewController } from '@umbraco-cms/backoffice/view'; import type { PageComponent, UmbRoute, @@ -31,7 +31,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements @state() private _hasRootProperties = false; */ - #viewContext?: UmbViewContext; + #viewContext?: typeof UMB_VIEW_CONTEXT.TYPE; @state() private _hasRootGroups = false; @@ -51,7 +51,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements @state() private _hintMap: Map = new Map(); - #tabViewContexts: Array = []; + #tabViewContexts: Array = []; #structureManager?: UmbContentTypeStructureManager; @@ -150,7 +150,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements #createViewContext(viewAlias: string | null, tabName: string) { if (!this.#tabViewContexts.find((context) => context.viewAlias === viewAlias)) { - const view = new UmbViewContext(this, viewAlias); + const view = new UmbViewController(this, viewAlias); this.#tabViewContexts.push(view); if (viewAlias === null) { @@ -176,7 +176,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements } } - #currentProvidedView?: UmbViewContext; + #currentProvidedView?: UmbViewController; #provideViewContext(viewAlias: string | null, component: PageComponent) { const view = this.#tabViewContexts.find((context) => context.viewAlias === viewAlias); @@ -188,6 +188,11 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements throw new Error(`View context with alias ${viewAlias} not found`); } this.#currentProvidedView = view; + // ViewAlias null is only for the root tab, therefor we can implement this hack. + if (viewAlias === null) { + // Specific hack for the Generic tab to only show its name if there are other tabs. + view.setBrowserTitle(this._tabs && this._tabs?.length > 0 ? '#general_generic' : undefined); + } view.provideAt(component as any); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts index bd4e7ed78b..086ac32e49 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts @@ -6,6 +6,7 @@ import type { UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext, ManifestWorkspace, + UmbNamableWorkspaceContext, } from '@umbraco-cms/backoffice/workspace'; import { UmbSubmittableWorkspaceContextBase, @@ -26,7 +27,7 @@ type PropertyTypeDataModel = UmbPropertyTypeScaffoldModel; export class UmbPropertyTypeWorkspaceContext extends UmbSubmittableWorkspaceContextBase - implements UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext + implements UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext, UmbNamableWorkspaceContext { // Just for context token safety: public readonly IS_PROPERTY_TYPE_WORKSPACE_CONTEXT = true; @@ -62,11 +63,22 @@ export class UmbPropertyTypeWorkspaceContext this.validationContext = new UmbValidationContext(this); this.addValidationContext(this.validationContext); - this.observe(this.unique, (unique) => { - if (unique) { - this.validationContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique })); - } - }); + this.observe( + this.unique, + (unique) => { + if (unique) { + this.validationContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique })); + } + }, + null, + ); + this.observe( + this.name, + (name) => { + this.view.setBrowserTitle(name); + }, + null, + ); this.#init = this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, (context) => { this.#contentTypeContext = context; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts index 67f35e0f7f..e9067ba9cb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts @@ -55,6 +55,7 @@ export class UmbModalElement extends UmbLitElement { } this.#modalContext.addEventListener('umb:destroy', this.#onContextDestroy); + this.#modalContext.view.provideAt(this); this.element = await this.#createContainerElement(); // Makes sure that the modal triggers the reject of the context promise when it is closed by pressing escape. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts index b27388d406..870b849d60 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts @@ -2,10 +2,11 @@ import { UmbModalToken } from '../token/modal-token.js'; import type { UmbModalConfig, UmbModalType } from '../types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; -import { umbDeepMerge } from '@umbraco-cms/backoffice/utils'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { umbDeepMerge } from '@umbraco-cms/backoffice/utils'; import { UmbId } from '@umbraco-cms/backoffice/id'; import { UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbViewController } from '@umbraco-cms/backoffice/view'; import { UMB_ROUTE_CONTEXT } from '@umbraco-cms/backoffice/router'; import type { ElementLoaderProperty } from '@umbraco-cms/backoffice/extension-api'; import type { IRouterSlot } from '@umbraco-cms/backoffice/router'; @@ -61,6 +62,8 @@ export class UmbModalContext< #size = new UmbStringState('small'); public readonly size = this.#size.asObservable(); + public readonly view; + constructor( host: UmbControllerHost, modalAlias: string | UmbModalToken, @@ -71,6 +74,9 @@ export class UmbModalContext< this.router = args.router ?? null; this.alias = modalAlias; + this.view = new UmbViewController(this, modalAlias.toString()); + + let title: string | undefined = undefined; let size = 'small'; if (this.alias instanceof UmbModalToken) { @@ -78,8 +84,11 @@ export class UmbModalContext< size = this.alias.getDefaultModal()?.size ?? size; this.element = this.alias.getDefaultModal()?.element || this.element; this.backdropBackground = this.alias.getDefaultModal()?.backdropBackground || this.backdropBackground; + title = this.alias.getDefaultModal()?.title ?? undefined; } + this.view.setBrowserTitle(title); + this.type = args.modal?.type || this.type; size = args.modal?.size ?? size; this.element = args.modal?.element || this.element; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts index 3fe7068550..bd3d4a397c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts @@ -35,4 +35,9 @@ export interface UmbModalConfig { * Set the background property of the modal backdrop */ backdropBackground?: string; + + /** + * Set the title of the modal, this is used as Browser Title + */ + title?: string; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts index fc9f7fd124..6e3121f680 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts @@ -1,4 +1,5 @@ import type { UmbItemRepository } from './item/index.js'; +import type { UmbRepositoryItemsStatus } from './types.js'; import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; @@ -7,7 +8,6 @@ import { UmbExtensionApiInitializer } from '@umbraco-cms/backoffice/extension-ap import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbEntityUpdatedEvent } from '@umbraco-cms/backoffice/entity-action'; -import type { UmbRepositoryItemsStatus } from './types.js'; const ObserveRepositoryAlias = Symbol(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts new file mode 100644 index 0000000000..54d4d1fd8a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts @@ -0,0 +1,3 @@ +export * from './shortcut.context-token.js'; +export * from './shortcut.context.js'; +export * from './shortcut.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts new file mode 100644 index 0000000000..c682a7d1b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbShortcutController } from './shortcut.controller.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_SHORTCUT_CONTEXT = new UmbContextToken('UmbShortcutContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts new file mode 100644 index 0000000000..fd3144d627 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts @@ -0,0 +1,10 @@ +import { UMB_SHORTCUT_CONTEXT } from './shortcut.context-token.js'; +import { UmbShortcutController } from './shortcut.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbShortcutContext extends UmbShortcutController { + constructor(host: UmbControllerHost) { + super(host); + this.provideContext(UMB_SHORTCUT_CONTEXT, this as unknown as UmbShortcutContext); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts new file mode 100644 index 0000000000..8e79808ae0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts @@ -0,0 +1,189 @@ +import type { UmbShortcut } from '../types.js'; +import { UMB_SHORTCUT_CONTEXT } from './shortcut.context-token.js'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; + +type IncomingShortcutType = UmbPartialSome; + +const IsMac = navigator.userAgent ? /Mac/i.test(navigator.userAgent) : navigator.platform.toUpperCase().includes('MAC'); + +export class UmbShortcutController extends UmbControllerBase { + // + #inUnprovidingState = false; + + #parent?: UmbShortcutController; + + readonly #shortcuts = new UmbArrayState([], (x) => x.unique); + public readonly all = this.#shortcuts.asObservable(); + + constructor(host: UmbControllerHost) { + super(host); + + this.#shortcuts.sortBy((a, b) => (b.weight || 0) - (a.weight || 0)); + } + + #providerCtrl?: UmbContextProviderController; + #currentProvideHost?: UmbClassInterface; + /** + * Provide this validation context to a specific controller host. + * This can be used to Host a validation context in a Workspace, but provide it on a certain scope, like a specific Workspace View. + * @param {UmbClassInterface} controllerHost - The controller host to provide this validation context to. + */ + provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_SHORTCUT_CONTEXT, this as any); + } + + unprovide(): void { + if (this.#providerCtrl) { + // We need to set this in Unprovide state, so this context can be provided again later. + this.#inUnprovidingState = true; + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + this.#inUnprovidingState = false; + this.#currentProvideHost = undefined; + } + } + + inherit(): void { + this.consumeContext(UMB_SHORTCUT_CONTEXT, (parent) => { + this.inheritFrom(parent); + }).skipHost(); + // Notice skipHost ^^, this is because we do not want it to consume it self, as this would be a match for this consumption, instead we will look at the parent and above. [NL] + } + + inheritFrom(parent: UmbShortcutController | undefined): void { + if (this.#parent === parent) return; + this.#parent = parent; + } + + initiateChange() { + this.#shortcuts.mute(); + } + finishChange() { + this.#shortcuts.unmute(); + } + + /** + * Add a new hint + * @param {IncomingShortcutType} shortcut - The hint to add + * @returns {UmbShortcut['unique']} Unique value of the hint + */ + addOne(shortcut: IncomingShortcutType): string | symbol { + const newShortcut = { ...shortcut } as unknown as UmbShortcut; + newShortcut.unique ??= Symbol(); + newShortcut.weight ??= 0; + newShortcut.modifier ??= false; + newShortcut.shift ??= false; + newShortcut.alt ??= false; + this.#shortcuts.appendOne(newShortcut); + return shortcut.unique!; + } + + /** + * Add multiple rules + * @param {IncomingShortcutType[]} shortcuts - Array of hints to add + */ + add(shortcuts: IncomingShortcutType[]) { + this.#shortcuts.mute(); + shortcuts.forEach((hint) => this.addOne(hint)); + this.#shortcuts.unmute(); + } + + /** + * Remove a hint + * @param {UmbShortcut['unique']} unique Unique value of the hint to remove + */ + removeOne(unique: UmbShortcut['unique']) { + this.#shortcuts.removeOne(unique); + } + + /** + * Remove multiple hints + * @param {UmbShortcut['unique'][]} uniques Array of unique values to remove + */ + remove(uniques: UmbShortcut['unique'][]) { + this.#shortcuts.remove(uniques); + } + + /** + * Check if a hint exists + * @param {UmbShortcut['unique']} unique Unique value of the hint to check + * @returns {boolean} True if the hint exists, false otherwise + */ + has(unique: UmbShortcut['unique']): boolean { + return this.#shortcuts.getHasOne(unique); + } + + /** + * Get all hints + * @returns {UmbShortcut[]} Array of hints + */ + getAll(): UmbShortcut[] { + return this.#shortcuts.getValue(); + } + + /** + * Get all hints + * @param key + * @param modifier + * @param shift + * @param alt + * @returns {UmbShortcut[]} Array of hints + */ + findShortcut(key: string, modifier: boolean, shift: boolean = false, alt: boolean = false): UmbShortcut | undefined { + const shortcuts = this.#shortcuts.getValue(); + for (const s of shortcuts) { + if (s.key.toLowerCase() === key.toLowerCase() && s.modifier === modifier && s.shift === shift && s.alt === alt) { + return s; + } + } + + return undefined; + } + + /** + * Clear all hints + */ + clear(): void { + this.#shortcuts.setValue([]); + } + + activate() { + window.addEventListener('keydown', this.#onKeyDown); + } + + deactivate() { + window.removeEventListener('keydown', this.#onKeyDown); + } + + #onKeyDown = (e: KeyboardEvent) => { + const keyDown = e.key.toLowerCase(); + const modifierDown = IsMac ? e.metaKey : e.ctrlKey; + + const shortcut = this.findShortcut(keyDown, modifierDown, e.shiftKey, e.altKey); + if (shortcut) { + e.preventDefault(); + shortcut.action(); + } + }; + + override destroy(): void { + super.destroy(); + if (this.#inUnprovidingState === true) { + // TODO: What is it i'm doing here, check if it actually makes sense, if so add a comment on why [NL] + return; + } + this.unprovide(); + this.#parent = undefined; + + this.#shortcuts.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts new file mode 100644 index 0000000000..66e7bbbc85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts @@ -0,0 +1,2 @@ +export * from './context/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts new file mode 100644 index 0000000000..a645cdb6a7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts @@ -0,0 +1,12 @@ +export interface UmbShortcut { + unique: string | symbol; + key: string; + modifier: boolean; + shift: boolean; + alt: boolean; + label?: string; + weight?: number; + action: () => void | Promise; + // TODO: Consider implementing a global option, to make a shortcut be available despite children setting up their own inheritance scopes. [NL] + // TODO: Addition thought, also a bit dangerous cause how do you know the interest of the children. [NL] +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts index 3fff04337f..49147ec12b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts @@ -1,2 +1,3 @@ +export * from './view.controller.js'; export * from './view.context.js'; export * from './view.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts index 0375dcdfb4..087ba4f583 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -1,10 +1,6 @@ +import { UmbShortcutController } from '../../shortcut/context/shortcut.controller.js'; import { UMB_VIEW_CONTEXT } from './view.context-token.js'; -import { - UmbBooleanState, - UmbClassState, - UmbStringState, - mergeObservables, -} from '@umbraco-cms/backoffice/observable-api'; +import { UmbClassState, UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import { UmbHintController } from '@umbraco-cms/backoffice/hint'; import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; @@ -14,12 +10,7 @@ import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -const ObserveParentActiveCtrlAlias = Symbol(); - /** - * - * TODO: - * Include Shortcuts * * The View Context handles the aspects of three Features: * Browser Titles — Provide a title for this view and it will be set or joint with parent views depending on the inheritance setting. @@ -28,6 +19,8 @@ const ObserveParentActiveCtrlAlias = Symbol(); * */ export class UmbViewController extends UmbControllerBase { + // + static #ActiveView?: UmbViewController; // #attached = false; #providerCtrl?: UmbContextProviderController; @@ -37,13 +30,34 @@ export class UmbViewController extends UmbControllerBase { // State used to know if the context can be auto activated when attached. #autoActivate = true; - #active = new UmbBooleanState(false); - public readonly active = this.#active.asObservable(); + #active = false; get isActive() { - return this.#active.getValue(); + return this.#active; } - #hasActiveChild = false; - #inherit?: boolean; + #setActive() { + this.#active = true; + if (this.#inherit) { + // Secure the parent in the inheritance chain is active. + this.#parentView?._internal_activate(); + } else { + // This is for a single, or top level of the inheritance chain, so we can disable the previous active view. + if (UmbViewController.#ActiveView && UmbViewController.#ActiveView !== this) { + UmbViewController.#ActiveView._internal_deactivate(); + UmbViewController.#ActiveView = undefined; + } + UmbViewController.#ActiveView = this; + } + } + #removeActive() { + this.#active = false; + if (!this.#inherit) { + if (UmbViewController.#ActiveView === this) { + UmbViewController.#ActiveView = undefined; + } + } + } + + #inherit = false; #explicitInheritance?: boolean; #parentView?: UmbViewController; #title?: string; @@ -55,9 +69,11 @@ export class UmbViewController extends UmbControllerBase { #variantId = new UmbClassState(undefined); protected readonly variantId = this.#variantId.asObservable(); - public hints; + public readonly hints; - readonly firstHintOfVariant; + public readonly shortcuts = new UmbShortcutController(this); + + public readonly firstHintOfVariant; constructor(host: UmbControllerHost, viewAlias: string | null) { super(host); @@ -79,24 +95,25 @@ export class UmbViewController extends UmbControllerBase { this.#consumeParentCtrl = this.consumeContext(UMB_VIEW_CONTEXT, (parentView) => { // In case of explicit inheritance we do not want to overview the parent view. if (this.#explicitInheritance) return; - if (this.isActive && !this.#hasActiveChild) { - // If we were active we will react as if we got deactivated and then activated again below if state allows. [NL] - this.#propagateActivation(); - } - this.#active.setValue(false); if (parentView) { - this.#parentView = parentView; - } - if (this.#inherit) { - this.#inheritFromParent(); + this.#setParentView(parentView); } // only activate if we had an incoming parentView, cause if not we are in a disassembling state. [NL] if (parentView && this.#attached && this.#autoActivate) { - this._internal_activate(); + this._internal_requestActivate(); } }).skipHost(); } + #setParentView(view: UmbViewController | undefined) { + if (this.#parentView === view) return; + this.#parentView = view; + + if (this.#inherit) { + this.#inheritFromParent(); + } + } + public setVariantId(variantId: UmbVariantId | undefined): void { this.#variantId.setValue(variantId); this.hints.updateScaffold({ variantId: variantId }); @@ -105,7 +122,6 @@ export class UmbViewController extends UmbControllerBase { public setBrowserTitle(title: string | undefined): void { if (this.#title === title) return; this.#title = title; - // TODO: This check should be if its the most child being active, but again think about how the parents in the active chain should work. this.#computeTitle(); this.#updateTitle(); } @@ -119,9 +135,10 @@ export class UmbViewController extends UmbControllerBase { this.#currentProvideHost = controllerHost; this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); this.hints.provideAt(controllerHost); + this.shortcuts.provideAt(controllerHost); - if (this.#attached && this.#autoActivate) { - this._internal_activate(); + if (this.#attached) { + this._internal_requestActivate(); } } @@ -131,30 +148,41 @@ export class UmbViewController extends UmbControllerBase { this.#providerCtrl = undefined; } this.hints.unprovide(); + this.shortcuts.unprovide(); this._internal_deactivate(); + this.#requestActivateParent(); } override hostConnected(): void { const wasActive = this.isActive; + const wasAttached = this.#attached; this.#attached = true; super.hostConnected(); + if (!wasAttached) { + this.#parentView?._internal_addChild(this); + } // Check that we have a providerController, otherwise this is not provided. [NL] if (this.#autoActivate && !wasActive) { - this._internal_activate(); + this._internal_requestActivate(); } } override hostDisconnected(): void { const wasAttached = this.#attached; - const wasActive = this.isActive; this.#attached = false; - this.#active.setValue(false); - super.hostDisconnected(); - if (wasAttached === true && wasActive) { - // Check that we have a providerController, otherwise this is not provided. [NL] - this.#propagateActivation(); + if (wasAttached) { + this.#parentView?._internal_removeChild(this); } + + this._internal_deactivate(); + super.hostDisconnected(); + this.#autoActivate = true; + this.#requestActivateParent(); + } + + public isInheriting() { + return this.#inherit; } public inherit() { @@ -166,21 +194,7 @@ export class UmbViewController extends UmbControllerBase { this.#explicitInheritance = true; this.#consumeParentCtrl?.destroy(); this.#consumeParentCtrl = undefined; - this.#parentView = context; - // Notice because we cannot break the inheritance, we do not need to stop this observation in any of the logic. [NL] - this.observe( - this.#parentView?.active, - (isActive) => { - if (isActive) { - this._internal_activate(); - } else { - this._internal_deactivate(); - } - }, - ObserveParentActiveCtrlAlias, - ); - this.#inheritFromParent(); - this.#propagateActivation(); + this.#setParentView(context); } #inheritFromParent(): void { @@ -205,19 +219,10 @@ export class UmbViewController extends UmbControllerBase { this.hints.inheritFrom(this.#parentView?.hints); } - #propagateActivation() { - if (!this.#parentView) return; - if (this.#inherit) { - if (this.isActive) { - this.#parentView._internal_childActivated(); - } else { - this.#parentView._internal_childDeactivated(); - } - } else { - if (this.isActive) { - this.#parentView._internal_deactivate(); - } else { - this.#parentView._internal_activate(); + #requestActivateParent() { + if (!this.#inherit) { + if (this.#parentView) { + this.#parentView._internal_requestActivate(); } } } @@ -227,58 +232,52 @@ export class UmbViewController extends UmbControllerBase { * Notify that a view context has been activated. */ // eslint-disable-next-line @typescript-eslint/naming-convention - public _internal_activate() { + public _internal_requestActivate(): boolean { if (!this.#providerCtrl) { // If we are not provided we should not be activated. [NL] - return; + return false; } + // TODO: Check this one: We do not want a parent to auto activate if a child is having the activation. [NL], well maybe it not that bad because of the asking of the children... this.#autoActivate = true; if (this.isActive) { - return; + return true; } // If not attached then propagate the activation to the parent. [NL] if (this.#attached === false) { if (!this.#parentView) { throw new Error('Cannot activate a view that is not attached to the DOM.'); } - this.#propagateActivation(); } else { - this.#active.setValue(true); - this.#propagateActivation(); - this.#updateTitle(); - // TODO: Start shortcuts. [NL] - } - } - - /** - * @internal - * Notify that a child has been activated. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - public _internal_childActivated() { - if (this.#hasActiveChild) return; - this.#hasActiveChild = true; - this._internal_activate(); - } - - /** - * @internal - * Notify that a child is no longer activated. - */ - // eslint-disable-next-line @typescript-eslint/naming-convention - public _internal_childDeactivated() { - this.#hasActiveChild = false; - if (this.#attached === false) { - if (this.#parentView) { - return; - } else { - throw new Error('Cannot re-activate(_childDeactivated) a view that is not attached to the DOM.'); + // Check if any of the children likes to be activated instead: + // A reverse loop ensures latest added child gets first chance to activate. This may matter in some future issue-scenario, I will say it could be that it is not the right way to determine if multiple children wants to be active. [NL] + let i = this.#children.length; + while (i--) { + const child = this.#children[i]; + if (child._internal_requestActivate()) { + // If we have an active child we should not update the title. + return true; + } + } + // if not then check your self: + if (this.#autoActivate && this.#attached) { + this._internal_activate(); + return true; } } - if (this.#autoActivate) { - this._internal_activate(); - } else { - this.#propagateActivation(); + return false; + } + + /** + * @internal + * Notify that a view context has been activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_activate() { + if (this.#attached) { + this.#autoActivate = true; + this.#setActive(); + this.#updateTitle(); + this.shortcuts.activate(); } } @@ -289,16 +288,21 @@ export class UmbViewController extends UmbControllerBase { */ // eslint-disable-next-line @typescript-eslint/naming-convention public _internal_deactivate() { - this.#autoActivate = false; if (!this.isActive) return; - this.#active.setValue(false); - // TODO: Stop shortcuts. [NL] - // Deactivate parents: - this.#propagateActivation(); + this.#autoActivate = false; + + // Deactive children: + this.#children.forEach((child) => { + if (child.isInheriting()) { + child._internal_deactivate(); + } + }); + this.shortcuts.deactivate(); + this.#removeActive(); } #updateTitle() { - if (!this.#active || this.#hasActiveChild) { + if (!this.#active || this.#hasActiveChildren()) { return; } const localTitle = this.getComputedTitle(); @@ -320,9 +324,32 @@ export class UmbViewController extends UmbControllerBase { return this.#computedTitle.getValue(); } + #children: UmbViewController[] = []; + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_addChild(child: UmbViewController) { + this.#children.push(child); + if (this.isActive) { + child._internal_activate(); + } + } + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_removeChild(child: UmbViewController) { + const index = this.#children.indexOf(child); + if (index !== -1) { + this.#children.splice(index, 1); + } + // update title? + if (this.#active && !this.#hasActiveChildren()) { + this.#updateTitle(); + } + } + #hasActiveChildren() { + return this.#children.some((child) => child.isActive); + } + override destroy(): void { this.#inherit = false; - this.#active.setValue(false); + this.#removeActive(); this.#autoActivate = false; (this as any).provideAt = undefined; this.unprovide(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 35aa800b95..0cc97b1c69 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -26,18 +26,18 @@ export default defineConfig({ 'entity-action/index': './entity-action/index.ts', 'entity-bulk-action/index': './entity-bulk-action/index.ts', 'entity-create-option-action/index': './entity-create-option-action/index.ts', - 'entity/index': './entity/index.ts', 'entity-item/index': './entity-item/index.ts', + 'entity/index': './entity/index.ts', 'entry-point': 'entry-point.ts', 'event/index': './event/index.ts', 'extension-registry/index': './extension-registry/index.ts', - 'http-client/index': './http-client/index.ts', 'hint/index': './hint/index.ts', + 'http-client/index': './http-client/index.ts', 'icon-registry/index': './icon-registry/index.ts', 'id/index': './id/index.ts', + 'interaction-memory/index': './interaction-memory/index.ts', 'lit-element/index': './lit-element/index.ts', 'localization/index': './localization/index.ts', - 'interaction-memory/index': './interaction-memory/index.ts', 'menu/index': './menu/index.ts', 'modal/index': './modal/index.ts', 'models/index': './models/index.ts', @@ -53,8 +53,9 @@ export default defineConfig({ 'resources/index': './resources/index.ts', 'router/index': './router/index.ts', 'section/index': './section/index.ts', - 'server/index': './server/index.ts', 'server-file-system/index': './server-file-system/index.ts', + 'server/index': './server/index.ts', + 'shortcut/index': './shortcut/index.ts', 'sorter/index': './sorter/index.ts', 'store/index': './store/index.ts', 'style/index': './style/index.ts', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts index 4d14510056..28d055df58 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts @@ -2,7 +2,6 @@ import type { UmbNamableWorkspaceContext } from '../types.js'; import { UmbNameWriteGuardManager } from '../namable/index.js'; import { UmbEntityDetailWorkspaceContextBase } from './entity-detail-workspace-base.js'; import type { UmbEntityDetailWorkspaceContextArgs, UmbEntityDetailWorkspaceContextCreateArgs } from './types.js'; -import { UmbViewContext } from '@umbraco-cms/backoffice/view'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; @@ -24,8 +23,6 @@ export abstract class UmbEntityNamedDetailWorkspaceContextBase< public readonly nameWriteGuard = new UmbNameWriteGuardManager(this); - public readonly view = new UmbViewContext(this, null); - constructor(host: UmbControllerHost, args: UmbEntityDetailWorkspaceContextArgs) { super(host, args); this.nameWriteGuard.fallbackToPermitted(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts index 00a1906991..0f2bb13877 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts @@ -1,22 +1,26 @@ import { UMB_WORKSPACE_CONTEXT } from '../../workspace.context-token.js'; import type { UmbWorkspaceContext } from '../../workspace-context.interface.js'; -import type { ManifestWorkspace } from '../../extensions/types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbEntityContext, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { ManifestWorkspaceDefaultKind } from './types.js'; export class UmbDefaultWorkspaceContext extends UmbContextBase implements UmbWorkspaceContext { public workspaceAlias!: string; #entityContext = new UmbEntityContext(this); + public readonly view = new UmbViewContext(this, null); + constructor(host: UmbControllerHost) { super(host, UMB_WORKSPACE_CONTEXT.toString()); } - set manifest(manifest: ManifestWorkspace) { + set manifest(manifest: ManifestWorkspaceDefaultKind) { this.workspaceAlias = manifest.alias; this.setEntityType(manifest.meta.entityType); + this.view.setBrowserTitle(manifest.meta.headline); } setUnique(unique: UmbEntityUnique): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts index 445cead0f0..d62ec59335 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts @@ -8,6 +8,7 @@ import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbValidationController } from '@umbraco-cms/backoffice/validation'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; export abstract class UmbSubmittableWorkspaceContextBase extends UmbContextBase @@ -20,6 +21,8 @@ export abstract class UmbSubmittableWorkspaceContextBase #validationContexts: Array = []; + public readonly view = new UmbViewContext(this, null); + /** * Appends a validation context to the workspace. * @param context @@ -54,6 +57,13 @@ export abstract class UmbSubmittableWorkspaceContextBase this.consumeContext(UMB_MODAL_CONTEXT, (context) => { (this.modalContext as UmbModalContext | undefined) = context; }); + + this.view.shortcuts.addOne({ + key: 's', + modifier: true, + action: () => this.requestSubmit(), + label: '#general_submit', + }); } protected resetState() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/save-modal/document-save-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/save-modal/document-save-modal.element.ts index 6eebcb0c70..3990df78f9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/save-modal/document-save-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/save-modal/document-save-modal.element.ts @@ -1,9 +1,10 @@ import type { UmbDocumentVariantOptionModel } from '../../types.js'; import type { UmbDocumentSaveModalData, UmbDocumentSaveModalValue } from './document-save-modal.token.js'; import { css, customElement, html, state } from '@umbraco-cms/backoffice/external/lit'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import '../shared/document-variant-language-picker.element.js'; @@ -56,21 +57,23 @@ export class UmbDocumentSaveModalElement extends UmbModalBaseElement< } override render() { - return html` - - -
- - -
-
`; + return html` + + +
+ + +
+
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts index 755a366b4f..84923fd865 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts @@ -2,9 +2,10 @@ import { UmbDocumentVariantState, type UmbDocumentVariantOptionModel } from '../ import { isNotPublishedMandatory } from '../../utils.js'; import type { UmbDocumentPublishModalData, UmbDocumentPublishModalValue } from './document-publish-modal.token.js'; import { css, customElement, html, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import '../../../modals/shared/document-variant-language-picker.element.js'; @@ -103,33 +104,34 @@ export class UmbDocumentPublishModalElement extends UmbModalBaseElement< override render() { const headline = this.data?.headline ?? this.localize.term('content_publishModalTitle'); - return html` -

- -

+ return html` + +

- ${when( - !this._isInvariant, - () => - html` `, - )} + ${when( + !this._isInvariant, + () => + html``, + )} -
- - -
-
`; +
+ + +
+
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts index fe7ae40fca..dfafaf0c4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts @@ -1 +1,3 @@ export * from './document-publishing.workspace-context.token.js'; + +export const UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE = 'umb-document-publishing-shortcut'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts index 0bd43d3764..0173b8e100 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts @@ -11,21 +11,22 @@ import { UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL } from '../publish-with-des import { UMB_DOCUMENT_PUBLISH_MODAL } from '../publish/constants.js'; import { UmbUnpublishDocumentEntityAction } from '../unpublish/index.js'; import { UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT } from './document-publishing.workspace-context.token.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE } from './constants.js'; +import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; +import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; -import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; -import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; -import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; -import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; -import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { /** @@ -48,7 +49,18 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { this.#init = Promise.all([ this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, async (context) => { + if (this.#documentWorkspaceContext) { + // remove shortcut: + this.#documentWorkspaceContext.view.shortcuts.removeOne(UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE); + } this.#documentWorkspaceContext = context; + this.#documentWorkspaceContext?.view.shortcuts.addOne({ + unique: UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE, + label: this.#localize.term('content_saveAndPublishShortcut'), + key: 'p', + modifier: true, + action: () => this.saveAndPublish(), + }); this.#initPendingChanges(); }) .asPromise({ preventTimeout: true }) diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts index e765508dd2..2f6f8b380b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts @@ -15,6 +15,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { query } from '@umbraco-cms/backoffice/router'; import type { UmbWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; export type UmbPoolingInterval = 0 | 2000 | 5000 | 10000 | 20000 | 30000; export interface UmbPoolingConfig { @@ -31,6 +32,8 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW public readonly workspaceAlias: string = 'Umb.Workspace.LogViewer'; #repository: UmbLogViewerRepository; + public readonly view = new UmbViewContext(this, null); + getEntityType() { return 'log-viewer'; } @@ -108,6 +111,8 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW // TODO: Revisit usage of workspace for this case... currently no other workspace context provides them self with their own token, we need to update UMB_APP_LOG_VIEWER_CONTEXT to become a workspace context. [NL] this.provideContext(UMB_WORKSPACE_CONTEXT, this); this.#repository = new UmbLogViewerRepository(host); + + this.view.setBrowserTitle('#treeHeaders_logViewer'); } override hostConnected() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user/current-user-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user/current-user-modal.token.ts index 7247fda5a2..5084ba3d2c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user/current-user-modal.token.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/current-user/modals/current-user/current-user-modal.token.ts @@ -4,5 +4,6 @@ export const UMB_CURRENT_USER_MODAL = new UmbModalToken('Umb.Modal.CurrentUser', modal: { type: 'sidebar', size: 'small', + title: '#general_user', }, }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/manifests.ts index 43f583c939..ae9c6cb1fd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/workspace/manifests.ts @@ -6,7 +6,7 @@ export const manifests: Array = [ type: 'workspace', kind: 'routable', alias: UMB_WEBHOOK_WORKSPACE_ALIAS, - name: 'Webhook Root Workspace', + name: 'Webhook Workspace', api: () => import('./webhook-workspace.context.js'), meta: { entityType: UMB_WEBHOOK_ENTITY_TYPE, diff --git a/src/Umbraco.Web.UI.Client/tsconfig.json b/src/Umbraco.Web.UI.Client/tsconfig.json index 010ad94033..1d040c15b2 100644 --- a/src/Umbraco.Web.UI.Client/tsconfig.json +++ b/src/Umbraco.Web.UI.Client/tsconfig.json @@ -58,9 +58,9 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/collection": ["./src/packages/core/collection/index.ts"], "@umbraco-cms/backoffice/components": ["./src/packages/core/components/index.ts"], "@umbraco-cms/backoffice/const": ["./src/packages/core/const/index.ts"], + "@umbraco-cms/backoffice/content-picker": ["./src/packages/property-editors/content-picker/index.ts"], "@umbraco-cms/backoffice/content-type": ["./src/packages/content/content-type/index.ts"], "@umbraco-cms/backoffice/content": ["./src/packages/content/content/index.ts"], - "@umbraco-cms/backoffice/content-picker": ["./src/packages/property-editors/content-picker/index.ts"], "@umbraco-cms/backoffice/culture": ["./src/packages/core/culture/index.ts"], "@umbraco-cms/backoffice/current-user": ["./src/packages/user/current-user/index.ts"], "@umbraco-cms/backoffice/dashboard": ["./src/packages/core/dashboard/index.ts"], @@ -76,8 +76,8 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/entity-create-option-action": [ "./src/packages/core/entity-create-option-action/index.ts" ], - "@umbraco-cms/backoffice/entity": ["./src/packages/core/entity/index.ts"], "@umbraco-cms/backoffice/entity-item": ["./src/packages/core/entity-item/index.ts"], + "@umbraco-cms/backoffice/entity": ["./src/packages/core/entity/index.ts"], "@umbraco-cms/backoffice/event": ["./src/packages/core/event/index.ts"], "@umbraco-cms/backoffice/extension-registry": ["./src/packages/core/extension-registry/index.ts"], "@umbraco-cms/backoffice/health-check": ["./src/packages/health-check/index.ts"], @@ -97,9 +97,9 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/media-type": ["./src/packages/media/media-types/index.ts"], "@umbraco-cms/backoffice/media": ["./src/packages/media/media/index.ts"], "@umbraco-cms/backoffice/member-group": ["./src/packages/members/member-group/index.ts"], + "@umbraco-cms/backoffice/member-public-access": ["./src/packages/members/member-public-access/index.ts"], "@umbraco-cms/backoffice/member-type": ["./src/packages/members/member-type/index.ts"], "@umbraco-cms/backoffice/member": ["./src/packages/members/member/index.ts"], - "@umbraco-cms/backoffice/member-public-access": ["./src/packages/members/member-public-access/index.ts"], "@umbraco-cms/backoffice/menu": ["./src/packages/core/menu/index.ts"], "@umbraco-cms/backoffice/modal": ["./src/packages/core/modal/index.ts"], "@umbraco-cms/backoffice/models": ["./src/packages/core/models/index.ts"], @@ -125,9 +125,10 @@ DON'T EDIT THIS FILE DIRECTLY. It is generated by /devops/tsconfig/index.js "@umbraco-cms/backoffice/search": ["./src/packages/search/index.ts"], "@umbraco-cms/backoffice/section": ["./src/packages/core/section/index.ts"], "@umbraco-cms/backoffice/segment": ["./src/packages/segment/index.ts"], - "@umbraco-cms/backoffice/server": ["./src/packages/core/server/index.ts"], "@umbraco-cms/backoffice/server-file-system": ["./src/packages/core/server-file-system/index.ts"], + "@umbraco-cms/backoffice/server": ["./src/packages/core/server/index.ts"], "@umbraco-cms/backoffice/settings": ["./src/packages/settings/index.ts"], + "@umbraco-cms/backoffice/shortcut": ["./src/packages/core/shortcut/index.ts"], "@umbraco-cms/backoffice/sorter": ["./src/packages/core/sorter/index.ts"], "@umbraco-cms/backoffice/static-file": ["./src/packages/static-file/index.ts"], "@umbraco-cms/backoffice/store": ["./src/packages/core/store/index.ts"], From 3b8d8c57c3ea8b2a1d59e379847b5c4dd5e7f0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Mon, 22 Sep 2025 18:05:30 +0200 Subject: [PATCH 33/56] Content Editor: Fix shared label and localize it (#20188) * correct shared label and localize it * fix localization --- .../src/assets/lang/en.ts | 3 +++ .../components/property/property.context.ts | 24 ++++++++++++------- .../components/property/property.element.ts | 8 +++---- 3 files changed, 22 insertions(+), 13 deletions(-) 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 ebeebc9445..76795bae32 100644 --- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts +++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts @@ -357,6 +357,9 @@ export default { saveAndPublishModalTitle: 'Save and publish', publishModalTitle: 'Publish', openSplitViewForVariant: (variant: string) => `Open ${variant} in split view`, + sharedAcrossCultures: 'Shared across cultures', + sharedAcrossSegments: 'Shared across segments', + shared: 'Shared', }, blueprints: { createBlueprintFrom: "Create a new Document Blueprint from '%0%'", diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts index 78f037a246..d2cc504675 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts @@ -107,9 +107,9 @@ export class UmbPropertyContext extends UmbContextBase { constructor(host: UmbControllerHost) { super(host, UMB_PROPERTY_CONTEXT); - this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (variantContext) => { - this.#datasetContext = variantContext; - this.setVariantId(variantContext?.getVariantId?.()); + this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => { + this.#datasetContext = context; + this.setVariantId(context?.getVariantId?.()); this._generateVariantDifferenceString(); this._observeProperty(); }); @@ -179,13 +179,19 @@ export class UmbPropertyContext extends UmbContextBase { let shareMessage; if (contextVariantId && propertyVariantId) { - if (contextVariantId.segment !== propertyVariantId.segment) { - // TODO: Translate this, ideally the actual culture is mentioned in the message: - shareMessage = 'Shared across culture'; + // If on a Segment viewing a segment-shared property: + // TODO: Do not use the content variant id, but know wether the property is configured to vary by segment. + // Because we can view a default segment, then we do not know if the property is shared or not. [NL] + if (contextVariantId.segment !== null && propertyVariantId.segment === null) { + if (contextVariantId.culture !== null) { + shareMessage = 'content_sharedAcrossCultures'; + } else { + shareMessage = 'content_sharedAcrossSegments'; + } } - if (contextVariantId.culture !== propertyVariantId.culture) { - // TODO: Translate this: - shareMessage = 'Shared'; + // TODO: Do not use the content variant id, but know wether the property is configured to vary by culture. (this is first a problem when we introduce the invariant-variant) + if (contextVariantId.culture !== null && propertyVariantId.culture === null) { + shareMessage = 'content_shared'; } } this.#variantDifference.setValue(shareMessage); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts index a20fe487d4..f13b4679e4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts @@ -165,7 +165,7 @@ export class UmbPropertyElement extends UmbLitElement { } @state() - private _variantDifference?: string; + private _variantDifferenceTerm?: string; @state() private _element?: ManifestPropertyEditorUi['ELEMENT_TYPE']; @@ -238,7 +238,7 @@ export class UmbPropertyElement extends UmbLitElement { this.observe( this.#propertyContext.variantDifference, (variantDifference) => { - this._variantDifference = variantDifference; + this._variantDifferenceTerm = variantDifference; }, null, ); @@ -418,9 +418,9 @@ export class UmbPropertyElement extends UmbLitElement { ?mandatory=${this._mandatory} ?invalid=${this._invalid}> ${this.#renderPropertyActionMenu()} - ${this._variantDifference + ${this._variantDifferenceTerm ? html`
- ${this._variantDifference} + ${this.localize.term(this._variantDifferenceTerm)}
` : ''} ${this.#renderPropertyEditor()} From a6e736849ff5f5539855a9b8e1bf927a3a6ebceb Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 23 Sep 2025 06:23:00 +0200 Subject: [PATCH 34/56] Update Umbraco version for LTS release in UmbracoProject template --- templates/UmbracoProject/.template.config/template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/UmbracoProject/.template.config/template.json b/templates/UmbracoProject/.template.config/template.json index 21968ef946..fe8b91ba18 100644 --- a/templates/UmbracoProject/.template.config/template.json +++ b/templates/UmbracoProject/.template.config/template.json @@ -98,7 +98,7 @@ }, { "condition": "(UmbracoRelease == 'LTS')", - "value": "13.10.0" + "value": "13.10.1" } ] } From 493dd1dc78a4505cd76e9f9dbbadfba0b7690021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asbj=C3=B8rn=20Riis-Knudsen?= Date: Tue, 23 Sep 2025 08:59:08 +0200 Subject: [PATCH 35/56] Fix DecimalPropertyEditor on non-English systems (#20215) * Directly convert from double or float when possible. Also fixes string parsing to work on all cultures. Fixes #20214 * Added unit tests to verify behaviour. --------- Co-authored-by: Andy Butland --- .../PropertyEditors/DecimalPropertyEditor.cs | 17 ++++++++++++----- .../DecimalPropertyValueEditorTests.cs | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs index 5d10cf7e76..58c86b1426 100644 --- a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs @@ -64,11 +64,18 @@ public class DecimalPropertyEditor : DataEditor => TryParsePropertyValue(editorValue.Value); private static decimal? TryParsePropertyValue(object? value) - => value is decimal decimalValue - ? decimalValue - : decimal.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out var parsedDecimalValue) - ? parsedDecimalValue - : null; + => value switch + { + decimal d => d, + double db => (decimal)db, + float f => (decimal)f, + IFormattable f => decimal.TryParse(f.ToString(null, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedDecimalValue) + ? parsedDecimalValue + : null, + _ => decimal.TryParse(value?.ToString(), CultureInfo.CurrentCulture, out var parsedDecimalValue) + ? parsedDecimalValue + : null, + }; /// /// Base validator for the decimal property editor validation against data type configured values. diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs index 92b4f8579a..b70523403e 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DecimalPropertyValueEditorTests.cs @@ -26,8 +26,10 @@ public class DecimalPropertyValueEditorTests { 123, 123m }, { -123, -123m }, { 123.45d, 123.45m }, + { 123.45f, 123.45m }, { "123.45", 123.45m }, { "1234.56", 1234.56m }, + { "1,234.56", 1234.56m }, { "123,45", 12345m }, { "1.234,56", null }, { "123 45", null }, @@ -49,6 +51,18 @@ public class DecimalPropertyValueEditorTests } } + [SetCulture("it-IT")] + [SetUICulture("it-IT")] + [TestCase("123,45", 123.45)] + [TestCase("1.234,56", 1234.56)] + [TestCase("123.45", 12345)] + [TestCase("1,234.56", null)] + public void Can_Parse_Values_From_Editor_Using_Culture_With_Non_EnUs_Decimal_Separator(object value, decimal? expected) + { + var fromEditor = FromEditor(value); + Assert.AreEqual(expected, fromEditor); + } + [Test] public void Can_Parse_Values_To_Editor() { From f379c9bbddbe535353d8cfd948c2f9b527021afe Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Tue, 23 Sep 2025 08:05:50 +0100 Subject: [PATCH 36/56] Member Workspace: Fixes property validation (closes #20210) (#20222) --- .../member-validation.server.data-source.ts | 1 + .../member-workspace-view-member.element.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts index b6889b741f..544069add2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts @@ -94,6 +94,7 @@ export class UmbMemberValidationServerDataSource { path: { id: model.unique }, body, }), + { disableNotifications: true }, ); if (data && typeof data === 'string') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts index ab101f6c0e..3f1fa1071f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts @@ -92,7 +92,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement @input=${() => this.#onPasswordUpdate()} value=${this._workspaceContext.newPassword} required - ${umbBindToValidation(this, '$.password', this._workspaceContext.newPassword)}> + ${umbBindToValidation( + this, + "$.values[?(@.alias == 'password' && @.culture == null && @.segment == null)].Value", + this._workspaceContext.newPassword, + )}> @@ -162,7 +166,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement name="login" label=${this.localize.term('general_username')} value=${this._workspaceContext.username} - ${umbBindToValidation(this, '$.username', this._workspaceContext.username)} + ${umbBindToValidation( + this, + "$.values[?(@.alias == 'username' && @.culture == null && @.segment == null)].Value", + this._workspaceContext.username, + )} required required-message=${this.localize.term('user_loginnameRequired')} @input=${(e: Event) => this.#onChange('username', (e.target as HTMLInputElement).value)}> @@ -174,7 +182,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement name="email" label=${this.localize.term('general_email')} value=${this._workspaceContext.email} - ${umbBindToValidation(this, '$.email', this._workspaceContext.email)} + ${umbBindToValidation( + this, + "$.values[?(@.alias == 'email' && @.culture == null && @.segment == null)].Value", + this._workspaceContext.email, + )} required required-message=${this.localize.term('user_emailRequired')} @input=${(e: Event) => this.#onChange('email', (e.target as HTMLInputElement).value)}> From 8213da1b7781c1936b3e25e7dd709846839c1878 Mon Sep 17 00:00:00 2001 From: Nicklas Kramer Date: Tue, 23 Sep 2025 11:17:25 +0200 Subject: [PATCH 37/56] Trees: Expanding sibling endpoints to include all entities with trees (#20150) * Adding member types sibling endpoints * Introducing sibling endpoint for Partial Views and logic. * Introducing sibling endpoint for stylesheets * Introducing sibling endpoint for scripts * Introducing FileSystemTreeServiceBase.cs * Introducing interfaces for implementation specific services * Introducing services for specific trees * Modifying controller bases to fit new interface and logic. * Obsoleting old constructors related to PartialView * Obsoleting ctors related to Stylesheets * Obsoleting ctors related to scripts * Adding tests for scriptsTreeService * Adding tests for siblings * Removing unused dependencies * Removing signs and replacing it with flags * Fixing breaking changes by obsoletion * Fixing more breaking changes * Registering missing service * Fixing breaking changes again * Changing name of method GetSiblingsViewModels * Rewritten tests for less bloat and less duplicate code * Expanding tests to include other methods from service * Test refactoring: avoided populating file systems that weren't under test, updated encapsulation, renaming, further re-use. * Management API: Expanding the existing sibling endpoints to support trashed entities (#20154) * Refactoring existing logic to include trashed items * Including tests for trashed entities * Groundwork for trashed siblings * Documents trashed siblings endpoint * Controller for Media trashed items * Expanding tests to include a test for trashed siblings * Code review corrections * Resolving code review --------- Co-authored-by: Andy Butland --- .../SiblingsDocumentRecycleBinController.cs | 24 +++++ .../SiblingsMediaRecycleBinController.cs | 24 +++++ .../Tree/SiblingMemberTypeTreeController.cs | 28 +++++ .../AncestorsPartialViewTreeController.cs | 20 +++- .../Tree/ChildrenPartialViewTreeController.cs | 19 +++- .../Tree/PartialViewTreeControllerBase.cs | 30 +++++- .../Tree/RootPartialViewTreeController.cs | 19 +++- .../Tree/SiblingsPartialViewTreeController.cs | 41 +++++++ .../RecycleBin/RecycleBinControllerBase.cs | 42 ++++++++ .../Tree/AncestorsScriptTreeController.cs | 19 +++- .../Tree/ChildrenScriptTreeController.cs | 19 +++- .../Script/Tree/RootScriptTreeController.cs | 19 +++- .../Script/Tree/ScriptTreeControllerBase.cs | 24 +++++ .../Tree/SiblingsScriptTreeController.cs | 41 +++++++ .../Tree/StaticFileTreeControllerBase.cs | 28 +++-- .../Tree/AncestorsStylesheetTreeController.cs | 19 +++- .../Tree/ChildrenStylesheetTreeController.cs | 20 +++- .../Tree/RootStylesheetTreeController.cs | 19 +++- .../Tree/SiblingsStylesheetTreeController.cs | 41 +++++++ .../Tree/StylesheetTreeControllerBase.cs | 30 +++++- .../Tree/FileSystemTreeControllerBase.cs | 72 ++++++++----- .../TreeBuilderExtensions.cs | 5 + .../FileSystem/FileSystemTreeServiceBase.cs | 102 ++++++++++++++++++ .../FileSystem/IFileSystemTreeService.cs | 18 ++++ .../FileSystem/IPartialViewTreeService.cs | 5 + .../Services/FileSystem/IScriptTreeService.cs | 5 + .../FileSystem/IStyleSheetTreeService.cs | 7 ++ .../FileSystem/PartialViewTreeService.cs | 14 +++ .../Services/FileSystem/ScriptTreeService.cs | 14 +++ .../FileSystem/StyleSheetTreeService.cs | 14 +++ .../Repositories/IEntityRepository.cs | 27 +++++ src/Umbraco.Core/Services/EntityService.cs | 41 +++++++ src/Umbraco.Core/Services/IEntityService.cs | 31 +++++- .../Implement/EntityRepository.cs | 87 ++++++++++++--- .../Trees/FileSystemTreeServiceTestsBase.cs | 83 ++++++++++++++ .../Trees/PartialViewTreeServiceTests.cs | 54 ++++++++++ .../Services/Trees/ScriptTreeServiceTests.cs | 53 +++++++++ .../Trees/StyleSheetTreeServiceTests.cs | 53 +++++++++ .../Services/EntityServiceTests.cs | 21 ++++ 39 files changed, 1163 insertions(+), 69 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs new file mode 100644 index 0000000000..06f2f9284f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs @@ -0,0 +1,24 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document.RecycleBin; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin; + +[ApiVersion("1.0")] +public class SiblingsDocumentRecycleBinController : DocumentRecycleBinControllerBase +{ + public SiblingsDocumentRecycleBinController(IEntityService entityService, IDocumentPresentationFactory documentPresentationFactory) + : base(entityService, documentPresentationFactory) + { + } + + [HttpGet("siblings")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + => await GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs new file mode 100644 index 0000000000..5628aa551e --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs @@ -0,0 +1,24 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Media.RecycleBin; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.Media.RecycleBin; + +[ApiVersion("1.0")] +public class SiblingsMediaRecycleBinController : MediaRecycleBinControllerBase +{ + public SiblingsMediaRecycleBinController(IEntityService entityService, IMediaPresentationFactory mediaPresentationFactory) + : base(entityService, mediaPresentationFactory) + { + } + + [HttpGet("siblings")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + => await GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs new file mode 100644 index 0000000000..4ed8e9f949 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.Flags; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Controllers.MemberType.Tree; + +public class SiblingMemberTypeTreeController : MemberTypeTreeControllerBase +{ + public SiblingMemberTypeTreeController( + IEntityService entityService, + FlagProviderCollection flagProviders, + IMemberTypeService memberTypeService) + : base(entityService, flagProviders, memberTypeService) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after) + => await GetSiblings(target, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs index 4048e1b9e7..3f79545e39 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs @@ -1,16 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree; [ApiVersion("1.0")] public class AncestorsPartialViewTreeController : PartialViewTreeControllerBase { + private readonly IPartialViewTreeService _partialViewTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public AncestorsPartialViewTreeController(IPartialViewTreeService partialViewTreeService) + : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _partialViewTreeService = partialViewTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public AncestorsPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems) + : base(partialViewTreeService, fileSystems) => + _partialViewTreeService = partialViewTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public AncestorsPartialViewTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs index 2877248e41..099f01f342 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs @@ -1,17 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree; [ApiVersion("1.0")] public class ChildrenPartialViewTreeController : PartialViewTreeControllerBase { + private readonly IPartialViewTreeService _partialViewTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public ChildrenPartialViewTreeController(IPartialViewTreeService partialViewTreeService) + : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _partialViewTreeService = partialViewTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public ChildrenPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems) + : base(partialViewTreeService, fileSystems) => + _partialViewTreeService = partialViewTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public ChildrenPartialViewTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs index 11e6a946ea..b45aff4616 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Web.Common.Authorization; @@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree; [Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViews)] public class PartialViewTreeControllerBase : FileSystemTreeControllerBase { - public PartialViewTreeControllerBase(FileSystems fileSystems) - => FileSystem = fileSystems.PartialViewsFileSystem ?? - throw new ArgumentException("Missing partial views file system", nameof(fileSystems)); + private readonly IPartialViewTreeService _partialViewTreeService; + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public PartialViewTreeControllerBase(IPartialViewTreeService partialViewTreeService) + : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) => + _partialViewTreeService = partialViewTreeService; + + // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this. + [ActivatorUtilitiesConstructor] + [Obsolete("Scheduled for removal in Umbraco 19")] + public PartialViewTreeControllerBase(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems) + : base(partialViewTreeService) + { + _partialViewTreeService = partialViewTreeService; + FileSystem = fileSystems.PartialViewsFileSystem ?? + throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + } + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public PartialViewTreeControllerBase(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService()) + => FileSystem = fileSystems.PartialViewsFileSystem ?? + throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + + [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")] protected override IFileSystem FileSystem { get; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs index 4247ded602..4e42266389 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs @@ -1,17 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree; [ApiVersion("1.0")] public class RootPartialViewTreeController : PartialViewTreeControllerBase { + private readonly IPartialViewTreeService _partialViewTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public RootPartialViewTreeController(IPartialViewTreeService partialViewTreeService) + : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _partialViewTreeService = partialViewTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public RootPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems) + : base(partialViewTreeService, fileSystems) => + _partialViewTreeService = partialViewTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public RootPartialViewTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs new file mode 100644 index 0000000000..af1e3171a6 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree; + +public class SiblingsPartialViewTreeController : PartialViewTreeControllerBase +{ + private readonly IPartialViewTreeService _partialViewTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public SiblingsPartialViewTreeController(IPartialViewTreeService partialViewTreeService) + : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _partialViewTreeService = partialViewTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems) + : base(partialViewTreeService, fileSystems) => + _partialViewTreeService = partialViewTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsPartialViewTreeController(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + string path, + int before, + int after) + => await GetSiblings(path, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs index 850d7240d0..59e10df454 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Controllers.Content; using Umbraco.Cms.Api.Management.ViewModels.Item; using Umbraco.Cms.Api.Management.ViewModels.RecycleBin; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; @@ -45,6 +46,24 @@ public abstract class RecycleBinControllerBase : ContentControllerBase return Task.FromResult>>(Ok(result)); } + protected async Task>> GetSiblings(Guid target, int before, int after) + { + IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter); + if (siblings.Length == 0) + { + return NotFound(); + } + + IEntitySlim entity = siblings.First(); + Guid? parentKey = GetParentKey(entity); + + TItem[] treeItemViewModels = MapRecycleBinViewModels(parentKey, siblings); + + SubsetViewModel result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter); + + return Ok(result); + } + protected virtual TItem MapRecycleBinViewModel(Guid? parentKey, IEntitySlim entity) { if (entity == null) @@ -136,4 +155,27 @@ public abstract class RecycleBinControllerBase : ContentControllerBase private PagedViewModel PagedViewModel(IEnumerable treeItemViewModels, long totalItems) => new() { Total = totalItems, Items = treeItemViewModels }; + + protected SubsetViewModel SubsetViewModel(IEnumerable treeItemViewModels, long totalBefore, long totalAfter) + => new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = treeItemViewModels }; + + protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) => + _entityService + .GetTrashedSiblings( + target, + [ItemObjectType], + before, + after, + out totalBefore, + out totalAfter, + ordering: Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.Text))) + .ToArray(); + + /// + /// Gets the parent key for an entity, or root if null or no parent. + /// + protected virtual Guid? GetParentKey(IEntitySlim entity) => + entity.ParentId > 0 + ? _entityService.GetKey(entity.ParentId, ItemObjectType).Result + : Constants.System.RootKey; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs index ce3ae4189c..ed5acbc0c3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs @@ -1,7 +1,10 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; @@ -9,8 +12,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; [ApiVersion("1.0")] public class AncestorsScriptTreeController : ScriptTreeControllerBase { + private readonly IScriptTreeService _scriptTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public AncestorsScriptTreeController(IScriptTreeService scriptTreeService) + : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _scriptTreeService = scriptTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public AncestorsScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems) + : base(scriptTreeService, fileSystems) => + _scriptTreeService = scriptTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public AncestorsScriptTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs index 73f028eab2..ba40037841 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs @@ -1,17 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; [ApiVersion("1.0")] public class ChildrenScriptTreeController : ScriptTreeControllerBase { + private readonly IScriptTreeService _scriptTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public ChildrenScriptTreeController(IScriptTreeService scriptTreeService) + : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _scriptTreeService = scriptTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public ChildrenScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems) + : base(scriptTreeService, fileSystems) => + _scriptTreeService = scriptTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public ChildrenScriptTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs index 3eff3b5f50..f29d3bdb44 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs @@ -1,17 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; [ApiVersion("1.0")] public class RootScriptTreeController : ScriptTreeControllerBase { + private readonly IScriptTreeService _scriptTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public RootScriptTreeController(IScriptTreeService scriptTreeService) + : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _scriptTreeService = scriptTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public RootScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems) + : base(scriptTreeService, fileSystems) => + _scriptTreeService = scriptTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public RootScriptTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs index e8bda7446d..ba302fc920 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Web.Common.Authorization; @@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; [Authorize(Policy = AuthorizationPolicies.TreeAccessScripts)] public class ScriptTreeControllerBase : FileSystemTreeControllerBase { + private readonly IScriptTreeService _scriptTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public ScriptTreeControllerBase(IScriptTreeService scriptTreeService) + : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) => + _scriptTreeService = scriptTreeService; + + // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this. + [ActivatorUtilitiesConstructor] + [Obsolete("Scheduled for removal in Umbraco 19")] + public ScriptTreeControllerBase(IScriptTreeService scriptTreeService, FileSystems fileSystems) + : base(scriptTreeService) + { + _scriptTreeService = scriptTreeService; + FileSystem = fileSystems.ScriptsFileSystem ?? + throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + } + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public ScriptTreeControllerBase(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService()) => FileSystem = fileSystems.ScriptsFileSystem ?? throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")] protected override IFileSystem FileSystem { get; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs new file mode 100644 index 0000000000..deec60cacb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree; + +public class SiblingsScriptTreeController : ScriptTreeControllerBase +{ + private readonly IScriptTreeService _scriptTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public SiblingsScriptTreeController(IScriptTreeService scriptTreeService) + : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _scriptTreeService = scriptTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems) + : base(scriptTreeService, fileSystems) => + _scriptTreeService = scriptTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsScriptTreeController(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + string path, + int before, + int after) + => await GetSiblings(path, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs index e4ee568150..0695f0b0ed 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; namespace Umbraco.Cms.Api.Management.Controllers.StaticFile.Tree; @@ -11,28 +14,39 @@ namespace Umbraco.Cms.Api.Management.Controllers.StaticFile.Tree; [ApiExplorerSettings(GroupName = "Static File")] public class StaticFileTreeControllerBase : FileSystemTreeControllerBase { + private readonly IFileSystemTreeService _fileSystemTreeService; private static readonly string[] _allowedRootFolders = { $"{Path.DirectorySeparatorChar}App_Plugins", $"{Path.DirectorySeparatorChar}wwwroot" }; + public StaticFileTreeControllerBase(IPhysicalFileSystem physicalFileSystem, IFileSystemTreeService fileSystemTreeService) + : base (fileSystemTreeService) + { + FileSystem = physicalFileSystem; + _fileSystemTreeService = fileSystemTreeService; + } + + [Obsolete("Please use the other constructor. Scheduled for removal in Umbraco 19")] public StaticFileTreeControllerBase(IPhysicalFileSystem physicalFileSystem) - => FileSystem = physicalFileSystem; + : this(physicalFileSystem, StaticServiceProvider.Instance.GetRequiredService()) + { + } protected override IFileSystem FileSystem { get; } - protected override string[] GetDirectories(string path) => + protected string[] GetDirectories(string path) => IsTreeRootPath(path) ? _allowedRootFolders : IsAllowedPath(path) - ? base.GetDirectories(path) + ? _fileSystemTreeService.GetDirectories(path) : Array.Empty(); - protected override string[] GetFiles(string path) + protected string[] GetFiles(string path) => IsTreeRootPath(path) || IsAllowedPath(path) == false ? Array.Empty() - : base.GetFiles(path); + : _fileSystemTreeService.GetFiles(path); - protected override FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf) + protected FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf) => IsAllowedPath(path) - ? base.GetAncestorModels(path, includeSelf) + ? _fileSystemTreeService.GetAncestorModels(path, includeSelf) : Array.Empty(); private bool IsTreeRootPath(string path) => path == Path.DirectorySeparatorChar.ToString(); diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs index 3863389125..3760808263 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs @@ -1,7 +1,10 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; @@ -9,8 +12,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; [ApiVersion("1.0")] public class AncestorsStylesheetTreeController : StylesheetTreeControllerBase { + private readonly IStyleSheetTreeService _styleSheetTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public AncestorsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService) + : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _styleSheetTreeService = styleSheetTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public AncestorsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems) + : base(styleSheetTreeService, fileSystems) => + _styleSheetTreeService = styleSheetTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public AncestorsStylesheetTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs index 3435f67225..41484bce50 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs @@ -1,20 +1,36 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; [ApiVersion("1.0")] public class ChildrenStylesheetTreeController : StylesheetTreeControllerBase { + private readonly IStyleSheetTreeService _styleSheetTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public ChildrenStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService) + : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _styleSheetTreeService = styleSheetTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public ChildrenStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems) + : base(styleSheetTreeService, fileSystems) => + _styleSheetTreeService = styleSheetTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public ChildrenStylesheetTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } - [HttpGet("children")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs index c01f15ea59..417c636a37 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs @@ -1,17 +1,34 @@ using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; [ApiVersion("1.0")] public class RootStylesheetTreeController : StylesheetTreeControllerBase { + private readonly IStyleSheetTreeService _styleSheetTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public RootStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService) + : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _styleSheetTreeService = styleSheetTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public RootStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems) + : base(styleSheetTreeService, fileSystems) => + _styleSheetTreeService = styleSheetTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] public RootStylesheetTreeController(FileSystems fileSystems) - : base(fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) { } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs new file mode 100644 index 0000000000..0f2b03b704 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs @@ -0,0 +1,41 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; + +public class SiblingsStylesheetTreeController : StylesheetTreeControllerBase +{ + private readonly IStyleSheetTreeService _styleSheetTreeService; + + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public SiblingsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService) + : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) + => _styleSheetTreeService = styleSheetTreeService; + + [ActivatorUtilitiesConstructor] + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems) + : base(styleSheetTreeService, fileSystems) => + _styleSheetTreeService = styleSheetTreeService; + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public SiblingsStylesheetTreeController(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems) + { + } + + [HttpGet("siblings")] + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + string path, + int before, + int after) + => await GetSiblings(path, before, after); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs index 428f892f6b..dd15a02d73 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Controllers.Tree; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Web.Common.Authorization; @@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree; [Authorize(Policy = AuthorizationPolicies.TreeAccessStylesheets)] public class StylesheetTreeControllerBase : FileSystemTreeControllerBase { - public StylesheetTreeControllerBase(FileSystems fileSystems) - => FileSystem = fileSystems.StylesheetsFileSystem ?? - throw new ArgumentException("Missing stylesheets file system", nameof(fileSystems)); + private readonly IStyleSheetTreeService _styleSheetTreeService; + // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted. + public StylesheetTreeControllerBase(IStyleSheetTreeService styleSheetTreeService) + : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) => + _styleSheetTreeService = styleSheetTreeService; + + // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this. + [ActivatorUtilitiesConstructor] + [Obsolete("Scheduled for removal in Umbraco 19")] + public StylesheetTreeControllerBase(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems) + : base(styleSheetTreeService) + { + _styleSheetTreeService = styleSheetTreeService; + FileSystem = fileSystems.ScriptsFileSystem ?? + throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + } + + [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")] + public StylesheetTreeControllerBase(FileSystems fileSystems) + : this(StaticServiceProvider.Instance.GetRequiredService()) + => FileSystem = fileSystems.ScriptsFileSystem ?? + throw new ArgumentException("Missing scripts file system", nameof(fileSystems)); + + [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")] protected override IFileSystem FileSystem { get; } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs index 1388e4b798..933986e2f3 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs @@ -1,8 +1,11 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.Extensions; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.FileSystem; using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Extensions; @@ -10,11 +13,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.Tree; public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase { + private readonly IFileSystemTreeService _fileSystemTreeService; + + [Obsolete("Has been moved to the individual services. Scheduled to be removed in Umbraco 19")] protected abstract IFileSystem FileSystem { get; } + [ActivatorUtilitiesConstructor] + protected FileSystemTreeControllerBase(IFileSystemTreeService fileSystemTreeService) => _fileSystemTreeService = fileSystemTreeService; + + [Obsolete("Use the other constructor. Scheduled for removal in Umbraco 19")] + protected FileSystemTreeControllerBase() + : this(StaticServiceProvider.Instance.GetRequiredService()) + { + } + + protected Task>> GetRoot(int skip, int take) { - FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(string.Empty, skip, take, out var totalItems); + FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetPathViewModels(string.Empty, skip, take, out var totalItems); PagedViewModel result = PagedViewModel(viewModels, totalItems); return Task.FromResult>>(Ok(result)); @@ -22,20 +38,39 @@ public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase protected Task>> GetChildren(string path, int skip, int take) { - FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(path, skip, take, out var totalItems); + FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetPathViewModels(path, skip, take, out var totalItems); PagedViewModel result = PagedViewModel(viewModels, totalItems); return Task.FromResult>>(Ok(result)); } + /// + /// Gets the sibling of the targeted item based on its path. + /// + /// The path to the item. + /// The amount of siblings you want to fetch from before the items position in the array. + /// The amount of siblings you want to fetch after the items position in the array. + /// A SubsetViewModel of the siblings of the item and the item itself. + protected Task>> GetSiblings(string path, int before, int after) + { + FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetSiblingsViewModels(path, before, after, out var totalBefore, out var totalAfter); + + SubsetViewModel result = new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = viewModels }; + return Task.FromResult>>(Ok(result)); + } + protected virtual Task>> GetAncestors(string path, bool includeSelf = true) { path = path.VirtualPathToSystemPath(); - FileSystemTreeItemPresentationModel[] models = GetAncestorModels(path, includeSelf); + FileSystemTreeItemPresentationModel[] models = _fileSystemTreeService.GetAncestorModels(path, includeSelf); return Task.FromResult>>(Ok(models)); } + private PagedViewModel PagedViewModel(IEnumerable viewModels, long totalItems) + => new() { Total = totalItems, Items = viewModels }; + + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] protected virtual FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf) { var directories = path.Split(Path.DirectorySeparatorChar).Take(Range.EndAt(Index.FromEnd(1))).ToArray(); @@ -52,49 +87,28 @@ public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase return result.ToArray(); } + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] protected virtual string[] GetDirectories(string path) => FileSystem .GetDirectories(path) .OrderBy(directory => directory) .ToArray(); + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] protected virtual string[] GetFiles(string path) => FileSystem .GetFiles(path) .OrderBy(file => file) .ToArray(); + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] protected virtual bool DirectoryHasChildren(string path) => FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any(); - private FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems) - { - path = path.VirtualPathToSystemPath(); - var allItems = GetDirectories(path) - .Select(directory => new { Path = directory, IsFolder = true }) - .Union(GetFiles(path).Select(file => new { Path = file, IsFolder = false })) - .ToArray(); - - totalItems = allItems.Length; - - FileSystemTreeItemPresentationModel ViewModel(string itemPath, bool isFolder) - => MapViewModel( - itemPath, - GetFileSystemItemName(isFolder, itemPath), - isFolder); - - return allItems - .Skip(skip) - .Take(take) - .Select(item => ViewModel(item.Path, item.IsFolder)) - .ToArray(); - } - + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] private string GetFileSystemItemName(bool isFolder, string itemPath) => isFolder ? Path.GetFileName(itemPath) : FileSystem.GetFileName(itemPath); - private PagedViewModel PagedViewModel(IEnumerable viewModels, long totalItems) - => new() { Total = totalItems, Items = viewModels }; - + [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")] private FileSystemTreeItemPresentationModel MapViewModel(string path, string name, bool isFolder) { var parentPath = Path.GetDirectoryName(path); diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs index ee95a14900..733223efa0 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs @@ -1,6 +1,8 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Services.Entities; +using Umbraco.Cms.Api.Management.Services.FileSystem; using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.DependencyInjection; @@ -9,6 +11,9 @@ internal static class TreeBuilderExtensions internal static IUmbracoBuilder AddTrees(this IUmbracoBuilder builder) { builder.Services.AddTransient(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs new file mode 100644 index 0000000000..43816e77b2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs @@ -0,0 +1,102 @@ +using Umbraco.Cms.Api.Management.Extensions; +using Umbraco.Cms.Api.Management.ViewModels.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.IO; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public abstract class FileSystemTreeServiceBase : IFileSystemTreeService +{ + protected abstract IFileSystem FileSystem { get; } + + public FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf) + { + var directories = path.Split(Path.DirectorySeparatorChar).Take(Range.EndAt(Index.FromEnd(1))).ToArray(); + var result = directories + .Select((directory, index) => MapViewModel(string.Join(Path.DirectorySeparatorChar, directories.Take(index + 1)), directory, true)) + .ToList(); + + if (includeSelf) + { + var selfIsFolder = FileSystem.FileExists(path) is false; + result.Add(MapViewModel(path, GetFileSystemItemName(selfIsFolder, path), selfIsFolder)); + } + + return result.ToArray(); + } + + public FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems) + { + path = path.VirtualPathToSystemPath(); + var allItems = GetDirectories(path) + .Select(directory => new { Path = directory, IsFolder = true }) + .Union(GetFiles(path).Select(file => new { Path = file, IsFolder = false })) + .ToArray(); + + totalItems = allItems.Length; + + FileSystemTreeItemPresentationModel ViewModel(string itemPath, bool isFolder) + => MapViewModel( + itemPath, + GetFileSystemItemName(isFolder, itemPath), + isFolder); + + return allItems + .Skip(skip) + .Take(take) + .Select(item => ViewModel(item.Path, item.IsFolder)) + .ToArray(); + } + + public FileSystemTreeItemPresentationModel[] GetSiblingsViewModels(string path, int before, int after, out long totalBefore, out long totalAfter) + { + var filePath = Path.GetDirectoryName(path); + var fileName = Path.GetFileName(path); + + FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(filePath!, 0, int.MaxValue, out totalBefore); + FileSystemTreeItemPresentationModel? target = viewModels.FirstOrDefault(item => item.Name == fileName); + var position = Array.IndexOf(viewModels, target); + + totalBefore = position - before < 0 ? 0 : position - before; + totalAfter = (viewModels.Length - 1) - (position + after) < 0 ? 0 : (viewModels.Length - 1) - (position + after); + + return viewModels + .Select((item, index) => new { item, index }) + .Where(item => item.index >= position - before && item.index <= position + after) + .Select(item => item.item) + .ToArray(); + } + + public string[] GetDirectories(string path) => FileSystem + .GetDirectories(path) + .OrderBy(directory => directory) + .ToArray(); + + public string[] GetFiles(string path) => FileSystem + .GetFiles(path) + .OrderBy(file => file) + .ToArray(); + + public bool DirectoryHasChildren(string path) + => FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any(); + + public string GetFileSystemItemName(bool isFolder, string itemPath) => isFolder + ? Path.GetFileName(itemPath) + : FileSystem.GetFileName(itemPath); + + private FileSystemTreeItemPresentationModel MapViewModel(string path, string name, bool isFolder) + { + var parentPath = Path.GetDirectoryName(path); + return new FileSystemTreeItemPresentationModel + { + Path = path.SystemPathToVirtualPath(), + Name = name, + HasChildren = isFolder && DirectoryHasChildren(path), + IsFolder = isFolder, + Parent = parentPath.IsNullOrWhiteSpace() + ? null + : new FileSystemFolderModel { Path = parentPath.SystemPathToVirtualPath() } + }; + } +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs new file mode 100644 index 0000000000..cdf96a4910 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs @@ -0,0 +1,18 @@ +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public interface IFileSystemTreeService +{ + FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf); + + FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems); + + FileSystemTreeItemPresentationModel[] GetSiblingsViewModels(string path, int before, int after, out long totalBefore, + out long totalAfter); + + string[] GetDirectories(string path); + + string[] GetFiles(string path); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs new file mode 100644 index 0000000000..6f2e48e80d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public interface IPartialViewTreeService : IFileSystemTreeService +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs new file mode 100644 index 0000000000..1681b52cbb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public interface IScriptTreeService : IFileSystemTreeService +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs new file mode 100644 index 0000000000..4426be4a68 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public interface IStyleSheetTreeService : IFileSystemTreeService +{ +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs new file mode 100644 index 0000000000..3da299008c --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public class PartialViewTreeService : FileSystemTreeServiceBase, IPartialViewTreeService +{ + private readonly IFileSystem _partialViewFileSystem; + + protected override IFileSystem FileSystem => _partialViewFileSystem; + + public PartialViewTreeService(FileSystems fileSystems) => + _partialViewFileSystem = fileSystems.PartialViewsFileSystem ?? + throw new ArgumentException("Missing partial views file system", nameof(fileSystems)); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs new file mode 100644 index 0000000000..ef870406f8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public class ScriptTreeService : FileSystemTreeServiceBase, IScriptTreeService +{ + private readonly IFileSystem _scriptFileSystem; + + protected override IFileSystem FileSystem => _scriptFileSystem; + + public ScriptTreeService(FileSystems fileSystems) => + _scriptFileSystem = fileSystems.ScriptsFileSystem ?? + throw new ArgumentException("Missing partial views file system", nameof(fileSystems)); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs new file mode 100644 index 0000000000..ed14819231 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs @@ -0,0 +1,14 @@ +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Api.Management.Services.FileSystem; + +public class StyleSheetTreeService : FileSystemTreeServiceBase, IStyleSheetTreeService +{ + private readonly IFileSystem _scriptFileSystem; + + protected override IFileSystem FileSystem => _scriptFileSystem; + + public StyleSheetTreeService(FileSystems fileSystems) => + _scriptFileSystem = fileSystems.StylesheetsFileSystem ?? + throw new ArgumentException("Missing partial views file system", nameof(fileSystems)); +} diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index 47cf15c3fc..9f8640c3c2 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -46,6 +46,33 @@ public interface IEntityRepository : IRepository return []; } + /// + /// Gets trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. + /// + /// The object type keys of the entities. + /// The key of the target entity whose siblings are to be retrieved. + /// The number of siblings to retrieve before the target entity. + /// The number of siblings to retrieve after the target entity. + /// An optional filter to apply to the result set. + /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. + /// Enumerable of trashed sibling entities. + IEnumerable GetTrashedSiblings( + ISet objectTypes, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } + /// /// Gets entities for a query /// diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index fe779507c4..c13111088e 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -359,6 +359,47 @@ public class EntityService : RepositoryService, IEntityService return siblings; } + /// + public IEnumerable GetTrashedSiblings( + Guid key, + IEnumerable objectTypes, + int before, + int after, + out long totalBefore, + out long totalAfter, + IQuery? filter = null, + Ordering? ordering = null) + { + if (before < 0) + { + throw new ArgumentOutOfRangeException(nameof(before), "The 'before' parameter must be greater than or equal to 0."); + } + + if (after < 0) + { + throw new ArgumentOutOfRangeException(nameof(after), "The 'after' parameter must be greater than or equal to 0."); + } + + ordering ??= new Ordering("sortOrder"); + + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + var objectTypeGuids = objectTypes.Select(x => x.GetGuid()).ToHashSet(); + + IEnumerable siblings = _entityRepository.GetTrashedSiblings( + objectTypeGuids, + key, + before, + after, + filter, + ordering, + out totalBefore, + out totalAfter); + + scope.Complete(); + return siblings; + } + /// public virtual IEnumerable GetDescendants(int id) { diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs index 1099c4af74..cab5615103 100644 --- a/src/Umbraco.Core/Services/IEntityService.cs +++ b/src/Umbraco.Core/Services/IEntityService.cs @@ -171,7 +171,7 @@ public interface IEntityService IEntitySlim? GetParent(int id, UmbracoObjectTypes objectType); /// - /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. + /// Gets non-trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. /// /// The key of the target entity whose siblings are to be retrieved. /// The object types of the entities. @@ -181,7 +181,7 @@ public interface IEntityService /// The ordering to apply to the siblings. /// Outputs the total number of siblings before the target entity. /// Outputs the total number of siblings after the target entity. - /// Enumerable of sibling entities. + /// Enumerable of non-trashed sibling entities. IEnumerable GetSiblings( Guid key, IEnumerable objectTypes, @@ -197,6 +197,33 @@ public interface IEntityService return []; } + /// + /// Gets trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. + /// + /// The key of the target entity whose siblings are to be retrieved. + /// The object types of the entities. + /// The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0. + /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0. + /// An optional filter to apply to the result set. + /// The ordering to apply to the siblings. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. + /// Enumerable of trashed sibling entities. + IEnumerable GetTrashedSiblings( + Guid key, + IEnumerable objectTypes, + int before, + int after, + out long totalBefore, + out long totalAfter, + IQuery? filter = null, + Ordering? ordering = null) + { + totalBefore = 0; + totalAfter = 0; + return []; + } + /// /// Gets the children of an entity. /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index d98657578a..a5f76bbf47 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -151,6 +151,72 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend Ordering ordering, out long totalBefore, out long totalAfter) + { + Sql? mainSql = SiblingsSql( + false, + objectTypes, + targetKey, + before, + after, + filter, + ordering, + out totalBefore, + out totalAfter); + + List? keys = Database.Fetch(mainSql); + + if (keys is null || keys.Count == 0) + { + return []; + } + + // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last. + return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); + } + + /// + public IEnumerable GetTrashedSiblings( + ISet objectTypes, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + Sql? mainSql = SiblingsSql( + true, + objectTypes, + targetKey, + before, + after, + filter, + ordering, + out totalBefore, + out totalAfter); + + List? keys = Database.Fetch(mainSql); + + if (keys is null || keys.Count == 0) + { + return []; + } + + // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last. + return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); + } + + private Sql? SiblingsSql( + bool isTrashed, + ISet objectTypes, + Guid targetKey, + int before, + int after, + IQuery? filter, + Ordering ordering, + out long totalBefore, + out long totalAfter) { // Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough // without us also having to do a nested query for the parent ID too. @@ -170,7 +236,7 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend .Select($"ROW_NUMBER() OVER ({orderingSql.SQL}) AS rn") .AndSelect(n => n.UniqueId) .From() - .Where(x => x.ParentId == parentId && x.Trashed == false) + .Where(x => x.ParentId == parentId && x.Trashed == isTrashed) .WhereIn(x => x.NodeObjectType, objectTypes); // Apply the filter if provided. @@ -203,25 +269,16 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset; var beforeArgumentsArray = beforeArguments.ToArray(); var afterArgumentsArray = afterArguments.ToArray(); - Sql? mainSql = Sql() + + totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true); + totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false); + + return Sql() .Select("UniqueId") .From().AppendSubQuery(rowNumberSql, "NumberedNodes") .Where($"rn >= ({targetRowSql.SQL}) - @{beforeAfterParameterIndex}", beforeArgumentsArray) .Where($"rn <= ({targetRowSql.SQL}) + @{beforeAfterParameterIndex}", afterArgumentsArray) .OrderBy("rn"); - - List? keys = Database.Fetch(mainSql); - - totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true); - totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false); - - if (keys is null || keys.Count == 0) - { - return []; - } - - // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last. - return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); } private static int GetBeforeAfterParameterOffset(ISet objectTypes, IQuery? filter) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs new file mode 100644 index 0000000000..e328b90d41 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/FileSystemTreeServiceTestsBase.cs @@ -0,0 +1,83 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Tests.Common.TestHelpers; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; + +public abstract class FileSystemTreeServiceTestsBase : UmbracoIntegrationTest +{ + protected FileSystems FileSystems { get; private set; } + + protected IFileSystem TestFileSystem { get; private set; } + + protected abstract string FileSystemPath { get; } + + protected IHostingEnvironment HostingEnvironment => GetRequiredService(); + + [SetUp] + public void SetUpFileSystem() + { + TestFileSystem = new PhysicalFileSystem(IOHelper, HostingEnvironment, LoggerFactory.CreateLogger(), HostingEnvironment.MapPathWebRoot(FileSystemPath), HostingEnvironment.ToAbsolute(FileSystemPath)); + + FileSystems = FileSystemsCreator.CreateTestFileSystems( + LoggerFactory, + IOHelper, + GetRequiredService>(), + HostingEnvironment, + GetPartialViewsFileSystem(), + GetStylesheetsFileSystem(), + GetScriptsFileSystem(), + null); + for (int i = 0; i < 10; i++) + { + using var stream = CreateStream(Path.Join("tests")); + TestFileSystem.AddFile($"file{i}", stream); + } + } + + private static Stream CreateStream(string contents = null) + { + if (string.IsNullOrEmpty(contents)) + { + contents = "/* test */"; + } + + var bytes = Encoding.UTF8.GetBytes(contents); + return new MemoryStream(bytes); + } + + protected virtual IFileSystem? GetPartialViewsFileSystem() => null; + + protected virtual IFileSystem? GetStylesheetsFileSystem() => null; + + protected virtual IFileSystem? GetScriptsFileSystem() => null; + + [TearDown] + public void TearDownFileSystem() + { + Purge(TestFileSystem, string.Empty); + FileSystems = null; + } + + private static void Purge(IFileSystem fs, string path) + { + var files = fs.GetFiles(path, "*"); + foreach (var file in files) + { + fs.DeleteFile(file); + } + + var dirs = fs.GetDirectories(path); + foreach (var dir in dirs) + { + Purge(fs, dir); + fs.DeleteDirectory(dir); + } + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs new file mode 100644 index 0000000000..0dbe9f39a3 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/PartialViewTreeServiceTests.cs @@ -0,0 +1,54 @@ +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; + +public class PartialViewTreeServiceTests : FileSystemTreeServiceTestsBase +{ + protected override string FileSystemPath => Constants.SystemDirectories.PartialViews; + + protected override IFileSystem? GetPartialViewsFileSystem() => TestFileSystem; + + [Test] + public void Can_Get_Siblings_From_PartialView_Tree_Service() + { + var service = new PartialViewTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after); + int index = Array.FindIndex(treeModel, item => item.Name == "file5"); + + Assert.AreEqual(treeModel[index].Name, "file5"); + Assert.AreEqual(treeModel[index - 1].Name, "file4"); + Assert.AreEqual(treeModel[index + 1].Name, "file6"); + Assert.That(treeModel.Length == 3); + Assert.AreEqual(after, 3); + Assert.AreEqual(before, 4); + } + + [Test] + public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + { + var service = new PartialViewTreeService(FileSystems); + + var path = Path.Join("tests", "file5"); + FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + + Assert.IsNotEmpty(treeModel); + Assert.AreEqual(treeModel.Length, 2); + Assert.AreEqual(treeModel[0].Name, "tests"); + } + + [Test] + public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + { + var service = new PartialViewTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, totalItems); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs new file mode 100644 index 0000000000..481fbd609b --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/ScriptTreeServiceTests.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; + +public class ScriptTreeServiceTests : FileSystemTreeServiceTestsBase +{ + protected override string FileSystemPath => GlobalSettings.UmbracoScriptsPath; + + protected override IFileSystem? GetScriptsFileSystem() => TestFileSystem; + + [Test] + public void Can_Get_Siblings_From_Script_Tree_Service() + { + var service = new ScriptTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after); + int index = Array.FindIndex(treeModel, item => item.Name == "file5"); + + Assert.AreEqual(treeModel[index].Name, "file5"); + Assert.AreEqual(treeModel[index - 1].Name, "file4"); + Assert.AreEqual(treeModel[index + 1].Name, "file6"); + Assert.That(treeModel.Length == 3); + Assert.AreEqual(after, 3); + Assert.AreEqual(before, 4); + } + + [Test] + public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + { + var service = new ScriptTreeService(FileSystems); + + var path = Path.Join("tests", "file5"); + FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + + Assert.IsNotEmpty(treeModel); + Assert.AreEqual(treeModel.Length, 2); + Assert.AreEqual(treeModel[0].Name, "tests"); + } + + [Test] + public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + { + var service = new ScriptTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, totalItems); + } +} diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs new file mode 100644 index 0000000000..2fe15f27ea --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Services/Trees/StyleSheetTreeServiceTests.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Services.FileSystem; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core.IO; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Services.Trees; + +public class StyleSheetTreeServiceTests : FileSystemTreeServiceTestsBase +{ + protected override string FileSystemPath => GlobalSettings.UmbracoCssPath; + + protected override IFileSystem? GetStylesheetsFileSystem() => TestFileSystem; + + [Test] + public void Can_Get_Siblings_From_StyleSheet_Tree_Service() + { + var service = new StyleSheetTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModel = service.GetSiblingsViewModels("file5", 1, 1, out long before, out var after); + int index = Array.FindIndex(treeModel, item => item.Name == "file5"); + + Assert.AreEqual(treeModel[index].Name, "file5"); + Assert.AreEqual(treeModel[index - 1].Name, "file4"); + Assert.AreEqual(treeModel[index + 1].Name, "file6"); + Assert.That(treeModel.Length == 3); + Assert.AreEqual(after, 3); + Assert.AreEqual(before, 4); + } + + [Test] + public void Can_Get_Ancestors_From_StyleSheet_Tree_Service() + { + var service = new StyleSheetTreeService(FileSystems); + + var path = Path.Join("tests", "file5"); + FileSystemTreeItemPresentationModel[] treeModel = service.GetAncestorModels(path, true); + + Assert.IsNotEmpty(treeModel); + Assert.AreEqual(treeModel.Length, 2); + Assert.AreEqual(treeModel[0].Name, "tests"); + } + + [Test] + public void Can_Get_PathViewModels_From_StyleSheet_Tree_Service() + { + var service = new StyleSheetTreeService(FileSystems); + + FileSystemTreeItemPresentationModel[] treeModels = service.GetPathViewModels(string.Empty, 0, Int32.MaxValue, out var totalItems); + + Assert.IsNotEmpty(treeModels); + Assert.AreEqual(treeModels.Length, totalItems); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs index 090cc3c1f0..7cd63c18d1 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/EntityServiceTests.cs @@ -972,6 +972,27 @@ internal sealed class EntityServiceTests : UmbracoIntegrationTest Assert.IsTrue(result[2].Key == children[3].Key); } + [Test] + public void EntityService_Siblings_Returns_Trashed_Siblings() + { + ContentService.EmptyRecycleBin(); + var children = CreateDocumentSiblingsTestData(); + + for (int i = 0; i <= 3; i++) + { + ContentService.MoveToRecycleBin(children[i]); + } + + var result = EntityService.GetTrashedSiblings(children[1].Key, [UmbracoObjectTypes.Document], 1, 1, out long totalBefore, out long totalAfter).ToArray(); + Assert.AreEqual(0, totalBefore); + Assert.AreEqual(1, totalAfter); + Assert.AreEqual(3, result.Length); + Assert.IsTrue(result[0].Key == children[0].Key); + Assert.IsTrue(result[1].Key == children[1].Key); + Assert.IsTrue(result[2].Key == children[2].Key); + Assert.IsFalse(result.Any(x => x.Key == children[3].Key)); + } + [Test] public void EntityService_Siblings_SkipsFilteredEntities_UsingFilterWithSet() { From 8a2f1bc233ce40fcf376d92696839af28075e142 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:55:56 +0200 Subject: [PATCH 38/56] Localization: Fixes an issue where the fallback localization was not available after changing DefaultUILanguage (fixes #20216) (#20230) fix: always load the default/fallback language code so it is available --- .../core/localization/registry/localization.registry.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts index a51d3651b8..15d6b5349c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts @@ -118,6 +118,9 @@ export class UmbLocalizationRegistry { ) // Subscribe to the observable to trigger the loading of translations .subscribe(); + + // Always register the fallback language (en) to ensure there is always at least one language available + this.loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE); } #loadExtension = async (extension: ManifestLocalization) => { From 9f2d404ca39d5e55af9147c41d3cb31f2c4e727f Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 23 Sep 2025 13:56:52 +0200 Subject: [PATCH 39/56] Add test for sibling endpoint returning parent id even when its a folder (#20118) * Improve GetManagementApiUrl to use the globally defined default version if not specified on the controller * Add a test to check logic introduced in #20083 * Update tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs Co-authored-by: Andy Butland * Update tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs Co-authored-by: Andy Butland --------- Co-authored-by: Andy Butland --- .../DocumentTypeSiblingControllerTests.cs | 60 +++++++++++++++++++ .../UmbracoTestServerTestBase.cs | 5 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs new file mode 100644 index 0000000000..5242716a35 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Trees/DocumentTypeSiblingControllerTests.cs @@ -0,0 +1,60 @@ +using System.Linq.Expressions; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; +using Umbraco.Cms.Api.Management.Controllers.DocumentType.Tree; +using Umbraco.Cms.Api.Management.ViewModels.Tree; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Trees; + +[TestFixture] +internal sealed class DocumentTypeSiblingControllerTests : ManagementApiTest +{ + private IContentTypeContainerService ContentTypeContainerService => GetRequiredService(); + + private ContentTypeService ContentTypeService => (ContentTypeService)GetRequiredService(); + + protected override Expression> MethodSelector => + x => x.Siblings(CancellationToken.None, Guid.Empty, 0, 0, false); + + [Test] + public async Task Document_Type_Siblings_Under_Folder_Have_Correct_Parent() + { + // create folder + Attempt folderResult = + await ContentTypeContainerService.CreateAsync(null, "Root Container", null, Constants.Security.SuperUserKey); + + // create contentTypeOne + IContentType contentTypeOne = ContentTypeBuilder.CreateBasicContentType(); + contentTypeOne.Alias = "contentTypeOne"; + contentTypeOne.ParentId = folderResult.Result.Id; + contentTypeOne.Variations = ContentVariation.Nothing; + ContentTypeService.Save(contentTypeOne); + + // create contentTypeTwo + IContentType contentTypeTwo = ContentTypeBuilder.CreateBasicContentType(); + contentTypeTwo.Alias = "contentTypeTwo"; + contentTypeTwo.ParentId = folderResult.Result.Id; + contentTypeTwo.Variations = ContentVariation.Nothing; + ContentTypeService.Save(contentTypeTwo); + + // get siblings of doctype one + await AuthenticateClientAsync(Client, "test@test.test", "test@test.test", true); + var siblingsResponse = await GetManagementApiResponseAsync(contentTypeOne.Key); + var responseModel = await siblingsResponse.Content.ReadFromJsonAsync>(); + + Assert.IsTrue(responseModel.Items.All(i => i.Parent!.Id == folderResult.Result.Key)); + } + + private async Task GetManagementApiResponseAsync(Guid target) + { + var url = GetManagementApiUrl(x => + x.Siblings(CancellationToken.None, target, 10, 10, false)); + return await Client.GetAsync(url); + } +} diff --git a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs index 134e06ef64..19bf18c5b7 100644 --- a/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs +++ b/tests/Umbraco.Tests.Integration/TestServerTest/UmbracoTestServerTestBase.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Api.Delivery.Controllers.Content; @@ -132,7 +133,9 @@ namespace Umbraco.Cms.Tests.Integration.TestServerTest } - methodParams["version"] = method?.GetCustomAttribute()?.Versions?.First().MajorVersion.ToString(); + methodParams["version"] = + method?.GetCustomAttribute()?.Versions?.First().MajorVersion.ToString() // get it from the attribute + ?? Factory.Services.GetRequiredService>()?.Value.DefaultApiVersion.MajorVersion.ToString(); // or use the default version from options if (method == null) { throw new MissingMethodException( From 85cf3d835e09f7fb5a55f1090fd6323a7e23e390 Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Tue, 23 Sep 2025 13:10:46 +0100 Subject: [PATCH 40/56] Block List: Fixed clipboard label for nested block items (closes #19929) (#20223) * Clipboard label for nested block item * Falsey check --- .../block/block/workspace/block-element-manager.ts | 8 +++++++- .../block/block/workspace/block-workspace.context.ts | 3 ++- .../actions/copy/copy-to-clipboard.property-action.ts | 1 + 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts index 6ac9729c10..935b341087 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts @@ -84,7 +84,12 @@ export class UmbBlockElementManager { + const contentTypeLabel = this.structure.getOwnerContentType()?.name; + const blockLabel = host.getName(); + return contentTypeLabel ? `${contentTypeLabel} ${blockLabel}` : blockLabel; + }; this.propertyViewGuard.fallbackToPermitted(); this.propertyWriteGuard.fallbackToPermitted(); @@ -94,6 +99,7 @@ export class UmbBlockElementManager { if (key) { this.validation.setDataPath('$.' + dataPathPropertyName + `[?(@.key == '${key}')]`); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts index 7be599c5a2..770ef5fd19 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts @@ -26,6 +26,7 @@ import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui'; export type UmbBlockWorkspaceElementManagerNames = 'content' | 'settings'; + export class UmbBlockWorkspaceContext extends UmbSubmittableWorkspaceContextBase implements UmbRoutableWorkspaceContext @@ -470,7 +471,7 @@ export class UmbBlockWorkspaceContext Date: Tue, 23 Sep 2025 17:12:57 +0200 Subject: [PATCH 41/56] Display the latest update date in document collection view --- .../repository/document-collection.server.data-source.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts index 83d7ab091c..0f91dd516d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts @@ -48,7 +48,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS unique: item.id, entityType: UMB_DOCUMENT_ENTITY_TYPE, contentTypeAlias: item.documentType.alias, - createDate: new Date(variant.createDate), + createDate: item.variants + .map((v) => new Date(v.createDate)) + .reduce((earliest, current) => (current < earliest ? current : earliest)), creator: item.creator, icon: item.documentType.icon, isProtected: item.isProtected, @@ -56,7 +58,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS name: variant.name, sortOrder: item.sortOrder, state: variant.state, - updateDate: new Date(variant.updateDate), + updateDate: item.variants + .map((v) => new Date(v.updateDate)) + .reduce((latest, current) => (current > latest ? current : latest)), updater: item.updater, values: item.values.map((item) => { return { alias: item.alias, value: item.value as string }; From a02cd8ecb1c9b83c16d7377b79db343016f2db68 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 06:53:10 +0200 Subject: [PATCH 42/56] Alignment of daterange in logviewer (#20244) --- .../components/log-viewer-date-range-selector.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts index a776f4499c..e07e40afd4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts @@ -102,7 +102,7 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement { :host([horizontal]) .input-container { display: flex; - align-items: baseline; + align-items: center; gap: var(--uui-size-space-3); } `, From 9cc801a7f7919c272395a350e871c112c6465844 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 06:59:21 +0200 Subject: [PATCH 43/56] Make package item readonly instead of disabled (#20246) --- .../installed/installed-packages-section-view-item.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts index 6402ba7be6..ad05d682ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts @@ -122,7 +122,7 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { name=${ifDefined(this.name)} version="${ifDefined(this.version ?? undefined)}" @open=${this.#onConfigure} - ?disabled="${!this._packageView}"> + ?readonly="${!this._packageView}"> ${this.customIcon ? html`` : nothing}
${this.hasPendingMigrations From 8136b251f1cdad90f0f40b3b16e22dd2fcababc9 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 07:03:14 +0200 Subject: [PATCH 44/56] Adjust height of image crop input append (#20245) --- .../image-crops/property-editor-ui-image-crops.element.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts index a5428232cb..f17f453110 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts @@ -265,6 +265,7 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen font-size: var(--uui-type-small-size); display: flex; align-items: center; + height: 100%; } .action-wrapper { display: flex; From e96f0e58b77309bfcd8bc1b1d1e45f2fdfc78173 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:11:58 +0200 Subject: [PATCH 45/56] Collection view: Wrong `Last edited` date displayed in document collection view (closes #19988) (#20235) Display the latest update date in document collection view --- .../repository/document-collection.server.data-source.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts index 83d7ab091c..0f91dd516d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts @@ -48,7 +48,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS unique: item.id, entityType: UMB_DOCUMENT_ENTITY_TYPE, contentTypeAlias: item.documentType.alias, - createDate: new Date(variant.createDate), + createDate: item.variants + .map((v) => new Date(v.createDate)) + .reduce((earliest, current) => (current < earliest ? current : earliest)), creator: item.creator, icon: item.documentType.icon, isProtected: item.isProtected, @@ -56,7 +58,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS name: variant.name, sortOrder: item.sortOrder, state: variant.state, - updateDate: new Date(variant.updateDate), + updateDate: item.variants + .map((v) => new Date(v.updateDate)) + .reduce((latest, current) => (current > latest ? current : latest)), updater: item.updater, values: item.values.map((item) => { return { alias: item.alias, value: item.value as string }; From 8c25295294cb7070cb41098a0f4ad88d2e355595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 24 Sep 2025 09:32:58 +0200 Subject: [PATCH 46/56] Workspace Base: make nonesense getter method to make Lit happy (#20238) make nonesense getter method to make lit happy --- .../components/workspace-editor/workspace-editor.element.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index df17e89301..5f69e62840 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -62,6 +62,9 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { public set overrides(value: Array> | undefined) { this.#navigationContext.setOverrides(value); } + public get overrides(): Array> | undefined { + return undefined; + } @state() private _workspaceViews: Array = []; From 36d46624bf6ff11361910d9480730135272bfc63 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 24 Sep 2025 09:48:47 +0200 Subject: [PATCH 47/56] Dropdown: Support migrated empty values (closes #20029) (#20247) * Display the latest update date in document collection view * Don't consider "" as a missing option when initializing the drop down list. * Don't flag "" as a missing option when validatng server-side. --------- Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com> --- .../PropertyEditors/Validators/MultipleValueValidator.cs | 2 +- .../dropdown/property-editor-ui-dropdown.element.ts | 6 +++--- .../PropertyEditors/MultiValuePropertyEditorTests.cs | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs index ed1d11ad18..9cde751afc 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs @@ -48,7 +48,7 @@ public class MultipleValueValidator : IValueValidator } var invalidValues = values - .Where(x => valueListConfiguration.Items.Contains(x) is false) + .Where(x => x.IsNullOrWhiteSpace() is false && valueListConfiguration.Items.Contains(x) is false) .ToList(); if (invalidValues.Count == 1) diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index e077081df6..5622585166 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -78,7 +78,7 @@ export class UmbPropertyEditorUIDropdownElement // If selection includes a value that is not in the list, add it to the list this.#selection.forEach((value) => { - if (!this._options.find((item) => item.value === value)) { + if (value !== '' && !this._options.find((item) => item.value === value)) { this._options.push({ name: `${value} (${this.localize.term('validation_legacyOption')})`, value, @@ -109,7 +109,7 @@ export class UmbPropertyEditorUIDropdownElement this.#setValue(value ? [value] : []); } - #onChangeMulitple(event: Event & { target: HTMLSelectElement }) { + #onChangeMultiple(event: Event & { target: HTMLSelectElement }) { const selected = event.target.selectedOptions; const value = selected ? Array.from(selected).map((option) => option.value) : []; this.#setValue(value); @@ -155,7 +155,7 @@ export class UmbPropertyEditorUIDropdownElement } return html` - ${map( this._options, (item) => html``, diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultiValuePropertyEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultiValuePropertyEditorTests.cs index 599a047c16..b1f4f76085 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultiValuePropertyEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultiValuePropertyEditorTests.cs @@ -128,6 +128,7 @@ public class MultiValuePropertyEditorTests Assert.AreEqual("Item 3", result.Items[2]); } + [TestCase("", true, "")] [TestCase("Red", true, "")] [TestCase("Yellow", false, "notOneOfOptions")] [TestCase("Red,Green", true, "")] From 909bc5ac335fd62eabafbb636abb9f54533c0177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 24 Sep 2025 09:58:38 +0200 Subject: [PATCH 48/56] View Context: rename setBrowserTitle to setTitle (#20248) rename setBrowserTitle to setTitle --- .../workspace/content-type-workspace-context-base.ts | 2 +- .../content/workspace/content-detail-workspace-base.ts | 2 +- .../content/workspace/views/edit/content-editor.element.ts | 4 ++-- .../workspace/property-type-workspace.context.ts | 2 +- .../src/packages/core/modal/context/modal.context.ts | 2 +- .../src/packages/core/section/section.context.ts | 2 +- .../src/packages/core/view/context/view.controller.ts | 2 +- .../components/workspace-editor/workspace-editor.context.ts | 2 +- .../entity-detail/entity-named-detail-workspace-base.ts | 2 +- .../core/workspace/kinds/default/default-workspace.context.ts | 2 +- .../log-viewer/workspace/logviewer-workspace.context.ts | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index 3f46cbe18f..2d23f998dd 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -71,7 +71,7 @@ export abstract class UmbContentTypeWorkspaceContextBase< // 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), null); - this.observe(this.name, (name) => this.view.setBrowserTitle(name), null); + this.observe(this.name, (name) => this.view.setTitle(name), null); // TODO: sometimes the browserTitle for a parent view is set later than the child is updating. We need to fix this as well enable a parent browser title to be updating on the go. [NL] } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index 2714250e54..a0a978a883 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -340,7 +340,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< const variantName = variants.find( (v) => v.culture === activeVariant?.culture && v.segment === activeVariant?.segment, )?.name; - this.view.setBrowserTitle(variantName); + this.view.setTitle(variantName); }, null, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts index 85e79ba0fa..a5da1c1333 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts @@ -158,7 +158,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements view.hints.setPathFilter((paths) => paths[0].includes('tab/') === false); } - view.setBrowserTitle(tabName); + view.setTitle(tabName); view.inheritFrom(this.#viewContext); this.observe( @@ -191,7 +191,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements // ViewAlias null is only for the root tab, therefor we can implement this hack. if (viewAlias === null) { // Specific hack for the Generic tab to only show its name if there are other tabs. - view.setBrowserTitle(this._tabs && this._tabs?.length > 0 ? '#general_generic' : undefined); + view.setTitle(this._tabs && this._tabs?.length > 0 ? '#general_generic' : undefined); } view.provideAt(component as any); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts index 086ac32e49..faa4e5ae2a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts @@ -75,7 +75,7 @@ export class UmbPropertyTypeWorkspaceContext this.observe( this.name, (name) => { - this.view.setBrowserTitle(name); + this.view.setTitle(name); }, null, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts index 870b849d60..fa2a78d9c3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts @@ -87,7 +87,7 @@ export class UmbModalContext< title = this.alias.getDefaultModal()?.title ?? undefined; } - this.view.setBrowserTitle(title); + this.view.setTitle(title); this.type = args.modal?.type || this.type; size = args.modal?.size ?? size; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts index 85fc30489d..483fecd772 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts @@ -29,7 +29,7 @@ export class UmbSectionContext extends UmbContextBase { this.#manifestPathname.setValue(manifest?.meta?.pathname); const sectionLabel = manifest ? manifest.meta?.label || manifest.name : undefined; this.#manifestLabel.setValue(sectionLabel); - this.#viewContext.setBrowserTitle(sectionLabel); + this.#viewContext.setTitle(sectionLabel); } getPathname() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts index 087ba4f583..269ac2393c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -119,7 +119,7 @@ export class UmbViewController extends UmbControllerBase { this.hints.updateScaffold({ variantId: variantId }); } - public setBrowserTitle(title: string | undefined): void { + public setTitle(title: string | undefined): void { if (this.#title === title) return; this.#title = title; this.#computeTitle(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts index 7efdb59688..6ddf055ad7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts @@ -42,7 +42,7 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { .forEach((manifest) => { const context = new UmbWorkspaceViewContext(this, manifest); context.setVariantId(this.#variantId); - context.setBrowserTitle(manifest.meta.label); + context.setTitle(manifest.meta.label); context.inherit(); contexts.push(context); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts index 28d055df58..9b8bef46c1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts @@ -29,7 +29,7 @@ export abstract class UmbEntityNamedDetailWorkspaceContextBase< this.observe( this.name, (name) => { - this.view.setBrowserTitle(name); + this.view.setTitle(name); }, null, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts index 0f2bb13877..6c3e0185bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts @@ -20,7 +20,7 @@ export class UmbDefaultWorkspaceContext extends UmbContextBase implements UmbWor set manifest(manifest: ManifestWorkspaceDefaultKind) { this.workspaceAlias = manifest.alias; this.setEntityType(manifest.meta.entityType); - this.view.setBrowserTitle(manifest.meta.headline); + this.view.setTitle(manifest.meta.headline); } setUnique(unique: UmbEntityUnique): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts index 2f6f8b380b..9d7199c471 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts @@ -112,7 +112,7 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW this.provideContext(UMB_WORKSPACE_CONTEXT, this); this.#repository = new UmbLogViewerRepository(host); - this.view.setBrowserTitle('#treeHeaders_logViewer'); + this.view.setTitle('#treeHeaders_logViewer'); } override hostConnected() { From 3c7e05dc4a22450b43109a01d9b012de61a53825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 24 Sep 2025 10:18:39 +0200 Subject: [PATCH 49/56] Server Validation: Ugly hack to convert server validation path to match JSON model (#20240) * Ugly hack to convert server validation path to match JSON model * Update src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../validation/context/server-model-validator.context.ts | 7 +++++++ ...validation-property-path-translation.controller.test.ts | 4 ++-- .../views/member/member-workspace-view-member.element.ts | 6 +++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts index 1971488ed3..2e5596dec7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts @@ -94,6 +94,13 @@ export class UmbServerModelValidatorContext extends UmbContextBase implements Um path = '$.' + path; } } + + // Correct the end of the path to ensure the `.value` properties start with a lowercase v. (notice it the server in some cases returns it with an upperCase V): [NL] + // This is surely a Hack but the Backend seem not to be able to solve this. [NL] + if (path.endsWith('.Value')) { + path = path.slice(0, -6) + '.value'; + } + newBodies.forEach((body: string) => messages.push({ type: 'server', key: UmbId.new(), path, body })); //this.#context!.messages.addMessages('server', path, errorBody.errors[path]); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts index 357b47a0ca..9bf4434665 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts @@ -62,11 +62,11 @@ describe('UmbValidationPropertyPathTranslationController', () => { }); it('returns Value', async () => { - const paths: Array = ['$[0].Value']; + const paths: Array = ['$[0].value']; const result = await ctrl.translateProperties(paths, propertiesData, UmbDataPathPropertyValueQuery); - expect(result[0]).to.be.equal(`$[${UmbDataPathPropertyValueQuery(propertiesData[0])}].Value`); + expect(result[0]).to.be.equal(`$[${UmbDataPathPropertyValueQuery(propertiesData[0])}].value`); }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts index 3f1fa1071f..8deea0f5ec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts @@ -94,7 +94,7 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement required ${umbBindToValidation( this, - "$.values[?(@.alias == 'password' && @.culture == null && @.segment == null)].Value", + "$.values[?(@.alias == 'password' && @.culture == null && @.segment == null)].value", this._workspaceContext.newPassword, )}> @@ -168,7 +168,7 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement value=${this._workspaceContext.username} ${umbBindToValidation( this, - "$.values[?(@.alias == 'username' && @.culture == null && @.segment == null)].Value", + "$.values[?(@.alias == 'username' && @.culture == null && @.segment == null)].value", this._workspaceContext.username, )} required @@ -184,7 +184,7 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement value=${this._workspaceContext.email} ${umbBindToValidation( this, - "$.values[?(@.alias == 'email' && @.culture == null && @.segment == null)].Value", + "$.values[?(@.alias == 'email' && @.culture == null && @.segment == null)].value", this._workspaceContext.email, )} required From cacd5c23bd81507d89129c649464e18695b4c60c Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 11:16:28 +0200 Subject: [PATCH 50/56] Register more heading icons (#20242) --- .../packages/core/icon-registry/icon-dictionary.json | 12 ++++++++++++ .../src/packages/core/icon-registry/icons.ts | 9 +++++++++ .../core/icon-registry/icons/icon-heading-5.ts | 1 + .../core/icon-registry/icons/icon-heading-6.ts | 1 + .../core/icon-registry/icons/icon-heading.ts | 1 + 5 files changed, 24 insertions(+) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index d4534b46de..32568953f5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -978,6 +978,10 @@ "file": "hard-drive.svg", "legacy": true }, + { + "name": "icon-heading", + "file": "heading.svg" + }, { "name": "icon-heading-1", "file": "heading-1.svg" @@ -994,6 +998,14 @@ "name": "icon-heading-4", "file": "heading-4.svg" }, + { + "name": "icon-heading-5", + "file": "heading-5.svg" + }, + { + "name": "icon-heading-6", + "file": "heading-6.svg" + }, { "name": "icon-headphones", "file": "headphones.svg" diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index 0f83d37184..735f7aca17 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -769,6 +769,9 @@ legacy: true, hidden: true, path: () => import("./icons/icon-hard-drive.js"), },{ +name: "icon-heading", +path: () => import("./icons/icon-heading.js"), +},{ name: "icon-heading-1", path: () => import("./icons/icon-heading-1.js"), },{ @@ -781,6 +784,12 @@ path: () => import("./icons/icon-heading-3.js"), name: "icon-heading-4", path: () => import("./icons/icon-heading-4.js"), },{ +name: "icon-heading-5", +path: () => import("./icons/icon-heading-5.js"), +},{ +name: "icon-heading-6", +path: () => import("./icons/icon-heading-6.js"), +},{ name: "icon-headphones", path: () => import("./icons/icon-headphones.js"), },{ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts new file mode 100644 index 0000000000..ee81cce068 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts new file mode 100644 index 0000000000..1ef9984dcf --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts new file mode 100644 index 0000000000..ee931a06a4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file From a6c92d8a91d1ef8a4e2d368f64b278230d362363 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 11:18:58 +0200 Subject: [PATCH 51/56] Tiptap RTE: Adds heading (h4-h6) toolbar buttons (#20243) * Register more heading icons * TipTap heading extensions * Change icon for headings --- .../heading/heading4.tiptap-toolbar-api.ts | 12 ++++++ .../heading/heading5.tiptap-toolbar-api.ts | 12 ++++++ .../heading/heading6.tiptap-toolbar-api.ts | 12 ++++++ .../tiptap/extensions/heading/manifests.ts | 41 ++++++++++++++++++- 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading4.tiptap-toolbar-api.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading5.tiptap-toolbar-api.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading6.tiptap-toolbar-api.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading4.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading4.tiptap-toolbar-api.ts new file mode 100644 index 0000000000..4618088bf3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading4.tiptap-toolbar-api.ts @@ -0,0 +1,12 @@ +import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js'; +import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +export default class UmbTiptapToolbarHeading4ExtensionApi extends UmbTiptapToolbarElementApiBase { + override isActive(editor?: Editor) { + return editor?.isActive('heading', { level: 4 }) === true; + } + + override execute(editor?: Editor) { + editor?.chain().focus().toggleHeading({ level: 4 }).run(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading5.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading5.tiptap-toolbar-api.ts new file mode 100644 index 0000000000..8bc7e87d55 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading5.tiptap-toolbar-api.ts @@ -0,0 +1,12 @@ +import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js'; +import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +export default class UmbTiptapToolbarHeading5ExtensionApi extends UmbTiptapToolbarElementApiBase { + override isActive(editor?: Editor) { + return editor?.isActive('heading', { level: 5 }) === true; + } + + override execute(editor?: Editor) { + editor?.chain().focus().toggleHeading({ level: 5 }).run(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading6.tiptap-toolbar-api.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading6.tiptap-toolbar-api.ts new file mode 100644 index 0000000000..712c929e2f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/heading6.tiptap-toolbar-api.ts @@ -0,0 +1,12 @@ +import { UmbTiptapToolbarElementApiBase } from '../tiptap-toolbar-element-api-base.js'; +import type { Editor } from '@umbraco-cms/backoffice/external/tiptap'; + +export default class UmbTiptapToolbarHeading3ExtensionApi extends UmbTiptapToolbarElementApiBase { + override isActive(editor?: Editor) { + return editor?.isActive('heading', { level: 6 }) === true; + } + + override execute(editor?: Editor) { + editor?.chain().focus().toggleHeading({ level: 6 }).run(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/manifests.ts index 8655614e03..a7f2f60c06 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/heading/manifests.ts @@ -5,7 +5,7 @@ export const manifests: Array = [ name: 'Headings Tiptap Extension', api: () => import('./heading.tiptap-api.js'), meta: { - icon: 'icon-heading-1', + icon: 'icon-heading', label: 'Headings', group: '#tiptap_extGroup_formatting', }, @@ -49,4 +49,43 @@ export const manifests: Array = [ label: 'Heading 3', }, }, + { + type: 'tiptapToolbarExtension', + kind: 'button', + alias: 'Umb.Tiptap.Toolbar.Heading4', + name: 'Heading 4 Tiptap Toolbar Extension', + api: () => import('./heading4.tiptap-toolbar-api.js'), + forExtensions: ['Umb.Tiptap.Heading'], + meta: { + alias: 'heading4', + icon: 'icon-heading-4', + label: 'Heading 4', + }, + }, + { + type: 'tiptapToolbarExtension', + kind: 'button', + alias: 'Umb.Tiptap.Toolbar.Heading5', + name: 'Heading 5 Tiptap Toolbar Extension', + api: () => import('./heading5.tiptap-toolbar-api.js'), + forExtensions: ['Umb.Tiptap.Heading'], + meta: { + alias: 'heading5', + icon: 'icon-heading-5', + label: 'Heading 5', + }, + }, + { + type: 'tiptapToolbarExtension', + kind: 'button', + alias: 'Umb.Tiptap.Toolbar.Heading6', + name: 'Heading 6 Tiptap Toolbar Extension', + api: () => import('./heading6.tiptap-toolbar-api.js'), + forExtensions: ['Umb.Tiptap.Heading'], + meta: { + alias: 'heading6', + icon: 'icon-heading-6', + label: 'Heading 6', + }, + }, ]; From 6d7c722ec378bc9be8ae12f403e0ff32d49b30a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 24 Sep 2025 11:29:42 +0200 Subject: [PATCH 52/56] Upload field Property Editor: Fix resetting value to undefined when empty (#20134) * set value to undefined when empty * fix nullable checks * ensure promise rejection when validation fails * avoid js error when detailStore is not present * implement editor as form control * remove unused --------- Co-authored-by: Mads Rasmussen --- .../content-detail-workspace-base.ts | 16 +++++++----- .../validation/mixins/form-control.mixin.ts | 8 ++++++ .../common/save/save.action.ts | 2 +- .../input-upload-field.element.ts | 25 ++++++++++++------- ...property-editor-ui-upload-field.element.ts | 17 +++++++++---- .../media-validation.server.data-source.ts | 6 +++++ .../member-collection.repository.ts | 2 +- .../repository/user-collection.repository.ts | 2 +- 8 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index a0a978a883..95fd159185 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -821,7 +821,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< * Request a submit of the workspace, in the case of Document Workspaces the validation does not need to be valid for this to be submitted. * @returns {Promise} a promise which resolves once it has been completed. */ - public override requestSubmit() { + public override requestSubmit(): Promise { return this._handleSubmit(); } @@ -831,7 +831,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< /** * Request a save of the workspace, in the case of Document Workspaces the validation does not need to be valid for this to be saved. - * @returns {Promise} a promise which resolves once it has been completed. + * @returns {Promise} A promise which resolves once it has been completed. */ public requestSave() { return this._handleSave(); @@ -847,11 +847,11 @@ export abstract class UmbContentDetailWorkspaceContextBase< return this._data.constructData(variantIds); } - protected async _handleSubmit() { + protected async _handleSubmit(): Promise { await this._handleSave(); this._closeModal(); } - protected async _handleSave() { + protected async _handleSave(): Promise { const data = this.getData(); if (!data) { throw new Error('Data is missing'); @@ -877,7 +877,9 @@ export abstract class UmbContentDetailWorkspaceContextBase< value: { selection: selected }, }).catch(() => undefined); - if (!result?.selection.length) return; + if (!result?.selection.length) { + return Promise.reject('Cannot save without selecting at least one variant.'); + } variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? []; } else { @@ -897,7 +899,9 @@ export abstract class UmbContentDetailWorkspaceContextBase< () => false, ); if (valid || this.#ignoreValidationResultOnSubmit) { - return this.performCreateOrUpdate(variantIds, saveData); + await this.performCreateOrUpdate(variantIds, saveData); + } else { + return Promise.reject('Validation issues prevent saving'); } } else { await this.performCreateOrUpdate(variantIds, saveData); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts index 51e925802a..c2259ca66b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts @@ -263,6 +263,14 @@ export function UmbFormControlMixin< * @returns {void} */ protected addFormControlElement(element: UmbNativeFormControlElement) { + if (!element) { + throw new Error('Element is null or undefined'); + } + if (!element.validity) { + console.log(element); + throw new Error('Element is not a Form Control'); + } + if (this.#formCtrlElements.includes(element)) return; this.#formCtrlElements.push(element); element.addEventListener(UmbValidationInvalidEvent.TYPE, this.#runValidatorsCallback); element.addEventListener(UmbValidationValidEvent.TYPE, this.#runValidatorsCallback); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts index e4b9905186..5cd20e0976 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts @@ -49,6 +49,6 @@ export class UmbSaveWorkspaceAction< override async execute() { await this._retrieveWorkspaceContext; - return await this._workspaceContext?.requestSave(); + await this._workspaceContext?.requestSave(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index ed92ba6453..6accb523ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -16,19 +16,26 @@ import type { } from '@umbraco-cms/backoffice/dropzone'; import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; @customElement('umb-input-upload-field') -export class UmbInputUploadFieldElement extends UmbLitElement { +export class UmbInputUploadFieldElement extends UmbFormControlMixin( + UmbLitElement, +) { @property({ type: Object, attribute: false }) - set value(value: UmbMediaValueType) { + override set value(value: UmbMediaValueType | undefined) { + super.value = value; this.#src = value?.src ?? ''; this.#setPreviewAlias(); } - get value(): UmbMediaValueType { - return { - src: this.#src, - temporaryFileId: this.temporaryFile?.temporaryUnique, - }; + override get value(): UmbMediaValueType | undefined { + if (this.#src || this.temporaryFile?.temporaryUnique) { + return { + src: this.#src, + temporaryFileId: this.temporaryFile?.temporaryUnique, + }; + } + return undefined; } #src = ''; @@ -86,7 +93,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { } async #getPreviewElementAlias() { - if (!this.value.src) return; + if (!this.value?.src) return; const manifests = await this.#getManifests(); const fallbackAlias = manifests.find((manifest) => stringOrStringArrayContains(manifest.forMimeTypes, '*/*'), @@ -158,7 +165,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { } override render() { - if (!this.temporaryFile && !this.value.src) { + if (!this.temporaryFile && !this.value?.src) { return this.#renderDropzone(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts index 3390df2062..09d2ba58b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts @@ -1,21 +1,24 @@ import type { UmbInputUploadFieldElement } from '../../components/input-upload-field/input-upload-field.element.js'; import type { UmbMediaValueType } from './types.js'; -import { html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; import type { UmbPropertyEditorUiElement, UmbPropertyEditorConfigCollection, } from '@umbraco-cms/backoffice/property-editor'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; + +import '../../components/input-upload-field/input-upload-field.element.js'; /** * @element umb-property-editor-ui-upload-field */ @customElement('umb-property-editor-ui-upload-field') -export class UmbPropertyEditorUIUploadFieldElement extends UmbLitElement implements UmbPropertyEditorUiElement { - @property({ type: Object }) - value: UmbMediaValueType = {}; - +export class UmbPropertyEditorUIUploadFieldElement + extends UmbFormControlMixin(UmbLitElement) + implements UmbPropertyEditorUiElement +{ @state() private _fileExtensions?: Array; @@ -29,6 +32,10 @@ export class UmbPropertyEditorUIUploadFieldElement extends UmbLitElement impleme } } + override firstUpdated() { + this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-upload-field')!); + } + #onChange(event: CustomEvent) { this.value = (event.target as UmbInputUploadFieldElement).value; this.dispatchEvent(new UmbChangeEvent()); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts index 00f5852875..f930d0d167 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts @@ -49,6 +49,9 @@ export class UmbMediaValidationServerDataSource { MediaService.postMediaValidate({ body, }), + { + disableNotifications: true, + }, ); if (data && typeof data === 'string') { @@ -86,6 +89,9 @@ export class UmbMediaValidationServerDataSource { path: { id: model.unique }, body, }), + { + disableNotifications: true, + }, ); if (data && typeof data === 'string') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts index 62ec20d00d..995f5d7900 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts @@ -19,7 +19,7 @@ export class UmbMemberCollectionRepository extends UmbMemberRepositoryBase imple const { data, error } = await this.#collectionSource.getCollection(filter); if (data) { - this.detailStore!.appendItems(data.items); + this.detailStore?.appendItems(data.items); } return { data, error, asObservable: () => this.detailStore!.all() }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.repository.ts index b5c454fe32..1849365064 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/collection/repository/user-collection.repository.ts @@ -19,7 +19,7 @@ export class UmbUserCollectionRepository extends UmbUserRepositoryBase implement const { data, error } = await this.#collectionSource.getCollection(filter); if (data) { - this.detailStore!.appendItems(data.items); + this.detailStore?.appendItems(data.items); } return { data, error, asObservable: () => this.detailStore!.all() }; From 6001d94f50f720d3ee69bdf2d2145273a0c17fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 24 Sep 2025 12:55:00 +0200 Subject: [PATCH 53/56] Add more icons correct Content Type Designer Icons (#20252) * add more icons correct Content Type Designer Icons * add icon --- .../content-type-design-editor-property.element.ts | 11 ++++++++--- .../packages/core/icon-registry/icon-dictionary.json | 8 ++++++++ .../src/packages/core/icon-registry/icons.ts | 6 ++++++ .../core/icon-registry/icons/icon-heading-5.ts | 2 +- .../core/icon-registry/icons/icon-heading-6.ts | 2 +- .../packages/core/icon-registry/icons/icon-heading.ts | 2 +- .../icon-registry/icons/icon-stretch-horizontal.ts | 1 + .../core/icon-registry/icons/icon-trending-up-down.ts | 1 + 8 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-property.element.ts index 75b3f5b8c8..408930a756 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-property.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-property.element.ts @@ -310,6 +310,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { ${this.#renderVariantTags()} ${this.property.appearance?.labelOnTop == true ? html` + ${this.localize.term('contentTypeEditor_displaySettingsLabelOnTop')} ` : nothing} @@ -348,7 +349,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { ) { return html` - ${this.localize.term( + ${this.localize.term( 'contentTypeEditor_cultureAndVariantInvariantLabel', )} @@ -357,13 +358,17 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement { if (this.ownerVariesByCulture && !this.property.variesByCulture) { return html` - ${this.localize.term('contentTypeEditor_cultureInvariantLabel')} + ${this.localize.term( + 'contentTypeEditor_cultureInvariantLabel', + )} `; } if (this.ownerVariesBySegment && !this.property.variesBySegment) { return html` - ${this.localize.term('contentTypeEditor_segmentInvariantLabel')} + ${this.localize.term( + 'contentTypeEditor_segmentInvariantLabel', + )} `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json index 32568953f5..a21e080e58 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json @@ -2226,6 +2226,10 @@ "name": "icon-tree", "file": "tree-deciduous.svg" }, + { + "name": "icon-trending-up-down", + "file": "trending-up-down.svg" + }, { "name": "icon-trophy", "file": "trophy.svg" @@ -2427,6 +2431,10 @@ "name": "icon-star", "file": "star.svg" }, + { + "name": "icon-stretch-horizontal", + "file": "stretch-horizontal.svg" + }, { "name": "icon-database", "file": "database.svg" diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts index 735f7aca17..5e85f87f8f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts @@ -1810,6 +1810,9 @@ path: () => import("./icons/icon-trash.js"), name: "icon-tree", path: () => import("./icons/icon-tree.js"), },{ +name: "icon-trending-up-down", +path: () => import("./icons/icon-trending-up-down.js"), +},{ name: "icon-trophy", path: () => import("./icons/icon-trophy.js"), },{ @@ -1977,6 +1980,9 @@ path: () => import("./icons/icon-zoom-out.js"), name: "icon-star", path: () => import("./icons/icon-star.js"), },{ +name: "icon-stretch-horizontal", +path: () => import("./icons/icon-stretch-horizontal.js"), +},{ name: "icon-database", path: () => import("./icons/icon-database.js"), },{ diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts index ee81cce068..af737d0595 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts @@ -1 +1 @@ -export default ``; \ No newline at end of file +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts index 1ef9984dcf..f23122ecdc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts @@ -1 +1 @@ -export default ``; \ No newline at end of file +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts index ee931a06a4..ab812d887c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts @@ -1 +1 @@ -export default ``; \ No newline at end of file +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts new file mode 100644 index 0000000000..a4409ea351 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts new file mode 100644 index 0000000000..9d08233c6e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts @@ -0,0 +1 @@ +export default ``; \ No newline at end of file From a8cdba6e7076b6abea96f8fa80972545efb93939 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:05:00 +0200 Subject: [PATCH 54/56] Tiptap: QA Added acceptance tests for regression issue #19763 (#20226) * Added test * Finished up test * Updated tests * Removed comment * Updated testCommand * Reverted smokeTest --- .../RichTextEditor/ContentWithTiptap.spec.ts | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts index 1b83e2a2f6..fc39dacff6 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/ContentWithTiptap.spec.ts @@ -1,4 +1,4 @@ -import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers'; import {expect} from "@playwright/test"; const contentName = 'TestContent'; @@ -85,3 +85,54 @@ test('can publish content with RTE Tiptap property editor', async ({umbracoApi, expect(contentData.variants[0].state).toBe(expectedState); expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); }); + +// This is a test for the regression issue #19763 +test('can save a variant content node after removing embedded block in RTE', async ({umbracoApi, umbracoUi}) => { + // Arrange + // Language + const danishIsoCode = 'da'; + await umbracoApi.language.createDanishLanguage(); + // Content Names + const englishContentName = 'English Content'; + const danishContentName = 'Danish Content'; + // Element Type + const elementTypeName = 'Default Element Type'; + const elementTypeGroupName = 'Content'; + const elementTypeDataTypeName = 'Textstring'; + const elementTypeDataType = await umbracoApi.dataType.getByName(elementTypeDataTypeName); + // Rich Text Editor + const richTextEditorDataTypeName = 'Rich Text Editor with a block'; + const textStringValue = 'Block Content'; + const elementTypeId = await umbracoApi.documentType.createDefaultElementTypeWithVaryByCulture(elementTypeName, elementTypeGroupName, elementTypeDataTypeName, elementTypeDataType.id, true, false); + const richTextEditorId = await umbracoApi.dataType.createRichTextEditorWithABlock(richTextEditorDataTypeName, elementTypeId); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, richTextEditorDataTypeName, richTextEditorId, 'TestGroup', true, false); + const cultures = [{isoCode: 'en-US', name: englishContentName}, {isoCode: 'da', name: danishContentName}]; + await umbracoApi.document.createDocumentWithMultipleVariantsWithSharedProperty(contentName, documentTypeId, AliasHelper.toAlias(richTextEditorDataTypeName), 'Umbraco.RichText', cultures, ''); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(englishContentName); + + // Act + await umbracoUi.content.clickInsertBlockButton(); + await umbracoUi.content.clickLinkWithName(elementTypeName); + await umbracoUi.content.enterTextstring(textStringValue); + await umbracoUi.content.clickCreateModalButton(); + await umbracoUi.content.clickSaveButtonForContent(); + await umbracoUi.content.clickSaveButton(); + const contentData = await umbracoApi.document.getByName(englishContentName); + expect(contentData.values[0].value.blocks.contentData[0].values[0].value).toBe(textStringValue); + await umbracoUi.content.clearTipTapEditor(); + await umbracoUi.content.clickSaveButtonForContent(); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.isErrorNotificationVisible(false); + await umbracoUi.content.waitForContentToBeCreated(); + expect(await umbracoApi.document.doesNameExist(englishContentName)).toBeTruthy(); + + // Clean + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.documentType.ensureNameNotExists(elementTypeName); + await umbracoApi.dataType.ensureNameNotExists(richTextEditorDataTypeName); + await umbracoApi.language.ensureIsoCodeNotExists(danishIsoCode); +}); From 1aa8bdf3aa59d345c05de468f9502517c22c5901 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Wed, 24 Sep 2025 14:01:34 +0200 Subject: [PATCH 55/56] Bump version to 16.4.0-rc --- src/Umbraco.Web.UI.Client/package-lock.json | 4 ++-- src/Umbraco.Web.UI.Client/package.json | 2 +- version.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index 33947947a9..14a2f517ea 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@umbraco-cms/backoffice", - "version": "16.3.0-rc", + "version": "16.4.0-rc", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@umbraco-cms/backoffice", - "version": "16.3.0-rc", + "version": "16.4.0-rc", "license": "MIT", "workspaces": [ "./src/packages/*", diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 3c831031ac..a46d8b4211 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -1,7 +1,7 @@ { "name": "@umbraco-cms/backoffice", "license": "MIT", - "version": "16.3.0-rc", + "version": "16.4.0-rc", "type": "module", "exports": { ".": null, diff --git a/version.json b/version.json index 0b4f9c3997..d8d415334b 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "16.3.0-rc", + "version": "16.4.0-rc", "assemblyVersion": { "precision": "build" }, From e93802769c985fce2db8c79ca1dce05a5d412c3d Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Wed, 24 Sep 2025 21:19:14 +0200 Subject: [PATCH 56/56] Make remove button consistent with other multi value item remove button (#20257) --- .../multiple-color-picker-item-input.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts index 47d05e9cbe..6fed4cef67 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts @@ -189,7 +189,7 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin(