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()}
${this._currentInput}
@@ -207,19 +270,25 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '')
`;
}
- #enteredTags() {
- return html` ${this.items.map((tag) => {
- return html`
-
- ${tag}
- ${this.#renderRemoveButton(tag)}
-
- `;
- })}`;
+ #renderTags() {
+ return html`
+
+ ${repeat(
+ this.items,
+ (tag) => tag,
+ (tag, index) => html`
+ this.#onTagKeydown(e, index)}>
+ ${tag}
+ ${this.#renderRemoveButton(tag)}
+
+ `,
+ )}
+
+ `;
}
#renderTagOptions() {
- if (!this._currentInput.length || !this._matches.length) return nothing;
+ if (!this._matches.length) return nothing;
const matchfilter = this._matches.filter((tag) => tag.text !== this.#items.find((x) => x === tag.text));
if (!matchfilter.length) return;
return html`
@@ -228,7 +297,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '')
matchfilter.slice(0, 5),
(tag: TagResponseModel) => tag.id,
(tag: TagResponseModel, index: number) => {
- return html`
@@ -265,7 +334,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '')
#renderRemoveButton(tag: string) {
if (this.readonly) return nothing;
- return html` this.#delete(tag)}"> `;
+ return html` this.#delete(tag)}"> `;
}
static override styles = [
@@ -289,6 +358,17 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '')
}
/** Tags */
+ #tags {
+ display: flex;
+ gap: var(--uui-size-space-2);
+ flex-wrap: wrap;
+ border-radius: var(--uui-size-1);
+
+ &:focus {
+ outline: var(--uui-size-1) solid var(--uui-color-focus);
+ outline-offset: var(--uui-size-1);
+ }
+ }
uui-tag {
position: relative;
@@ -306,15 +386,20 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '')
white-space: nowrap;
}
- /** Created tags */
+ /** Existing tags */
+ .tag {
+ &:focus {
+ outline: var(--uui-size-1) solid var(--uui-color-focus);
+ }
- .tag uui-icon {
- margin-left: var(--uui-size-space-2);
- }
+ uui-icon {
+ margin-left: var(--uui-size-space-2);
- .tag uui-icon:hover,
- .tag uui-icon:active {
- color: var(--uui-color-selected-contrast);
+ &:hover,
+ &:active {
+ color: var(--uui-color-selected-contrast);
+ }
+ }
}
/** Main tag */
From aa2d4f12075e3de8056c7dfdae430d675222216c Mon Sep 17 00:00:00 2001
From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
Date: Thu, 18 Sep 2025 17:37:21 +0200
Subject: [PATCH 07/56] Variants: Implements validation hints to the variant
selector (closes #19953) (#20179)
* feat: gets hints and assigns to variants to enable the view to show a badge if there is a hint
* feat: find the first hint on the non-active variant
* feat: protect against non-variants
* feat: ignore invariant variants
* feat: adds a render method for hints
* chore: removes comment
* only add a new hint if the weight is higher
---
...ace-split-view-variant-selector.element.ts | 53 ++++++++++++++++++-
1 file changed, 52 insertions(+), 1 deletion(-)
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 3e2ff19967..7cb20c7228 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
@@ -10,6 +10,8 @@ import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui';
import type { UmbEntityVariantModel, UmbEntityVariantOptionModel } from '@umbraco-cms/backoffice/variant';
import type { UUIInputElement, UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui';
import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api';
+import { UMB_HINT_CONTEXT } from '@umbraco-cms/backoffice/hint';
+import type { UmbHint, UmbVariantHint } from '@umbraco-cms/backoffice/hint';
@customElement('umb-workspace-split-view-variant-selector')
export class UmbWorkspaceSplitViewVariantSelectorElement<
@@ -96,8 +98,35 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
this.#observeDatasetContext();
this.#observeCurrentVariant();
});
+
+ this.consumeContext(UMB_HINT_CONTEXT, (context) => {
+ this.observe(
+ context?.descendingHints(),
+ (hints) => {
+ this._hintMap.clear();
+ hints?.forEach((hint) => {
+ if (this.#isVariantHint(hint) && hint.variantId) {
+ // Add the hint if there is no existing hint for this variantId or if the existing hint has a lower weight
+ const existingHint = this._hintMap.get(hint.variantId.toString());
+ if (!existingHint || existingHint.weight < hint.weight) {
+ this._hintMap.set(hint.variantId.toString(), hint);
+ }
+ }
+ });
+ this.requestUpdate('_hintMap');
+ },
+ 'umbObserveHints',
+ );
+ });
}
+ #isVariantHint(hint: UmbHint): hint is UmbVariantHint {
+ return hint && 'variantId' in hint;
+ }
+
+ @state()
+ private _hintMap = new Map();
+
async #observeVariants(workspaceContext?: UmbVariantDatasetWorkspaceContext) {
this.observe(
workspaceContext?.variantOptions,
@@ -302,6 +331,16 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
override render() {
if (!this._variantId) return nothing;
+ let firstHintOnInactiveVariant: UmbVariantHint | undefined;
+
+ if (this._activeVariant) {
+ 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 html`
+ ${this.#renderHintBadge(firstHintOnInactiveVariant)}
${this._activeVariants.length > 1
? html`
@@ -360,8 +400,11 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
const variantId = UmbVariantId.Create(variantOption);
const notCreated = this.#isCreateMode(variantOption, variantId);
const subVariantOptions = this.#getSegmentVariantOptionsForCulture(variantOption, variantId);
+ const hint = this._hintMap.get(variantId.toString());
+ const active = this.#isVariantActive(variantId);
+
return html`
-
+
${this._variesBySegment && this.#isCreated(variantOption) && subVariantOptions.length > 0
? html`
${this.#renderExpandToggle(variantId)}
`
: nothing}
@@ -381,6 +424,7 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
${this.#getVariantSpecInfo(variantOption)}
+ ${this.#renderHintBadge(!active ? hint : undefined)}
${this.#renderSplitViewButton(variantOption)}
@@ -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.

-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