From d5a2f0572edf9a842c05247cde3a8f9b6285c0e8 Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Mon, 20 Oct 2025 10:51:38 +0100 Subject: [PATCH 01/17] Preview: Redirect to published URL on exit (#20556) * Preview Exit: Gets the page's published URL on exit for redirect * Preview Open Website: Uses the page's published URL * Tweaked the published URL logic * Code amends based on @copilot's suggestions --- .../apps/preview/apps/preview-exit.element.ts | 2 +- .../apps/preview-open-website.element.ts | 2 +- .../src/apps/preview/preview.context.ts | 35 ++++++++++++++++--- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts index 95e907a38a..26af65490b 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-exit.element.ts @@ -6,7 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; export class UmbPreviewExitElement extends UmbLitElement { async #onClick() { const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT); - previewContext?.exitPreview(0); + await previewContext?.exitPreview(0); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts index aba28768e5..4a77454df8 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/apps/preview-open-website.element.ts @@ -6,7 +6,7 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; export class UmbPreviewOpenWebsiteElement extends UmbLitElement { async #onClick() { const previewContext = await this.getContext(UMB_PREVIEW_CONTEXT); - previewContext?.openWebsite(); + await previewContext?.openWebsite(); } override render() { diff --git a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts index 21899e41f8..75de96a38c 100644 --- a/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts +++ b/src/Umbraco.Web.UI.Client/src/apps/preview/preview.context.ts @@ -1,10 +1,12 @@ -import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { tryExecute } from '@umbraco-cms/backoffice/resources'; import { umbConfirmModal } from '@umbraco-cms/backoffice/modal'; +import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbBooleanState, UmbStringState } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; import { UmbDocumentPreviewRepository } from '@umbraco-cms/backoffice/document'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; const UMB_LOCALSTORAGE_SESSION_KEY = 'umb:previewSessions'; @@ -89,6 +91,19 @@ export class UmbPreviewContext extends UmbContextBase { }); } + async #getPublishedUrl(): Promise { + if (!this.#unique) return null; + + // NOTE: We should be reusing `UmbDocumentUrlRepository` here, but the preview app doesn't register the `itemStore` extensions, so can't resolve/consume `UMB_DOCUMENT_URL_STORE_CONTEXT`. [LK] + const { data } = await tryExecute(this, DocumentService.getDocumentUrls({ query: { id: [this.#unique] } })); + + if (!data?.length) return null; + const urlInfo = this.#culture ? data[0].urlInfos.find((x) => x.culture === this.#culture) : data[0].urlInfos[0]; + + if (!urlInfo?.url) return null; + return urlInfo.url.startsWith('/') ? `${this.#serverUrl}${urlInfo.url}` : urlInfo.url; + } + #getSessionCount(): number { return Math.max(Number(localStorage.getItem(UMB_LOCALSTORAGE_SESSION_KEY)), 0) || 0; } @@ -170,7 +185,12 @@ export class UmbPreviewContext extends UmbContextBase { this.#webSocket = undefined; } - const url = this.#previewUrl.getValue() as string; + let url = await this.#getPublishedUrl(); + + if (!url) { + url = this.#previewUrl.getValue() as string; + } + window.location.replace(url); } @@ -190,8 +210,13 @@ export class UmbPreviewContext extends UmbContextBase { return this.getHostElement().shadowRoot?.querySelector('#wrapper') as HTMLElement; } - openWebsite() { - const url = this.#previewUrl.getValue() as string; + async openWebsite() { + let url = await this.#getPublishedUrl(); + + if (!url) { + url = this.#previewUrl.getValue() as string; + } + window.open(url, '_blank'); } From 7751e40ba859be33f3eb8924329c8ccd60961c91 Mon Sep 17 00:00:00 2001 From: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com> Date: Tue, 21 Oct 2025 13:50:13 +0700 Subject: [PATCH 02/17] E2E: QA Fixed the flaky tests related to publishing content with image cropper (#20577) Added more waits --- .../tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts index 717fc227c3..21da07d52f 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/ContentWithImageCropper.spec.ts @@ -60,6 +60,8 @@ test('can publish content with the image cropper data type', {tag: '@smoke'}, as // Act await umbracoUi.content.goToContentWithName(contentName); await umbracoUi.content.uploadFile(imageFilePath); + // Wait for the upload to complete + await umbracoUi.waitForTimeout(1000); await umbracoUi.content.clickSaveAndPublishButton(); // Assert From 5337c38f2c7e8126b7a247dcc5710d82ea6a221f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:56:59 +0000 Subject: [PATCH 03/17] Bump vite from 7.1.9 to 7.1.11 in /src/Umbraco.Web.UI.Client Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.9 to 7.1.11. - [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.11/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.1.11 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- src/Umbraco.Web.UI.Client/package-lock.json | 8 ++++---- src/Umbraco.Web.UI.Client/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json index b2657a2a27..cf63f6c064 100644 --- a/src/Umbraco.Web.UI.Client/package-lock.json +++ b/src/Umbraco.Web.UI.Client/package-lock.json @@ -58,7 +58,7 @@ "typescript": "5.9.3", "typescript-eslint": "^8.45.0", "typescript-json-schema": "^0.65.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-plugin-static-copy": "^3.1.3", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" @@ -16364,9 +16364,9 @@ } }, "node_modules/vite": { - "version": "7.1.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", - "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index d9145b2732..f9ce4923ea 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -261,7 +261,7 @@ "typescript": "5.9.3", "typescript-eslint": "^8.45.0", "typescript-json-schema": "^0.65.1", - "vite": "^7.1.9", + "vite": "^7.1.11", "vite-plugin-static-copy": "^3.1.3", "vite-tsconfig-paths": "^5.1.4", "web-component-analyzer": "^2.0.0" From ae41438a366aec1198d3529b4c4051f10d05201c Mon Sep 17 00:00:00 2001 From: Lee Kelleher Date: Tue, 21 Oct 2025 08:28:01 +0100 Subject: [PATCH 04/17] Tiptap RTE: Allow removal of unregistered extensions (#20571) * Tiptap toolbar config: enable removal of unregistered extensions * Tiptap statusbar config: enable removal of unregistered extensions * Tiptap toolbar config: Typescript tidy-up * Tiptap toolbar sorting amend Removed the need for the `tiptap-toolbar-alias` attribute, we can reuse the `data-mark`. * Tiptap extension config UI amend If the extension doesn't have a `description`, then add the `alias` to the title/tooltip, to give a DX hint. * Tiptap toolbar: adds `title` to placeholder skeleton * Added missing `forExtensions` for Style Select and Horizontal Rule toolbar extensions * Update src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/property-editor-ui-tiptap-statusbar-configuration.element.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../toolbar/tiptap-toolbar.element.ts | 6 +- .../extensions/horizontal-rule/manifests.ts | 1 + .../extensions/style-select/manifests.ts | 1 + ...tiptap-extensions-configuration.element.ts | 14 +---- ...-tiptap-statusbar-configuration.element.ts | 62 ++++++++++++------- .../tiptap-statusbar-configuration.context.ts | 5 +- ...ui-tiptap-toolbar-configuration.element.ts | 4 +- .../tiptap-toolbar-configuration.context.ts | 4 +- ...tap-toolbar-group-configuration.element.ts | 42 +++++++------ .../packages/tiptap/property-editors/types.ts | 9 +-- 10 files changed, 79 insertions(+), 69 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts index 50e548eec3..0627c2c3e7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/components/toolbar/tiptap-toolbar.element.ts @@ -92,11 +92,11 @@ export class UmbTiptapToolbarElement extends UmbLitElement { } #renderActions(aliases: Array) { - return repeat(aliases, (alias) => this.#lookup?.get(alias) ?? this.#renderActionPlaceholder()); + return repeat(aliases, (alias) => this.#lookup?.get(alias) ?? this.#renderActionPlaceholder(alias)); } - #renderActionPlaceholder() { - return html``; + #renderActionPlaceholder(alias: string) { + return html``; } static override readonly styles = css` diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts index 47523bb921..86ec324126 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/horizontal-rule/manifests.ts @@ -16,6 +16,7 @@ export const manifests: Array = [ alias: 'Umb.Tiptap.Toolbar.HorizontalRule', name: 'Horizontal Rule Tiptap Toolbar Extension', api: () => import('./horizontal-rule.tiptap-toolbar-api.js'), + forExtensions: ['Umb.Tiptap.HorizontalRule'], meta: { alias: 'horizontalRule', icon: 'icon-horizontal-rule', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts index 60c0894e84..b6a98f1ddf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/extensions/style-select/manifests.ts @@ -4,6 +4,7 @@ export const manifests: Array = [ kind: 'styleMenu', alias: 'Umb.Tiptap.Toolbar.StyleSelect', name: 'Style Select Tiptap Extension', + forExtensions: ['Umb.Tiptap.Heading', 'Umb.Tiptap.Blockquote', 'Umb.Tiptap.CodeBlock'], items: [ { label: 'Headers', diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts index ad71fe5568..89ac81a68f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/extensions-configuration/property-editor-ui-tiptap-extensions-configuration.element.ts @@ -1,14 +1,4 @@ -import { - css, - customElement, - html, - ifDefined, - nothing, - property, - state, - repeat, - when, -} from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing, property, state, repeat, when } from '@umbraco-cms/backoffice/external/lit'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; @@ -166,7 +156,7 @@ export class UmbPropertyEditorUiTiptapExtensionsConfigurationElement ${repeat( group.extensions, (item) => html` -
  • +
  • this.#context.removeStatusbarItem([areaIndex, itemIndex])} - @dragend=${this.#onDragEnd} - @dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [areaIndex, itemIndex])}> -
    - ${when(item.icon, (icon) => html``)} - ${label} -
    - - `; + switch (item.kind) { + case 'unknown': + return html` + this.#context.removeStatusbarItem([areaIndex, itemIndex])}> + `; + + default: + return html` + this.#context.removeStatusbarItem([areaIndex, itemIndex])} + @dragend=${this.#onDragEnd} + @dragstart=${(e: DragEvent) => this.#onDragStart(e, alias, [areaIndex, itemIndex])}> +
    + ${when(item.icon, (icon) => html``)} + ${label} +
    +
    + `; + } } static override readonly styles = [ @@ -303,8 +317,8 @@ export class UmbPropertyEditorUiTiptapStatusbarConfigurationElement --color-standalone: var(--uui-color-danger-standalone); --color-emphasis: var(--uui-color-danger-emphasis); --color-contrast: var(--uui-color-danger); - --uui-button-contrast-disabled: var(--uui-color-danger); - --uui-button-border-color-disabled: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-danger); } div { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts index 8b3e29cf6e..84844d673f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/statusbar-configuration/tiptap-statusbar-configuration.context.ts @@ -31,6 +31,7 @@ export class UmbTiptapStatusbarConfigurationContext extends UmbContextBase { const _extensions = extensions .sort((a, b) => a.alias.localeCompare(b.alias)) .map((ext) => ({ + kind: 'default', alias: ext.alias, label: ext.meta.label, icon: ext.meta.icon, @@ -75,8 +76,8 @@ export class UmbTiptapStatusbarConfigurationContext extends UmbContextBase { .filter((ext) => ext.alias?.toLowerCase().includes(query) || ext.label?.toLowerCase().includes(query)); } - public getExtensionByAlias(alias: string): UmbTiptapStatusbarExtension | undefined { - return this.#lookup?.get(alias); + public getExtensionByAlias(alias: string): UmbTiptapStatusbarExtension { + return this.#lookup?.get(alias) ?? { label: '', alias, icon: '', kind: 'unknown' }; } public isExtensionEnabled(alias: string): boolean { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts index bb000b6d7c..cebfc2463c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/property-editor-ui-tiptap-toolbar-configuration.element.ts @@ -255,9 +255,7 @@ export class UmbPropertyEditorUiTiptapToolbarConfigurationElement #renderGroup(group?: UmbTiptapToolbarGroupViewModel, rowIndex = 0, groupIndex = 0) { if (!group) return nothing; const showActionBar = this._toolbar[rowIndex].data.length > 1 && group.data.length === 0; - const items: UmbTiptapToolbarExtension[] = group!.data - .map((alias) => this.#context?.getExtensionByAlias(alias)) - .filter((item): item is UmbTiptapToolbarExtension => !!item); + const items = group.data.map((alias) => this.#context?.getExtensionByAlias(alias)); return html`
    ext.alias?.toLowerCase().includes(query) || ext.label?.toLowerCase().includes(query)); } - public getExtensionByAlias(alias: string): UmbTiptapToolbarExtension | undefined { - return this.#lookup?.get(alias); + public getExtensionByAlias(alias: string): UmbTiptapToolbarExtension { + return this.#lookup?.get(alias) ?? { label: '', alias, icon: '', kind: 'unknown' }; } public isExtensionEnabled(alias: string): boolean { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts index cc7b02d45c..dd8eba4ac8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/toolbar-configuration/tiptap-toolbar-group-configuration.element.ts @@ -10,9 +10,9 @@ export class UmbTiptapToolbarGroupConfigurationElement< TiptapToolbarItem extends UmbTiptapToolbarExtension = UmbTiptapToolbarExtension, > extends UmbLitElement { #sorter = new UmbSorterController(this, { - getUniqueOfElement: (element) => element.getAttribute('tiptap-toolbar-alias'), - getUniqueOfModel: (modelEntry) => modelEntry.alias!, - itemSelector: 'uui-button', + getUniqueOfElement: (element) => element.dataset.mark, + getUniqueOfModel: (modelEntry) => `tiptap-toolbar-item:${modelEntry.alias}`, + itemSelector: '.draggable', identifier: 'umb-tiptap-toolbar-sorter', containerSelector: '.items', resolvePlacement: UmbSorterResolvePlacementAsGrid, @@ -71,7 +71,7 @@ export class UmbTiptapToolbarGroupConfigurationElement< } #renderItem(item: TiptapToolbarItem, index = 0) { - const label = this.localize.string(item.label); + const label = this.localize.string(item.label) || item.alias; const forbidden = !this.#context?.isExtensionEnabled(item.alias); switch (item.kind) { @@ -80,13 +80,11 @@ export class UmbTiptapToolbarGroupConfigurationElement< return html` this.#onRequestRemove(item, index)}>
    ${label} @@ -95,18 +93,29 @@ export class UmbTiptapToolbarGroupConfigurationElement< `; + case 'unknown': + return html` + this.#onRequestRemove(item, index)}> + `; + case 'button': + case 'colorPickerButton': default: return html` this.#onRequestRemove(item, index)}>
    ${when( @@ -131,23 +140,18 @@ export class UmbTiptapToolbarGroupConfigurationElement< uui-button { --uui-button-font-weight: normal; - &[draggable='true'], - &[draggable='true'] > .inner { + &.draggable, + &.draggable > .inner { cursor: move; } - &[disabled], - &[disabled] > .inner { - cursor: not-allowed; - } - &.forbidden { --color: var(--uui-color-danger); --color-standalone: var(--uui-color-danger-standalone); --color-emphasis: var(--uui-color-danger-emphasis); --color-contrast: var(--uui-color-danger); - --uui-button-contrast-disabled: var(--uui-color-danger); - --uui-button-border-color-disabled: var(--uui-color-danger); + --uui-button-contrast: var(--uui-color-danger); + --uui-button-border-color: var(--uui-color-danger); } div { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts index c7c80b8c23..a6572054c9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tiptap/property-editors/types.ts @@ -1,17 +1,18 @@ export type UmbTiptapSortableViewModel = { unique: string; data: T }; -export type UmbTiptapStatusbarExtension = { +export type UmbTiptapExtensionBase = { + kind?: string; alias: string; label: string; icon: string; dependencies?: Array; }; +export type UmbTiptapStatusbarExtension = UmbTiptapExtensionBase; + export type UmbTiptapStatusbarViewModel = UmbTiptapSortableViewModel>; -export type UmbTiptapToolbarExtension = UmbTiptapStatusbarExtension & { - kind?: string; -}; +export type UmbTiptapToolbarExtension = UmbTiptapExtensionBase; export type UmbTiptapToolbarRowViewModel = UmbTiptapSortableViewModel>; export type UmbTiptapToolbarGroupViewModel = UmbTiptapSortableViewModel>; From 81a8a0c191a56921e2bda536e50d01e95910324d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Oct 2025 09:57:29 +0200 Subject: [PATCH 05/17] Hybrid Cache: Resolve start-up errors with mis-matched types (#20554) * Be consistent in use of GetOrCreateAsync overload in exists and retrieval. Ensure nullability of ContentCacheNode is consistent in exists and retrieval. * Applied suggestion from code review. * Move seeding to Umbraco application starting rather than started, ensuring an initial request is served. * Tighten up hybrid cache exists check with locking around check and remove, and use of cancellation token. --- .../UmbracoBuilderExtensions.cs | 3 +- .../Extensions/HybridCacheExtensions.cs | 67 +++++++++++++------ .../SeedingNotificationHandler.cs | 6 +- .../Services/DocumentCacheService.cs | 4 +- .../Services/MediaCacheService.cs | 4 +- .../DocumentHybridCacheTests.cs | 60 ++++++++++++++++- .../Extensions/HybridCacheExtensionsTests.cs | 55 +++++++-------- 7 files changed, 141 insertions(+), 58 deletions(-) diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs index ca625dacdf..e97c60fd62 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,4 +1,3 @@ - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -74,7 +73,7 @@ public static class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); - builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); builder.AddCacheSeeding(); return builder; } diff --git a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs index ee1b7aefda..ccd5897494 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; @@ -7,19 +8,24 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; /// internal static class HybridCacheExtensions { + // Per-key semaphores to ensure the GetOrCreateAsync + RemoveAsync sequence + // executes atomically for a given cache key. + private static readonly ConcurrentDictionary _keyLocks = new(); + /// /// Returns true if the cache contains an item with a matching key. /// /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// True if the item exists already. False if it doesn't. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { - (bool exists, _) = await TryGetValueAsync(cache, key); + (bool exists, _) = await TryGetValueAsync(cache, key, token).ConfigureAwait(false); return exists; } @@ -29,34 +35,55 @@ internal static class HybridCacheExtensions /// The type of the value of the item in the cache. /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// A tuple of and the object (if found) retrieved from the cache. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { var exists = true; - T? result = await cache.GetOrCreateAsync( - key, - null!, - (_, _) => - { - exists = false; - return new ValueTask(default(T)!); - }, - new HybridCacheEntryOptions(), - null, - CancellationToken.None); + // Acquire a per-key semaphore so that GetOrCreateAsync and the possible RemoveAsync + // complete without another thread retrieving/creating the same key in-between. + SemaphoreSlim sem = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. - // So remove it again. - if (exists is false) + await sem.WaitAsync().ConfigureAwait(false); + + try { - await cache.RemoveAsync(key); - } + T? result = await cache.GetOrCreateAsync( + key, + cancellationToken => + { + exists = false; + return default; + }, + new HybridCacheEntryOptions(), + null, + token).ConfigureAwait(false); - return (exists, result); + // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. + // So remove it again. Because we're holding the per-key lock there is no chance another thread + // will observe the temporary entry between GetOrCreateAsync and RemoveAsync. + if (exists is false) + { + await cache.RemoveAsync(key).ConfigureAwait(false); + } + + return (exists, result); + } + finally + { + sem.Release(); + + // Only remove the semaphore mapping if it still points to the same instance we used. + // This avoids removing another thread's semaphore or corrupting the map. + if (_keyLocks.TryGetValue(key, out SemaphoreSlim? current) && ReferenceEquals(current, sem)) + { + _keyLocks.TryRemove(key, out _); + } + } } } diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs index 0581bd2654..38a6618c70 100644 --- a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; @@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.HybridCache.Services; namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; -internal sealed class SeedingNotificationHandler : INotificationAsyncHandler +internal sealed class SeedingNotificationHandler : INotificationAsyncHandler { private readonly IDocumentCacheService _documentCacheService; private readonly IMediaCacheService _mediaCacheService; @@ -24,7 +24,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (existsInCache is false) { uncachedKeys.Add(key); @@ -278,7 +278,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService return false; } - return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview)); + return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview), CancellationToken.None); } public async Task RefreshContentAsync(IContent content) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 65b8f91945..46d782bdbe 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -133,7 +133,7 @@ internal sealed class MediaCacheService : IMediaCacheService return false; } - return await _hybridCache.ExistsAsync($"{keyAttempt.Result}"); + return await _hybridCache.ExistsAsync($"{keyAttempt.Result}", CancellationToken.None); } public async Task RefreshMediaAsync(IMedia media) @@ -170,7 +170,7 @@ internal sealed class MediaCacheService : IMediaCacheService var cacheKey = GetCacheKey(key, false); - var existsInCache = await _hybridCache.ExistsAsync(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, CancellationToken.None); if (existsInCache is false) { uncachedKeys.Add(key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs index 0c4207331a..088f14cd31 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -25,10 +26,10 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); - private IContentEditingService ContentEditingService => GetRequiredService(); - private IContentPublishingService ContentPublishingService => GetRequiredService(); + private IDocumentCacheService DocumentCacheService => GetRequiredService(); + private const string NewName = "New Name"; private const string NewTitle = "New Title"; @@ -460,6 +461,61 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte Assert.IsNull(textPage); } + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Check_Where_Not_Found() + { + // Arrange + var testPageKey = Guid.NewGuid(); + + // Act & Assert + // - assert we cannot get the content that doesn't yet exist from the cache + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + // - create and publish the content + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + // - assert we can now get the content from the cache + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + } + + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Exists_Check() + { + // Act + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(PublishedTextPageId); + Assert.IsTrue(hasContentForTextPageCached); + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId); + + // Assert + AssertPublishedTextPage(textPage); + } + + [Test] + public async Task Can_Do_Exists_Check_On_Created_Published_Content() + { + var testPageKey = Guid.NewGuid(); + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(testPage.Id); + Assert.IsTrue(hasContentForTextPageCached); + } + private void AssertTextPage(IPublishedContent textPage) { Assert.Multiple(() => diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs index 152fe28b4e..8da30cd118 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.Extensions.Caching.Hybrid; using Moq; using NUnit.Framework; @@ -33,15 +34,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -56,24 +57,24 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => { - return factory(state!, token); + return factory(state, token); }); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists); @@ -89,15 +90,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -114,15 +115,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -138,15 +139,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(null!); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -160,16 +161,16 @@ public class HybridCacheExtensionsTests string key = "test-key"; _cacheMock.Setup(cache => cache.GetOrCreateAsync( - key, - null, - It.IsAny>>(), - It.IsAny(), - null, - CancellationToken.None)) + key, + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), + It.IsAny(), + null, + CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => @@ -178,7 +179,7 @@ public class HybridCacheExtensionsTests }); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists); From 1ceec183a3d28409b924b972a9bb88a360a9abd0 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Oct 2025 11:38:01 +0200 Subject: [PATCH 06/17] Media: Fixes SQL error to ensure database relation between user group media start folder and deleted media item is removed (closes #20555) (#20572) Fixes SQL error to ensure database relation between user group media start folder and deleted media item is removed. --- .../Persistence/Repositories/Implement/MediaRepository.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs index 75dc8a3eb7..7dd53cbeb4 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/MediaRepository.cs @@ -270,7 +270,7 @@ public class MediaRepository : ContentRepositoryBase Date: Tue, 21 Oct 2025 12:05:10 +0200 Subject: [PATCH 07/17] Publishing: Resolve exceptions on publish branch (#20464) * Reduce log level of image cropper converter to avoid flooding logs with expected exceptions. * Don't run publish branch long running operation on a background thread such that UmbracoContext is available. * Revert to background thread and use EnsureUmbracoContext to ensure we can get an IUmbracoContext in the URL providers. * Updated tests. * Applied suggestion from code review. * Clarified comment. --- .../Services/ContentPublishingService.cs | 12 ++++++++++-- .../ValueConverters/ImageCropperValueConverter.cs | 7 ++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index 698efbda91..b46608aa14 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -8,6 +8,7 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Web; using Umbraco.Extensions; namespace Umbraco.Cms.Core.Services; @@ -26,6 +27,7 @@ internal sealed class ContentPublishingService : IContentPublishingService private readonly IRelationService _relationService; private readonly ILogger _logger; private readonly ILongRunningOperationService _longRunningOperationService; + private readonly IUmbracoContextFactory _umbracoContextFactory; public ContentPublishingService( ICoreScopeProvider coreScopeProvider, @@ -37,7 +39,8 @@ internal sealed class ContentPublishingService : IContentPublishingService IOptionsMonitor optionsMonitor, IRelationService relationService, ILogger logger, - ILongRunningOperationService longRunningOperationService) + ILongRunningOperationService longRunningOperationService, + IUmbracoContextFactory umbracoContextFactory) { _coreScopeProvider = coreScopeProvider; _contentService = contentService; @@ -53,6 +56,7 @@ internal sealed class ContentPublishingService : IContentPublishingService { _contentSettings = contentSettings; }); + _umbracoContextFactory = umbracoContextFactory; } /// @@ -290,7 +294,7 @@ internal sealed class ContentPublishingService : IContentPublishingService return MapInternalPublishingAttempt(minimalAttempt); } - _logger.LogInformation("Starting async background thread for publishing branch."); + _logger.LogDebug("Starting long running operation for publishing branch {Key} on background thread.", key); Attempt enqueueAttempt = await _longRunningOperationService.RunAsync( PublishBranchOperationType, async _ => await PerformPublishBranchAsync(key, cultures, publishBranchFilter, userKey, returnContent: false), @@ -324,6 +328,10 @@ internal sealed class ContentPublishingService : IContentPublishingService Guid userKey, bool returnContent) { + // Ensure we have an UmbracoContext in case running on a background thread so operations that run in the published notification handlers + // have access to this (e.g. webhooks). + using UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); IContent? content = _contentService.GetById(key); if (content is null) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs index 9cb5d57474..0218a10b63 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/ImageCropperValueConverter.cs @@ -1,6 +1,7 @@ // Copyright (c) Umbraco. // See LICENSE for more details. +using System.Text.Json; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; @@ -53,10 +54,10 @@ public class ImageCropperValueConverter : PropertyValueConverterBase, IDeliveryA { value = _jsonSerializer.Deserialize(sourceString); } - catch (Exception ex) + catch (JsonException ex) { - // cannot deserialize, assume it may be a raw image URL - _logger.LogError(ex, "Could not deserialize string '{JsonString}' into an image cropper value.", sourceString); + // Cannot deserialize, assume it may be a raw image URL. + _logger.LogDebug(ex, "Could not deserialize string '{JsonString}' into an image cropper value.", sourceString); value = new ImageCropperValue { Src = sourceString }; } From 5488c77e0e960899c28f64fd51bffd8759a533cd Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Oct 2025 15:26:29 +0200 Subject: [PATCH 08/17] Bumped version to 16.3.2. --- src/Umbraco.Web.UI.Client/package.json | 2 +- version.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json index 766ffa96ba..74bf09f5a9 100644 --- a/src/Umbraco.Web.UI.Client/package.json +++ b/src/Umbraco.Web.UI.Client/package.json @@ -1,7 +1,7 @@ { "name": "@umbraco-cms/backoffice", "license": "MIT", - "version": "16.3.1", + "version": "16.3.2", "type": "module", "exports": { ".": null, diff --git a/version.json b/version.json index 922b55eca9..7167818c6d 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "16.3.1", + "version": "16.3.2", "assemblyVersion": { "precision": "build" }, From 8aa9dc8f1938aae457d4e22dcf5c96272b627057 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 21 Oct 2025 09:57:29 +0200 Subject: [PATCH 09/17] Hybrid Cache: Resolve start-up errors with mis-matched types (#20554) * Be consistent in use of GetOrCreateAsync overload in exists and retrieval. Ensure nullability of ContentCacheNode is consistent in exists and retrieval. * Applied suggestion from code review. * Move seeding to Umbraco application starting rather than started, ensuring an initial request is served. * Tighten up hybrid cache exists check with locking around check and remove, and use of cancellation token. --- .../UmbracoBuilderExtensions.cs | 3 +- .../Extensions/HybridCacheExtensions.cs | 67 +++++++++++++------ .../SeedingNotificationHandler.cs | 6 +- .../Services/DocumentCacheService.cs | 4 +- .../Services/MediaCacheService.cs | 4 +- .../DocumentHybridCacheTests.cs | 60 ++++++++++++++++- .../Extensions/HybridCacheExtensionsTests.cs | 55 +++++++-------- 7 files changed, 141 insertions(+), 58 deletions(-) diff --git a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs index ca625dacdf..e97c60fd62 100644 --- a/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,4 +1,3 @@ - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; @@ -74,7 +73,7 @@ public static class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); - builder.AddNotificationAsyncHandler(); + builder.AddNotificationAsyncHandler(); builder.AddCacheSeeding(); return builder; } diff --git a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs index ee1b7aefda..ccd5897494 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; @@ -7,19 +8,24 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; /// internal static class HybridCacheExtensions { + // Per-key semaphores to ensure the GetOrCreateAsync + RemoveAsync sequence + // executes atomically for a given cache key. + private static readonly ConcurrentDictionary _keyLocks = new(); + /// /// Returns true if the cache contains an item with a matching key. /// /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// True if the item exists already. False if it doesn't. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { - (bool exists, _) = await TryGetValueAsync(cache, key); + (bool exists, _) = await TryGetValueAsync(cache, key, token).ConfigureAwait(false); return exists; } @@ -29,34 +35,55 @@ internal static class HybridCacheExtensions /// The type of the value of the item in the cache. /// An instance of /// The name (key) of the item to search for in the cache. + /// The cancellation token. /// A tuple of and the object (if found) retrieved from the cache. /// /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 /// Will never add or alter the state of any items in the cache. /// - public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token) { var exists = true; - T? result = await cache.GetOrCreateAsync( - key, - null!, - (_, _) => - { - exists = false; - return new ValueTask(default(T)!); - }, - new HybridCacheEntryOptions(), - null, - CancellationToken.None); + // Acquire a per-key semaphore so that GetOrCreateAsync and the possible RemoveAsync + // complete without another thread retrieving/creating the same key in-between. + SemaphoreSlim sem = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); - // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. - // So remove it again. - if (exists is false) + await sem.WaitAsync().ConfigureAwait(false); + + try { - await cache.RemoveAsync(key); - } + T? result = await cache.GetOrCreateAsync( + key, + cancellationToken => + { + exists = false; + return default; + }, + new HybridCacheEntryOptions(), + null, + token).ConfigureAwait(false); - return (exists, result); + // In checking for the existence of the item, if not found, we will have created a cache entry with a null value. + // So remove it again. Because we're holding the per-key lock there is no chance another thread + // will observe the temporary entry between GetOrCreateAsync and RemoveAsync. + if (exists is false) + { + await cache.RemoveAsync(key).ConfigureAwait(false); + } + + return (exists, result); + } + finally + { + sem.Release(); + + // Only remove the semaphore mapping if it still points to the same instance we used. + // This avoids removing another thread's semaphore or corrupting the map. + if (_keyLocks.TryGetValue(key, out SemaphoreSlim? current) && ReferenceEquals(current, sem)) + { + _keyLocks.TryRemove(key, out _); + } + } } } diff --git a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs index 0581bd2654..38a6618c70 100644 --- a/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs +++ b/src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; @@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.HybridCache.Services; namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers; -internal sealed class SeedingNotificationHandler : INotificationAsyncHandler +internal sealed class SeedingNotificationHandler : INotificationAsyncHandler { private readonly IDocumentCacheService _documentCacheService; private readonly IMediaCacheService _mediaCacheService; @@ -24,7 +24,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, cancellationToken).ConfigureAwait(false); if (existsInCache is false) { uncachedKeys.Add(key); @@ -278,7 +278,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService return false; } - return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview)); + return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview), CancellationToken.None); } public async Task RefreshContentAsync(IContent content) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 65b8f91945..46d782bdbe 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -133,7 +133,7 @@ internal sealed class MediaCacheService : IMediaCacheService return false; } - return await _hybridCache.ExistsAsync($"{keyAttempt.Result}"); + return await _hybridCache.ExistsAsync($"{keyAttempt.Result}", CancellationToken.None); } public async Task RefreshMediaAsync(IMedia media) @@ -170,7 +170,7 @@ internal sealed class MediaCacheService : IMediaCacheService var cacheKey = GetCacheKey(key, false); - var existsInCache = await _hybridCache.ExistsAsync(cacheKey); + var existsInCache = await _hybridCache.ExistsAsync(cacheKey, CancellationToken.None); if (existsInCache is false) { uncachedKeys.Add(key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs index 0c4207331a..088f14cd31 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -25,10 +26,10 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte private IPublishedContentCache PublishedContentHybridCache => GetRequiredService(); - private IContentEditingService ContentEditingService => GetRequiredService(); - private IContentPublishingService ContentPublishingService => GetRequiredService(); + private IDocumentCacheService DocumentCacheService => GetRequiredService(); + private const string NewName = "New Name"; private const string NewTitle = "New Title"; @@ -460,6 +461,61 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte Assert.IsNull(textPage); } + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Check_Where_Not_Found() + { + // Arrange + var testPageKey = Guid.NewGuid(); + + // Act & Assert + // - assert we cannot get the content that doesn't yet exist from the cache + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNull(testPage); + + // - create and publish the content + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + // - assert we can now get the content from the cache + testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + } + + [Test] + public async Task Can_Get_Published_Content_By_Id_After_Previous_Exists_Check() + { + // Act + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(PublishedTextPageId); + Assert.IsTrue(hasContentForTextPageCached); + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId); + + // Assert + AssertPublishedTextPage(textPage); + } + + [Test] + public async Task Can_Do_Exists_Check_On_Created_Published_Content() + { + var testPageKey = Guid.NewGuid(); + var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey); + var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey); + Assert.IsTrue(publishResult.Success); + + var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey); + Assert.IsNotNull(testPage); + + var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(testPage.Id); + Assert.IsTrue(hasContentForTextPageCached); + } + private void AssertTextPage(IPublishedContent textPage) { Assert.Multiple(() => diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs index 152fe28b4e..8da30cd118 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs @@ -1,3 +1,4 @@ +using System; using Microsoft.Extensions.Caching.Hybrid; using Moq; using NUnit.Framework; @@ -33,15 +34,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -56,24 +57,24 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => { - return factory(state!, token); + return factory(state, token); }); // Act - var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists); @@ -89,15 +90,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -114,15 +115,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(expectedValue); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -138,15 +139,15 @@ public class HybridCacheExtensionsTests _cacheMock .Setup(cache => cache.GetOrCreateAsync( key, - null!, - It.IsAny>>(), + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), It.IsAny(), null, CancellationToken.None)) .ReturnsAsync(null!); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsTrue(exists); @@ -160,16 +161,16 @@ public class HybridCacheExtensionsTests string key = "test-key"; _cacheMock.Setup(cache => cache.GetOrCreateAsync( - key, - null, - It.IsAny>>(), - It.IsAny(), - null, - CancellationToken.None)) + key, + It.IsAny>>(), + It.IsAny>, CancellationToken, ValueTask>>(), + It.IsAny(), + null, + CancellationToken.None)) .Returns(( string key, - object? state, - Func> factory, + Func> state, + Func>, CancellationToken, ValueTask> factory, HybridCacheEntryOptions? options, IEnumerable? tags, CancellationToken token) => @@ -178,7 +179,7 @@ public class HybridCacheExtensionsTests }); // Act - var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key, CancellationToken.None); // Assert Assert.IsFalse(exists); From 942ccc82d9aca9aef02198dd75bdf6bf07c253d7 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:22:36 +0200 Subject: [PATCH 10/17] docs: Add 'Running Umbraco in Different Modes' section to copilot-instructions --- .github/copilot-instructions.md | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b438a0027b..ed34279ab9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -94,11 +94,18 @@ The solution contains 30 C# projects organized as follows: ## Common Tasks -### Frontend Development -For frontend-only changes: -1. Configure backend for frontend development: - ```json - +### Running Umbraco in Different Modes + +**Production Mode (Standard Development)** +Use this for backend development, testing full builds, or when you don't need hot reloading: +1. Build frontend assets: `cd src/Umbraco.Web.UI.Client && npm run build:for:cms` +2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build` +3. Access backoffice: `https://localhost:44339/umbraco` +4. Application uses compiled frontend from `wwwroot/umbraco/backoffice/` + +**Vite Dev Server Mode (Frontend Development with Hot Reload)** +Use this for frontend-only development with hot module reloading: +1. Configure backend for frontend development - Add to `src/Umbraco.Web.UI/appsettings.json` under `Umbraco:CMS:Security`: ```json "BackOfficeHost": "http://localhost:5173", "AuthorizeCallbackPathName": "/oauth_complete", @@ -107,6 +114,10 @@ For frontend-only changes: ``` 2. Run backend: `cd src/Umbraco.Web.UI && dotnet run --no-build` 3. Run frontend dev server: `cd src/Umbraco.Web.UI.Client && npm run dev:server` +4. Access backoffice: `http://localhost:5173/` (no `/umbraco` prefix) +5. Changes to TypeScript/Lit files hot reload automatically + +**Important:** Remove the `BackOfficeHost` configuration before committing or switching back to production mode. ### Backend-Only Development For backend-only changes, disable frontend builds: From caeb3454e1fe37a45580dc3bf81a2a31fecab18e Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Tue, 21 Oct 2025 16:22:57 +0200 Subject: [PATCH 11/17] build(dev): adds umbracoapplicationurl to vscode launch params --- .vscode/launch.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/launch.json b/.vscode/launch.json index ef4677989e..f4d47c3dab 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -101,6 +101,7 @@ "env": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_URLS": "https://localhost:44339", + "UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL": "https://localhost:44339", "UMBRACO__CMS__SECURITY__BACKOFFICEHOST": "http://localhost:5173", "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKPATHNAME": "/oauth_complete", "UMBRACO__CMS__SECURITY__AUTHORIZECALLBACKLOGOUTPATHNAME": "/logout", From 79639c0571ebd932d149ba0a1610281d04c39a49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 22 Oct 2025 11:52:09 +0200 Subject: [PATCH 12/17] Item Repository: Sort statuses by order of unique (#20603) * utility * ability to replace * deprecate removeStatus * no need to call this any longer * Sort statuses and ensure not appending statuses, only updating them # Conflicts: # src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts --- .../observable-api/states/array-state.test.ts | 70 ++++++++++++------- .../libs/observable-api/states/array-state.ts | 33 +++++++++ .../src/libs/observable-api/utils/index.ts | 1 + .../utils/replace-in-unique-array.function.ts | 19 +++++ .../core/picker-input/picker-input.context.ts | 1 - .../repository/repository-items.manager.ts | 34 ++++++--- 6 files changed, 119 insertions(+), 39 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/replace-in-unique-array.function.ts diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts index d9d5d1ad4f..74c2e63c5c 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.test.ts @@ -5,7 +5,7 @@ describe('ArrayState', () => { type ObjectType = { key: string; another: string }; type ArrayType = ObjectType[]; - let subject: UmbArrayState; + let state: UmbArrayState; let initialData: ArrayType; beforeEach(() => { @@ -14,12 +14,12 @@ describe('ArrayState', () => { { key: '2', another: 'myValue2' }, { key: '3', another: 'myValue3' }, ]; - subject = new UmbArrayState(initialData, (x) => x.key); + state = new UmbArrayState(initialData, (x) => x.key); }); it('replays latests, no matter the amount of subscriptions.', (done) => { let amountOfCallbacks = 0; - const observer = subject.asObservable(); + const observer = state.asObservable(); observer.subscribe((value) => { amountOfCallbacks++; expect(value).to.be.equal(initialData); @@ -36,8 +36,8 @@ describe('ArrayState', () => { it('remove method, removes the one with the key', (done) => { const expectedData = [initialData[0], initialData[2]]; - subject.remove(['2']); - const observer = subject.asObservable(); + state.remove(['2']); + const observer = state.asObservable(); observer.subscribe((value) => { expect(JSON.stringify(value)).to.be.equal(JSON.stringify(expectedData)); done(); @@ -45,17 +45,17 @@ describe('ArrayState', () => { }); it('getHasOne method, return true when key exists', () => { - expect(subject.getHasOne('2')).to.be.true; + expect(state.getHasOne('2')).to.be.true; }); it('getHasOne method, return false when key does not exists', () => { - expect(subject.getHasOne('1337')).to.be.false; + expect(state.getHasOne('1337')).to.be.false; }); it('filter method, removes anything that is not true of the given predicate method', (done) => { const expectedData = [initialData[0], initialData[2]]; - subject.filter((x) => x.key !== '2'); - const observer = subject.asObservable(); + state.filter((x) => x.key !== '2'); + const observer = state.asObservable(); observer.subscribe((value) => { expect(JSON.stringify(value)).to.be.equal(JSON.stringify(expectedData)); done(); @@ -64,11 +64,11 @@ describe('ArrayState', () => { it('add new item via appendOne method.', (done) => { const newItem = { key: '4', another: 'myValue4' }; - subject.appendOne(newItem); + state.appendOne(newItem); const expectedData = [...initialData, newItem]; - const observer = subject.asObservable(); + const observer = state.asObservable(); observer.subscribe((value) => { expect(value.length).to.be.equal(expectedData.length); expect(value[3].another).to.be.equal(expectedData[3].another); @@ -78,9 +78,25 @@ describe('ArrayState', () => { it('partially update an existing item via updateOne method.', (done) => { const newItem = { another: 'myValue2.2' }; - subject.updateOne('2', newItem); + state.updateOne('2', newItem); - const observer = subject.asObservable(); + const observer = state.asObservable(); + observer.subscribe((value) => { + expect(value.length).to.be.equal(initialData.length); + expect(value[0].another).to.be.equal('myValue1'); + expect(value[1].another).to.be.equal('myValue2.2'); + done(); + }); + }); + + it('replaces only existing items via replace method.', (done) => { + const newItems = [ + { key: '2', another: 'myValue2.2' }, + { key: '4', another: 'myValue4.4' }, + ]; + state.replace(newItems); + + const observer = state.asObservable(); observer.subscribe((value) => { expect(value.length).to.be.equal(initialData.length); expect(value[0].another).to.be.equal('myValue1'); @@ -90,7 +106,7 @@ describe('ArrayState', () => { }); it('getObservablePart for a specific entry of array', (done) => { - const subObserver = subject.asObservablePart((data) => data.find((x) => x.key === '2')); + const subObserver = state.asObservablePart((data) => data.find((x) => x.key === '2')); subObserver.subscribe((entry) => { if (entry) { expect(entry.another).to.be.equal(initialData[1].another); @@ -103,7 +119,7 @@ describe('ArrayState', () => { let amountOfCallbacks = 0; const newItem = { key: '4', another: 'myValue4' }; - const subObserver = subject.asObservablePart((data) => data.find((x) => x.key === newItem.key)); + const subObserver = state.asObservablePart((data) => data.find((x) => x.key === newItem.key)); subObserver.subscribe((entry) => { amountOfCallbacks++; if (amountOfCallbacks === 1) { @@ -118,16 +134,16 @@ describe('ArrayState', () => { } }); - subject.appendOne(newItem); + state.appendOne(newItem); }); it('asObservable returns the replaced item', (done) => { const newItem = { key: '2', another: 'myValue4' }; - subject.appendOne(newItem); + state.appendOne(newItem); const expectedData = [initialData[0], newItem, initialData[2]]; - const observer = subject.asObservable(); + const observer = state.asObservable(); observer.subscribe((value) => { expect(value.length).to.be.equal(expectedData.length); expect(value[1].another).to.be.equal(newItem.another); @@ -137,9 +153,9 @@ describe('ArrayState', () => { it('getObservablePart returns the replaced item', (done) => { const newItem = { key: '2', another: 'myValue4' }; - subject.appendOne(newItem); + state.appendOne(newItem); - const subObserver = subject.asObservablePart((data) => data.find((x) => x.key === newItem.key)); + const subObserver = state.asObservablePart((data) => data.find((x) => x.key === newItem.key)); subObserver.subscribe((entry) => { expect(entry).to.be.equal(newItem); // Second callback should give us the right data: if (entry) { @@ -152,7 +168,7 @@ describe('ArrayState', () => { it('getObservablePart replays existing data to any amount of subscribers.', (done) => { let amountOfCallbacks = 0; - const subObserver = subject.asObservablePart((data) => data.find((x) => x.key === '2')); + const subObserver = state.asObservablePart((data) => data.find((x) => x.key === '2')); subObserver.subscribe((entry) => { if (entry) { amountOfCallbacks++; @@ -173,7 +189,7 @@ describe('ArrayState', () => { it('getObservablePart replays existing data to any amount of subscribers.', (done) => { let amountOfCallbacks = 0; - const subObserver = subject.asObservablePart((data) => data.find((x) => x.key === '2')); + const subObserver = state.asObservablePart((data) => data.find((x) => x.key === '2')); subObserver.subscribe((entry) => { if (entry) { amountOfCallbacks++; @@ -194,7 +210,7 @@ describe('ArrayState', () => { it('append only updates observable if changes item', (done) => { let count = 0; - const observer = subject.asObservable(); + const observer = state.asObservable(); observer.subscribe((value) => { count++; if (count === 1) { @@ -212,12 +228,12 @@ describe('ArrayState', () => { Promise.resolve().then(() => { // Despite how many times this happens it should not trigger any change. - subject.append(initialData); - subject.append(initialData); - subject.append(initialData); + state.append(initialData); + state.append(initialData); + state.append(initialData); Promise.resolve().then(() => { - subject.appendOne({ key: '4', another: 'myValue4' }); + state.appendOne({ key: '4', another: 'myValue4' }); }); }); }); diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts index 26cf9261ab..3dab6a21c0 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/states/array-state.ts @@ -1,6 +1,7 @@ import { partialUpdateFrozenArray } from '../utils/partial-update-frozen-array.function.js'; import { pushAtToUniqueArray } from '../utils/push-at-to-unique-array.function.js'; import { pushToUniqueArray } from '../utils/push-to-unique-array.function.js'; +import { replaceInUniqueArray } from '../utils/replace-in-unique-array.function.js'; import { UmbDeepState } from './deep-state.js'; /** @@ -262,6 +263,38 @@ export class UmbArrayState extends UmbDeepState { return this; } + /** + * @function replace + * @param {Partial} entires - data of entries to be replaced. + * @returns {UmbArrayState} Reference to it self. + * @description - Replaces one or more entries, requires the ArrayState to be constructed with a getUnique method. + * @example Example append some data. + * const data = [ + * { key: 1, value: 'foo'}, + * { key: 2, value: 'bar'} + * ]; + * const myState = new UmbArrayState(data, (x) => x.key); + * const updates = [ + * { key: 1, value: 'foo2'}, + * { key: 3, value: 'bar2'} + * ]; + * myState.replace(updates); + * // Only the existing item gets replaced: + * myState.getValue(); // -> [{ key: 1, value: 'foo2'}, { key: 2, value: 'bar'}] + */ + replace(entries: Array): UmbArrayState { + if (this.getUniqueMethod) { + const next = [...this.getValue()]; + entries.forEach((entry) => { + replaceInUniqueArray(next, entry as T, this.getUniqueMethod!); + }); + this.setValue(next); + } else { + throw new Error("Can't replace entries of an ArrayState without a getUnique method provided when constructed."); + } + return this; + } + /** * @function updateOne * @param {U} unique - Unique value to find entry to update. diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts index 3b3452ab11..0c90285e6c 100644 --- a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/index.ts @@ -12,5 +12,6 @@ export * from './observe-multiple.function.js'; export * from './partial-update-frozen-array.function.js'; export * from './push-at-to-unique-array.function.js'; export * from './push-to-unique-array.function.js'; +export * from './replace-in-unique-array.function.js'; export * from './simple-hash-code.function.js'; export * from './strict-equality-memoization.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/replace-in-unique-array.function.ts b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/replace-in-unique-array.function.ts new file mode 100644 index 0000000000..1970c0b2a9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/libs/observable-api/utils/replace-in-unique-array.function.ts @@ -0,0 +1,19 @@ +/** + * @function replaceInUniqueArray + * @param {T[]} data - An array of objects. + * @param {T} entry - The object to replace with. + * @param {getUniqueMethod: (entry: T) => unknown} [getUniqueMethod] - Method to get the unique value of an entry. + * @description - Replaces an item of an Array. + * @example Example replace an entry of an Array. Where the key is unique and the item will only be replaced if matched with existing. + * const data = [{key: 'myKey', value:'initialValue'}]; + * const entry = {key: 'myKey', value: 'replacedValue'}; + * const newDataSet = replaceInUniqueArray(data, entry, x => x.key === key); + */ +export function replaceInUniqueArray(data: T[], entry: T, getUniqueMethod: (entry: T) => unknown): T[] { + const unique = getUniqueMethod(entry); + const indexToReplace = data.findIndex((x) => getUniqueMethod(x) === unique); + if (indexToReplace !== -1) { + data[indexToReplace] = entry; + } + return data; +} 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 de943aa88a..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 @@ -133,7 +133,6 @@ export class UmbPickerInputContext< #removeItem(unique: string) { const newSelection = this.getSelection().filter((value) => value !== unique); this.setSelection(newSelection); - this.#itemManager.removeStatus(unique); this.getHostElement().dispatchEvent(new UmbChangeEvent()); } } 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 cf227b3cf2..c60dc8badf 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 @@ -75,17 +75,20 @@ export class UmbRepositoryItemsManager exte (uniques) => { if (uniques.length === 0) { this.#items.setValue([]); + this.#statuses.setValue([]); return; } // TODO: This could be optimized so we only load the appended items, but this requires that the response checks that an item is still present in uniques. [NL] - // Check if we already have the items, and then just sort them: - const items = this.#items.getValue(); + // Check if we already have the statuses, and then just sort them: + const statuses = this.#statuses.getValue(); if ( - uniques.length === items.length && - uniques.every((unique) => items.find((item) => this.#getUnique(item) === unique)) + uniques.length === statuses.length && + uniques.every((unique) => statuses.find((status) => status.unique === unique)) ) { + const items = this.#items.getValue(); this.#items.setValue(this.#sortByUniques(items)); + this.#statuses.setValue(this.#sortByUniques(statuses)); } else { // We need to load new items, so ... this.#requestItems(); @@ -124,9 +127,17 @@ export class UmbRepositoryItemsManager exte return this.#items.asObservablePart((items) => items.find((item) => this.#getUnique(item) === unique)); } + /** + * @deprecated - This is resolved by setUniques, no need to update statuses. + * @param unique {string} - The unique identifier of the item to remove the status of. + */ removeStatus(unique: string) { - const newStatuses = this.#statuses.getValue().filter((status) => status.unique !== unique); - this.#statuses.setValue(newStatuses); + new UmbDeprecation({ + removeInVersion: '18.0.0', + deprecated: 'removeStatus', + solution: 'Statuses are removed automatically when setting uniques', + }).warn(); + this.#statuses.filter((status) => status.unique !== unique); } async getItemByUnique(unique: string) { @@ -144,6 +155,7 @@ export class UmbRepositoryItemsManager exte const requestedUniques = this.getUniques(); this.#statuses.setValue( + // No need to do sorting here as we just got the unique in the right order above. requestedUniques.map((unique) => ({ state: { type: 'loading', @@ -164,7 +176,7 @@ export class UmbRepositoryItemsManager exte } if (error) { - this.#statuses.append( + this.#statuses.replace( requestedUniques.map((unique) => ({ state: { type: 'error', @@ -185,7 +197,7 @@ export class UmbRepositoryItemsManager exte const resolvedUniques = requestedUniques.filter((unique) => !rejectedUniques.includes(unique)); this.#items.remove(rejectedUniques); - this.#statuses.append([ + this.#statuses.replace([ ...rejectedUniques.map( (unique) => ({ @@ -226,12 +238,11 @@ export class UmbRepositoryItemsManager exte const { data, error } = await this.repository.requestItems([unique]); if (error) { - this.#statuses.appendOne({ + this.#statuses.updateOne(unique, { state: { type: 'error', error: '#general_notFound', }, - unique, } as UmbRepositoryItemsStatus); } @@ -244,11 +255,12 @@ export class UmbRepositoryItemsManager exte const newItems = [...items]; newItems[index] = data[0]; this.#items.setValue(this.#sortByUniques(newItems)); + // No need to update statuses here, as the item is the same, just updated. } } } - #sortByUniques(data?: Array): Array { + #sortByUniques>(data?: Array): Array { if (!data) return []; const uniques = this.getUniques(); return [...data].sort((a, b) => { From 48759b9852990f021fb4cc0ffb5609c91ba79061 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 22 Oct 2025 12:21:42 +0200 Subject: [PATCH 13/17] Migrations: Use reliable GUID to check for existence of data type when creating (#20604) * Use reliable GUID to check for existence of data type in migration. * Retrieve just a single field in existence check. --- .../MigrateMediaTypeLabelProperties.cs | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_3_0/MigrateMediaTypeLabelProperties.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_3_0/MigrateMediaTypeLabelProperties.cs index efa48f00f2..71c824f357 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_3_0/MigrateMediaTypeLabelProperties.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_16_3_0/MigrateMediaTypeLabelProperties.cs @@ -6,7 +6,9 @@ using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Infrastructure.Persistence; using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_16_3_0; @@ -69,7 +71,7 @@ public class MigrateMediaTypeLabelProperties : AsyncMigrationBase private void IfNotExistsCreateBytesLabel() { - if (Database.Exists(Constants.DataTypes.LabelBytes)) + if (NodeExists(_labelBytesDataTypeKey)) { return; } @@ -89,7 +91,7 @@ public class MigrateMediaTypeLabelProperties : AsyncMigrationBase CreateDate = DateTime.Now, }; - _ = Database.Insert(Constants.DatabaseSchema.Tables.Node, "id", false, nodeDto); + Database.Insert(Constants.DatabaseSchema.Tables.Node, "id", false, nodeDto); var dataTypeDto = new DataTypeDto { @@ -100,12 +102,12 @@ public class MigrateMediaTypeLabelProperties : AsyncMigrationBase Configuration = "{\"umbracoDataValueType\":\"BIGINT\", \"labelTemplate\":\"{=value | bytes}\"}", }; - _ = Database.Insert(Constants.DatabaseSchema.Tables.DataType, "pk", false, dataTypeDto); + Database.Insert(Constants.DatabaseSchema.Tables.DataType, "pk", false, dataTypeDto); } private void IfNotExistsCreatePixelsLabel() { - if (Database.Exists(Constants.DataTypes.LabelPixels)) + if (NodeExists(_labelPixelsDataTypeKey)) { return; } @@ -125,7 +127,7 @@ public class MigrateMediaTypeLabelProperties : AsyncMigrationBase CreateDate = DateTime.Now, }; - _ = Database.Insert(Constants.DatabaseSchema.Tables.Node, "id", false, nodeDto); + Database.Insert(Constants.DatabaseSchema.Tables.Node, "id", false, nodeDto); var dataTypeDto = new DataTypeDto { @@ -136,7 +138,16 @@ public class MigrateMediaTypeLabelProperties : AsyncMigrationBase Configuration = "{\"umbracoDataValueType\":\"INT\", \"labelTemplate\":\"{=value}px\"}", }; - _ = Database.Insert(Constants.DatabaseSchema.Tables.DataType, "pk", false, dataTypeDto); + Database.Insert(Constants.DatabaseSchema.Tables.DataType, "pk", false, dataTypeDto); + } + + private bool NodeExists(Guid uniqueId) + { + Sql sql = Database.SqlContext.Sql() + .Select(x => x.NodeId) + .From() + .Where(x => x.UniqueId == uniqueId); + return Database.FirstOrDefault(sql) is not null; } private async Task MigrateMediaTypeLabels() From 21bf23b67df097cd0d40c8a44e582055185ff973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 22 Oct 2025 12:37:13 +0200 Subject: [PATCH 14/17] Dictionary: Fix shortcut Ctrl + S not saving dictionary items (#20605) * switched event listener from 'change' to 'input' * Update workspace-view-dictionary-editor.element.ts --- .../workspace-view-dictionary-editor.element.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts index b987fc3a64..dff606e519 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/workspace/views/workspace-view-dictionary-editor.element.ts @@ -1,7 +1,6 @@ import { UMB_DICTIONARY_WORKSPACE_CONTEXT } from '../dictionary-workspace.context-token.js'; import type { UmbDictionaryDetailModel } from '../../types.js'; import type { UUITextareaElement } from '@umbraco-cms/backoffice/external/uui'; -import { UUITextareaEvent } from '@umbraco-cms/backoffice/external/uui'; import { css, html, customElement, state, repeat } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbLanguageCollectionRepository, type UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language'; @@ -72,13 +71,11 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { } #onTextareaChange(e: Event) { - if (e instanceof UUITextareaEvent) { - const target = e.composedPath()[0] as UUITextareaElement; - const translation = (target.value as string).toString(); - const isoCode = target.getAttribute('name')!; + const target = e.composedPath()[0] as UUITextareaElement; + const translation = (target.value as string).toString(); + const isoCode = target.getAttribute('name')!; - this.#workspaceContext?.setPropertyValue(isoCode, translation); - } + this.#workspaceContext?.setPropertyValue(isoCode, translation); } override render() { @@ -104,7 +101,7 @@ export class UmbWorkspaceViewDictionaryEditorElement extends UmbLitElement { slot="editor" name=${language.unique} label="translation" - @change=${this.#onTextareaChange} + @input=${this.#onTextareaChange} .value=${translation?.translation ?? ''} ?readonly=${this.#isReadOnly(language.unique)}> `; From c2eea5d6cc95e328d2b55831a5b6683c3bfdc396 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 22 Oct 2025 13:37:19 +0200 Subject: [PATCH 15/17] Populate IncludeDescendants on ContentPublishedNotification when publishing branch (forward port of #20578). --- src/Umbraco.Core/Notifications/ContentPublishedNotification.cs | 1 + src/Umbraco.Core/Services/ContentService.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Notifications/ContentPublishedNotification.cs b/src/Umbraco.Core/Notifications/ContentPublishedNotification.cs index 07baedc473..f242ef20a9 100644 --- a/src/Umbraco.Core/Notifications/ContentPublishedNotification.cs +++ b/src/Umbraco.Core/Notifications/ContentPublishedNotification.cs @@ -24,6 +24,7 @@ public sealed class ContentPublishedNotification : EnumerableObjectNotification< public ContentPublishedNotification(IEnumerable target, EventMessages messages, bool includeDescendants) : base(target, messages) => IncludeDescendants = includeDescendants; + /// /// Gets a enumeration of which are being published. /// diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 5d4ed2f98c..fd12d4dbb5 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -2207,7 +2207,7 @@ public class ContentService : RepositoryService, IContentService variesByCulture ? culturesPublished.IsCollectionEmpty() ? null : culturesPublished : ["*"], null, eventMessages)); - scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages).WithState(notificationState)); + scope.Notifications.Publish(new ContentPublishedNotification(publishedDocuments, eventMessages, true).WithState(notificationState)); scope.Complete(); } From 62c1d44a5d924085d526426d45722e1c61c84387 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 22 Oct 2025 13:46:56 +0200 Subject: [PATCH 16/17] Webhooks: Register OutputExpansionStrategy for webhooks if Delivery API is not enabled (#20559) * Register slimmed down OutputExpansionStrategy for webhooks if deliveryapi is not enabled * PR review comment resolution --- ...tContextOutputExpansionStrategyAccessor.cs | 12 ++ .../RequestContextServiceAccessorBase.cs | 20 +++ .../ElementOnlyOutputExpansionStrategy.cs | 148 ++++++++++++++++++ .../UmbracoBuilderExtensions.cs | 35 +++-- ...RequestContextOutputExpansionStrategyV2.cs | 145 +---------------- .../WebhooksBuilderExtensions.cs | 10 +- 6 files changed, 215 insertions(+), 155 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs create mode 100644 src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs create mode 100644 src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs diff --git a/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs new file mode 100644 index 0000000000..cb375857b7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Common.Accessors; + +public sealed class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase, IOutputExpansionStrategyAccessor +{ + public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } +} diff --git a/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs new file mode 100644 index 0000000000..2748746a96 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Umbraco.Cms.Api.Common.Accessors; + +public abstract class RequestContextServiceAccessorBase + where T : class +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + protected RequestContextServiceAccessorBase(IHttpContextAccessor httpContextAccessor) + => _httpContextAccessor = httpContextAccessor; + + public bool TryGetValue([NotNullWhen(true)] out T? requestStartNodeService) + { + requestStartNodeService = _httpContextAccessor.HttpContext?.RequestServices.GetService(); + return requestStartNodeService is not null; + } +} diff --git a/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs new file mode 100644 index 0000000000..a5f113c7c7 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs @@ -0,0 +1,148 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Common.Rendering; + +public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy +{ + protected const string All = "$all"; + protected const string None = ""; + protected const string ExpandParameterName = "expand"; + protected const string FieldsParameterName = "fields"; + + private readonly IApiPropertyRenderer _propertyRenderer; + + protected Stack ExpandProperties { get; } = new(); + + protected Stack IncludeProperties { get; } = new(); + + public ElementOnlyOutputExpansionStrategy( + IApiPropertyRenderer propertyRenderer) + { + _propertyRenderer = propertyRenderer; + } + + public virtual IDictionary MapContentProperties(IPublishedContent content) + => content.ItemType == PublishedItemType.Content + ? MapProperties(content.Properties) + : throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}"); + + public virtual IDictionary MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true) + { + if (media.ItemType != PublishedItemType.Media) + { + throw new ArgumentException($"Invalid item type. This method can only be used with item type {PublishedItemType.Media}, got: {media.ItemType}"); + } + + IPublishedProperty[] properties = media + .Properties + .Where(p => skipUmbracoProperties is false || p.Alias.StartsWith("umbraco") is false) + .ToArray(); + + return properties.Any() + ? MapProperties(properties) + : new Dictionary(); + } + + public virtual IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties, true); + + private IDictionary MapProperties(IEnumerable properties, bool forceExpandProperties = false) + { + Node? currentExpandProperties = ExpandProperties.Count > 0 ? ExpandProperties.Peek() : null; + if (ExpandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false) + { + return new Dictionary(); + } + + Node? currentIncludeProperties = IncludeProperties.Count > 0 ? IncludeProperties.Peek() : null; + var result = new Dictionary(); + foreach (IPublishedProperty property in properties) + { + Node? nextIncludeProperties = GetNextProperties(currentIncludeProperties, property.Alias); + if (currentIncludeProperties is not null && currentIncludeProperties.Items.Any() && nextIncludeProperties is null) + { + continue; + } + + Node? nextExpandProperties = GetNextProperties(currentExpandProperties, property.Alias); + + IncludeProperties.Push(nextIncludeProperties); + ExpandProperties.Push(nextExpandProperties); + + result[property.Alias] = GetPropertyValue(property); + + ExpandProperties.Pop(); + IncludeProperties.Pop(); + } + + return result; + } + + private Node? GetNextProperties(Node? currentProperties, string propertyAlias) + => currentProperties?.Items.FirstOrDefault(i => i.Key == All) + ?? currentProperties?.Items.FirstOrDefault(i => i.Key == "properties")?.Items.FirstOrDefault(i => i.Key == All || i.Key == propertyAlias); + + private object? GetPropertyValue(IPublishedProperty property) + => _propertyRenderer.GetPropertyValue(property, ExpandProperties.Peek() is not null); + + protected sealed class Node + { + public string Key { get; private set; } = string.Empty; + + public List Items { get; } = new(); + + public static Node Parse(string value) + { + // verify that there are as many start brackets as there are end brackets + if (value.CountOccurrences("[") != value.CountOccurrences("]")) + { + throw new ArgumentException("Value did not contain an equal number of start and end brackets"); + } + + // verify that the value does not start with a start bracket + if (value.StartsWith("[")) + { + throw new ArgumentException("Value cannot start with a bracket"); + } + + // verify that there are no empty brackets + if (value.Contains("[]")) + { + throw new ArgumentException("Value cannot contain empty brackets"); + } + + var stack = new Stack(); + var root = new Node { Key = "root" }; + stack.Push(root); + + var currentNode = new Node(); + root.Items.Add(currentNode); + + foreach (char c in value) + { + switch (c) + { + case '[': // Start a new node, child of the current node + stack.Push(currentNode); + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ',': // Start a new node, but at the same level of the current node + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ']': // Back to parent of the current node + currentNode = stack.Pop(); + break; + default: // Add char to current node key + currentNode.Key += c; + break; + } + } + + return root; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 7d20039897..a0860a3422 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -35,28 +35,35 @@ public static class UmbracoBuilderExtensions builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); - builder.Services.AddScoped(provider => - { - HttpContext? httpContext = provider.GetRequiredService().HttpContext; - ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion(); - if (apiVersion is null) - { - return provider.GetRequiredService(); - } - // V1 of the Delivery API uses a different expansion strategy than V2+ - return apiVersion.MajorVersion == 1 - ? provider.GetRequiredService() - : provider.GetRequiredService(); - }); + builder.Services.AddUnique( + provider => + { + HttpContext? httpContext = provider.GetRequiredService().HttpContext; + ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion(); + if (apiVersion is null) + { + return provider.GetRequiredService(); + } + + // V1 of the Delivery API uses a different expansion strategy than V2+ + return apiVersion.MajorVersion == 1 + ? provider.GetRequiredService() + : provider.GetRequiredService(); + }, + ServiceLifetime.Scoped); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + + // Webooks register a more basic implementation, remove it. + builder.Services.AddUnique(ServiceLifetime.Singleton); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs index e1a29b3ec7..779ed31083 100644 --- a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs +++ b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs @@ -1,62 +1,25 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Api.Common.Rendering; using Umbraco.Cms.Core.DeliveryApi; -using Umbraco.Cms.Core.Models.PublishedContent; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Rendering; -internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansionStrategy +internal sealed class RequestContextOutputExpansionStrategyV2 : ElementOnlyOutputExpansionStrategy, IOutputExpansionStrategy { - private const string All = "$all"; - private const string None = ""; - private const string ExpandParameterName = "expand"; - private const string FieldsParameterName = "fields"; - - private readonly IApiPropertyRenderer _propertyRenderer; private readonly ILogger _logger; - private readonly Stack _expandProperties; - private readonly Stack _includeProperties; - public RequestContextOutputExpansionStrategyV2( IHttpContextAccessor httpContextAccessor, IApiPropertyRenderer propertyRenderer, ILogger logger) + : base(propertyRenderer) { - _propertyRenderer = propertyRenderer; _logger = logger; - _expandProperties = new Stack(); - _includeProperties = new Stack(); InitializeExpandAndInclude(httpContextAccessor); } - public IDictionary MapContentProperties(IPublishedContent content) - => content.ItemType == PublishedItemType.Content - ? MapProperties(content.Properties) - : throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}"); - - public IDictionary MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true) - { - if (media.ItemType != PublishedItemType.Media) - { - throw new ArgumentException($"Invalid item type. This method can only be used with item type {PublishedItemType.Media}, got: {media.ItemType}"); - } - - IPublishedProperty[] properties = media - .Properties - .Where(p => skipUmbracoProperties is false || p.Alias.StartsWith("umbraco") is false) - .ToArray(); - - return properties.Any() - ? MapProperties(properties) - : new Dictionary(); - } - - public IDictionary MapElementProperties(IPublishedElement element) - => MapProperties(element.Properties, true); - private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor) { string? QueryValue(string key) => httpContextAccessor.HttpContext?.Request.Query[key]; @@ -66,7 +29,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion try { - _expandProperties.Push(Node.Parse(toExpand)); + ExpandProperties.Push(Node.Parse(toExpand)); } catch (ArgumentException ex) { @@ -76,7 +39,7 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion try { - _includeProperties.Push(Node.Parse(toInclude)); + IncludeProperties.Push(Node.Parse(toInclude)); } catch (ArgumentException ex) { @@ -84,102 +47,4 @@ internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansion throw new ArgumentException($"Could not parse the '{FieldsParameterName}' parameter: {ex.Message}"); } } - - private IDictionary MapProperties(IEnumerable properties, bool forceExpandProperties = false) - { - Node? currentExpandProperties = _expandProperties.Peek(); - if (_expandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false) - { - return new Dictionary(); - } - - Node? currentIncludeProperties = _includeProperties.Peek(); - var result = new Dictionary(); - foreach (IPublishedProperty property in properties) - { - Node? nextIncludeProperties = GetNextProperties(currentIncludeProperties, property.Alias); - if (currentIncludeProperties is not null && currentIncludeProperties.Items.Any() && nextIncludeProperties is null) - { - continue; - } - - Node? nextExpandProperties = GetNextProperties(currentExpandProperties, property.Alias); - - _includeProperties.Push(nextIncludeProperties); - _expandProperties.Push(nextExpandProperties); - - result[property.Alias] = GetPropertyValue(property); - - _expandProperties.Pop(); - _includeProperties.Pop(); - } - - return result; - } - - private Node? GetNextProperties(Node? currentProperties, string propertyAlias) - => currentProperties?.Items.FirstOrDefault(i => i.Key == All) - ?? currentProperties?.Items.FirstOrDefault(i => i.Key == "properties")?.Items.FirstOrDefault(i => i.Key == All || i.Key == propertyAlias); - - private object? GetPropertyValue(IPublishedProperty property) - => _propertyRenderer.GetPropertyValue(property, _expandProperties.Peek() is not null); - - private sealed class Node - { - public string Key { get; private set; } = string.Empty; - - public List Items { get; } = new(); - - public static Node Parse(string value) - { - // verify that there are as many start brackets as there are end brackets - if (value.CountOccurrences("[") != value.CountOccurrences("]")) - { - throw new ArgumentException("Value did not contain an equal number of start and end brackets"); - } - - // verify that the value does not start with a start bracket - if (value.StartsWith("[")) - { - throw new ArgumentException("Value cannot start with a bracket"); - } - - // verify that there are no empty brackets - if (value.Contains("[]")) - { - throw new ArgumentException("Value cannot contain empty brackets"); - } - - var stack = new Stack(); - var root = new Node { Key = "root" }; - stack.Push(root); - - var currentNode = new Node(); - root.Items.Add(currentNode); - - foreach (char c in value) - { - switch (c) - { - case '[': // Start a new node, child of the current node - stack.Push(currentNode); - currentNode = new Node(); - stack.Peek().Items.Add(currentNode); - break; - case ',': // Start a new node, but at the same level of the current node - currentNode = new Node(); - stack.Peek().Items.Add(currentNode); - break; - case ']': // Back to parent of the current node - currentNode = stack.Pop(); - break; - default: // Add char to current node key - currentNode.Key += c; - break; - } - } - - return root; - } - } } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs index 8d2d20a1d6..81d764f697 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs @@ -1,5 +1,9 @@ -using Umbraco.Cms.Api.Management.Factories; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.Accessors; +using Umbraco.Cms.Api.Common.Rendering; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Webhook; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Extensions; @@ -12,6 +16,10 @@ internal static class WebhooksBuilderExtensions builder.Services.AddUnique(); builder.AddMapDefinition(); + // deliveryApi will overwrite these more basic ones. + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + return builder; } } From 4a65f56d9d10d344ed117b209b444e6790403287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 22 Oct 2025 13:57:09 +0200 Subject: [PATCH 17/17] =?UTF-8?q?Hotfix:=20Implement=20a=20specific=20sort?= =?UTF-8?q?ing=20method=20for=20statuses=20as=20the=20existing=20has=20?= =?UTF-8?q?=E2=80=A6=20(#20609)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a specific sorting method for statuses as the existing has to support deprecated implementation of custom getUnique method --- .../core/repository/repository-items.manager.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 c60dc8badf..0b50d72399 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 @@ -88,7 +88,7 @@ export class UmbRepositoryItemsManager exte ) { const items = this.#items.getValue(); this.#items.setValue(this.#sortByUniques(items)); - this.#statuses.setValue(this.#sortByUniques(statuses)); + this.#statuses.setValue(this.#sortStatusByUniques(statuses)); } else { // We need to load new items, so ... this.#requestItems(); @@ -260,7 +260,7 @@ export class UmbRepositoryItemsManager exte } } - #sortByUniques>(data?: Array): Array { + #sortByUniques(data?: Array): Array { if (!data) return []; const uniques = this.getUniques(); return [...data].sort((a, b) => { @@ -270,6 +270,17 @@ export class UmbRepositoryItemsManager exte }); } + /** Just needed for the deprecation implementation to work, do not bring this into 17.0 [NL] */ + #sortStatusByUniques(data?: Array): Array { + if (!data) return []; + const uniques = this.getUniques(); + return [...data].sort((a, b) => { + const aIndex = uniques.indexOf(a.unique ?? ''); + const bIndex = uniques.indexOf(b.unique ?? ''); + return aIndex - bIndex; + }); + } + #onEntityUpdatedEvent = (event: UmbEntityUpdatedEvent) => { const eventUnique = event.getUnique();