Compare commits

..
Author SHA1 Message Date
Rick ButterfieldandClaude Opus 4.8 1afa84e78b chore: incidental working-tree changes (starter kit 18.0.0-rc, regenerated lockfile/mock worker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:48:17 +01:00
Rick ButterfieldandClaude Opus 4.8 48455449dd feat(visual-editor): framework-emit empty-block add-content affordance
Move the empty editable block-property affordance out of per-view template
boilerplate into the block render helpers. GetBlock{List,Grid}HtmlAsync and the
single-block GetBlockHtmlAsync now emit an annotated empty container
(<div class="umb-block-{list,grid,single}" data-umb-block-property="alias">)
from their alias-bearing overloads when the property is editable-in-visual-editor
and the visual editor is active, via a shared BlockEmptyState helper. The
blocklist/blockgrid/embedded-blockgrid default views revert to their plain form
and the ViewData alias plumbing is removed. The guest gains a single-block
empty-container branch; the element resolves the single-block schema alias so the
add produces a correctly shaped value. Works automatically for any template using
the standard helper, including custom ones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:48:05 +01:00
Rick ButterfieldandClaude Opus 4.8 acc21b44f3 feat(visual-editor): empty-state support in shipped block grid template
Mirror the sample-site empty-state handling into the embedded BlockGrid/default.cshtml
that ships to new installs, so empty editable block grids are annotated and offer
an add-content affordance in the visual editor. Gated on preview/visual-editor mode,
so normal front-end rendering of empty grids is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:00:07 +01:00
Rick ButterfieldandClaude Opus 4.8 31ff6c5f68 fix(visual-editor): use unqualified type names in block partials
Replace the non-idiomatic global:: qualification with plain unqualified type names.
The sample site's _ViewImports.cshtml already imports Umbraco.Extensions and
Umbraco.Cms.Core.Models.PublishedContent, so BlockGridTemplateExtensions /
BlockListTemplateExtensions / VisualEditorPropertyTracker resolve directly — and
without a leading 'Umbraco.' the UmbracoHelper property no longer shadows the namespace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:46:26 +01:00
Rick ButterfieldandClaude Opus 4.8 daba4f8eea fix(visual-editor): global:: qualify namespace refs in block partials
UmbracoViewPage exposes an 'Umbraco' property (UmbracoHelper) that shadows the
'Umbraco' root namespace inside Razor code blocks, so 'Umbraco.Extensions...' and
'Umbraco.Cms...' bound to the helper and failed to compile at render time. Force
namespace resolution with global:: in both block partials (the block-list one had
the same latent error since the tidy-up round but was never rendered empty).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:42:16 +01:00
Rick ButterfieldandClaude Opus 4.8 38dfc75a7e feat(visual-editor): use alias-aware block grid overload in Home template
Home.cshtml rendered bodyText via the model-only GetBlockGridHtmlAsync overload,
which can't flow the property alias, so an empty grid there wouldn't annotate for
the visual editor. Align it with the other sample templates (Blogpost/ContentPage/
Product) which already pass (Model, "bodyText").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:26:50 +01:00
Rick ButterfieldandClaude Opus 4.8 aa7df42ffc feat(visual-editor): empty-state support for block grid properties
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:24:05 +01:00
Rick ButterfieldandClaude Opus 4.8 3cac997b12 fix(visual-editor): wrap rendered content in ModelsBuilder model via IPublishedModelFactory
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:14:55 +01:00
Rick ButterfieldandClaude Opus 4.8 85d0e32b18 fix(visual-editor): thread per-value culture/segment into render so variant edits preview correctly
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:25:17 +01:00
Rick ButterfieldandClaude Opus 4.8 689495d9cc fix(visual-editor): exempt guest morphdom import from external-imports lint rule
The guest runs in the preview iframe and is bundled standalone by esbuild, not via
the backoffice import map, so morphdom is imported directly from node_modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:16:18 +01:00
Rick ButterfieldandClaude Opus 4.8 f274f42548 docs(visual-editor): mark partial re-render implemented (Phase 3 complete)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 08:09:11 +01:00
Rick ButterfieldandClaude Opus 4.8 653c36c8fe fix(visual-editor): share drag state across re-renders so cross-node drag survives morph
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:45:59 +01:00
Rick ButterfieldandClaude Opus 4.8 a301384ed6 feat(visual-editor): morph preview DOM on partial re-render (guest)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:39:08 +01:00
Rick ButterfieldandClaude Opus 4.8 5be1f2fcb6 feat(visual-editor): route edits through partial re-render, suppress self-reload after save
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:30:49 +01:00
Rick ButterfieldandClaude Opus 4.8 b0857c33ff fix(visual-editor): add editableInVisualEditor to OpenApi PropertyTypeAppearance
The server's PropertyTypeAppearance viewmodel exposes EditableInVisualEditor but
OpenApi.json was stale (only labelOnTop), so regenerating the server API client
dropped the property and broke the property-type data sources that consume it.
Add it to the schema so the generated client is consistent with the server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:21:12 +01:00
Rick ButterfieldandClaude Opus 4.8 7d5ad4feb2 feat(visual-editor): debounced latest-wins render controller (client)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:17:02 +01:00
Rick ButterfieldandClaude Opus 4.8 0abe9f6c7c chore(visual-editor): regenerate OpenApi.json and server API client for render endpoint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:12:44 +01:00
Rick ButterfieldandClaude Opus 4.8 32c974a987 docs(visual-editor): xml docs on render controller, move ApiVersion to concrete controller
Add SA1600-compliant XML documentation to VisualEditorControllerBase (class summary)
and RenderVisualEditorController (constructor + action). Move [ApiVersion("1.0")] from
the abstract base to the concrete controller, matching the pattern used by
CultureControllerBase/AllCultureController and DataTypeControllerBase/CreateDataTypeController.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:03:02 +01:00
Rick ButterfieldandClaude Opus 4.8 2946d81334 feat(visual-editor): add management API render endpoint
Exposes IVisualEditorRenderService via POST /umbraco/management/api/v1/visual-editor/render,
authorized for back-office users, as part of the partial re-render (Phase 3) implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:50:47 +01:00
Rick ButterfieldandClaude Opus 4.8 2fca102591 fix(visual-editor): clear tracker state after render, log unavailable-render cases, tidy test usings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:47:39 +01:00
Rick ButterfieldandClaude Opus 4.8 245915bba9 feat(visual-editor): render service producing annotated HTML from unsaved values
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:27:47 +01:00
Rick ButterfieldandClaude Opus 4.8 4cdaf0d829 fix(visual-editor): merge variant overrides, skip unresolvable aliases, complete XML docs
- Add <param> and <returns> XML doc tags to IVisualEditorContentFactory.CreateWithOverridesAsync (SA1611/SA1615)
- Refactor override loop: resolve property+editor once via ResolveEditorAsync; continue on failure so unresolvable aliases leave the base node value untouched instead of silently blanking with null
- Merge variant PropertyData instead of replacing the whole array: strip only the matching (culture, segment) entry and append the new override entry, preserving sibling cultures/segments
- Add integration test: CreateWithOverrides_Merges_Variant_Override_Preserving_Other_Cultures (confirmed FAIL before fix, PASS after)
- Add integration test: CreateWithOverrides_Returns_Null_For_Unknown_Document_Key

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:06:08 +01:00
Rick ButterfieldandClaude Opus 4.8 a5fd7413ed feat(visual-editor): build IPublishedContent with unsaved value overrides for preview render
Introduces IVisualEditorContentFactory (public contract in Umbraco.Core) and its
implementation VisualEditorContentFactory (internal in Umbraco.PublishedCache.HybridCache).
The factory resolves a document's draft IContent via IContentService, builds a
ContentCacheNode via ICacheNodeFactory, overlays caller-supplied unsaved property values
(converted from editor format to source format via FromEditor), then converts the mutated
node to a preview IPublishedContent via IPublishedContentFactory — matching the approach
proven by the throwaway spike. Registered as singleton in AddUmbracoHybridCache().
Integration test covers TextBox, RichText, and BlockList overrides end-to-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:34:24 +01:00
Rick ButterfieldandClaude Opus 4.8 91ff3f2299 docs(visual-editor): fold conversion-spike findings into partial re-render design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:00:23 +01:00
Rick ButterfieldandClaude Opus 4.8 6b960030f5 docs(visual-editor): add partial re-render (Phase 3) design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:14:39 +01:00
Rick ButterfieldandClaude Opus 4.8 fb4d86639b fix(visual-editor): enforce editable-in-visual-editor opt-in at property interaction points
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:33:32 +01:00
Rick ButterfieldandClaude Opus 4.8 6189177ecc docs(visual-editor): mark tidy-up design as implemented
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:25:48 +01:00
Rick ButterfieldandClaude Opus 4.8 80c0e8e8b6 fix(visual-editor): exempt guest message protocol keys from naming-convention lint
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:22:58 +01:00
Rick ButterfieldandClaude Opus 4.8 9a45da142e docs(visual-editor): refresh plan statuses, close opt-in question, defer standalone window
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:16:21 +01:00
Rick ButterfieldandClaude Opus 4.8 f81452a062 docs(visual-editor): correct guest script asset path and document GetScriptTag parameters
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:13:32 +01:00
Rick ButterfieldandClaude Opus 4.8 1529eb9c6f feat(visual-editor): add-content placeholder for empty root-level block lists
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:08:43 +01:00
Rick ButterfieldandClaude Opus 4.8 b3ca6a6f3f feat(visual-editor): annotate block list containers with property alias in preview
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:03:32 +01:00
Rick ButterfieldandClaude Opus 4.8 c8e9345fe9 refactor(visual-editor): extract Map-indexed property structure resolver
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:55:22 +01:00
Rick ButterfieldandClaude Opus 4.8 ba33517148 refactor(visual-editor): extract SignalR controller, drop dead suppressRefresh state
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:48:16 +01:00
Rick ButterfieldandClaude Opus 4.8 30c809e269 fix(visual-editor): strict opt-in for document properties, unfiltered block modal fields
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:43:45 +01:00
Rick ButterfieldandClaude Opus 4.8 d4db0d2079 refactor(visual-editor): extract typed message router with origin and source validation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:38:08 +01:00
Rick ButterfieldandClaude Opus 4.8 9683871c6a fix(visual-editor): pin guest script postMessage to parent origin
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:31:48 +01:00
Rick ButterfieldandClaude Opus 4.8 c7904ea013 docs(visual-editor): add tidy-up implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:19:17 +01:00
Rick ButterfieldandClaude Opus 4.8 d629bb8654 docs(visual-editor): add tidy-up design spec
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:02:24 +01:00
Rick Butterfield 6b22d5558b Merge branch 'main' into feature/visual-editor
# Conflicts:
#	.gitignore
#	src/Umbraco.Cms.Api.Management/OpenApi.json
2026-06-11 09:15:25 +01:00
de61491093 Tests: Update DomainCacheServiceTests to mock GetAllAsync instead of removed GetAll (#23097)
update unit tests for domain cache service

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-06-10 12:14:01 +02:00
Jacob Overgaard deafc20db9 Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 13:20:53 +02:00
Niels LyngsøandGitHub 28cdbe5317 TipTap: Let the stylesheet load parallel to tiptap-extensions (#23024)
do not await stylesheets to be loaded before extensions
2026-06-09 11:14:49 +00:00
6d1487ef4e Global Search: Localize global search manifest labels and names (#23091)
* add localization for search manifest

* only localization for label

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-06-09 08:35:15 +00:00
Jacob Overgaard 46aa184e10 Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 10:31:49 +02:00
3dbd4baefe Backoffice Search: Batch the ancestors lookup for search results to avoid exceeding the maximum URL length (closes #23032) (#23048)
* Ensure requests to fetch ancestors after retrieving search results are batched to avoid a single query exceeding the maximum URL length.

* Guard against undefined ancestor entries from a failed batch

batchTryExecute resolves each chunk via tryExecute, which never rejects, so
a per-chunk failure comes back as a fulfilled result carrying an error and
leaves an undefined hole in the amalgamated data without surfacing an error.
Detect that before mapping and return an explicit error instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Assert ancestor id uniqueness and silence direct-api lint rule

Strengthen the batching tests to assert every search-result id is requested
exactly once (Set size), not just that the total count matches. Add the
no-direct-api-import disable on the controller's api callback, matching the
existing url data sources, since the call is wrapped by the controller.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Addessed Codescene warnings.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:31:37 +02:00
Jacob Overgaard c1ba303fdc Merge remote-tracking branch 'origin/v17/dev' 2026-06-09 10:30:22 +02:00
Andy ButlandandGitHub fa5dd209c1 Tags: Fix null reference error in tags input on render (closes #23044) (#23049)
Fix intermittent null reference exception in tags element.
2026-06-09 10:29:27 +02:00
Andy ButlandandGitHub 4cc4acee62 Published Cache: Fix multi-site domains falling back to the first root node after restart (#23084)
* Prevent empty domain cache during concurrent initialization.

* Addressed code review comments and added further comment to the code.

* Use Lock object.
2026-06-07 15:35:38 +02:00
62663d9573 Runtime Cache: Fix IAppPolicyCache.ClearByKey intermittently failing to clear cached items (closes #23064) (#23068)
* Prevent ClearByKey leaving stale runtime cache items.

* Lowered loop timer.

* Clarified code comment.

* Improve assertions and comments in tests.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-06-05 09:23:26 +02:00
b582f9d2ef Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:59:11 +02:00
a3424eb40d Dependencies: Upgrade Examine to 3.8.0 (#23075)
Upgrade Examine to 3.8.0

Co-authored-by: Simon Gibbs <sgibbs@qmu.ac.uk>
2026-06-05 06:58:20 +02:00
Andy Butland 68d03ae7c4 Merge branch 'release/18.0' 2026-06-05 06:42:24 +02:00
Jacob Overgaard 91c9b79926 Merge remote-tracking branch 'origin/v17/dev' 2026-06-04 10:41:08 +02:00
ad90db8b38 Performance: Coalesce concurrent tree data requests (Management API client) (#23021)
* perf(tree): coalesce concurrent identical tree data requests

The tree data request manager hit the network on every call, so multiple
concurrent consumers (sidebar tree, breadcrumb structure, pickers) each
fetched the same data independently — e.g. three identical tree/document/root
requests per document-workspace load.

Apply the existing UmbManagementApiInFlightRequestCache (already used by the
item and detail request managers) to the tree request manager via a shared
static cache, coalescing concurrent identical root/children/ancestors/siblings
calls into a single in-flight request, cleared on settle (in-flight only, so
no stale-cache risk). The document tree opts in; other trees are unchanged
until they pass a cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(tree): cover request coalescing; address review feedback

- Add focused tests: concurrent identical root requests share one call,
  the in-flight entry is cleared on settle, and no cache means no coalescing.
- Build the cache key lazily (only when a cache is wired) so non-opted-in
  trees keep the original lightweight path.
- Constrain the #coalesce generic to drop the cast on cache.set.
- Document the new inflightRequestCache arg; trim the comment to one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 10:40:58 +02:00
Erik-Jan WestendorpandAndy Butland 8e3b821a55 Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:53 +02:00
Erik-Jan WestendorpandGitHub 5ade6ae6ec Localization: Add Dutch translations for create and delete actions (#23039)
Update nl.ts
2026-06-04 08:37:12 +02:00
Andy Butland 7ca8d3d872 Merge branch 'v17/dev' 2026-06-04 07:59:53 +02:00
Andy ButlandandGitHub 8ac989c4e3 Data Types: Tolerate invalid configuration when determining the editor value storage type (closes #23057) (#23058)
* Tolerate invalid data type configuration when getting the editor value storage type.

* Add logging in case of error.

* Resolve warning.

* Removed exception from warning (it's not useful).

* Log an error instead of a warning.
2026-06-04 14:38:21 +09:00
Andy Butland fceb2f421f Merge branch 'v17/dev' 2026-06-04 06:45:50 +02:00
Andy ButlandandGitHub 3913a61b74 Background Jobs: Resolve server role so recurring jobs run when no application URL is configured (#23033)
Resolve server role when no application URL is configured.
2026-06-04 06:37:08 +02:00
Andreas ZerbstandGitHub 245bc336a5 E2E: QA: Add acceptance test for backoffice search (#22730)
* Added tests

* Cleaned up

* Updated command

* Fixes based on comments

* Split tests

* Updated helpers

* Fixed constant helper after merge

* Use correct helper

* Cleaned up

* Update smokeTest command in package.json
2026-06-03 19:49:12 +00:00
Andy Butland c5efd65e23 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-06-03 18:49:28 +02:00
Andy Butland cb9ac904f6 Merge branch 'v17/dev' 2026-06-03 18:49:12 +02:00
Laura NetoandGitHub cc38e5724b SonarCloud: Simplify to single workflow, skip fork PRs (#23054) 2026-06-03 18:37:07 +02:00
Lee KelleherandGitHub 90bedcd42e Menu Structure: Guard against use-after-destroy in async structure request (#23055)
* Menu Structure: Guard against use-after-destroy in async structure request

When navigating to a trashed item, the IS_NOT_TRASHED condition initially
permits the standard menu structure context, which is then destroyed once the
workspace confirms the item is trashed. The in-flight async #requestStructure()
could resume after destruction and call setValue() on a completed subject,
throwing "_subject is undefined".

Guard the state mutations with the framework's existing _host-cleared-on-destroy
signal, and handle the previously fire-and-forget #requestStructure() promises so
a teardown mid-request is silently abandoned rather than surfacing as an uncaught
rejection. Applied to both the variant and non-variant menu structure base
contexts.

* Menu Structure: Make #requestStructure non-throwing instead of catching at call sites

Per PR review feedback: replace the blanket .catch(() => {}) wrappers with
early returns inside #requestStructure(). The _host guard already prevents
post-destroy state mutation; the throws only fire for can't-happen missing
observable states and were producing unhandled rejections with no caller
able to act on them.

* Added console warning, if the host is still available
2026-06-03 18:32:29 +02:00
Lee KelleherandGitHub c54189aa90 Block Grid: Guard validator against torn-down manager on navigation (#22852)
* Block Grid: Guard validator against torn-down manager on navigation

The form-control mixin's updated() hook runs validators when the element
re-renders during teardown. If navigation has already disposed _manager,
checkBlockTypeConfigurationValidity would throw "Cannot read properties
of undefined (reading 'getContentTypeKeyOfContentKey')".

Early-return as valid when the manager is gone and use optional chaining
on the per-entry lookup as a safety net.

* Removed optional chaining of `_manager`

As `_manager` has already been checked.

* Reverting the `_manager` optional chaining

As TypeScript compiler doesn't like it, (inside the `filter` callback).
2026-06-03 15:29:41 +02:00
88ec0a248f Media: Restore friendly naming of uploaded media items (closes #22989) (#22998)
* Update uploaded media file name to a friendly name.

* Correct test description for acronym handling.

The case JUST-A-FILE.jpg verifies all-uppercase words are preserved
as acronyms, not that lowercase words get lowercased.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Match server-side StripFileExtension semantics in toFriendlyName.

The TypeScript helper previously delegated to getFileExtension, which
diverges from the C# StripFileExtension on two edge cases:
- a trailing dot ("file.") is stripped by the server but not the client
- an "extension" containing whitespace is preserved by the server but
  stripped by the client

Inlined a stripFileExtension helper that mirrors the C# rules exactly,
making the "keep in sync" cross-reference accurate. Added tests for both
divergent cases and replaced the contrived leading/trailing whitespace
test with a realistic interior-whitespace case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add parity test for trailing-whitespace extension span.

Restores the '  spaced-name.jpg  ' case as a parity test against
StripFileExtension's "extension containing whitespace is preserved"
rule. Output is 'Spaced Name.Jpg' (Jpg title-cased, matching the
server's TextInfo.ToTitleCase behaviour on the now-unstripped extension).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Handle getContext rejection in ensureMediaNameFromFile.

getContext rejects on timeout when the dataset context never resolves;
callers used void ensureMediaNameFromFile(...) so an unhandled rejection
would bubble. Catch the rejection and treat it as an absent context.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 12:37:49 +01:00
Jacob Overgaard 7158aec145 Merge branch 'release/18.0' 2026-06-03 13:06:55 +02:00
a3f65b2920 Redirects: Adding notification for redirect save and deletion (#22985)
* Creating notification objects and status

* Adjusting service and tracker to support cancelable notifications

* Adding Operation Status Results to base controller.

* Adding notification support to the delete controller.

* Integration tests

* Changes in accordance to CR

* Changes in accordance to code review

* Fixed further use of obsolete methods in tests.

* Added comment explaining why messages on create or update cancellation are suppressed.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 09:37:31 +00:00
230b5db528 Localisation: Translate the "Library" section header into other languages (#23043)
* Translate library to Dutch and Spanish

* Add translations for other cultures.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-03 07:35:06 +00:00
Nhu DinhandGitHub 2372c40056 E2E: QA Added acceptance tests for element folder permission (#22905)
* Added constant setting for element folder permission

* Added api helper for element folder

* Added api helper for user group with element folder permission

* Added ui helper for element folder permission in user group

* Added tests for element folder permission

* Added locator for restore element

* Added api helper for Combined element + element folder permission methods

* Added more tests for restore element folder

* Make tests run in the pipeline

* Fixed comments

* Reverted npm command
2026-06-03 03:19:02 +00:00
75d2b0129d E2E: QA Added acceptance tests for Element Type settings (#23005)
* Updated createEmptyElementType

* Updated tests due to test helper changes

* Added ui helper for not applicable message for element type

* Added tests for showing message for non-applicable Element Type settings

* Added tests for preventing disabling isElement when elements of that type exist

* Make tests run in the pipeline

* Fixed comments

* Update tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/DocumentType/DocumentTypeSettingsTab.spec.ts

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>

* Reverted npm command

---------

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-06-03 03:13:52 +00:00
Jacob Overgaard b410e060b6 Merge remote-tracking branch 'origin/v17/dev' 2026-06-02 16:32:14 +02:00
Lee KelleherandGitHub 3e22733081 Document Recycle Bin: Checks user permission for Document "Read" (#23041)
* Adds conditions to Document Recycle Bin

that the user must have "Read" permission.

* Directly imports Media Recycle Bin condition

this will remove an extra fetch request.
2026-06-02 16:31:51 +02:00
Laura NetoandGitHub a86777a8f2 Build: Add SonarCloud CI workflow (#22960)
* Add SonarCloud CI workflow

Adds a manual-dispatch GitHub Actions workflow for SonarQube Cloud
analysis (build, unit test coverage, scan). Moves file_header_template
and SA1636/SA1633 suppression from .editorconfig comments and
.globalconfig into the active .editorconfig .NET language conventions
section, removing the duplicated suppression from .globalconfig.

* Remove branch filter from pull_request trigger in SonarCloud workflow

Runs analysis on all PRs regardless of target branch.

* Adjust sonarcloud gh action based on feedback

* Add .sonarqube to .gitignore

* Attempt to split build and analysis in order to be able to run in PRs from forks

* Adjust SonarCloud workflows

* Rename SonarCloud workflows to reflect their actual purpose

* Remove sonar.coverage.exclusions

* Include .github in sonar analysis

* Include build directory in sonar analysis

* Apply sonarcloud workflow fixes from test branch

* Remove setup-dotnet step from upload workflow

* Use default branch from context instead of hardcoded main in analysis workflow

* Update checkout action to v6 in upload workflow

* Add actions: read permission to upload workflow

* Enable SCM integration in upload workflow
2026-06-02 14:49:10 +02:00
Andy ButlandandGitHub 232077e820 EF Core: Retry transient SQLite lock errors during long-running operations (closes #22939) (#22969)
* Retry transient SQLite lock errors during long-running operations.

* Addressed code review comments.
2026-06-02 13:59:37 +02:00
Jacob Overgaard 4e815d7e9d Merge remote-tracking branch 'origin/v17/dev' 2026-06-02 12:46:33 +02:00
Jacob Overgaard 943d1eeccd Merge branch 'release/17.5.0' into v17/dev 2026-06-02 12:44:29 +02:00
Jacob Overgaard b3666dad8b build(deps): bumps @umbraco-ui/uui to 1.18.0 2026-06-02 12:43:21 +02:00
Nhu DinhandGitHub 318843793f E2E: QA Updated acceptance tests for removing the Element Picker from elements to reflect the recent changes (#23037)
* Added comments for the out-of-date tests

* Fixed element with element picker tests due to response changes
2026-06-02 10:20:40 +00:00
Andy Butland c10e23fd92 Merge branch 'v17/dev' 2026-06-01 14:41:53 +02:00
Sven GeusensandAndy Butland 28a403361e Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 14:38:35 +02:00
38d73b3a41 Developer Experience: Improve cohost editor polyfill to work with dotnet watch (closes #22773) (#22999)
* Improve cohost polyfill

* Apply suggestions from code review

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Move <target/> part of the polyfill to targets file.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-06-01 12:34:24 +00:00
6143b10643 E2E: QA Added acceptance tests for backoffice element search (#22884)
* Added tests

* Cleaned up

* Updated command

* Fixes based on comments

* Split tests

* Updated helpers

* Fixed constant helper after merge

* Use correct helper

* Added constant for element search

* Added ui helper for element backoffice search

* Added tests for element backoffice search

* Updated tests for finding element by name

* Apply suggestion from @andr317c

Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>

* Cleaned up

* Reverted npm command

* Fixed npm command

---------

Co-authored-by: Andreas Zerbst <andr317c@live.dk>
Co-authored-by: Andreas Zerbst <73799582+andr317c@users.noreply.github.com>
2026-06-01 11:07:22 +00:00
577652f707 Backoffice: Strip inherited class comments from TypeDoc API docs (#23004)
* Backoffice: Strip inherited class comments from TypeDoc API docs

TypeDoc copies the nearest documented ancestor's class comment onto every
undocumented subclass, which meant every UmbLitElement descendant on
apidocs.umbraco.com showed "The base class for all Umbraco LitElement
elements." as its own description. This plugin clears class-level
comments whose sourcePath doesn't match the reflection's own file, so
classes with no JSDoc render blank instead of borrowing the base's text.
Inherited member comments (methods, properties) are left alone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Backoffice: Address review comments on TypeDoc strip-inherited plugin

Drop the misleading "strip trailing line/column" sentence — nothing actually
strips, and a future TypeDoc release that appends positions to sourcePath
would now self-document its breakage instead of being hidden by a comment.

Document the sources[0]-only limitation around declaration merging in the
docblock so the constraint is visible to future maintainers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 10:59:42 +02:00
Niels Lyngsø 5c0cb154f5 cherry picked #23027 2026-06-01 10:04:01 +02:00
Niels LyngsøandGitHub 0d73243b7c Property Editors: Add value summary extensions for collection views (#23027)
Squashed commit of the following:

commit 146b41889b
Author: Mads Rasmussen <madsr@hey.com>
Date:   Wed May 27 13:47:48 2026 +0200

    Delete package-lock.json

commit dd68594995
Author: Mads Rasmussen <madsr@hey.com>
Date:   Wed May 27 13:43:24 2026 +0200

    Simplify content-picker resolved item shape

commit 2bbbd40d9a
Author: Mads Rasmussen <madsr@hey.com>
Date:   Wed May 27 13:21:41 2026 +0200

    content picker value summary add tests & observable support

commit 6a8ee67f54
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 27 12:45:58 2026 +0200

    Inline markdown editor value-type constant

commit 40bd631be5
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 27 11:01:47 2026 +0200

    Remove unused import

commit d066ac4ce0
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 27 10:40:10 2026 +0200

    Enable table text clipping; remove value-summary styles

commit 38a8c77c7b
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 17:00:00 2026 +0200

    Add user-picker value-summary tests and mock

commit 45bcf34186
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 16:44:49 2026 +0200

    Add tests for member-group value resolver

commit a9aad9cff0
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 16:20:37 2026 +0200

    Add member picker value-summary resolver tests

commit e39f3c15ba
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 15:00:36 2026 +0200

    Add media picker value summary resolver tests

commit 9694ac2870
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 14:58:54 2026 +0200

    Update value-summary.resolver.test.ts

commit 641d5f2817
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 14:37:23 2026 +0200

    add tests for the document picker value summary resolver

commit ac8fb96339
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 14:15:39 2026 +0200

    Use check icon for non-empty value summaries

    Replace the document icon with a check icon in value-summary components to indicate non-empty content. Updated the value-summary element in code-editor, markdown-editor, and tiptap-rte to return <uui-icon name='icon-check'> when a value is present, standardizing the visual cue across these editors.

commit 89499c6862
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 13:41:02 2026 +0200

    align member picker

commit 358c8a8629
Author: Mads Rasmussen <madsr@hey.com>
Date:   Tue May 26 13:38:56 2026 +0200

    combine resolver and element into one file to only lazy load one file

commit b759a348c0
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 26 10:30:13 2026 +0200

    Add value-summary manifests to packages

commit 1c692a471b
Merge: d34361b78c fc261d1ce4
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 26 10:29:31 2026 +0200

    Merge remote-tracking branch 'origin/main' into v17/feature/value-summary-property-editors

commit d34361b78c
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 26 10:09:08 2026 +0200

    Update imports

commit 34847e952e
Merge: c194023069 09af8c044b
Author: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Date:   Wed May 20 10:40:23 2026 +0200

    Merge branch 'main' into v17/feature/value-summary-property-editors

commit c194023069
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 20 10:38:04 2026 +0200

    Centralize value summary truncation styles in umb-value-summary-extension

commit 3788be8266
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 17:16:51 2026 +0200

    Use item models for resolvers instead of raw string IDs

commit d48fe020f3
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 15:32:29 2026 +0200

    Update the visual of tags and block list

commit faf840b67f
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 15:22:36 2026 +0200

    Render all the values in the checkbox list

commit 4818c6aef4
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 14:36:30 2026 +0200

    Rename value summary element tags and classes to include property-editor

commit 97e4d3474b
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 12:36:28 2026 +0200

    Remove undefined from UmbValueTypeMap declarations

commit de4683f6da
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 19 11:44:16 2026 +0200

    Add value summary to element picker

commit d38af1da0c
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 16:54:54 2026 +0200

    Guard against raw string value in member group picker value summary

commit f74b1a742a
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 14:57:32 2026 +0200

    Export value type constants from package indexes

commit e0067845bf
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 12:48:29 2026 +0200

    Fix imports

commit 320dd8fe6f
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 11:59:20 2026 +0200

    Use relative repository imports in pickers

commit 9a16b774db
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 11:41:10 2026 +0200

    Use DOM parsing for RTE value summary

commit 320bae917c
Merge: 728aedbe0b 18e27151b0
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 11:35:12 2026 +0200

    Merge branch 'v17/feature/value-summary-property-editors' of https://github.com/umbraco/Umbraco-CMS into v17/feature/value-summary-property-editors

commit 728aedbe0b
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 11:35:09 2026 +0200

    Truncate the fallback element

commit 18e27151b0
Merge: d3c62629d3 4b82828a23
Author: Engiber Lozada <89547469+engijlr@users.noreply.github.com>
Date:   Mon May 18 11:25:04 2026 +0200

    Merge branch 'main' into v17/feature/value-summary-property-editors

commit d3c62629d3
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 18 11:09:21 2026 +0200

    Add checkbox-list value summary; truncate labels

commit c593ed2cff
Author: engjlr <enl@umbraco.dk>
Date:   Fri May 15 13:11:16 2026 +0200

    Add valueSummary components for editors

commit 87043d6d2a
Author: engjlr <enl@umbraco.dk>
Date:   Fri May 15 09:17:16 2026 +0200

    Add value summaries for user and member-group pickers

commit 9f1838c581
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 13 12:16:36 2026 +0200

    Add value summaries for content/member/document picker

commit dc9ad61225
Author: engjlr <enl@umbraco.dk>
Date:   Wed May 13 10:37:20 2026 +0200

    Add value summaries for media picker and cropper

commit c97928ebf0
Author: engjlr <enl@umbraco.dk>
Date:   Tue May 12 11:44:41 2026 +0200

    Add value-summary support for several editors

commit ff2ae07a02
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 11 15:59:10 2026 +0200

    Add value summary for multiple text string and tags

commit f7b3da2b7e
Author: engjlr <enl@umbraco.dk>
Date:   Mon May 11 15:14:48 2026 +0200

    Add value summary for Toggle and date/time editors
2026-06-01 10:00:37 +02:00
Andy Butland 84ad9a1443 Merge branch 'v17/dev' 2026-06-01 08:33:13 +02:00
4a621a13bc Performance: Parallelize independent boot API requests (server status/config + public extensions) (#23020)
* perf(core): parallelize independent boot API requests

UmbServerConnection.connect() awaited server status and configuration
sequentially even though they are independent reads; run them with
Promise.allSettled so both errors surface (the app cannot function
without either) while saving a round-trip.

During app startup, public (login) extension registration was awaited
before the auth flow; kick it off in parallel and await it only before
routing, where the login screen actually needs it.

Each serialized call costs a full management-API round-trip, which is
negligible locally but ~150 ms each on high-latency (e.g. Cloud) hosts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(core): only mark connection connected once both calls succeed

Move isConnected.setValue(true) out of #setStatus() into connect() after
the allSettled check, so the observable never reflects a partially
established connection when configuration fails but status succeeded.

Addresses review feedback on the parallelized connect().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 08:20:34 +02:00
Nhu DinhandGitHub 346a22aeca E2E: QA Added acceptance tests for audit log in element (#22972)
* Added constant variables for element audit trail message

* Added ui helper for history item of element

* Updated tests for audit lofg for element
2026-05-29 15:52:05 +07:00
5cd0f2278c Login: Removes @hey-api/openapi-ts from the login project (#22757)
* feat: removes @hey-api/openapi-ts from the login project

this is an ongoing project to be able to finally  merge 'login' into 'client'

* docs(login): update CLAUDE.md to reflect removal of @hey-api/openapi-ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 16:57:06 +01:00
bc2048573c Link Picker: Render picked content/media as non-interactive when their workspace URL can't be resolved (closes #22955) (#22964)
* Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.

* Apply read-only on the picked content ref only in the non-routable link picker

* Address PR feedback: simplify document item resolver guard in the link picker, document the implicit uui-card-media disabled dependency in input-media, and cover the disabled card state with a test.

* Drop out of date comments.

* Simplify updates.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 13:23:48 +00:00
Andy ButlandandGitHub 424209ac06 References: Fix missing node name when content referenced by media or member (closes #22990) (#22965)
Fix missing node name when content referenced by media or member.
2026-05-28 13:34:25 +01:00
Andy Butland 5cf577bf57 Fix broken tiptap reference for storybook build. 2026-05-28 13:32:09 +02:00
ed4b207fe7 Backoffice: Render $index in block detail overlay label (closes #21154) (#22959)
* Render $index in block detail overlay label.

* Cache $index, resolve append sentinel, and cover with unit tests.

* refactor(block): use pipeline for index deduplication and clean up stale observer

* Rename function to remove the unnecessary umb prefix.

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 11:15:21 +00:00
Andy ButlandandGitHub 40e027d0ea Content Type Editor: Fix empty Design tab when opened in a modal workspace (closes #22855) (#22954)
* Fix empty content type Design tab when opened in a modal workspace.

* Addressed code review comments.

* Fixed failing E2E tests.
2026-05-28 09:39:11 +00:00
Andy Butland 70258066d4 Merge branch 'v17/dev' 2026-05-28 10:46:31 +02:00
Andy Butland 5cfbfe7cc3 Merge branch 'v17/dev' 2026-05-28 10:44:20 +02:00
Andy ButlandandGitHub a6f6bdf8bc Backoffice: Hide "Edit permissions" button in create modals from users without Settings section access (closes #22981) (#22984)
* Show the edit permissions for document type button only for users with settings access.

* Fix translation for message (the "Permissions" tab is not called "Structure").

* Addressed code review feedback.
2026-05-28 09:23:26 +01:00
Andy Butland 4b9c0eb667 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-05-28 09:56:39 +02:00
2581e3fdbc Code Quality: Fix CS0618 obsolete API warnings in Umbraco.Examine.Lucene project (#22978)
* Suppress CS0618 obsolete API warnings in Umbraco.Examine.Lucene

Each obsolete API in this project cannot be migrated to its
non-obsolete replacement without either a breaking public API
change or a change in runtime behaviour:

- LuceneIndex.CommitCount: obsolete with no replacement; retained
  in diagnostics metadata to preserve existing output
- IHostingEnvironment.MapPathContentRoot: the IHostEnvironment
  extension replacement resolves a different environment
  abstraction
- FileSystemDirectoryFactory base constructor: the non-obsolete
  overload alters Lucene directory configuration behaviour

Each warning is suppressed locally with an explanatory comment
rather than changed, preserving existing behaviour.

Fixes #15015

* Tightened up comments. Added obsoletion version on unversioned attributes.
Removed warning supressions and fixed constructors.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-28 09:54:55 +02:00
Jacob Overgaard 4f5985c4d0 build: fixed timeoutInMinutes which should be on the task-level and not job-level 2026-05-28 09:28:40 +02:00
Jacob Overgaard 7d63afe8d6 Merge branch 'release/18.0' 2026-05-28 08:56:53 +02:00
Jacob OvergaardandClaude Opus 4.7 8e6a791de0 Backoffice: Drop redundant search manifest import from Storybook preview
`core/manifests.ts` already imports and spreads `core/search/manifests.ts`
into its aggregate (line 23 + 58), so importing `searchManifests`
separately in `.storybook/preview.js` and spreading it next to
`coreManifests` registered the same manifests twice. Remove the redundant
import and spread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:47:38 +02:00
Jacob OvergaardandClaude Opus 4.7 5ffea3152b Backoffice: Repoint Storybook preview imports at umbraco-package.ts
PR #22957 deleted every package's `manifests.ts` and consolidated the
exports into `umbraco-package.ts`, but `.storybook/preview.js` still
imported from the old paths. The result was a Vite resolve error during
`npm run build-storybook` (first failure: "Could not resolve
../src/packages/block/manifests from .storybook/preview.js").

37 import paths swapped from `…/<pkg>/manifests` to
`…/<pkg>/umbraco-package`. The two packages that still expose their
manifests via a standalone `manifests.ts` — `core` and `core/search` —
are left untouched.

Verified by `npm run build-storybook` — succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:43:38 +02:00
Jacob OvergaardandClaude Opus 4.7 2043ff1dbd Tiptap: Fix dead manifests.js import in the input-tiptap story
PR #22995 added `input-tiptap.stories.ts` with an import from
`'../../manifests.js'`, but PR #22957 (already on release/17.5.0) had
deleted that file and moved the `manifests` array into
`umbraco-package.ts`. The merge into release/17.5.0 didn't catch the dead
import, so Storybook 404s on the story load.

Point the import at the new home — `manifests` is still exported by name,
so this is a one-line path fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:40:17 +02:00
Jacob Overgaard 85e0169100 Merge branch 'release/18.0' 2026-05-28 08:28:32 +02:00
Jacob Overgaard f1bc1db6ce Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-28 08:27:15 +02:00
Jacob Overgaard c4d5b89fc5 Merge remote-tracking branch 'origin/release/17.5.0' into v17/dev 2026-05-28 08:27:04 +02:00
7597a8ad40 Sort Dialog: Show current language node names (closes #22872) (#22948)
* Display variant node name on sort children dialog.

* Preserve user sort order when patching variant names on culture change

Patch names in-place on the existing _tableItems rather than rebuilding
from _children, so a user's drag-sorted or column-ordered arrangement is
not silently reverted if the app culture changes while the modal is open.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Refactor to reduce cyclomatic complexity of #resolveName method.

* Resolve sort dialog variant names and icons via item data resolvers

Replace the inlined variant-name logic in the content sort dialog with the
shared UmbItemDataResolver abstraction, and add UmbMediaItemDataResolver so
media items resolve their active-culture name and icon the same way documents
do. Each content sort entity action now supplies its resolver through manifest
meta, flowing into the modal via a new content-specific modal data type and a
base-action _getModalData() hook. This also removes the previously hard-coded
document icon in the dialog.

* Disable load more when page of items is being retrieved.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-28 05:50:16 +00:00
d28507e2e5 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:45:01 +02:00
7b75324172 Migrations: Convert all sibling RTE blocks (closes #22979) (#22980)
* Fix migration of embedded block data when blocks are direct siblings in the 13 RTE source code.

* Apply suggestions from code review to update comments.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address review: keep RteBlockHelper in original namespace; tidy docs and comment

- Move RteBlockHelper back to Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_15_0_0.LocalLinks
  to avoid a binary breaking change within the obsolete window (scheduled removal in v18).
  Kept as its own file rather than reverting it into LocalLinkRteProcessor.cs.
- Add a <remarks> note on ConvertBlockUdisToKeys explaining that blocks with malformed UDIs
  are dropped rather than preserved.
- Replace the opaque "fix recursive hiccup" comment in LocalLinkRteProcessor with one that
  describes what the line actually does.
- Move RteBlockHelperTests back to mirror the production namespace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 07:38:56 +02:00
172a3be5ac Repositories: Batch WHERE IN queries to avoid SQL Server 2100-parameter limit (#22987)
* Batch WHERE IN queries to avoid SQL Server 2100-parameter limit and add memory files.

* Drop past-incident references from SQL parameter-limit docs

The memory files should describe the current rule and safe patterns;
specific historical bugs belong in commit history, not CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Update comments from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Addressed memory file feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 10:44:15 +09:00
ca28195b7c Tiptap: Load enabled extensions in parallel and inline manifest APIs (#22995)
* Tiptap: Load enabled extensions in parallel and inline manifest APIs

Replace the for…of/await loop in umb-input-tiptap's #loadExtensions with
Promise.all over .map, so all enabled Tiptap extension APIs are fetched
in parallel. Configured-extension order in _extensions is preserved.

Inline the first-party Tiptap manifest API references: every
`api: () => import('./X.tiptap-api.js')` and the equivalent toolbar /
statusbar / kind references now use a static top-of-file import and
`api: ClassName`. The dynamic `await import('rich-text-essentials.tiptap-api.js')`
fallback in input-tiptap.element.ts is inlined for the same reason.

External (plugin-supplied) Tiptap extensions and the lazy modal/toolbar
UI element imports are unchanged.

Why: on Umbraco Cloud, opening a document workspace with a rich text
editor takes ~16 s uncached, of which ~14.6 s is a single serial
waterfall — 31 extension APIs fetched one after the other from a
for…of await loop, ~170 ms RTT stacked. Replacing the loop with
Promise.all collapses that to roughly one round-trip; eagerly bundling
the first-party manifests removes the dynamic chunk explosion that made
the waterfall so long in the first place. The toolbar APIs (~20 of them)
already load in a sub-100 ms parallel burst against the same server,
confirming HTTP/2 multiplexing handles bulk parallel requests fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Inline element references for toolbar/statusbar/modal/clipboard manifests

Extends the manifest-inlining pass to the remaining `element: () => import(...)`
and runtime API loader sites in the Tiptap package — toolbar/menu/action-button
kinds, the table & character-map & anchor modals, the colour-picker button, the
property-editor configuration UIs, both clipboard translators, the style-menu
kind, and the default toolbar API fallback in tiptap-toolbar.element.ts.

Result on the same Cloud test site (uncached, 17.5-rc):
  Tiptap chunk count: 71 → 4
  Total tiptap bytes: ~3.2 MB → ~3.1 MB (essentially unchanged)
  Phase 5 of the load — the serial extension chain — collapses to a single
  consolidated chunk fetch.

`input-tiptap.element.ts` and `property-editor-ui-tiptap.element.ts` are
intentionally not inlined into anything else: `<umb-input-tiptap>` is a public
element usable standalone (custom dashboards, workspace views), and the
property-editor shell loads via the property-editor UI loader. They remain
exported as their own modules.

CLAUDE.md updated to document the new convention for first-party Tiptap
extensions (direct class refs) and the carve-out for external plugin
extensions that may keep `() => import(...)` to ship their API code in a
separate chunk.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Move extension APIs and elements into a shared lazy boundary chunk

The previous PR collapsed ~70 Tiptap chunks into 3 by inlining first-party API
and element references directly into manifest files. That win came with a real
downside flagged in code review (lke / mra): the API/element implementation
bytes ended up in the manifest registration bundle, so every workspace —
including ones without an RTE — paid ~700 KB of Tiptap code on boot.

This commit keeps the chunk-coalescing win but restores the lazy boundary by
routing every first-party manifest's `api` / `element` reference through a
single shared bundle file `extensions/extension-apis.bundle.ts`. Each manifest
holds a dynamic-import thunk pointing at that one bundle, so:

- Rollup still emits a single chunk for all Tiptap extension code (no chunk
  explosion).
- The manifest registration bundle stays slim — it carries only metadata
  (alias / label / icon / group / kind / forExtensions) plus the thunks.
- The bundle is only fetched the first time `<umb-input-tiptap>` actually
  mounts.

Data-type configuration UIs (`extensions-configuration`,
`toolbar-configuration`, `statusbar-configuration`) read manifest metadata
via `umbExtensionsRegistry.byType(...)` only — they never call
`loadManifestApi` / `loadManifestElement`, so the data-type editor continues
to work without loading any Tiptap implementation code.

Property-editor UI elements (`tiptap-rte`, the three configuration UIs) also
revert to `() => import('./X.element.js')` so each loads on demand from its
own chunk rather than being inlined into the manifest bundle.

`umb-input-tiptap` no longer statically imports the Rich Text Essentials API;
it prepends the alias to the observed list instead, so essentials resolves
through the same lazy bundle as every other extension.

Added a test and stories file that mount `<umb-input-tiptap>` standalone (no
property-editor wrapper) to make the public usage pattern explicit.

Built and verified via `npm run build:for:cms`:
- `dist-cms/packages/tiptap/manifests.js`           48 KB  (eager at boot)
- `dist-cms/packages/tiptap/extension-apis.bundle-*.js` 84 KB  (lazy)
- `dist-cms/packages/tiptap/tiptap-toolbar-element-api-base-*.js` 654 KB
  (lazy dependency of the bundle)
- per-element property-editor UI chunks load on demand when settings open

`npm run check:circular`, `npm run compile`, `npx wtr src/packages/tiptap`
all pass.

Related to #21152, builds on #22995.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Tiptap: Don't mount <umb-input-tiptap> in the standalone test

Mounting the element via fixture() spins up an UmbTiptapRteContext that
consumes UMB_SERVER_CONTEXT. In the unit-test runtime no server context
provider exists, so the context request stays pending. When @open-wc's
fixture tears down at end-of-file the request rejects with
"host disconnected" — surfaced as an unhandled promise rejection that
web-test-runner counts as a fatal runner error, exiting 1 even though every
individual test passed. The rejection happened to be in flight while a
block-grid clipboard test was active in CI, which is why the failure surfaced
there rather than in the tiptap test file itself.

Drop the manifest-registration assertion too — pulling the package-level
`manifests.ts` aggregator triggers a transitive 404 on the
`@umbraco-cms/backoffice/tiptap` importmap entry in the wtr environment.

The class-export + custom-element-registration checks are enough to prove
standalone exportability. The Storybook stories still cover the visual
end-to-end load path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:32:20 +00:00
Jacob Overgaard 4520bb6e91 Merge branch 'release/18.0' 2026-05-27 14:33:46 +02:00
Jacob Overgaard 5a5902e1d4 Merge remote-tracking branch 'origin/v17/dev' 2026-05-27 14:28:44 +02:00
Jacob Overgaard 4c1fde9e0c Merge branch 'release/17.5.0' into v17/dev 2026-05-27 14:28:03 +02:00
Mads RasmussenandJacob Overgaard f0013330e6 Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-27 14:26:56 +02:00
Mads RasmussenandJacob Overgaard bd2c985187 Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-27 14:26:34 +02:00
Mads RasmussenandJacob Overgaard 9711a5d012 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-27 14:24:57 +02:00
Engiber LozadaandGitHub 52cccafa86 Content Editor: Fix workspace footer breadcrumb overflow hiding (closes #20132) (#22323)
* Allow the breadcrumbs to collapse in the workspace view

* Remove redundant styles
2026-05-27 10:52:49 +00:00
Jacob Overgaard c2416429b7 Merge remote-tracking branch 'origin/v17/dev' 2026-05-27 09:45:15 +02:00
Andreas Zerbst 2fa7067803 Fixed required value 2026-05-27 09:27:13 +02:00
Andy ButlandandGitHub 808cba2747 Members: Default Approved to true when creating a member (closes #22991) (#22993)
Default new members created via the backoffice to approved.
2026-05-27 06:59:24 +00:00
mole b0a825e6c0 Fix test filters 2026-05-26 12:31:44 +02:00
Mads Rasmussen da0117f240 Merge branch 'v17/dev' of https://github.com/umbraco/Umbraco-CMS into v17/dev 2026-05-26 09:50:48 +02:00
mole fc261d1ce4 Fix intergration tests 2026-05-26 09:40:12 +02:00
Jacob Overgaard 31944675c0 Merge remote-tracking branch 'origin/v17/dev' 2026-05-26 08:33:28 +02:00
Jacob OvergaardandGitHub 61d3e4c53d Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478) (#22951)
* Backoffice: Add Cache-Control headers to cache-busted backoffice assets (AB#68478)

Adds a UseUmbracoBackOfficeCacheHeaders middleware that sets
Cache-Control: public, max-age=31536000, immutable on responses served
from the cache-busted backoffice path (/umbraco/backoffice/<hash>/*).
The hash in the URL is derived from the Umbraco version, so the URL
itself invalidates on every release - making 'immutable' safe regardless
of whether individual filenames contain a content hash.

In debug mode the cache-bust hash changes per request, so the header is
set to 'no-cache' to avoid filling the browser disk cache with single-use
entries.

Design is non-destructive to consumer customisation, addressing the
review feedback on the v14 attempt (#14475):

- Does not touch StaticFileOptions; consumer
  services.Configure<StaticFileOptions>(...) and OnPrepareResponse
  callbacks continue to work unchanged.
- Sets the header via Response.OnStarting with a ContainsKey guard, so
  any synchronous Cache-Control set upstream wins; consumer OnStarting
  callbacks registered later fire first (LIFO) and also win.
- Skips non-2xx responses to avoid long-lived caching of error responses.

Related: GH #21152, PR #22896.

* Backoffice: Correct rationale for no-cache in debug mode

Reword the XML doc on UseUmbracoBackOfficeCacheHeaders to reflect that
IBackOfficePathGenerator is a singleton, so the cache-bust hash is
computed once at startup even in debug mode (per Copilot review on
#22951). The reason for no-cache is not "hash changes per request" but
that built assets may change in place during dev iteration; no-cache
allows fast 304 revalidation while no-store would force full
re-downloads.

No functional change.

* Backoffice: Add unit tests for UseUmbracoBackOfficeCacheHeaders

Covers six scenarios via a minimal in-process pipeline composed with
Microsoft.AspNetCore.TestHost:

- Production: 200 under hash prefix gets immutable header
- Debug: 200 under hash prefix gets no-cache
- Non-2xx under prefix: header not set (status gate)
- Path outside prefix: header not set (path gate)
- Consumer synchronous override: ContainsKey guard skips, consumer wins
- Consumer OnStarting override: LIFO ordering lets consumer win

Adds Microsoft.AspNetCore.TestHost to Umbraco.Tests.UnitTests (standard
Microsoft package, version pinned in tests/Directory.Packages.props).

* Backoffice: Extract cache-headers logic into IMiddleware class

Matches the existing Umbraco middleware convention (BootFailedMiddleware,
PreviewAuthenticationMiddleware, UmbracoRequestMiddleware, etc.) per
Kenn's note: prefer UseMiddleware<T>() with a DI-resolved class over
inline builder.Use lambdas.

The new UmbracoBackOfficeCacheHeadersMiddleware:
- Implements IMiddleware; registered as a singleton in AddWebComponents
- Computes prefix and header value once in the constructor (both
  dependencies are singletons themselves, so this is stable)
- Behaviour is unchanged from the inline version

The UseUmbracoBackOfficeCacheHeaders extension method becomes a thin
UseMiddleware<T>() wrapper. Tests updated to register the middleware in
the TestServer DI container so it can be resolved through UseMiddleware.

* Backoffice: Document IMiddleware convention in Web.Common CLAUDE.md

Adds an explicit "Convention" note before the middleware list so future
contributors (and AI assistants) default to the IMiddleware class +
AddSingleton + UseMiddleware<T>() pattern rather than inline
builder.Use(async ...) lambdas. Also lists the new
UmbracoBackOfficeCacheHeadersMiddleware in the folder structure and
middleware reference.

* Backoffice: Tighten middleware convention note with full corroboration

Lists every IMiddleware implementer in the codebase (10/10) and calls
out the two known inline-lambda exceptions (CspNonceExtensions,
WebApplicationExtensions) so the rule reads as the established
convention rather than an absolute, while still steering new work
toward IMiddleware + AddSingleton + UseMiddleware<T>().

* Backoffice: Register cache-headers middleware in AddBackOfficeCore

DI scope validation runs in Development/CI and pre-checks every
singleton's dependency graph can be constructed. The middleware was
registered in AddWebComponents (which runs for every Umbraco bootstrap),
but its IBackOfficePathGenerator dependency is only registered by
AddBackOffice(). The previous CI run on this branch surfaced the
problem in four Delivery-only/Website-only bootstrap tests
(CoreWithDeliveryApi_BootsSuccessfully, DeliveryOnlyScenario_BootsSuccessfully,
etc.) with "Unable to resolve service for type 'IBackOfficePathGenerator'
while attempting to activate 'UmbracoBackOfficeCacheHeadersMiddleware'".

Move the registration alongside IBackOfficePathGenerator in
AddBackOfficeCore (Api.Management), which is the same scope as the
backoffice itself. This also matches the wire-up gate in
UmbracoApplicationBuilder.cs that only calls UseUmbracoBackOfficeCacheHeaders
when IBackOfficeEnabledMarker is registered.

CLAUDE.md updated with the rule ("register the middleware next to its
dependencies' registration") and a pitfall note about DI scope validation.

* Backoffice: Address review feedback from AndyButland (PR #22951)

- Move UseUmbracoBackOfficeCacheHeadersTests from Umbraco.Tests.UnitTests
  to Umbraco.Tests.Integration. It uses HostBuilder + TestServer to
  exercise the real HTTP pipeline, which is integration-shaped rather
  than unit-shaped. Drop Microsoft.AspNetCore.TestHost from UnitTests
  (Mvc.Testing in Integration provides it transitively) and from
  tests/Directory.Packages.props.
- Soften the misleading "no trailing slash" comment in
  UmbracoBackOfficeCacheHeadersMiddleware — we trim anyway, so the
  comment is now framed as defensive normalisation.
- Trim the dense middleware convention note in Web.Common/CLAUDE.md to
  one paragraph (rule + the two known inline-lambda exceptions). Move
  the DI-scope-validation pitfall narrative out of CLAUDE.md and into a
  three-line code comment next to the AddSingleton call in
  AddBackOfficeCore where it actually applies.

* Backoffice: HTTP verb gate, 304 inclusion, namespace + unused using (PR #22951 review)

Three more from AndyButland's review:

1. Verb gate + 304 inclusion in UmbracoBackOfficeCacheHeadersMiddleware.
   Restrict the path-prefix match to GET and HEAD so POST/PUT/DELETE
   responses and OPTIONS (CORS preflight) responses don't get tagged as
   immutable. Include 304 alongside 2xx in the status gate so
   intermediate caches (CDN/proxy) receive the Cache-Control directive on
   revalidation responses too. Extended the test suite with four new
   cases: NotModifiedResponseUnderPrefix_SetsImmutable,
   HeadRequestUnderPrefix_SetsImmutable,
   OptionsRequestUnderPrefix_DoesNotSetHeader,
   PostRequestUnderPrefix_DoesNotSetHeader. All 10 tests pass.
2. Test namespace updated to Umbraco.Cms.Tests.Integration.* to match
   the convention used by ~629 other files in Umbraco.Tests.Integration
   (vs the 2 outliers I copied from).
3. Drop unused 'using Umbraco.Extensions;' from the test file.

* Backoffice: Extract conditional checks to satisfy CodeScene complexity gate

CodeScene flagged InvokeAsync with "Complex Conditional" (advisory rule,
code health impact 9.69) after the verb + 304 additions in the prior
commit. Extract the two checks into IsCacheableAssetRequest and
ShouldSetCacheControl helper methods. No behaviour change; tests still
green (10/10, 149 ms).
2026-05-26 08:31:10 +02:00
Andy Butland 5a28f6e0f1 Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-05-26 06:33:52 +02:00
Zeegaan 6e2ba699ee Merge remote-tracking branch 'origin/v17/dev' 2026-05-26 12:22:28 +09:00
51d70877d1 QA: Stabilise rollback content versioning E2E test (#22975)
* Stabilise rollback E2E test by waiting for document reload before asserting.

* Condense rollback wait comment per code-review feedback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Addressed code review feedback.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:21:03 +09:00
Andy ButlandandGitHub 2dcfe68208 Backoffice search: Preserve Lucene score order through content-picker lookups (closes #22862) (#22977)
* Preserve Lucene score order through content-picker lookups.

* Removed unnecessary tests.  Addressed code review comments.
2026-05-26 12:15:21 +09:00
ce06c4ba4d Management API: ensure the order from the search endpoints taking a collection of keys is preserved (#22973)
* ensure the order from the search endpoints taking a collection of keys is preserved

* Align cosmetic changes to ensure later merge up doesn't run into conflicts.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 17:32:17 +00:00
Andy Butland c2b6210137 Merge branch 'v17/dev' 2026-05-25 10:21:31 +02:00
Andy Butland a91de6e677 Fix StoryBook build failure following updates in #22957. 2026-05-25 10:20:45 +02:00
Andy Butland f7a45dc9d6 Merge branch 'v17/dev' 2026-05-25 10:13:58 +02:00
faf3824a0a Backoffice: Embed implementations directly in core manifests to reduce startup network requests (#22944)
* Use direct imports in core manifests

* Extract theme aliases into constants file

---------

Co-authored-by: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com>
2026-05-25 10:07:24 +02:00
9cd2e6ecd2 Management API: ensure the order from the search endpoints taking a collection of keys is preserved (#22920)
* update order search result for element, member type, dictionary...

* undo dictionary search API

* reorder search value

* Apply OrderByRequestedIds

* add unit tests for search order

* Reverted unnecessarily changed files, minor test clean-up, aligned controllers for XML docs.

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 07:38:49 +00:00
Andy Butland c64c6cfd92 Merge branch 'v17/dev' 2026-05-25 08:35:28 +02:00
Andy Butland ca30a7604e Merge branch 'release/18.0' 2026-05-25 08:35:09 +02:00
Andy Butland 58ed9899be Merge branch 'release/17.5.0' into v17/dev 2026-05-25 08:01:04 +02:00
Engiber LozadaandGitHub bc7bd9a32a Body Layout: Replace overflow: auto with uui-scroll-container (#22950)
* replace overflow: auto with uui-scroll-container in layout components

* Remove stale comment
2026-05-25 07:57:22 +02:00
8160ede4b6 Background Jobs: Refine RecurringBackgroundJobBase API (#22966)
* Add IgnoredDelayChanged event to allow updates during back-off

* Make Period and IgnoredDelay settable on RecurringBackgroundJobBase with auto-raising events

* Address PR review: handle CTS race, restore negative-IgnoredDelay guard, clarify setter remarks

- Swallow ObjectDisposedException in OnIgnoredDelayChanged for the shutdown race where an in-flight handler reads the to-be-disposed CTS via Interlocked.Exchange before Dispose disposes it.
- Restore "skip back-off when IgnoredDelay <= TimeSpan.Zero (and not Timeout.InfiniteTimeSpan)" guard in IgnoreAndWaitAsync to defend against direct IRecurringBackgroundJob implementations / property overrides returning a negative value that would otherwise tight-loop via ComputeNextDelay clamping to zero.
- Add regression test for the negative-IgnoredDelay skip path.
- Mirror the constructor "stored without raising" remark on the Period and IgnoredDelay setter doc comments.

* Dispose newly-installed CTS when shutdown race wins the rotate-and-cancel

* Clarify XML docs.

* Introduce helper for cancellation source rotate and cancel.

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-25 05:42:41 +00:00
Jacob Overgaard d2e32d6fcb Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS 2026-05-23 10:11:21 +02:00
Jacob Overgaard bec333e37b Merge remote-tracking branch 'origin/v17/dev' 2026-05-23 10:11:10 +02:00
Mads RasmussenandGitHub e06a583f1a Backoffice: Embed package root manifests into umbraco-package.ts to reduce startup requests (#22957)
* Consolidate block package into index export

* keep umbraco-package.ts and embed manifests instead

* Inline package manifests into umbraco-package

* update docs
2026-05-23 10:09:03 +02:00
Andy Butland 61cc37ad1d Revert "Entity refs render readonly when their workspace URL can't be resolved."
This reverts commit dc7b34eb58.
2026-05-23 09:42:42 +02:00
Andy Butland dc7b34eb58 Entity refs render readonly when their workspace URL can't be resolved.
Also fixes name on remove dialog.
2026-05-23 09:35:42 +02:00
Andy Butland dfe98ffa3b Disable failing link to selected content from link picker launched from RTE.
Also fixes name on remove dialog.
2026-05-23 09:16:47 +02:00
Andreas Lykke BorgandAndy Butland 6b8e8935fd Accessibility: Added missing labels to webhook details and headers (#22918)
* Added missing labels to webhoot details and headers

* Changed toggle label to aria-label to remove visible text
2026-05-22 19:04:41 +02:00
Andreas Lykke BorgandGitHub 4344fe9060 Accessibility: Added missing labels to webhook details and headers (#22918)
* Added missing labels to webhoot details and headers

* Changed toggle label to aria-label to remove visible text
2026-05-22 19:02:10 +02:00
Andy ButlandandSven Geusens 53c74efd35 Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 12:04:12 +02:00
Jacob Overgaard 485257a949 Merge branch 'v17/dev' 2026-05-22 11:23:07 +02:00
Jacob Overgaard 1c058a32d9 Merge branch 'release/17.5.0' into v17/dev 2026-05-22 11:22:13 +02:00
Andy ButlandandGitHub 12f838277c Migrations: Append data-anchor value to href when missing in local link migration (closes #22860) (#22936)
* Support anchor fragments that are included in the data attribute but missing in the href when migrating local links.

* Addressed code review feedback.
2026-05-22 11:20:05 +02:00
Jacob OvergaardandClaude Opus 4.7 1ae2a780dd Workspace Actions: Restore waiting state for buttons with additional options (closes #18670, #20593) (#22554)
* Workspace Actions: Restore waiting state for buttons with additional options

The waiting state was suppressed whenever a workspace action reported
hasAdditionalOptions() (e.g. Save and publish on multi-variant sites),
so users saw the button jump straight from idle to the success tick
with no in-flight feedback.

Always set 'waiting' on click (unless the action is a link). The
variant-picker modal still opens on top of the button, so the spinner
is effectively invisible during selection — but it becomes visible
as soon as the modal closes and the publish request is in flight.

Fixes #22551

* Workspace Actions: Spin button only while real work is in flight

Replace the eager always-set-waiting behaviour from the previous commit
with an opt-in `isPending` signal so the spinner appears only while
actual work (validation + HTTP) is happening - not while the variant
picker modal is open, and never as a spurious success tick when the
user cancels the modal.

Changes:

- Add optional `isPending: Observable<boolean>` to UmbWorkspaceAction
  and a default UmbBooleanState + protected setPending() on the base
  class. Optional + backwards compatible for external implementers.

- Add optional `onActionStarting` callback (via a shared
  UmbWorkspaceActionExecutionOptions type) to
  UmbPublishableWorkspaceContext.saveAndPublish and
  UmbSaveableWorkspaceContext.requestSave. The document publishing
  context and content detail workspace base invoke the callback at the
  join point right after the variant picker resolves (or is skipped
  for the single-variant case), so it never fires when the modal is
  cancelled.

- Wire the document save and save-and-publish actions to clear pending
  at the start of execute() and pass an onActionStarting callback that
  flips it true when work begins.

- Update the workspace action element to observe api.isPending: when
  the observable is present the waiting state is driven by the
  observable (and the success tick is suppressed if the action
  resolves without ever signalling pending - i.e. a cancellation).
  When the observable is absent the element falls back to the legacy
  eager-waiting behaviour. Failures always surface the failed tick.

Fixes #22551

* Reduce cyclomatic complexity of #onClick and _handleSave

CodeScene Code Health Review flagged two complexity issues:

- UmbWorkspaceActionElement.#onClick reached cyclomatic complexity 9
  (threshold is < 9). Extracted the api-execution branch into a new
  private #runApiAction helper so #onClick collapses to a simple
  link-vs-action dispatch.
- _handleSave was already over the threshold (14); my optional-chain
  callback invocation pushed it to 16. Moved the
  `executionOptions?.onActionStarting?.()` call into a #notifyActionStarting
  helper so the call site is a plain method call and contributes zero
  cyclomatic complexity to _handleSave.

No behavioural change.

* Reduce cyclomatic complexity of #handleSaveAndPublish

Same fix as the previous commit's #notifyActionStarting extraction in
content-detail-workspace-base: move the optional-chain callback
invocation into a private helper so #handleSaveAndPublish stays at its
pre-PR cyclomatic complexity (15) instead of degrading to 17.

No behavioural change.

* DRY: extract notifyWorkspaceActionStarting into a shared utility

Both UmbDocumentPublishingWorkspaceContext.#handleSaveAndPublish and
UmbContentDetailWorkspaceContextBase._handleSave had identical private
optional-chain callback off the host method's cyclomatic complexity.

Replace both with a single exported notifyWorkspaceActionStarting()
utility co-located with UmbWorkspaceActionExecutionOptions. This:

- Removes a duplication point between the two contexts.
- Gives future workspace context implementations a ready-made way to
  honour the optional callback without re-inventing the helper or
  paying the cyclomatic-complexity cost at the call site.

No behavioural change.

* Rename isPending -> isExecuting to mirror the execute() method

Niels suggested correlating the observable's name with the action's
`execute()` method, so the symbol set is now:

- isExecuting (observable on UmbWorkspaceAction interface)
- _isExecuting / setExecuting (UmbWorkspaceActionBase)
- #observeIsExecuting / #executionStarted (workspace-action element)
- isExecutingObserver (observer alias)

Pure rename; no behavioural change.

* Address Copilot review: lazy isExecuting, observer scope, finally reset

Five Copilot findings on PR #22554. Three real regressions + two
contract violations, all addressed:

1. UmbWorkspaceActionBase always exposing `isExecuting` made every
   existing subclass appear to opt in to the new modal-aware flow,
   suppressing waiting/success states for actions that never call
   setExecuting(true). Made `_isExecuting`/`isExecuting` lazy: only
   created on the first setExecuting() call. Opt-in subclasses call
   `setExecuting(false)` in their constructor so the observable is
   exposed before the workspace-action element reads it. Subclasses
   that don't opt in keep `isExecuting` undefined and the element
   falls back to legacy eager waiting feedback.

2. Element observation of `isExecuting` now lives inside #runApiAction
   so it tracks whichever api is actually invoked (`_actionApi ?? #api`),
   correctly handling subclasses like UmbSaveAndPreviewWorkspaceActionElement
   that swap in a different api at runtime. The shared observer alias
   replaces any previous observation on re-clicks.

3. UmbSaveWorkspaceAction and UmbDocumentSaveAndPublishWorkspaceAction
   now wrap their execute() body in try/finally and reset
   setExecuting(false) on completion so the observable honours the
   "true while execute() is performing real work, false otherwise"
   contract instead of getting stuck at true between executions.

No behavioural change for actions that already worked correctly before
this PR; the regression-prone "always exposed" behaviour is gone.

* Address Claude review: Elements gap, type placement, tests + cleanup

Three follow-ups on top of c15eb2d0bc:

1. Elements gap — UmbElementSaveAndPublishWorkspaceAction +
   UmbElementPublishingWorkspaceContext now wire through the same
   onActionStarting/notifyWorkspaceActionStarting handshake as the
   Document equivalents, so multi-variant Elements (Forms, Commerce, etc.)
   get the spinner-after-modal behaviour rather than no spinner at all.

2. Type placement — moved UmbWorkspaceActionExecutionOptions out of
   publishable-workspace-context.interface.ts into its own file so the
   saveable interface no longer has a directional dependency on the
   publishable one. Both peer contexts now import from the same neutral
   location.

3. Unit tests — added blackbox coverage for notifyWorkspaceActionStarting
   (no-op on undefined options/callback, invokes when present) and the
   UmbWorkspaceActionBase.setExecuting lazy-opt-in contract (undefined
   until first call, observable then exposed, value flips, sequential
   emissions, stable reference across calls).

Code-review cleanup applied on the same pass:

- Dropped the redundant `setExecuting(false)` at the start of execute()
  in the save and save-and-publish actions; the finally block plus
  UmbBooleanState's value-dedup already cover idempotency on retries.
- Removed an overlong block comment on `_isExecuting`; the JSDoc on
  setExecuting already documents the lazy/opt-in contract for subclasses.
- Trimmed an internal motivation comment from notify-workspace-action-
  starting.function.ts that referenced cyclomatic complexity.
- Extracted a tiny makeAction() helper in the controller test to remove
  the `{ meta: {} as never }` repetition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Workspace Actions: Align Save button state with Save & Publish (Andy review feedback)

Two related fixes addressing the variant-Save inconsistency Andy reported:

- Element catch block now only sets `failed` once `#executionStarted` is
  true. Pre-flight rejections (user cancelling a variant-picker modal,
  context-missing throws, etc.) leave the button idle, matching the
  silent-cancel path used by `#handleSaveAndPublish`. Legacy actions
  that don't opt in to `isExecuting` are unaffected because they set
  `#executionStarted = true` eagerly on click.

- `UmbDocumentWorkspaceContext._handleSave` and
  `UmbElementWorkspaceContext._handleSave` now accept and forward the
  `UmbWorkspaceActionExecutionOptions` argument to `super._handleSave`.
  The previous overrides dropped the parameter, so the
  `onActionStarting` callback supplied by `UmbSaveWorkspaceAction` never
  fired - which is why Save showed no waiting/success indicator even
  on a successful submit.

Result: Save and Save-and-publish now behave identically -
cancel = no indicator, submit = waiting then success - for both
invariant and multi-variant documents and elements.

* Docs: Document the modal-aware execution feedback contract for workspace actions

New 'Button state when the action opens a modal' subsection in
docs/workspaces.md explaining the three-piece contract:
UmbWorkspaceActionExecutionOptions + notifyWorkspaceActionStarting +
UmbWorkspaceActionBase.setExecuting. Covers third-party authoring of
modal-aware buttons, the cancel/pre-flight idle behaviour, and the
silent-parameter-drop pitfall on _handleSave overrides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:00:49 +02:00
c542b1b4fd Workspace Actions: Restore waiting state for buttons with additional options (closes #18670, #20593) (#22554)
* Workspace Actions: Restore waiting state for buttons with additional options

The waiting state was suppressed whenever a workspace action reported
hasAdditionalOptions() (e.g. Save and publish on multi-variant sites),
so users saw the button jump straight from idle to the success tick
with no in-flight feedback.

Always set 'waiting' on click (unless the action is a link). The
variant-picker modal still opens on top of the button, so the spinner
is effectively invisible during selection — but it becomes visible
as soon as the modal closes and the publish request is in flight.

Fixes #22551

* Workspace Actions: Spin button only while real work is in flight

Replace the eager always-set-waiting behaviour from the previous commit
with an opt-in `isPending` signal so the spinner appears only while
actual work (validation + HTTP) is happening - not while the variant
picker modal is open, and never as a spurious success tick when the
user cancels the modal.

Changes:

- Add optional `isPending: Observable<boolean>` to UmbWorkspaceAction
  and a default UmbBooleanState + protected setPending() on the base
  class. Optional + backwards compatible for external implementers.

- Add optional `onActionStarting` callback (via a shared
  UmbWorkspaceActionExecutionOptions type) to
  UmbPublishableWorkspaceContext.saveAndPublish and
  UmbSaveableWorkspaceContext.requestSave. The document publishing
  context and content detail workspace base invoke the callback at the
  join point right after the variant picker resolves (or is skipped
  for the single-variant case), so it never fires when the modal is
  cancelled.

- Wire the document save and save-and-publish actions to clear pending
  at the start of execute() and pass an onActionStarting callback that
  flips it true when work begins.

- Update the workspace action element to observe api.isPending: when
  the observable is present the waiting state is driven by the
  observable (and the success tick is suppressed if the action
  resolves without ever signalling pending - i.e. a cancellation).
  When the observable is absent the element falls back to the legacy
  eager-waiting behaviour. Failures always surface the failed tick.

Fixes #22551

* Reduce cyclomatic complexity of #onClick and _handleSave

CodeScene Code Health Review flagged two complexity issues:

- UmbWorkspaceActionElement.#onClick reached cyclomatic complexity 9
  (threshold is < 9). Extracted the api-execution branch into a new
  private #runApiAction helper so #onClick collapses to a simple
  link-vs-action dispatch.
- _handleSave was already over the threshold (14); my optional-chain
  callback invocation pushed it to 16. Moved the
  `executionOptions?.onActionStarting?.()` call into a #notifyActionStarting
  helper so the call site is a plain method call and contributes zero
  cyclomatic complexity to _handleSave.

No behavioural change.

* Reduce cyclomatic complexity of #handleSaveAndPublish

Same fix as the previous commit's #notifyActionStarting extraction in
content-detail-workspace-base: move the optional-chain callback
invocation into a private helper so #handleSaveAndPublish stays at its
pre-PR cyclomatic complexity (15) instead of degrading to 17.

No behavioural change.

* DRY: extract notifyWorkspaceActionStarting into a shared utility

Both UmbDocumentPublishingWorkspaceContext.#handleSaveAndPublish and
UmbContentDetailWorkspaceContextBase._handleSave had identical private
#notifyActionStarting helpers introduced in this PR purely to keep the
optional-chain callback off the host method's cyclomatic complexity.

Replace both with a single exported notifyWorkspaceActionStarting()
utility co-located with UmbWorkspaceActionExecutionOptions. This:

- Removes a duplication point between the two contexts.
- Gives future workspace context implementations a ready-made way to
  honour the optional callback without re-inventing the helper or
  paying the cyclomatic-complexity cost at the call site.

No behavioural change.

* Rename isPending -> isExecuting to mirror the execute() method

Niels suggested correlating the observable's name with the action's
`execute()` method, so the symbol set is now:

- isExecuting (observable on UmbWorkspaceAction interface)
- _isExecuting / setExecuting (UmbWorkspaceActionBase)
- #observeIsExecuting / #executionStarted (workspace-action element)
- isExecutingObserver (observer alias)

Pure rename; no behavioural change.

* Address Copilot review: lazy isExecuting, observer scope, finally reset

Five Copilot findings on PR #22554. Three real regressions + two
contract violations, all addressed:

1. UmbWorkspaceActionBase always exposing `isExecuting` made every
   existing subclass appear to opt in to the new modal-aware flow,
   suppressing waiting/success states for actions that never call
   setExecuting(true). Made `_isExecuting`/`isExecuting` lazy: only
   created on the first setExecuting() call. Opt-in subclasses call
   `setExecuting(false)` in their constructor so the observable is
   exposed before the workspace-action element reads it. Subclasses
   that don't opt in keep `isExecuting` undefined and the element
   falls back to legacy eager waiting feedback.

2. Element observation of `isExecuting` now lives inside #runApiAction
   so it tracks whichever api is actually invoked (`_actionApi ?? #api`),
   correctly handling subclasses like UmbSaveAndPreviewWorkspaceActionElement
   that swap in a different api at runtime. The shared observer alias
   replaces any previous observation on re-clicks.

3. UmbSaveWorkspaceAction and UmbDocumentSaveAndPublishWorkspaceAction
   now wrap their execute() body in try/finally and reset
   setExecuting(false) on completion so the observable honours the
   "true while execute() is performing real work, false otherwise"
   contract instead of getting stuck at true between executions.

No behavioural change for actions that already worked correctly before
this PR; the regression-prone "always exposed" behaviour is gone.

* Address Claude review: Elements gap, type placement, tests + cleanup

Three follow-ups on top of c15eb2d0bc:

1. Elements gap — UmbElementSaveAndPublishWorkspaceAction +
   UmbElementPublishingWorkspaceContext now wire through the same
   onActionStarting/notifyWorkspaceActionStarting handshake as the
   Document equivalents, so multi-variant Elements (Forms, Commerce, etc.)
   get the spinner-after-modal behaviour rather than no spinner at all.

2. Type placement — moved UmbWorkspaceActionExecutionOptions out of
   publishable-workspace-context.interface.ts into its own file so the
   saveable interface no longer has a directional dependency on the
   publishable one. Both peer contexts now import from the same neutral
   location.

3. Unit tests — added blackbox coverage for notifyWorkspaceActionStarting
   (no-op on undefined options/callback, invokes when present) and the
   UmbWorkspaceActionBase.setExecuting lazy-opt-in contract (undefined
   until first call, observable then exposed, value flips, sequential
   emissions, stable reference across calls).

Code-review cleanup applied on the same pass:

- Dropped the redundant `setExecuting(false)` at the start of execute()
  in the save and save-and-publish actions; the finally block plus
  UmbBooleanState's value-dedup already cover idempotency on retries.
- Removed an overlong block comment on `_isExecuting`; the JSDoc on
  setExecuting already documents the lazy/opt-in contract for subclasses.
- Trimmed an internal motivation comment from notify-workspace-action-
  starting.function.ts that referenced cyclomatic complexity.
- Extracted a tiny makeAction() helper in the controller test to remove
  the `{ meta: {} as never }` repetition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Workspace Actions: Align Save button state with Save & Publish (Andy review feedback)

Two related fixes addressing the variant-Save inconsistency Andy reported:

- Element catch block now only sets `failed` once `#executionStarted` is
  true. Pre-flight rejections (user cancelling a variant-picker modal,
  context-missing throws, etc.) leave the button idle, matching the
  silent-cancel path used by `#handleSaveAndPublish`. Legacy actions
  that don't opt in to `isExecuting` are unaffected because they set
  `#executionStarted = true` eagerly on click.

- `UmbDocumentWorkspaceContext._handleSave` and
  `UmbElementWorkspaceContext._handleSave` now accept and forward the
  `UmbWorkspaceActionExecutionOptions` argument to `super._handleSave`.
  The previous overrides dropped the parameter, so the
  `onActionStarting` callback supplied by `UmbSaveWorkspaceAction` never
  fired - which is why Save showed no waiting/success indicator even
  on a successful submit.

Result: Save and Save-and-publish now behave identically -
cancel = no indicator, submit = waiting then success - for both
invariant and multi-variant documents and elements.

* Docs: Document the modal-aware execution feedback contract for workspace actions

New 'Button state when the action opens a modal' subsection in
docs/workspaces.md explaining the three-piece contract:
UmbWorkspaceActionExecutionOptions + notifyWorkspaceActionStarting +
UmbWorkspaceActionBase.setExecuting. Covers third-party authoring of
modal-aware buttons, the cancel/pre-flight idle behaviour, and the
silent-parameter-drop pitfall on _handleSave overrides.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:32:48 +00:00
Jacob Overgaard 767fafe06d Merge remote-tracking branch 'origin/v17/dev' 2026-05-22 09:38:31 +02:00
Jacob OvergaardandClaude Opus 4.7 04f0e229c7 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:35:44 +02:00
5d76706553 Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983) (#22896)
* Backoffice: Coalesce small Rollup chunks across all workspaces (AB#67983)

Set experimentalMinChunkSize=10_000 as the default in the shared Vite
helper. Every workspace inherits the coalescing automatically; the
threshold can still be overridden per workspace (pass 0 to disable).

Impact on dist-cms output:
- packages/core .js files: 981 -> 272 (-72%)
- All workspaces combined .js files: 2194 -> 1401 (-36%)
- Welcome dashboard .js requests: 510 -> 497 (-2.5%)
- packages/ufm requests in particular: 23 -> 12 (-48%)
- Gzipped bundle total: -1.2%
- Raw bytes: +1.4% (small overhead from merged chunks; gzip wins it back)

All entry chunks are preserved, so every public
@umbraco-cms/backoffice/<sub> import keeps resolving without changes
to package.json exports or tsconfig paths.

Further consolidation (collapsing core's per-subpath entries into a
single bundle with stubs) was prototyped but hits a TDZ cycle between
the eager entry and its dynamic-import descendants. Tracked for v18,
not part of this change.

* Backoffice: Normalise umbraco-package + manifests shapes (AB#67983)

Aligns the two outliers with the conventions used by the other 38
first-party packages:

- documents/umbraco-package.ts now uses the lazy bundle pattern
  (type: 'bundle', js: () => import('./manifests.js')) instead of
  eagerly importing manifests at module evaluation. The bundle
  initializer auto-loads the manifests at boot, so behaviour is
  unchanged.
- umbraco-news/manifests.ts now exports `manifests: Array<...>`
  instead of a bare `dashboard` object. The bundle initializer
  enumerates exports regardless of name, so behaviour is unchanged.

Preparatory cleanup so future build-time manifest aggregation can
treat every workspace uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 09:28:31 +02:00
Mads RasmussenandGitHub 1fdcb835bc Devops: Report bidirectional import detection for both Core and Packages modules (#22938)
report bidirectional imports for core modules
2026-05-22 07:29:37 +02:00
Mads RasmussenandGitHub 6b3bdb59b7 Backoffice: Swap relative imports to @umbraco-cms/backoffice module imports in core packages (#22942)
Use @umbraco-cms/backoffice imports

Replace numerous relative/internal import paths with centralized '@umbraco-cms/backoffice' package entry points across core modules.This consolidates exports, simplifies import paths.
2026-05-22 07:24:39 +02:00
Andy Butland 216fee987b Added a TODO for a future major. 2026-05-22 06:44:36 +02:00
Andy Butland ca6a32088a Merge branch 'v17/dev' 2026-05-21 19:41:41 +02:00
65ab1c0b2b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:39:21 +02:00
a54769758b Background Jobs: Rewrite RecurringHostedServiceBase with SemaphoreSlim and add signalling support (#22331)
* Compute next delay to compensate for time drift

* Use SemaphoreSlim to properly handle exceptions, cancellation tokens and triggering immediate executions

* Add RecurringBackgroundJobBase to contain default values and hide obsoleted method

* Add NextExecutionStrategy parameter to adjust the schedule after triggered executions

* Add TriggerExecution methods to RecurringBackgroundJobHostedServiceRunner

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Handle cancellation (application shutdown) and publish RecurringBackgroundJobCanceledNotification

* Match hosted services by Type instead of type name string

* Extract shared helper for TriggerExecution tests

* Clear trigger state when initial delay is interrupted

* Clear _nextExecutionSkipOnOvershoot unconditionally

* Combine ComputeNextDelay tests

* Consolidate trigger state into an immutable record for thread safety

* Use ConcurrentDictionary for thread-safe hosted service lookup

* Remove hosted services from dictionary on stop

* Fix API compatibility errors

* Removed unneeded using.

* Register RecurringBackgroundJobHostedServiceRunner as resolvable singleton

* Remove failed hosted service from dictionary when StartAsync throws

* Use semaphore signaling instead of Task.Delay in trigger tests

Use semaphore signaling instead of Task.Delay in trigger tests 2

* Inject TimeProvider into RecurringHostedServiceBase for deterministic testing

Fix timeprovider

* Use DelayCalculator.GetDelay instead of RecurringHostedServiceBase.GetDelay

* Fix Exception_In_PerformExecuteAsync_Does_Not_Kill_Loop test

* Avoid disposing period-change CTS while wait loop may still reference it

* Configure IEventMessagesFactory mock to return real EventMessages

* Clarify TriggerExecution(TimeSpan) docs and add ChangePeriod test

* Validate period is positive and use GetOrAdd to avoid creating unused hosted services

* Set up Period and Delay on mock job to satisfy constructor validation

* Ensure PeriodChanged event is unsubscribed again

* Fix trigger state race, simplify ReleaseSignal, and add canceled notification test

Fix trigger state

* Use Interlocked for _period reads/writes and implement thread-safe dispose pattern

* Remove hosted service from dictionary before stopping to prevent triggering during shutdown

* Replace Task.Yield with semaphore timeouts in negative assertions

* Tidy RecurringBackgroundJobBase docs and runner error handling

* Wait IgnoredDelay after ignored execution to prevent tight looping when Period is short or zero

* Add IRecurringBackgroundJobTrigger<TJob> for opt-in job triggering

* Register IRecurringBackgroundJobTrigger as open generic and drop AddTriggerableRecurringBackgroundJob

* Fix and add parameter validation

* Allow Timeout.InfiniteTimeSpan as Period for manual-trigger-only recurring jobs

* Migrate built-in jobs to RecurringBackgroundJobBase and require ITriggerableRecurringBackgroundJob in runner trigger overloads

* Support infinite Delay and honor TriggerExecution(TimeSpan) issued during the initial delay

* Handle edge case of backoff via InfiniteTimeSpan.

* Refactored large method.

* Added clarifying documentation.

* Suppress ExecutionContext flow when starting the recurring background loop, restoring previous timer behaviour.

* Relocate Suppress ExecutionContext flow to avoid package validation error.

* Align IRecurringBackgroundJobTrigger generic type constraint with AddRecurringBackgroundJob

* Rename ApplyTriggerState to ComputeNextDelayFromTriggerState

* Allow Timeout.InfiniteTimeSpan as IgnoredDelay to fully disable a job for the remaining application lifecycle

* Fix generic type constraint

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-21 19:36:04 +02:00
Andy Butland 62db2c06bb Merge branch 'v17/dev' 2026-05-21 19:07:14 +02:00
Andreas Lykke BorgandAndy Butland 1616997409 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:06:01 +02:00
Andreas Lykke BorgandGitHub 609b74b475 Image Cropper: Improve contrast of append label in crop options editor (closes #22878) (#22917)
Improve contrast of input append label in image crops editor
2026-05-21 19:05:33 +02:00
Andy Butland e756e40003 Fixed front-end build issue after merge. 2026-05-21 18:45:09 +02:00
Andy Butland b8d6c83d53 Merge branch 'v17/dev' 2026-05-21 18:13:55 +02:00
Engiber LozadaandAndy Butland 63289e22cb Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:12:23 +02:00
Engiber LozadaandGitHub 82b2991a18 Block Grid: Fix inline create button width not updating on workspace resize (closes #22527) (#22928)
* Add ResizeObserver for inline create buttons

* Disconnect layout resize observer on destroy

* Initialize ResizeObserver and simplify cleanup

* Remove optional chaining on layout observer disconnect
2026-05-21 18:11:24 +02:00
Andy Butland 0f357f595e Merge branch 'v17/dev' 2026-05-21 18:01:17 +02:00
Andy Butland 721cf53d40 Fix styling of redirect tracker enabled/disabled icon. 2026-05-21 17:59:27 +02:00
Andy Butland f8ba3d8cfc Merge branch 'release/17.5.0' into v17/dev 2026-05-21 17:41:20 +02:00
Andy ButlandandLan Nguyen Thuy 7f832d261d Reset password: Add inline validation messaging for password pattern requirements (#22880)
* add custom validation for password input in reset password

* update remove invalid listeners in disconnectedCallback

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-21 17:34:05 +02:00
Andy Butland 0116ddb81d Fixed code styling. 2026-05-21 17:16:54 +02:00
Andy Butland a4a2d4ee35 Fixed incorrect documentation. 2026-05-21 17:16:46 +02:00
4ecbface60 Reset password: Add inline validation messaging for password pattern requirements (#22880)
* add custom validation for password input in reset password

* update remove invalid listeners in disconnectedCallback

---------

Co-authored-by: Lan Nguyen Thuy <lnt@umbraco.dk>
2026-05-21 17:13:35 +02:00
Jacob Overgaard 80e6b481c0 Merge branch 'release/18.0' 2026-05-21 12:38:06 +02:00
Mads RasmussenandGitHub f79e9586b4 Collections: Replace direct filter pass-through in collection server data sources (#22921)
Pass explicit skip/take to collection services
2026-05-21 09:18:36 +01:00
nikolajlauridsen 60948d197a Merge branch 'v17/dev'
# Conflicts:
#	src/Umbraco.Core/Services/DocumentUrlService.cs
2026-05-21 10:15:13 +02:00
Mads RasmussenandGitHub c74a58246f Entity Data Picker: Fix "Not Found" in remove dialog for entities without a top-level name (#22915)
* Add item data resolver support to picker data sources

* add js docs

* remove duplicated fallback logic

* wip unit tests of requestItemName method

* Use DocumentVariantStateModel in mock documents to fix compiler

* Update input-entity-data.context.ts

* Update input-entity-data.context.test.ts
2026-05-21 09:13:45 +01:00
nikolajlauridsen 06b15157cf Merge branch 'release/17.4.2' into release/17.5.0
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:18:11 +02:00
nikolajlauridsen 82f7830d26 Merge branch 'release/17.4.2' into v17/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-21 09:16:39 +02:00
Andy Butland 337cd32258 Merge branch 'v17/dev' 2026-05-21 07:45:20 +02:00
Jacob OvergaardandClaude Opus 4.7 247935cc38 Tests: Fix unit tests broken by sync element fast-path and lazy property materialization
- ElementPickerValueConverterTests: also stub the new synchronous IPublishedElementCache.GetById,
  since Moq does not execute default interface implementations.
- PropertyCacheLevelTests.CacheUnknownTest: access a property inside Assert.Throws to trigger the
  now-lazy property wrapper materialization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:51:01 +02:00
Jacob OvergaardandGitHub b9c4c5be74 Test: adds 'mocha' and 'chai' as types for tsconfig (#22889)
fix(test): adds 'mocha' and 'chai' as types for tsconfig
2026-05-20 10:59:31 +01:00
8aaac65f83 Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:52:09 +02:00
8d5826c61f Content Editing: Fix save composition values on invariant content and save of default segment (closes #22800, #22865) (#22846)
* Fix edit of a variant property composed to an invariant document.

* Collapse multi-line guard comment to a single line per project policy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add unit test coverage for invariant content with a culture-variant composition property.

Adds a third mock document/document-type pair representing an invariant
content type whose flattened property list contains a culture-variant
property (the runtime shape produced when a variant composition is applied
to an invariant content type) and a setPropertyValue test asserting the
value is stored as a culture/segment-invariant entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Remove null guard for segment variant documents, as null segment is the default segment.

* update mock data and tests to include real compositions

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Mads Rasmussen <madsr@hey.com>
2026-05-20 10:43:02 +02:00
Lee KelleherandGitHub 5a73f63cfd feat(components): adds umb-entity-frame component + Storybook stories (#22844)
* feat(components): adds `umb-entity-frame` component + Storybook stories

* fix(components): address review feedback for `umb-entity-frame`

- Remove `pointer-events: auto` from `.tab` so the overlay is truly passive
  (was intercepting events above the parent and causing hover flicker when
  toggled via opacity).
- Replace `--uui-color-surface` tab text with `--uui-color-selected-contrast`
  (the proper paired contrast token) and expose
  `--umb-entity-frame-contrast-color` so consumers can override when supplying
  a non-default `--umb-entity-frame-color`. Fixes contrast in dark and
  high-contrast themes.
- Add `aria-hidden="true"` to `.tab`; the frame is purely decorative and the
  parent owns the real semantics.
- Add a unit test verifying slot content takes precedence over the `label`
  property.

* Removed `aria-hidden` from the label tab

As will need to be used with assistive technologies.
2026-05-20 08:39:49 +00:00
Andy Butland 09af8c044b Merge branch 'v17/dev' 2026-05-20 10:32:08 +02:00
e463cd3a0c Log Viewer: Defensively handle corrupt log files (closes #22820) (#22826)
* Defensively handle log file corruptions by amalgamating errors per file and reporting as warning.

* Addressed code review comments.

* Use local reference to Newtonsoft.Json so it's clear we are only using it for exception handling.

---------

Co-authored-by: Kenn Jacobsen <kja@umbraco.dk>
2026-05-20 07:22:54 +00:00
2901be793a Redirect URL Tracker: Remove ability to toggle from the UI (#22830)
* Remove the ability to enable or disable the redirect tracker from the UI.

* Addressed code review feedback.

* Update OpenApi.json.

* Regenerate backend SDK from updated OpenApi.json

* Update UI to use lozenge status indicator rather than an imperative action.

* Further UX tweak.

---------

Co-authored-by: Laura Neto <12862535+lauraneto@users.noreply.github.com>
2026-05-20 09:20:37 +02:00
Andy Butland 1dc47bf4a1 Merge branch 'release/18.0' 2026-05-19 18:44:42 +02:00
Andy Butland 0b86312f52 Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:01:12 +02:00
Andy ButlandandGitHub f4592111fa Children/Descendants: improve traversal performance (closes #22646) (#22742)
* Add benchmark test for measuring improvements to children and descendant retrieval.

* Remove unnecessary sort from retrieval of children.

* Return the result of the filtered collection of children/decendants without materialising.

* Lazily build property wrappers when materializing IPublishedContent.

* Cache the ordered children list on NavigationNode.

* Cache descendants per parent on the navigation snapshot.

* Add synchronous fast path for retrieved of cached content.

* Additional unit tests.

* Add TODO to make UpdateSortOrder internal.

* Addressed code review feedback.

* Further unit tests.

* Future-proofed code comments.
2026-05-19 18:00:34 +02:00
Andy Butland d6893dbf0b Merge branch 'v17/dev' 2026-05-19 17:59:45 +02:00
Andy Butland 4c909d8ce8 Merge branch 'release/17.4.1' into release/17.5.0 2026-05-19 17:54:19 +02:00
Andy Butland 12c699d5bd Merge branch 'release/17.4.1' into v17/dev 2026-05-19 17:47:47 +02:00
Jacob Overgaard 81c8afbd44 Merge remote-tracking branch 'origin/v17/dev' 2026-05-19 11:54:36 +02:00
Jacob Overgaard f352a2e90e Docs: clarify DefaultUILanguage vs fallback culture in package-development
Adds a 'Default UI language vs fallback culture' subsection so package
authors don't conflate the active UI locale (en-US by default) with the
fallback dictionary culture (en). A third-party language pack overriding
canonical keys must declare 'culture: en-US' on a default install,
otherwise the registry filters it out — the keys come from en.ts but
the override extension's culture has to match the active locale.

Surfaced by a tester report after PR #22743 merged the login screen's
localization into the backoffice client: the registry was forcing 'en'
active at boot (fixed in PR #22822) which masked the distinction, and
the docs didn't spell it out either.
2026-05-19 11:03:45 +02:00
Jacob Overgaard 1637d9b158 Merge branch 'release/17.5.0' into v17/dev 2026-05-19 10:55:58 +02:00
Jacob Overgaard 7737cd3d40 Localization: Honor DefaultUILanguage on initial load (closes #22808) (#22822)
* Localization: Honor DefaultUILanguage on initial load (closes #22808)

Closes #22808.

Previously, the configured DefaultUILanguage was silently overridden to
'en' at startup because the UmbLocalizationRegistry constructor called
loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE) unconditionally. The
configured locale rendered into <html lang="..."> by Razor never had a
chance to flow through to the active language.

Changes:

- localization.registry.ts: stop forcing the active language to 'en'
  in the constructor. Initial state is canonicalised from
  document.documentElement.lang, falling back to 'en' for empty or
  malformed input. The extension filter now always includes the
  default culture alongside the active locale so 'en' translations
  remain available as a key-level fallback regardless of which
  language is active. A synchronous tap mirrors the active locale to
  document.lang and the manager when the state changes, so a fresh
  element rendered between loadLanguage() and the async translation
  load picks up the right language immediately.

- localization.manager.ts: drop the MutationObserver on
  document.documentElement and rely on the registry as the single
  channel for language changes. setActiveLanguage accepts a `silent`
  option so the synchronous tap can update fields without firing a
  consumer notification (translations may still be loading). A new
  notifyLanguageChanged() method is fired by the registry once
  translations are in place.

- app.element.ts: subscribe to umbLocalizationRegistry.currentLanguage
  in connectedCallback and mirror it onto the host element's lang
  attribute, so myApp.lang reflects the source of truth rather than a
  stale snapshot of <html lang>.

- auth.element.ts (login app): same lang subscription, plus after the
  slim backoffice controller registers extensions, prefer the
  visitor's navigator.language if a matching localization extension
  exists (falls through baseName -> language -> en automatically).

Tests: new initialization tests for the registry, manager
setActiveLanguage tests, and the controller tests refactored to use
the new explicit setActiveLanguage API instead of writing directly to
document.documentElement.lang.

* Login: Only override DefaultUILanguage with navigator.language when default has no translation

If the admin sets DefaultUILanguage to a language we have a translation for,
respect that choice over the visitor's browser language. Falling back to
navigator.language only when the configured default isn't available avoids
silently ignoring the admin's explicit setting (e.g., DefaultUILanguage='da-DK'
on a site whose visitor's browser is 'en-GB' should still show Danish).

* Simplify: split setActiveLanguage from notifyLanguageChanged

Drop the silent option in favor of two intent-revealing methods:
setActiveLanguage updates the active language and direction without
side-effects; notifyLanguageChanged tells all connected controllers
to re-render against the current state. Callers compose them based
on what they need (the registry's pipeline updates language sync
then flushes notifications after async translation load).

Also extracts baseLocaleOf() helper, simplifies the navigator.language
match logic in the login app's #applyPreferredLanguage, and removes
narration-style comments in the new code.

* Restore deprecated UmbLocalizationManager.updateAll for backward compat

The old MutationObserver-driven updateAll() field was technically part
of the manager's public surface. Restore it as a deprecated alias that
reads document.lang/dir and forwards to setActiveLanguage + notifyLanguageChanged,
with a runtime UmbDeprecation warning pointing consumers at the new API.

* Fix deprecation removal version to v20 + correct baseLocaleOf JSDoc

Per the deprecation policy in CLAUDE.md (current major + 2): a method
deprecated in v18 must remain through v19 before removal, so the
earliest removal is v20, not v19.

Also corrects the baseLocaleOf JSDoc — Intl.Locale.baseName can include
script subtags (e.g. 'zh-Hant-TW'), not just language and region.

* Scope the active language to the host element, drop navigator.language

- Razor now sets `lang` on `<umb-app>` and `<umb-auth>` from
  DefaultUILanguage. The element passes its lang through on connect, so
  the host owns its own scope — future multi-backoffice scenarios (e.g.
  signing into two Umbraco Cloud sites in the same document) get their
  own language without fighting over a global `<html lang>`.

- The registry no longer reads or writes `document.documentElement.lang`.
  Host elements drive it via `loadLanguage()`; `<html lang>` stays as
  whatever Razor rendered.

- Removed the navigator.language preference detection in the login app.
  Not in scope for the bug fix and adds behavior the admin can't opt out
  of. The existing current-user-locale flow already handles per-user
  preference after login.

- Tests updated to assert on `umbLocalizationManager.documentLanguage`
  instead of `document.documentElement.lang`.

* Set <html lang="en"> to match the static (noscript) text in the templates

The page's `<html lang>` should describe the language of the document's
own innate content. Both Index.cshtml files only contain English static
text (the noscript fallback), so the page-level lang is now "en".

The dynamic UI inside <umb-app> / <umb-auth> carries its own `lang`
attribute (from DefaultUILanguage), which overrides for that subtree —
correct per the HTML spec for language inheritance.

* Drop deprecated UmbLocalizationManager.updateAll

It was public as an artifact of being an arrow function so it could be
passed to a MutationObserver without binding — not because it was
intended as part of the public API. External usage is effectively
zero, and the new explicit setActiveLanguage + notifyLanguageChanged
covers anyone who did reach for it.

* Docs: document active-language-on-host pattern in package-development.md

After PR #22822, the active UI language is driven by the shell elements
(<umb-app>, <umb-auth>) via their own lang attribute, not by <html lang>.
Document that so future contributors don't reach for the global.

* Collapse setActiveLanguage + notifyLanguageChanged into one method

The silent-write path is just `manager.documentLanguage = ...` — no new
method needed; the field is already public and was always writable. The
notify path keeps setActiveLanguage, which now both sets and notifies.

Net: one new public method on the manager instead of two.

* Inline the active-language write in the registry, drop setActiveLanguage

The previous version added setActiveLanguage on the manager as
a 'cleaner API' than direct field writes. But the manager's fields
(documentLanguage, documentDirection, connectedControllers) have
always been public, the registry is the only caller, and the wrapper
was just more public surface to maintain through the eventual
manager/registry collapse.

Net: -70 lines across the test file, no new public methods on the
manager, the registry pipeline writes the fields and iterates the
controllers directly where it would have called setActiveLanguage.

* Document that documentLanguage/Direction are read-only for consumers

Note in JSDoc that the only supported way to change the active language
is umbLocalizationRegistry.loadLanguage(). The fields stay writable for
the registry pipeline (cross-module internal); the comment is here so
the next contributor doesn't reach for them as a shortcut and end up
with the manager state out of sync with what's actually loaded.
2026-05-19 10:52:55 +02:00
Andy Butland beb9fcf4e2 Merge branch 'release/18.0' 2026-05-19 10:39:40 +02:00
Jacob OvergaardandGitHub 23851c872f Localization: Honor DefaultUILanguage on initial load (closes #22808) (#22822)
* Localization: Honor DefaultUILanguage on initial load (closes #22808)

Closes #22808.

Previously, the configured DefaultUILanguage was silently overridden to
'en' at startup because the UmbLocalizationRegistry constructor called
loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE) unconditionally. The
configured locale rendered into <html lang="..."> by Razor never had a
chance to flow through to the active language.

Changes:

- localization.registry.ts: stop forcing the active language to 'en'
  in the constructor. Initial state is canonicalised from
  document.documentElement.lang, falling back to 'en' for empty or
  malformed input. The extension filter now always includes the
  default culture alongside the active locale so 'en' translations
  remain available as a key-level fallback regardless of which
  language is active. A synchronous tap mirrors the active locale to
  document.lang and the manager when the state changes, so a fresh
  element rendered between loadLanguage() and the async translation
  load picks up the right language immediately.

- localization.manager.ts: drop the MutationObserver on
  document.documentElement and rely on the registry as the single
  channel for language changes. setActiveLanguage accepts a `silent`
  option so the synchronous tap can update fields without firing a
  consumer notification (translations may still be loading). A new
  notifyLanguageChanged() method is fired by the registry once
  translations are in place.

- app.element.ts: subscribe to umbLocalizationRegistry.currentLanguage
  in connectedCallback and mirror it onto the host element's lang
  attribute, so myApp.lang reflects the source of truth rather than a
  stale snapshot of <html lang>.

- auth.element.ts (login app): same lang subscription, plus after the
  slim backoffice controller registers extensions, prefer the
  visitor's navigator.language if a matching localization extension
  exists (falls through baseName -> language -> en automatically).

Tests: new initialization tests for the registry, manager
setActiveLanguage tests, and the controller tests refactored to use
the new explicit setActiveLanguage API instead of writing directly to
document.documentElement.lang.

* Login: Only override DefaultUILanguage with navigator.language when default has no translation

If the admin sets DefaultUILanguage to a language we have a translation for,
respect that choice over the visitor's browser language. Falling back to
navigator.language only when the configured default isn't available avoids
silently ignoring the admin's explicit setting (e.g., DefaultUILanguage='da-DK'
on a site whose visitor's browser is 'en-GB' should still show Danish).

* Simplify: split setActiveLanguage from notifyLanguageChanged

Drop the silent option in favor of two intent-revealing methods:
setActiveLanguage updates the active language and direction without
side-effects; notifyLanguageChanged tells all connected controllers
to re-render against the current state. Callers compose them based
on what they need (the registry's pipeline updates language sync
then flushes notifications after async translation load).

Also extracts baseLocaleOf() helper, simplifies the navigator.language
match logic in the login app's #applyPreferredLanguage, and removes
narration-style comments in the new code.

* Restore deprecated UmbLocalizationManager.updateAll for backward compat

The old MutationObserver-driven updateAll() field was technically part
of the manager's public surface. Restore it as a deprecated alias that
reads document.lang/dir and forwards to setActiveLanguage + notifyLanguageChanged,
with a runtime UmbDeprecation warning pointing consumers at the new API.

* Fix deprecation removal version to v20 + correct baseLocaleOf JSDoc

Per the deprecation policy in CLAUDE.md (current major + 2): a method
deprecated in v18 must remain through v19 before removal, so the
earliest removal is v20, not v19.

Also corrects the baseLocaleOf JSDoc — Intl.Locale.baseName can include
script subtags (e.g. 'zh-Hant-TW'), not just language and region.

* Scope the active language to the host element, drop navigator.language

- Razor now sets `lang` on `<umb-app>` and `<umb-auth>` from
  DefaultUILanguage. The element passes its lang through on connect, so
  the host owns its own scope — future multi-backoffice scenarios (e.g.
  signing into two Umbraco Cloud sites in the same document) get their
  own language without fighting over a global `<html lang>`.

- The registry no longer reads or writes `document.documentElement.lang`.
  Host elements drive it via `loadLanguage()`; `<html lang>` stays as
  whatever Razor rendered.

- Removed the navigator.language preference detection in the login app.
  Not in scope for the bug fix and adds behavior the admin can't opt out
  of. The existing current-user-locale flow already handles per-user
  preference after login.

- Tests updated to assert on `umbLocalizationManager.documentLanguage`
  instead of `document.documentElement.lang`.

* Set <html lang="en"> to match the static (noscript) text in the templates

The page's `<html lang>` should describe the language of the document's
own innate content. Both Index.cshtml files only contain English static
text (the noscript fallback), so the page-level lang is now "en".

The dynamic UI inside <umb-app> / <umb-auth> carries its own `lang`
attribute (from DefaultUILanguage), which overrides for that subtree —
correct per the HTML spec for language inheritance.

* Drop deprecated UmbLocalizationManager.updateAll

It was public as an artifact of being an arrow function so it could be
passed to a MutationObserver without binding — not because it was
intended as part of the public API. External usage is effectively
zero, and the new explicit setActiveLanguage + notifyLanguageChanged
covers anyone who did reach for it.

* Docs: document active-language-on-host pattern in package-development.md

After PR #22822, the active UI language is driven by the shell elements
(<umb-app>, <umb-auth>) via their own lang attribute, not by <html lang>.
Document that so future contributors don't reach for the global.

* Collapse setActiveLanguage + notifyLanguageChanged into one method

The silent-write path is just `manager.documentLanguage = ...` — no new
method needed; the field is already public and was always writable. The
notify path keeps setActiveLanguage, which now both sets and notifies.

Net: one new public method on the manager instead of two.

* Inline the active-language write in the registry, drop setActiveLanguage

The previous version added setActiveLanguage on the manager as
a 'cleaner API' than direct field writes. But the manager's fields
(documentLanguage, documentDirection, connectedControllers) have
always been public, the registry is the only caller, and the wrapper
was just more public surface to maintain through the eventual
manager/registry collapse.

Net: -70 lines across the test file, no new public methods on the
manager, the registry pipeline writes the fields and iterates the
controllers directly where it would have called setActiveLanguage.

* Document that documentLanguage/Direction are read-only for consumers

Note in JSDoc that the only supported way to change the active language
is umbLocalizationRegistry.loadLanguage(). The fields stay writable for
the registry pipeline (cross-module internal); the comment is here so
the next contributor doesn't reach for them as a shortcut and end up
with the manager state out of sync with what's actually loaded.
2026-05-19 09:21:03 +01:00
Andreas Lykke BorgandGitHub 7e4ef037b9 Issue template: Update version lookup instructions in bug report template (closes #22867) (#22881)
Update version lookup instructions in bug report template
2026-05-19 09:39:46 +02:00
Andy Butland 003656e21e Merge branch 'release/18.0' 2026-05-19 09:36:09 +02:00
Andy Butland 2b85dd5fd6 Merge branch 'v17/dev' 2026-05-19 09:17:23 +02:00
139ac6ad72 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:13:52 +02:00
cd4521bd77 Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers (#22875)
* Blueprints: Fix UdiEntityTypeHelper.ToUmbracoObjectType() for document blueprint containers

Adds the missing DocumentBlueprintContainer case so the conversion is
symmetric with FromUmbracoObjectType().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add missing case for MemberTypeContainer.

* Use reflection to ensure other future missed cases are surfaced without having to explicitly extend the tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-19 09:11:09 +02:00
80e2764eda Migrations: Add auto upgrade coordination for load-balanced setups (#22815)
* Add auto upgrade coordination for load balanced setups

* Add tests

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix(infrastructure): move TryBecomeLeaderAsync inside try/catch in UnattendedUpgradeBackgroundService

Ensures DB exceptions thrown during migration coordination set BootFailed
rather than faulting the background service silently.

* Fix feedback

* Update src/Umbraco.Infrastructure/Install/MigrationCoordinator.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Recheck state

* fix(tests): update concurrent race test for post-claim DetermineRuntimeLevel check

The winner now calls DetermineRuntimeLevel() once from the post-claim check
and must see Upgrading; the loser polls twice before seeing Run. Transition
the mock on the second call instead of the first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 12:06:34 +02:00
d9bb17de2a Relation Type: Migrate custom table collection view to generic table (#22837)
* migrate relation type table collection view to table kind

* update page locator

* Request relations when workspace unique is set

* fix types

* split models

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use constant for relation type collection alias + remove redundant fields

* Add observer keys in relation-type workspace view

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 08:26:42 +00:00
72fdf281fd Backoffice: Provide entity context via UMB_ENTITY_CONTEXT in menu components (#22835)
* Use UmbEntityContext for entity actions

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-15 09:26:07 +02:00
Andy ButlandandGitHub b24c9ba8ac Log Viewer: Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core. (#22843)
* Updated the saved log viewer searches for new installs to reference Umbraco.Cms instead of Umbraco.Core.

* Update mock and default data too.
2026-05-15 08:29:09 +09:00
2377e9a555 Cache: Add scope-level cache version tier to reduce DB hits in bulk operations (#22563)
* Cache cacheversion on scope

* Add tests

* Cache: Use ConcurrentDictionary for the inner per-scope version map

The inner Dictionary<string, Guid> was not thread-safe. Replacing it
with ConcurrentDictionary<string, Guid> removes the hidden assumption
that the root scope is only accessed from a single thread at a time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/Umbraco.Core/Cache/IRepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessorTests.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/Umbraco.Web.Common/Cache/RepositoryCacheVersionAccessor.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED

* Revert "Skips failing test so we can run nightly. THIS NEEDS TO BE REVERTED"

This reverts commit c34d1736c336b3fcf7803b44e88f6018fa45c275.

* Only write version once pr. scope

* Add tests

* Remove unnececary locks

* Fix thread-safety: replace HashSet with ConcurrentHashSet and use GetOrAdd to eliminate TOCTOU races

* Add unit tests for RepositoryCacheVersionService

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andreas Zerbst <andr317c@live.dk>
2026-05-15 08:28:06 +09:00
Andy Butland f964a18b5b Merge branch 'release/17.5.0' of https://github.com/umbraco/Umbraco-CMS into release/17.5.0 2026-05-14 19:10:30 +02:00
Lee KelleherandAndy Butland 2369f00544 Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 19:08:11 +02:00
Lee KelleherandGitHub 0f438c551c Mocks: Add missing signalR property to mock server configuration response (#22849)
Mocks: Add missing signalR property to mock server configuration response

The GetServerConfigurationResponse type was updated in #22700 to require
a signalR.skipNegotiation property, but the MSW mock handler was not
updated to match, causing a tsc compilation error.
2026-05-14 17:05:55 +00:00
Sebastiaan Janssen d822518dd3 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename regression test to follow Can_ naming convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
(cherry picked from commit abfa8cb144)
2026-05-14 10:42:20 +02:00
abfa8cb144 Core: Preserve path case in ShadowFileSystem (#22838)
* Core: Preserve path case in ShadowFileSystem

ShadowFileSystem stored staged files at their original case via _sfs.AddFile
but tracked them under a lowercased key (NormPath calling ToLowerInvariant).
On Complete(), Inner.AddFile(kvp.Key, _sfs.GetFullPath(kvp.Key)) reconstructed
the staged file's path from the lowercased key, so on case-sensitive file
systems (Linux) File.Move failed with FileNotFoundException whenever a path
contained any uppercase character.

Drop the ToLowerInvariant from NormPath and switch the tracking dictionary
to StringComparer.OrdinalIgnoreCase. Lookups remain case-insensitive
(matching Windows semantics) while the stored key now matches what was
written to disk. IsChild/IsDescendant updated to OrdinalIgnoreCase
StartsWith for consistency.

Added regression test reproducing the original FileNotFoundException with
Views/PageNotFound.cshtml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Rename regression test to follow Can_ naming convention

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Track canonical staged path per shadow node

The case-insensitive node dictionary preserved only the first inserted
key, so re-staging a logical path with a different case (e.g. AddFile
"Views/Foo.cshtml" then "views/foo.cshtml") wrote a phantom second file
to _sfs on Linux while Complete still resolved the original key — leaving
orphaned shadow files and committing stale content.

Track the original-case staged path on each ShadowNode and route all
_sfs operations (AddFile, OpenFile, GetFullPath, GetLastModified,
GetCreated, GetSize, MoveFile, Complete) through that canonical path.
Inner.AddFile on commit still uses the stored dictionary key, so the
destination case in the inner file system is unchanged.

Expanded the regression test to also exercise OpenFile, GetSize and
AddFile against a different-cased path, and to assert that the staged
file is written exactly once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Make ShadowNode.CanonicalPath non-nullable

Every node now carries the original-case path it tracks, set at construction.
This removes the defensive 'sf.CanonicalPath ?? path' fallbacks at the read
sites (OpenFile, GetFullPath, GetLastModified, GetCreated, GetSize, Complete)
which were unreachable but noise.

The GetCanonicalPath helper is gone; AddFile and MoveFile now use the existing
node variable inline ('sf?.CanonicalPath ?? path' — node can legitimately be
null when staging a path for the first time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review: normalize delete key, cross-platform test

DeleteDirectory(recursive=false) stored the deletion marker under the
caller-supplied path (which can contain backslashes) instead of the
normalized key, so a follow-up NormPath-based lookup could miss the
deletion and IsChild scans could become inconsistent. Use normPath.

The shadow-second-file assertion in the regression test used
File.Exists on a different-cased path; that returns true on
case-insensitive file systems (Windows / default macOS) regardless of
the actual stored case, so the assertion was platform-dependent.
Replaced it with a directory-count check that's cross-platform.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add cross-platform regression test so any reversion would be caught on a non-case sensitive file system.

* Cleaned up warnings, obsoletions and comments in the existing tests.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-14 10:36:47 +02:00
Andy Butland 21ab470d88 Merge branch 'release/17.4.0' into release/17.5.0 2026-05-14 08:31:39 +02:00
Andy Butland 5dd8378c57 Merge branch 'release/17.4.0' into v17/dev 2026-05-14 08:30:01 +02:00
Andy Butland 4b82828a23 Merge branch 'release/18.0' 2026-05-14 08:11:39 +02:00
Jacob OvergaardandGitHub 3e7c1fa8e4 Backoffice: Preserve prerelease tag when hoisting peer dependencies (#22841)
The publish cleanse step strips the prerelease suffix from hoisted dependency
ranges via `semver.minVersion(...).major/minor/patch`. For `^2.0.0-rc.1`
this produced `^2.0.0`, which no published `@umbraco-ui/uui` version
currently satisfies, breaking extension installs against
`@umbraco-cms/backoffice@18.0.0-beta1`+.

Use the full SemVer (including any prerelease) as the floor so
`^2.0.0-rc.1` stays satisfiable by the actual published rc.
2026-05-13 22:51:55 +02:00
Mads RasmussenandGitHub 8e7440580a User: Delete unused custom table collection view (#22839)
delete unused user table code
2026-05-13 17:36:03 +02:00
Mads RasmussenandGitHub 358d435948 Member Group: Migrate custom table collection view to generic table kind (#22833)
* Migrate member group custom table to use table kind

* Use data-mark collection view selector for member group view
2026-05-13 17:04:22 +02:00
Andreas Lykke BorgandGitHub dcf1595e74 Accessibility: Added missing labels to code block copy button and embedded media URL input (#22825)
* Added label to copy button

* Added label to url input in editor

* Changed term to url

* Removed unnecessary readonly #localize

* Added copied translation key
2026-05-13 14:17:29 +00:00
Andy Butland 7b579cd021 Merge branch 'v17/dev' 2026-05-13 15:45:47 +02:00
Jacob Overgaard 1edd6ec0bc Merge branch 'release/18.0' 2026-05-13 13:44:17 +02:00
0add0f5b18 Backoffice: Preserve user-supplied property editor UI group names (closes #22189) (#22196)
* Preserve user-supplied property editor UI group names.

* Add support for localised property editor groups, and use localised values for all core property editors.

* Fixed check to look for '#' as the first character of the provided group name.

* danish translation

---------

Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
2026-05-13 11:29:51 +02:00
Nhu DinhandGitHub 79aadd827a Build: Updated nightly E2E test pipeline schedule in v18 (#22802)
Update nightly e2e test pipeline
2026-05-13 06:33:02 +00:00
Ronald BarendseandAndy Butland 4921ab9257 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:29:22 +02:00
Ronald BarendseandGitHub e07f188bd4 SignalR: Mark ServerEventSender as a distributed cache notification handler (#22818)
* Mark ServerEventSender as distributed cache notification handler

* Batch and deduplicate notifications in ServerEventSender

* Introduce IDistributedCacheAsyncNotificationHandler<T> and use it in ServerEventSender

* Add ServerEventSender unit tests and address PR review feedback
2026-05-13 08:26:50 +02:00
Nhu DinhandGitHub 4bb5864e20 E2E: QA Updated acceptance tests to match the recent changes (#22801)
* Updated locator for user group table

* Updated json builder for user groups permission due to element folder permission

* Updated api helper to match with element folder permission

* Updated tests and add comments for the failing tests
2026-05-13 12:15:43 +07:00
Ronald Barendseandleekelleher 63bae5958a User Permission: Re-export fallback condition config type and global augmentation (#22794)
(cherry picked from commit 55fec1dc2a)
2026-05-12 17:15:28 +01:00
Ronald BarendseandGitHub 55fec1dc2a User Permission: Re-export fallback condition config type and global augmentation (#22794) 2026-05-12 17:14:48 +01:00
Andy Butlandandleekelleher 35d726ad31 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set failed button state when sort-children-of submit throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
(cherry picked from commit dfe93c5639)
2026-05-12 17:06:44 +01:00
dfe93c5639 Sort Children: Show loading state on Sort button (closes #22651) (#22813)
* Add submit button state to sort dialog.

* Guard against re-entrant submit in sort-children-of modal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Set failed button state when sort-children-of submit throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:04:03 +01:00
kjac 2a2252474f Merge remote-tracking branch 'origin/main' 2026-05-12 12:32:07 +02:00
Jacob Overgaard 9c15572a49 fix: reinstates ./element export 2026-05-12 12:13:39 +02:00
Jacob Overgaard 045db8d699 fix: reinstate ./library export 2026-05-12 12:13:05 +02:00
kjac ecd29d79ff Merge remote-tracking branch 'origin/main' 2026-05-12 11:56:45 +02:00
Jacob Overgaard 60104e2a0e chore: regenerates tsconfig.json 2026-05-12 11:02:23 +02:00
Jacob Overgaard 2d747c0b43 chore: set version back to 18.1.0 2026-05-12 10:43:05 +02:00
Nhu DinhandGitHub 22a7a9577b Build: Updated nightly E2E test pipeline schedule in v17 (#22803)
Updated nightly E2E test pipeline schedule
2026-05-12 15:36:27 +07:00
Kenn Jacobsen 6766eb9411 Content: Ensure correct variant change tracking when unpublishing variant content (#22799) 2026-05-12 10:21:41 +02:00
Jacob Overgaard c9c4704e1a fix: exports condition configs and fixes test imports 2026-05-12 10:12:46 +02:00
Jacob Overgaard 0b0fea04d9 chore: sets version in backoffice client and regen packagel ock 2026-05-12 10:08:01 +02:00
Kenn JacobsenandAndy Butland fc9ca861b0 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 10:01:20 +02:00
c784858b32 Content: Ensure correct variant change tracking when unpublishing variant content (#22799)
* Ensure correct change tracking when unpublishing

* Update src/Umbraco.Core/Services/PublishStatus/PublishStatusService.cs

Co-authored-by: Andy Butland <abutland73@gmail.com>

* Add comment

---------

Co-authored-by: Andy Butland <abutland73@gmail.com>
2026-05-12 09:58:20 +02:00
Sven Geusensandmole 68194e1a27 Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown

(cherry picked from commit 5ae17ace6a)
2026-05-12 09:51:25 +02:00
mole 91313fff0e Merge remote-tracking branch 'refs/remotes/origin/v17/dev'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/PublishedContent/PublishedValueFallbackTests.cs
#	version.json
2026-05-12 09:50:50 +02:00
Sven GeusensandGitHub 5ae17ace6a Distributed background jobs: Improve gracefull shutdown behaviour (#22796)
* Dont fail silently on missing ambientscope

This makes it in line with other methods in the repo

* Pass on Cancellationtoken to the job to support gracefull job shutdown
2026-05-12 09:28:00 +02:00
Nhu DinhandGitHub 3dca735a38 E2E: QA Added acceptance tests for current user workspace (#22457)
* Updated acceptance tests for current user profile

* Updated locator for save button

* Reverted npm command
2026-05-11 14:59:03 +00:00
Andy ButlandandGitHub cd476ab6ed Tiptap RTE: Ignore no-op transactions in onUpdate to prevent phantom dirty state (closes #22767) (#22781)
Ignore Tiptap no-op transactions in onUpdate to prevent phantom dirty state.
2026-05-11 14:13:21 +01:00
def18e440f Login: Reuse backoffice localization (closes #20082) (#22743)
* Login: Reuse backoffice localization for canonical login_* keys (closes #56402)

The login screen no longer ships its own localization tree. The slim backoffice controller registers the backoffice's built-in localization manifests, so all login screen text resolves from the same dictionary the in-backoffice auth view uses. Translators override one place; both screens reflect it.

All consumers in the Login project moved from auth_* to login_*. The Login project's localization/ directory is removed entirely. The auth.* keys it used to ship (form labels, mfa, invite, password reset) now live under login.* in the backoffice's en/da/de/nb/nl/sv lang files. Other backoffice languages fall back to en for these keys, automatically extending the login screen's language coverage.

* Backoffice localization: drop server-only email keys, add login.setPasswordInstruction in en/da/nb/sv

bottomText, resetPasswordEmailCopySubject, resetPasswordEmailCopyFormat, mfaSecurityCodeSubject and mfaSecurityCodeBody are read only by the server's own localization layer — they were dead weight in every backoffice lang dictionary that carried them. Removed across 23 lang files.

login.setPasswordInstruction is rendered on the new-password screen via the now-canonical login_* namespace; it was missing from en (the fallback), da, nb and sv. Added there using the same translation tone as the existing de/nl entries.

* Login: Honour legacy auth_greeting* overrides with UmbDeprecation warning

Translation packages still shipping 'auth_greeting0..6' overrides keep working on both welcome screens (the standalone login page and the in-backoffice umb-auth-view): when an auth_* greeting is registered the consumer prefers it, otherwise the canonical login_* key is used. Each legacy key triggers a one-time UmbDeprecation warning pointing at the canonical name. Scheduled for removal in v20.

* Fix Prettier formatting and correct issue references in deprecation message

Addresses Copilot review feedback on PR #22743:

- Run Prettier on the 6 backoffice lang files I added keys to (en/da/de/nb/nl/sv); the new entries used double quotes which violated the repo's singleQuote: true config and would have failed the format check.
- Update the UmbDeprecation 'solution' link and the inline source comments from #56402 (an ADO work item id) to #20082 (the actual GitHub issue tracking this work).

* Drop stale login_2fa* and login_mfaSecurityCodeMessage from bs.ts and cy.ts

Surfaced by 'devops/localization/compare-languages.js': bs and cy were the only lang files shipping these keys, and they have no en counterpart. The login_2fa* set is leftover from before the codebase renamed 2fa → mfa in the login flow (the live keys are login_mfa*). login_mfaSecurityCodeMessage is server-side only, like the other email-template keys cleaned up in 53ad52702e0. None of these are referenced anywhere in src/. The user-facing user_2fa* keys (consumed by current-user-mfa modals) are unrelated and untouched.

* Drop dead login_2fa* and login_mfaSecurityCodeMessage from nl, hr, tr

Same pattern as 26bc6211d27 (bs/cy cleanup), surfaced by re-running devops/localization/compare-languages.js after the previous pass:

- nl had both legacy 'login_2fa*' AND the canonical 'login_mfa*' (added in commit 1) sitting side by side after the auth.* → login.* port. Six true duplicates dropped, login_mfa* kept.
- hr and tr shipped legacy 'login_2fa*' that have no en counterpart, no consumer in src/, and no mfa pair locally. Dropped to align with en (the source of truth — every other locale should match it).

- All three files also still carried 'login_mfaSecurityCodeMessage' from the same family of server-side email-template keys cleaned up in 53ad52702e0; removed too.

user_2fa* / member_2fa keys are unrelated and untouched (consumed by current-user-mfa modals).

---------

Co-authored-by: leekelleher <leekelleher@gmail.com>
2026-05-11 11:59:58 +01:00
8a73d713cd Login: Consume sibling Umbraco.Web.UI.Client by source for v18 parity (#22591)
* Login: Consume sibling Umbraco.Web.UI.Client by source for v18 parity

The Login project previously depended on the published `@umbraco-cms/backoffice@^17.3.4` npm package for types, while at runtime the importmap served the in-repo v18 backoffice. The version mismatch forced `as any` workarounds and masked real API drift. Since v18 (with UUI 2.0) isn't on npm yet, switch Login to consume the sibling Client via a local `file:` dep so types and runtime align on v18.

Changes:
- Login `package.json`: `@umbraco-cms/backoffice` → `file:../Umbraco.Web.UI.Client`; added `pre{build,dev,watch}` hooks that run a guard script to fail fast when Client's `dist-cms/` is missing.
- Login `scripts/ensure-client-built.mjs`: new guard with a clear "build the Client first" message.
- Login `CLAUDE.md`: documents the contract and build ordering.
- StaticAssets `.csproj`: `BuildLogin` now depends on `BuildBackoffice` so MSBuild (and therefore the Azure pipeline) builds Client before Login automatically.
- Client `src/tsconfig.build.json`: `declaration: true` so `dist-cms/` ships `.d.ts`.
- Client `package.json`: new `build:types` step (`tsc --emitDeclarationOnly --incremental false && tsc-alias`) wired into `build:for:cms` after `build:workspaces`. Vite workspaces wipe their output dirs before rebuilding JS, stripping the tsc-emitted declarations; re-emitting after workspaces restores them. `tsc-alias` rewrites Client-internal path aliases (e.g. `@umbraco-cms/backoffice/external/lit`) to relative paths so sibling consumers can resolve them.
- `copy-to-cms.js`: filter `.d.ts` and `.tsbuildinfo` from the copy to `wwwroot/umbraco/backoffice` — they're only needed by sibling projects consuming `dist-cms` for types, not at runtime.
- `src/external/uui/vite.config.ts`: set `treeshake: false` so per-component `defineElement()` side-effect calls (used by UUI 2.0 for custom-element registration) are preserved in the bundle. Without this, `<uui-button>` etc. never register and the login screen renders empty controls.
- `src/external/uui/index.ts`: bare `import '@umbraco-ui/uui'` to make the side-effect intent explicit.
- Small v18-compat fixes for `Object.groupBy` (TS 8 types): removed stale `@ts-expect-error`, switched to `Object.entries` + `?? []` to satisfy the `Partial<Record>` return type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address review feedback and fix CI

- Add `ignoreDeprecations: "6.0"` to `tsconfig.json` and the tsconfig generator to silence the TS 6.0 warning about the implicit baseUrl that TypeScript assigns when `paths` is declared. This was the CI `build` failure. The generator is also synced with the user's es2022 → es2024 bump.
- Drop the now-redundant `--declaration` flag from `build:for:npm` (tsconfig.build.json now has `declaration: true`, so the flag was duplicating intent).
- Align Login's `engines` with the Client's (`node >=24.13`, `npm >=11`) so `file:` install doesn't trip EBADENGINE.
- Guard script: hardcode the relative "../Umbraco.Web.UI.Client" path in the error message instead of interpolating the absolute path, which overflowed the ASCII box in CI logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Login: update CLAUDE.md Node/npm versions to match engines

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* check-path-length: skip .d.ts/.tsbuildinfo and directory paths

The 120-char Windows MAX_PATH guard protects files that actually ship to
CMS installs. `.d.ts` and `.tsbuildinfo` live in `dist-cms/` for sibling
projects to consume as types and are filtered out by `copy-to-cms.js`
before reaching `wwwroot/umbraco/backoffice` — they never land on a
Windows CMS install. Directories on their own also don't trigger
MAX_PATH; only files within them do, and those are still checked.

Unblocks CI after enabling `declaration: true` in the Client build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* check-path-length: extract exceedsPathLimit helper (CodeScene)

Decomposes the complex conditional flagged by CodeScene into a named
predicate with a docstring, clarifying when a path is reported.

* Login: switch to generated tsconfig paths; revert dist-cms type machinery

PR #22591 originally aligned Login's TypeScript types with the in-repo v18
backoffice by emitting `.d.ts` into Client's `dist-cms/` and consuming it
via a `file:` dep. That layered six side-effects across the Client build
(declaration: true, build:types step, tsc-alias in postbuild, copy-to-cms
filter, check:paths skip, MSBuild ordering). Reviewers pushed back.

This rework moves the type contract from "ship .d.ts in dist-cms" to
"point Login's tsconfig paths at Client's TypeScript source" — Login's
runtime behaviour is unchanged (vite still externalises /^@umbraco-cms/,
host importmap still serves the JS), only the type-resolution mechanism
swaps.

What's reverted (back to the pre-PR shape):
- src/Umbraco.Web.UI.Client/src/tsconfig.build.json: declaration: false
- src/Umbraco.Web.UI.Client/package.json: drops `build:types` script,
  reverts `postbuild` to global-types only, drops `--declaration` from
  the tsc CLI in `build:for:cms` and restores it in `build:for:npm`
- src/Umbraco.Web.UI.Client/devops/build/copy-to-cms.js: simple cpSync
- src/Umbraco.Web.UI.Client/devops/build/check-path-length.js: original
- src/Umbraco.Web.UI.Client/tsconfig.json + devops/tsconfig/index.js:
  drops `ignoreDeprecations` (not needed once baseUrl is gone)
- src/Umbraco.Cms.StaticAssets/Umbraco.Cms.StaticAssets.csproj:
  `BuildLogin` no longer depends on `BuildBackoffice`

What's new on the Login side:
- src/Umbraco.Web.UI.Login/devops/tsconfig/index.js: generator that
  reads Client's `package.json` exports and emits a full `tsconfig.json`
  with `paths` mapping every `@umbraco-cms/backoffice/<sub>` to
  `../Umbraco.Web.UI.Client/src/.../index.ts`. Mirrors Client's existing
  generator pattern (DON'T EDIT header, JSON.stringify with tabs).
- src/Umbraco.Web.UI.Login/tsconfig.json: regenerated; standalone `tsc`
  works (no `--project` needed) and 140 path aliases resolve types
  directly from Client's source.
- src/Umbraco.Web.UI.Login/package.json: drops `@umbraco-cms/backoffice`
  npm dep entirely (file: was only nominal — types come via paths,
  runtime via importmap, transitives via Client's own `node_modules`
  which is `npm install`-ed by CI's backoffice-install.yml). Replaces
  the `ensure-client-built` guard with the generator on `pre*` hooks
  and adds `generate:tsconfig` for ad-hoc invocation.
- src/Umbraco.Web.UI.Login/CLAUDE.md: documents the new layered
  contract (paths/externalisation/importmap) and the install-Client-
  before-Login prerequisite.
- src/Umbraco.Web.UI.Login/scripts/ensure-client-built.mjs: deleted.

What stays from the original PR (independent fixes):
- src/Umbraco.Web.UI.Client/src/external/uui/{vite.config.ts,index.ts}:
  `treeshake: false` + bare side-effect import — keeps UUI 2.0
  per-component `defineElement` calls in the bundle so `<uui-button>`
  etc. actually register.
- Object.groupBy cleanups in 6 element files (TS 8 type narrowing).
- Client tsconfig generator: target/lib bumped to ES2024, `baseUrl`
  removed.

Verified locally:
- `cd Client && rm -rf dist-cms && cd ../Login && npx tsc` → clean
  (proves Login compiles without Client's dist-cms)
- `cd Client && npm run build:for:cms` → 0 emitted .d.ts (back to
  pre-PR shape), `check:paths` passes
- Login `npm run build` → 64 KB bundle (unchanged)
- Browser at https://localhost:44339/umbraco: UUI 2.0 components
  render, login with `test@umbraco.com`/`test123456` succeeds and
  redirects to /umbraco/section/content

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Login: address review — idempotent generator + correct MSBuild ordering

- StaticAssets.csproj: BuildLogin now depends on RestoreBackoffice (not
  BuildBackoffice — Login doesn't need dist-cms types). Login's tsc walks
  Client source via tsconfig path aliases and resolves transitive deps
  (lit, rxjs, …) from Client's node_modules. Without this dependency a
  fresh local `dotnet build` could run BuildLogin before Client is
  installed; CI was already safe via backoffice-install.yml's npm ci.

- devops/tsconfig/index.js: skip rewrite when content is unchanged. Pre-
  hooks ran the generator on every npm command and bumped tsconfig.json
  mtime even when nothing changed, which can invalidate caches and rattle
  watchers downstream. Read-then-compare-then-write makes the generator
  truly idempotent.

- devops/tsconfig/index.js: derive the alias prefix from
  `clientPkg.name` instead of hardcoding `@umbraco-cms/backoffice` so a
  package rename can't silently break paths.

azure-pipelines.yml needs no changes — backoffice-install.yml already
runs `npm ci` in Client before dotnet build kicks in MSBuild.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Login: postinstall + dev-mode Vite alias + theme CSS path

Audit cleanup pass on the rework:

- Login package.json: collapse predev/prebuild/prewatch into a single
  postinstall hook. The generator runs whenever npm install/ci runs
  (locally + in CI via RestoreLogin's npm i + the dotnet build chain).
  Removes the per-command "tsconfig.json already up to date" noise.

- Login vite.config.ts: in dev mode (`vite serve`), read `paths` from
  the generated tsconfig.json and apply them as `resolve.alias` so Vite
  can resolve `@umbraco-cms/backoffice/*` to Client source. Vite doesn't
  honor tsconfig `paths` natively — without this `npm run dev` failed
  with "Failed to resolve import @umbraco-cms/backoffice/utils ...".
  Build mode (`vite build`) still externalises the namespace via the
  unchanged rollupOptions.external regex; alias is dev-only.

- Login index.html: UUI 2.0 reorganised CSS — the old
  `@umbraco-ui/uui-css/dist/uui-css.css` path no longer exists. Point
  at `@umbraco-ui/uui/dist/themes/light.css` which is what Client now
  ships. Path is relative through Client's node_modules since Login no
  longer declares a UUI dep itself.

- Client input-entity-user-permission.element.ts: prettier flagged a
  multi-line .map() arrow that should be inline; collapse to one line.

- Login CLAUDE.md: document the postinstall-driven generator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Use Vite 8 native tsconfigPaths; drop helper plugin and trim comments

- Both vite.config.ts files use `resolve.tsconfigPaths: true` instead of the
  `vite-tsconfig-paths` plugin. Plugin and dep removed.
- Trim explanatory comments on csproj target, generator, UUI vite config and
  external/uui/index.ts to conclusions only.

* Login: tsconfig generator fails fast on unsupported exports shapes

Distinguish between the legitimate `.` self-reference (target === null) and
unexpected non-string targets (e.g., conditional exports objects). The latter
now throw with a clear message instead of being silently dropped from `paths`,
which would otherwise produce confusing 'Cannot find module' errors at tsc
time later.

* Login: allow Vite dev server to serve Client's UUI assets

The light.css imported from Client's node_modules pulls Lato fonts via
relative URL, which Vite refuses by default since they sit outside
Login's project root. Extend server.fs.allow to the parent directory
(both sibling projects).

* Client: regenerate tsconfig on postinstall

* Login: keep UUI registrations in dev mode

Vite 8's esbuild dep pre-bundle drops the per-component
`customElements.define()` side-effects in @umbraco-ui/uui (a known UUI
issue with Vite 8). Exclude UUI from optimizeDeps so it's served
unbundled in dev. Re-add the bare side-effect import in external/uui
so the entry module evaluates the chain. Production build is unaffected
(workspace's `treeshake: false` already preserves registrations).

Also document the new MSBuild Login targets in StaticAssets CLAUDE.md.

* Login: clarify why optimizeDeps.exclude is needed for UUI

Tested treeshake.moduleSideEffects: true in optimizeDeps.rollupOptions
on Vite 8 / Rolldown 1.0.0-rc.17 — registrations still get stripped.
Excluding the package from the pre-bundle is the only reliable workaround
until UUI's own Vite 8 upgrade lands. Comment captures the conclusion.

* Roll back Vite 8 → 7 in Client and Login

Vite 8.0.10 ships Rolldown 1.0.0-rc.17 which strips UUI 2.0
`customElements.define()` side-effects during dep pre-bundle, leaving
elements unregistered in dev mode. Rather than ship a v18 release tied
to a non-final Rolldown RC, revert the Vite bump and pick it up again
once Rolldown 1.0 final lands.

Changes:
- Client: vite ^8.0.10 → ^7.3.2; vite-plugin-static-copy ^4.1.0 → ^3.2.0;
  re-add vite-tsconfig-paths plugin; drop native `resolve.tsconfigPaths`.
- Login: vite ^8.0.10 → ^7.3.2; add vite-tsconfig-paths; configure plugin
  with `projects: ['./tsconfig.json', '../Umbraco.Web.UI.Client/tsconfig.json']`
  so it can resolve `@umbraco-cms/backoffice/*` imports inside Client
  source files (which would otherwise lack a discoverable tsconfig in
  Login's project tree). Drop `optimizeDeps.exclude` (no longer needed
  without Rolldown). Keep `server.fs.allow` for the cross-project font.

TypeScript 6 + ES2024 + tsconfig path generator + Login architectural
pivot all stay — those are independent of the Vite version.

Verified:
- Production https://localhost:44339/umbraco — login works
- Login dev http://localhost:5191/ — UUI registers, all custom elements defined
- Client dev http://localhost:5192/ — page loads, navigates to /section/content

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Copilot review

- vite.config.ts (Login): narrow server.fs.allow from the parent dir to
  Login + Client only, reducing the dev server's read scope.
- external/uui/vite.config.ts (Client): replace blanket `treeshake: false`
  with `moduleSideEffects: (id) => id.includes('@umbraco-ui/uui')` so
  Rollup keeps UUI's per-component registration calls but tree-shakes the
  rest. Bundle stays at 516 KB / 96 registered tags.

* fix merge overwrites

* update package lock

* fix: do not autogenerate tsconfig on postinstall

* removes postinstall script

* chore: generates tsconfig

* chore: update lockfile

* docs: updates claude.md

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Niels Lyngsø <niels.lyngso@gmail.com>
2026-05-11 09:42:28 +00:00
Andreas ZerbstandGitHub f6ac750d09 E2E: QA: Add missing helpers for Content Versioning (#22788)
* Added missing rollback helpers

* Updated helper to match locator
2026-05-11 06:33:44 +02:00
Niels Lyngsø 4b66c114c4 Revert "fix validation filter"
This reverts commit 0bdb1bb1ed.
2026-05-10 20:17:36 +02:00
Niels Lyngsø 0bdb1bb1ed fix validation filter 2026-05-10 20:16:23 +02:00
Niels Lyngsø 3142691e4f Update architecture.md 2026-05-08 15:13:47 +02:00
Niels Lyngsø c813481b4e update UUI for icon manager 2026-05-08 15:11:53 +02:00
11ff2c8039 Tests: Fix PublishedValueFallbackTests after ILocalizationService removal (#22772)
* fix(tests): replace removed ILocalizationService with ILanguageService in PublishedValueFallbackTests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-08 15:02:58 +02:00
Jacob Overgaard 80098706cf chore: ignores default log message for MSW 2026-05-08 13:05:50 +02:00
leekelleher dff3941e1f Merge branch 'v18/dev' 2026-05-08 10:02:00 +01:00
leekelleher 072e362284 Merge branch 'main' into v18/dev
# Conflicts:
#	src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/variant-state.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/item/document-collection-item-card.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-property-value.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search-result-item.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/utils.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/variant-state.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-split-view-variant-selector.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts
2026-05-08 10:01:33 +01:00
1c1787c445 Auth: Un-deprecate getLatestToken and route per-request fetches through it (#22736)
* Auth: un-deprecates getLatestToken and routes per-request fetches through it

getLatestToken is the only public API for "wait for any in-flight refresh,
trigger one if the access token has expired, then return". External and
internal consumers were warned off it without an equivalent replacement:
configureClient only helps @hey-api/openapi-ts clients, and consumers using
axios/ky/native fetch had no other gate.

- Removes the @deprecated JSDoc + UmbDeprecation.warn() call so the public
  surface no longer prints a console warning per call.
- Uses getLatestToken.bind(this) for the auth callback inside configureClient
  and the token callback inside getOpenApiConfiguration so both paths share
  the same #ensureTokenReady gate.
- Replaces the hard-coded `Authorization: Bearer [redacted]` in unlinkLogin
  and #makeLinkTokenRequest with `Bearer ${await getLatestToken()}` so those
  fetches participate in the refresh coordination rather than firing with a
  potentially-revoked cookie.

Also wires the UmbracoExtension template's entrypoint to call
authContext.configureClient(client), matching the v18 template change. The
framework awaits onInit, so this guarantees the API client is fully
configured before any element in the extension can use it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auth: tightens UmbAuthContext correctness and accepts any hey-api client

Pulls in a batch of non-breaking improvements to UmbAuthContext that came
out of an audit on the back of the un-deprecation work in this PR:

Public surface:
- configureClient(client) now accepts a new structural UmbApiClient type
  (exported from @umbraco-cms/backoffice/http-client). Each @hey-api/openapi-ts
  generation produces a fully-bound Client<…>; the backoffice's umbHttpClient
  and an extension's regenerated client are structurally identical but TS
  treats them as distinct generic instantiations. The widened parameter lets
  extensions wire their own client without `as never` casts at call sites.
  bindDefaultInterceptors keeps its strict typeof umbHttpClient parameter
  (preserving autocomplete inside interceptor callbacks); the cast happens
  once, internally.

Correctness:
- The auth context now holds a single UmbApiInterceptorController, lazy-
  initialised on first configureClient() call. Previously each call
  instantiated a new controller, which re-provided the UmbAuthSignalerContext
  on the host and stacked listeners — visible the moment an extension also
  called configureClient. One controller for the lifetime of the host, all
  configured clients share it.
- completeAuthorizationRequest checks sessionStorage before asking
  window.opener for the PKCE verifier. The previous order hung for the full
  postMessage timeout whenever oauth_complete loaded with a non-OAuth
  window.opener (which is set for ANY window.open target). The opener
  postMessage timeout is also dropped from 5s to 1.5s — a real popup parent
  responds within milliseconds; longer is just wait time for the unrelated-
  opener case.
- The cross-tab 'authorized' BroadcastChannel handler now routes through
  #setSessionLocally so the timestamp math stays in one place. The
  'sessionUpdate' handler still applies pre-computed timestamps directly
  (peer broadcast already did the math) but does so inside the
  #inSessionUpdateCallback guard, so a synchronous session$ observer can no
  longer trigger a spurious /token refresh on top of a peer's update.
- #ensureTokenReady drops its query-then-request pattern. Now always queues
  behind the umb:token-refresh lock with a no-op callback — if the lock is
  free it acquires immediately, if held it waits. Eliminates the race window
  between query() and request().
- destroy() invokes #popupCleanup before tearing down so an in-flight popup
  flow's window-level message listener and closed-poll interval don't leak
  past the context's lifetime. The cleanup helper itself now resolves the
  popup-flow Promise — every termination path (authorized, popup closed,
  superseded by a new flow, context destroyed) is observable to the awaiter
  instead of hanging forever.

Cleanup:
- makeAuthorizationRequest is annotated Promise<void> so the redirect and
  popup branches share an explicit return type.
- unlinkLogin wraps the parsed problem-details payload in a real Error (with
  the original payload exposed on `.cause`) so callers using `instanceof
  Error` or expecting a stack trace get sane behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:58:19 +02:00
7248f01292 Auth: Un-deprecate getLatestToken and route per-request fetches through it (#22736)
* Auth: un-deprecates getLatestToken and routes per-request fetches through it

getLatestToken is the only public API for "wait for any in-flight refresh,
trigger one if the access token has expired, then return". External and
internal consumers were warned off it without an equivalent replacement:
configureClient only helps @hey-api/openapi-ts clients, and consumers using
axios/ky/native fetch had no other gate.

- Removes the @deprecated JSDoc + UmbDeprecation.warn() call so the public
  surface no longer prints a console warning per call.
- Uses getLatestToken.bind(this) for the auth callback inside configureClient
  and the token callback inside getOpenApiConfiguration so both paths share
  the same #ensureTokenReady gate.
- Replaces the hard-coded `Authorization: Bearer [redacted]` in unlinkLogin
  and #makeLinkTokenRequest with `Bearer ${await getLatestToken()}` so those
  fetches participate in the refresh coordination rather than firing with a
  potentially-revoked cookie.

Also wires the UmbracoExtension template's entrypoint to call
authContext.configureClient(client), matching the v18 template change. The
framework awaits onInit, so this guarantees the API client is fully
configured before any element in the extension can use it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Auth: tightens UmbAuthContext correctness and accepts any hey-api client

Pulls in a batch of non-breaking improvements to UmbAuthContext that came
out of an audit on the back of the un-deprecation work in this PR:

Public surface:
- configureClient(client) now accepts a new structural UmbApiClient type
  (exported from @umbraco-cms/backoffice/http-client). Each @hey-api/openapi-ts
  generation produces a fully-bound Client<…>; the backoffice's umbHttpClient
  and an extension's regenerated client are structurally identical but TS
  treats them as distinct generic instantiations. The widened parameter lets
  extensions wire their own client without `as never` casts at call sites.
  bindDefaultInterceptors keeps its strict typeof umbHttpClient parameter
  (preserving autocomplete inside interceptor callbacks); the cast happens
  once, internally.

Correctness:
- The auth context now holds a single UmbApiInterceptorController, lazy-
  initialised on first configureClient() call. Previously each call
  instantiated a new controller, which re-provided the UmbAuthSignalerContext
  on the host and stacked listeners — visible the moment an extension also
  called configureClient. One controller for the lifetime of the host, all
  configured clients share it.
- completeAuthorizationRequest checks sessionStorage before asking
  window.opener for the PKCE verifier. The previous order hung for the full
  postMessage timeout whenever oauth_complete loaded with a non-OAuth
  window.opener (which is set for ANY window.open target). The opener
  postMessage timeout is also dropped from 5s to 1.5s — a real popup parent
  responds within milliseconds; longer is just wait time for the unrelated-
  opener case.
- The cross-tab 'authorized' BroadcastChannel handler now routes through
  #setSessionLocally so the timestamp math stays in one place. The
  'sessionUpdate' handler still applies pre-computed timestamps directly
  (peer broadcast already did the math) but does so inside the
  #inSessionUpdateCallback guard, so a synchronous session$ observer can no
  longer trigger a spurious /token refresh on top of a peer's update.
- #ensureTokenReady drops its query-then-request pattern. Now always queues
  behind the umb:token-refresh lock with a no-op callback — if the lock is
  free it acquires immediately, if held it waits. Eliminates the race window
  between query() and request().
- destroy() invokes #popupCleanup before tearing down so an in-flight popup
  flow's window-level message listener and closed-poll interval don't leak
  past the context's lifetime. The cleanup helper itself now resolves the
  popup-flow Promise — every termination path (authorized, popup closed,
  superseded by a new flow, context destroyed) is observable to the awaiter
  instead of hanging forever.

Cleanup:
- makeAuthorizationRequest is annotated Promise<void> so the redirect and
  popup branches share an explicit return type.
- unlinkLogin wraps the parsed problem-details payload in a real Error (with
  the original payload exposed on `.cause`) so callers using `instanceof
  Error` or expecting a stack trace get sane behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:54:04 +01:00
leekelleher 4fab629ee3 Documents: Alias DocumentVariantStateModel API model for backoffice client (#22716)
* Client: Aliased `DocumentVariantStateModel` for documents and document-blueprints packages

Hoist `UmbDocumentVariantState` and `UmbDocumentBlueprintVariantState` aliases (re-exporting `DocumentVariantStateModel`) into dedicated `variant-state.ts` leaf files. Internal package modules, mocks and the core split-view selector now consume the alias instead of referencing `DocumentVariantStateModel` directly, mirroring the structure on `v18/dev` to reduce upstream-merge conflicts.

* Revert mock data changes

to prevent importing the whole "document" module.

* Tweaked the `DocumentVariantStateModel` import for mock data

Otherwise this is problematic for cherry-picked commits for v18.0.

* Missed one!
# Conflicts:
#	src/Umbraco.Web.UI.Client/mocks/data/sets/default/document-blueprint.data.ts
#	src/Umbraco.Web.UI.Client/mocks/data/sets/default/document.data.ts
#	src/Umbraco.Web.UI.Client/mocks/data/sets/kitchen-sink/document.data.ts
#	src/Umbraco.Web.UI.Client/mocks/data/sets/user-permissions/document.data.ts
#	src/Umbraco.Web.UI.Client/mocks/db/document-blueprint.db.ts
#	src/Umbraco.Web.UI.Client/mocks/db/document-publishing.manager.ts
#	src/Umbraco.Web.UI.Client/mocks/db/document.db.ts
#	src/Umbraco.Web.UI.Client/mocks/tools/sqlite-to-mock/transform-documents.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/repository/item/types.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/types.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/item/document-collection-item-card.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/views/table/column-layouts/document-table-column-property-value.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/modals/shared/document-variant-language-picker.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/search/document-search-result-item.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/info-app/document-links-workspace-info-app.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/utils.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/variant-state.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/document-workspace-split-view-variant-selector.element.ts
#	src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/info/document-workspace-view-info.element.ts
2026-05-08 09:49:55 +01:00
Andy Butland a434ad7b33 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:19:00 +02:00
Andy Butland 3714ebbb29 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:18:09 +02:00
Andy ButlandandGitHub 1a74aa53c9 Published Content: Fix Fallback.ToAncestors with no match throwing exception at property level (closes #22759) (#22763)
* Fix Fallback.ToAncestors regression at property level.

* Further unit tests.
2026-05-08 10:15:28 +02:00
Andy Butland 1214771847 Bump version to 17.6.0-rc. 2026-05-08 10:07:34 +02:00
Lee KelleherandGitHub 396497a921 Documents: Alias DocumentVariantStateModel API model for backoffice client (#22716)
* Client: Aliased `DocumentVariantStateModel` for documents and document-blueprints packages

Hoist `UmbDocumentVariantState` and `UmbDocumentBlueprintVariantState` aliases (re-exporting `DocumentVariantStateModel`) into dedicated `variant-state.ts` leaf files. Internal package modules, mocks and the core split-view selector now consume the alias instead of referencing `DocumentVariantStateModel` directly, mirroring the structure on `v18/dev` to reduce upstream-merge conflicts.

* Revert mock data changes

to prevent importing the whole "document" module.

* Tweaked the `DocumentVariantStateModel` import for mock data

Otherwise this is problematic for cherry-picked commits for v18.0.

* Missed one!
2026-05-08 08:04:07 +00:00
Jacob OvergaardandClaude Sonnet 4.6 80cc752b3d Backoffice Mocks: Add missing element start node fields to documents mock set
`elementStartNodeIds` and `hasElementRootAccess` were added to
`UmbCurrentUserModel` by the Global Elements PR but the documents mock
data set was created without them, causing `undefined.map()` errors in
the document workspace CRUD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:08:40 +02:00
Jacob Overgaard 39125da978 Merge remote-tracking branch 'origin/main' into v18/dev 2026-05-08 09:03:36 +02:00
Niels Lyngsø bf32f9e5a6 update package-lock 2026-05-08 09:01:20 +02:00
Niels Lyngsø 3220739faa upgrade to UI LIbrary 1.17.3 2026-05-08 08:59:49 +02:00
Niels Lyngsø ff565b95e0 update package-lock 2026-05-08 08:54:28 +02:00
Niels Lyngsø 93a1f82b05 Merge branch 'release/17.4.0'
# Conflicts:
#	src/Umbraco.Web.UI.Client/package-lock.json
#	src/Umbraco.Web.UI.Client/package.json
#	tests/Umbraco.Tests.AcceptanceTest/package-lock.json
#	tests/Umbraco.Tests.AcceptanceTest/package.json
#	version.json
2026-05-08 08:53:57 +02:00
Andreas ZerbstandGitHub 54ded689e8 E2E: QA: Add .prettierrc.json to acceptance tests for formatting consistency (#22751)
Add .prettierrc.json to acceptance tests for formatting consistency
2026-05-08 09:19:26 +07:00
Jacob Overgaard 1c32829883 chore: fixes to use correct import of api types in mock data 2026-05-07 21:26:53 +02:00
Jacob Overgaard 1e59af34ff Merge remote-tracking branch 'origin/main' into v18/dev 2026-05-07 21:16:24 +02:00
Andy ButlandandGitHub 2292b7479d Color Picker: Refresh stored label when data type label changes (closes #22741) (#22761)
* Update stored color label if changed on save of document with color picker.

* Clarify intent of change event dispatch in label sync

* Make comparison case insensitive.

* Added unit tests for new behaviour.
2026-05-07 21:13:05 +02:00
Andy Butland 1baf4e5a5c Merge branch 'main' into v18/dev 2026-05-07 18:43:56 +02:00
Andy ButlandandGitHub 9b1fc50de3 Dictionary: Order SQL before FetchOneToMany to prevent duplicate items in collection view (closes #22640) (#22750)
* Order SQL before FetchOneToMany in dictionary entry retrieval to prevent duplicate items in collection view.

* Used PrimaryKey instead of UniqueId to take advantage of the clustered index.
2026-05-07 14:29:43 +00:00
Jacob Overgaard bcf9bf3f3a Merge branch 'release/18.0' into v18/dev 2026-05-07 16:23:19 +02:00
Niels Lyngsø e4dce93b79 Merge branch 'main' into v18/dev
# Conflicts:
#	tests/Umbraco.Tests.AcceptanceTest/lib/helpers/ContentUiHelper.ts
2026-05-07 14:06:36 +02:00
3052b57203 E2E: QA: add acceptance tests for content versioning (#22702)
* Added tests

* Updated

* Cleaned up

* Fixes based on comments

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Added helpers for verifying document

* Removed redundant method

* Cleaned up

* Reverted deletion of constants

* Undo revert

* Fixes based on comments

* updated command

* Added removed method

* Update smokeTest command in package.json

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:13:29 +00:00
aba8e3eb7a Icons: developer icon manager (#22437)
* extend icons with information from theseaurus

* implement new icon search logic

* clean-up data

* icon manager

* sorting with a backup of the name

* refactor into a controller

* improve multi word group search

* embed lucide data

* rename tech into technology

* remove paper from dollar

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* improve search

* related should not show up in search

* update threshold

* separate name words

* also consider full icon name match

* better comment

* other approach for full name matches

* full icon name search if query contains a -

* fix test

* remove related code

* updates to related

* make its own package

* revert changes

* update tsconfig

* package-lock

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 10:11:05 +00:00
Mads RasmussenandGitHub 040a0d5e49 Document Workspace: Add CRUD and property value tests for document workspace context (#22621)
* temp mock set

* test getPropertyValue

* Extend document workspace context tests to cover read/write property values

* move context files into context folder

* Add document CRUD tests, mock handler & interceptor

* temp mock error interceptor

* Return 404 when document not found

* Use undefined for entity unique state until initialized

* Fix import paths for document workspace editor

* Add test utils and extend document workspace tests

* Update document-workspace-context.test-utils.ts

* Match invariant variant when variantId missing

* Ensure finishPropertyValueChange runs on exit

Wrap setPropertyValue implementation in a try/finally and move finishPropertyValueChange into the finally block so cleanup always runs even if an error is thrown. No other functional changes — code was re-indented and organized but behavior remains the same except for guaranteed cleanup on error.

* Require variantId for culture/segment-variant props

* fix types

* fix mock modal typescript error

* Distinguish unloaded vs root entity unique

* use the real current user context

* hide mock set in UI

* rename mock set

* Move initiatePropertyValueChange into try

* Use 'satisfies' for UmbMockDataSet assertions

* Preserve requested unique on failed load

* Treat missing variantId as invariant

* Reset update lock on destroy

* remove unused group + user

* Guard _current.unmute and remove destroy override

* Add tests for element data manager

* Guard subject access and add destroy test

* Throw when calling methods after destroy
2026-05-07 09:36:05 +02:00
Laura Neto 6201c3dc40 Bump version to 18.1.0-rc 2026-05-06 19:15:46 +02:00
Rick ButterfieldandClaude Opus 4.6 fd6e6a1a69 feat(visual-editor): add preview context, bottom menu bar, and refactor block helpers
Introduce UmbVisualEditorPreviewContext to provide UMB_PREVIEW_CONTEXT for
previewApp extensions inside the visual editor. Add a bottom menu bar with
extension slot for preview apps. Consolidate block data update functions into
a single updateBlockDataValues helper and extract removeLayoutEntryFromAreas
to the shared block utils.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:55:26 +01:00
Rick Butterfield 6c8aee07ed Merge branch 'main' of https://github.com/umbraco/Umbraco-CMS into feature/visual-editor
# Conflicts:
#	src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs
#	src/Umbraco.Web.UI.Client/src/packages/core/backend-api/sdk.gen.ts
#	src/Umbraco.Web.UI.Client/src/packages/core/backend-api/types.gen.ts
2026-04-10 09:52:19 +01:00
Rick ButterfieldandClaude Opus 4.6 59ffe94525 refactor(visual-editor): code review cleanup
- Extract duplicated outline/shadow style literals into named constants
- Extract duplicated drag-event guard patterns into helper functions
  (isDragOverChildArea, isDragOverNestedBlock)
- Remove dead #save() and #saveAndRefresh() methods and empty else branches
- Remove deprecated findLayoutEntryRecursive re-export (no consumers)
- Remove unused UmbBlockWorkspaceOriginData import, use type-only import
  for UmbBlockManagerContext
- Use IBackOfficePathGenerator.BackOfficeAssetsPath instead of hardcoded
  "/backoffice" path segment in tag helper
- Extract #getAppearanceDefaults() helper in property settings view to
  avoid spread-with-missing-fields across appearance toggle handlers
- Make editableInVisualEditor required (not optional) in appearance model
  with false default, fix mock data across document/media/member types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:30:08 +00:00
Rick ButterfieldandClaude Opus 4.6 ae0638af8b chore: add Umbraco.TheStarterKit and regenerated API types
Add starter kit package to Web.UI for visual editor testing.
Includes regenerated OpenAPI spec and SDK types reflecting the
new EditableInVisualEditor property on PropertyTypeAppearance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:20 +00:00
Rick ButterfieldandClaude Opus 4.6 39f30d267c docs: add discussion notes to visual page builder plan
Capture feedback on visual editor direction: block manipulation
challenges, headless support, code deduplication, validation in
preview, document type-controlled editability, scroll retention,
responsive controls, and save-to-visual-editor workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:16:12 +00:00
Rick ButterfieldandClaude Opus 4.6 e4457715ee feat(visual-editor): improve block editing, drag-drop, and styling
- Fix guest script path (backoffice path was missing /backoffice segment)
- Use Lucide icons matching backoffice style for edit/delete action bars
- Consistent editable region styling: dashed blue outline with white
  inner shadow for visibility on both light and dark backgrounds
- Smooth 120ms transitions on hover/select state changes
- Fix cross-area drag-and-drop in nested block grids:
  - Stop dragstart propagation to prevent parent block hijacking drag
  - Skip block dragover/drop when cursor is over child areas
  - Fix area drop target check to not bail on parent block ancestor
- Fix empty area placeholder not hiding after block drop
- Use visual editor property modal for block editing instead of
  standard block workspace (which requires missing entries context)
- Remove auto-save on every edit — workspace state updates only,
  user saves when ready via workspace footer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:15:59 +00:00
Rick ButterfieldandClaude Opus 4.6 788a7cc4d5 feat(core): add EditableInVisualEditor property type setting
Add a per-property toggle that controls whether a property is editable
in the visual editor. Replaces the hard-coded editor-alias convention
(TextBox, TextArea, RichText, MarkdownEditor) with a configurable setting.

Full stack implementation:
- Core: IPropertyType, PropertyType, IPublishedPropertyType, PublishedPropertyType
- API: PropertyTypeAppearance DTO, mapping, presentation factory
- Infrastructure: DB DTO, mapper, factory, repository, migration (v17.4.0)
- Frontend: TypeScript model, toggle in property type settings (Visual Editor box),
  workspace context default, localization keys
- Annotation pipeline: TrackVisualEditorAccess now checks EditableInVisualEditor
  instead of hard-coded editor alias list

Uses opt-out model for blocks: if no properties on an element type have the
flag explicitly enabled, all properties are included in the editing modal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:15:26 +00:00
Rick ButterfieldandClaude Opus 4.6 996c6ff3fd refactor(block): deduplicate block manager contexts
Move shared state and methods from Block Grid, List, and Single manager
contexts into the base UmbBlockManagerContext: inlineEditingMode,
isSortMode, default createWithPresets(), and default insert().

Block List and Single managers are now near-empty subclasses. Block Grid
and RTE keep their overrides for area-aware layout and custom insert logic.

Also extracts shared block layout area utilities and adds index to the
base UmbBlockWorkspaceOriginData interface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:15:03 +00:00
Rick ButterfieldandClaude d88785c3b4 Use scoped lifetime and fix stale docs for visual editor tag helper
Change VisualEditorScriptTagHelperComponent registration from Transient
to Scoped since it depends on request-scoped state, and update the
remarks XML doc to remove the stale reference to WriteUmbracoContent.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-24 11:24:27 +00:00
Rick ButterfieldandClaude d02b7c64a0 Extract visual editor script injection into ITagHelperComponent
Move the guest script injection from UmbracoViewPage.WriteUmbracoContent()
into a dedicated VisualEditorScriptTagHelperComponent, using the standard
ASP.NET Core ITagHelperComponent extensibility point for body tag injection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-24 11:18:43 +00:00
Rick ButterfieldandClaude a8bab716b2 Simplify visual editor block helpers
- Replace 'Umbraco.BlockList' magic string with
  UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS constant
- Remove duplicate #findLayoutRecursive from VisualEditorBlockEntries,
  reuse exported findLayoutEntryRecursive from block helper
- Cache #resolveBlockPropertyStructures results to avoid repeated
  DocumentTypeService API calls for the same content type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-19 16:23:24 +00:00
Rick ButterfieldandClaude 8b1bec2fa0 Add clipboard paste support to visual editor
Wire up the block catalogue modal's clipboard tab so blocks can be
pasted from the clipboard into the visual editor. Uses the paste
translator pipeline and property value cloner to resolve, translate,
and clone clipboard entries into block values with fresh unique keys.
Pasted blocks are exposed as invariant so they render immediately.

Also adds mergeBlockValueInto helper and changes workspace view icon.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-19 15:50:27 +00:00
Rick ButterfieldandClaude 9a33b7ad7f Fix block grid support in visual editor
- Fix CSS selector scoping bug in injected.ts where `:scope >` only
  applied to the first part of a comma-separated selector, causing
  add-block buttons to be misplaced in nested grid containers
- Add recursive layout search in block bridge so blocks nested inside
  grid areas can be found by layoutOf and updated in-place by
  setOneLayout without being duplicated to the root level
- Add recursive layout removal in block helper so deleting a block
  inside a grid area removes the layout entry from the correct level
- Add confirm modal before block deletion matching the standard
  block editor pattern (blockEditor_confirmDeleteBlockTitle)
- Remove debug console.log/console.debug statements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-19 15:08:30 +00:00
Rick Butterfield 99e25ccbd4 Merge branch 'feature/visual-editor' of https://github.com/umbraco/Umbraco-CMS into feature/visual-editor 2026-03-19 09:14:51 +00:00
Rick ButterfieldandGitHub eef319e252 Add block grid support to visual editor block bridge 2026-03-19 09:14:36 +00:00
Claude 4f121da113 fix(web): await workspace path before opening block workspace in visual editor
The block workspace modal route registration is async (consumes
UMB_ROUTE_CONTEXT), so the workspace path may not be available
immediately after bridge construction. Make openEdit/openCreate async
and wait for the path. Also add missing originData to the modal setup
data so submit() doesn't throw.

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 09:12:49 +00:00
Rick Butterfield 4bdbe274e6 Merge remote-tracking branch 'origin/claude/fix-block-manager-context-NtO4n' into feature/visual-editor 2026-03-19 08:37:39 +00:00
Claude 3859202126 fix(block): ensure workspace initializes with invariant variantId and correct edit path
Two issues preventing the block workspace from rendering properties:

1. The workspace context's #gotManager() observes manager.variantId
   and early-returns if it's undefined. Without a variantId, the
   workspace never sets its own #variantId, so _workspaceVariantId
   stays undefined and the view renders nothing. Fix: always set a
   variantId on the manager, defaulting to UmbVariantId.CreateInvariant()
   when no variant context is available.

2. The edit path was missing /view/content suffix. The standard block
   entry navigates to {path}edit/{key}/view/content so the workspace's
   internal router matches the content view. Without this, the
   workspace editor's router may not correctly route to the content
   view where #setStructureManager is called.

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 08:27:12 +00:00
Claude 7b6828a804 feat(block): add grid area support and variant context to block bridge
Grid area support:
- VisualEditorBlockManager.setOneLayout() now detects parentUnique +
  areaKey in origin data and recursively walks the layout tree to
  insert into the correct area (mirrors UmbBlockGridManagerContext)
- Uses appendToFrozenArray/pushAtToUniqueArray for immutable frozen
  array updates
- Falls back to root-level insertion if parent block not found

Variant context:
- Bridge accepts optional UmbVariantId in config and exposes
  setVariantId() for live updates
- Visual editor element consumes UMB_VARIANT_CONTEXT and observes
  displayVariantId, forwarding it to the active bridge
- Manager receives the variant ID so new block expose entries get
  the correct culture/segment

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 08:13:34 +00:00
Rick ButterfieldandGitHub 3094d552e2 Integrate block workspace modal via VisualEditorBlockBridge 2026-03-19 08:02:42 +00:00
Claude 3d50062211 feat(block): add block bridge for visual editor workspace integration
Add VisualEditorBlockBridge — a controller that creates a per-property
block manager + entries context so the visual editor can open the
standard block workspace modal for editing blocks.

The bridge:
- Creates a concrete UmbBlockManagerContext subclass that provides
  UMB_BLOCK_MANAGER_CONTEXT on the host element
- Creates a concrete UmbBlockEntriesContext subclass that provides
  UMB_BLOCK_ENTRIES_CONTEXT for the workspace to consume
- Initializes both with the property's current block value
- Registers a workspace modal route
- Observes manager state changes and syncs back via callback

The visual editor element now uses the bridge when a block is clicked,
opening the standard block workspace instead of the custom property
modal. This gives blocks full access to the workspace editing
experience including nested blocks, validation, and live sync.

Also includes the contentTypeHasProperties data-passing fix from the
previous commit for the catalogue modal.

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 07:56:44 +00:00
Claude 1037f8f8f2 feat(block): pass contentTypeHasProperties via modal data for visual editor
The block catalogue modal needs to know which block types have editable
properties to show workspace links. Normally this comes from
UMB_BLOCK_MANAGER_CONTEXT, but the visual editor operates outside
the property editor DOM tree where that context lives.

Add an optional contentTypeHasProperties map to UmbBlockCatalogueModalData
so the visual editor can pass this info directly. The modal uses the
manager context when available, falling back to the data map.

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 07:35:41 +00:00
Claude b8607ad624 fix(block): make block catalogue modal work without UMB_BLOCK_MANAGER_CONTEXT
The catalogue modal gated all rendering on _manager being defined, which
meant it rendered nothing when opened from contexts that don't provide
UMB_BLOCK_MANAGER_CONTEXT (e.g. the visual editor). The manager is only
actually needed for getContentTypeHasProperties() on the block type card
href — make that optional instead of blocking the entire modal.

https://claude.ai/code/session_01NE6gfQjJYX9LapGFXFY7CU
2026-03-19 07:22:30 +00:00
Rick ButterfieldandClaude 5889c061d8 Visual Editor: Fix add-content buttons and use routed catalogue modal
The capture-phase click handler in the guest script was intercepting
clicks on "Add content" buttons — target.closest(ALL_SELECTOR) matched
the parent block element, swallowing the event before the button handler
fired. Added a guard to skip clicks inside [data-umb-add-block] elements.

Replaced non-routed umbOpenModal calls for the block catalogue with a
proper UmbModalRouteRegistrationController so umb-property can resolve
its dependencies through the routing context.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-19 07:15:49 +00:00
607 changed files with 25609 additions and 5568 deletions
+4 -12
View File
@@ -70,18 +70,6 @@ trim_trailing_whitespace = true
[*.less]
trim_trailing_whitespace = false
##########################################
# File Header (Uncomment to support file headers)
# https://docs.microsoft.com/visualstudio/ide/reference/add-file-header
##########################################
# [*.{cs,csx,cake,vb,vbx}]
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
# SA1636: File header copyright text should match
# Justification: .editorconfig supports file headers. If this is changed to a value other than "none", a stylecop.json file will need to added to the project.
# dotnet_diagnostic.SA1636.severity = none
##########################################
# .NET Language Conventions
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions
@@ -136,6 +124,10 @@ dotnet_code_quality_unused_parameters = all:warning
dotnet_style_operator_placement_when_wrapping = end_of_line
# https://github.com/dotnet/roslyn/pull/40070
dotnet_style_prefer_simplified_interpolation = true:warning
# File header preferences
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
dotnet_diagnostic.SA1633.severity = none # Suppressed until we decide to enforce it
dotnet_diagnostic.SA1636.severity = none # Suppressed since we are using StyleCop
# C# Code Style Settings
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings
+1 -1
View File
@@ -7,7 +7,7 @@ body:
id: "version"
attributes:
label: "Which Umbraco version are you using?"
description: "Please write the *exact* version, example: `10.1.0`. Use the help icon in the Umbraco backoffice to find the version you're using"
description: "Please write the *exact* version, example: `10.1.0`. Click the Umbraco logo in the top left corner of the backoffice to find the version you're using."
validations:
required: true
- type: textarea
+81
View File
@@ -0,0 +1,81 @@
name: "SonarQube Cloud - Analysis"
# This workflow runs the full SonarCloud analysis with the SONAR_TOKEN secret.
# It is skipped for fork PRs since secrets are not available in that context.
on:
push:
branches:
- main
- "v*/dev"
- "v*/main"
- "release/*"
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
env:
SONAR_PROJECT_KEY: umbraco_Umbraco-CMS
SONAR_ORGANIZATION: umbraco
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
name: Build and analyze
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v5
- name: Cache SonarQube packages
uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Install tools
run: |
dotnet tool install --global dotnet-sonarscanner
dotnet tool install --global dotnet-coverage
- name: Load sonar params
run: echo "SONARQUBE_SCANNER_PARAMS=$(jq -c . .github/workflows/sonarcloud/sonar-params.json)" >> $GITHUB_ENV
- name: Begin analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: |
dotnet-sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.token="$SONAR_TOKEN"
- name: Restore
run: dotnet restore umbraco.sln
- name: Build solution
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
- name: Run unit tests with coverage
run: |
dotnet-coverage collect \
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
--output TestResults/coverage.xml \
--output-format xml
- name: End analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
@@ -0,0 +1,7 @@
{
"sonar.cs.vscoveragexml.reportsPaths": "TestResults/coverage.xml",
"sonar.inclusions": "src/**,templates/**,tools/**,tests/**,.github/**,build/**",
"sonar.exclusions": "**/bin/**,**/obj/**,**/node_modules/**,**/lang/*.ts,**/mocks/**,**/wwwroot/**,**/dist-cms/**,**/*.generated.cs,src/Umbraco.Web.UI/umbraco/**,src/Umbraco.Cms.Persistence.EFCore.*/Migrations/**,src/Umbraco.Web.UI.Client/src/packages/core/backend-api/**,**/.nuget/**",
"sonar.test.inclusions": "tests/**,**/*.test.ts,**/*.spec.ts",
"sonar.typescript.tsconfigPaths": "src/Umbraco.Web.UI.Client/tsconfig.json,src/Umbraco.Web.UI.Client/tsconfig.node.json,src/Umbraco.Web.UI.Login/tsconfig.json"
}
+4
View File
@@ -120,4 +120,8 @@ trace.zip
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
/src/Umbraco.Cms/appsettings-schema.json
.worktrees
.playwright-mcp/
# SonarQube local analysis cache
.sonarqube/
-1
View File
@@ -48,7 +48,6 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = sug
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion
dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_*
dotnet_diagnostic.SA1309.severity = none # FieldNamesMustNotBeginWithUnderscore
+16
View File
@@ -448,6 +448,14 @@ When a PR changes Management API controllers or models, the `OpenApi.json` file
The backoffice is published to npm as `@umbraco-cms/backoffice`. Runtime dependencies are provided via importmap; npm peerDependencies provide types only. For full details on dependency hoisting, version range logic, and plugin development, see `/src/Umbraco.Web.UI.Client/CLAUDE.md` → "npm Package Publishing".
### SQL Server 2100-parameter limit
Any `WHERE IN (@0, @1, ...)` built from a runtime-sized collection risks hitting SQL Server's 2100-parameter ceiling and throwing `SqlException` 8003 in production.
Batch with `IEnumerable<T>.InGroupsOf(Constants.Sql.MaxParameterCount)` or `Database.FetchByGroups(...)` whenever the collection size is driven by user data — not just when it currently fits. Watch for products of two scaling dimensions (documents × languages, properties × versions) and config-tunable batch sizes whose defaults are safe but ceilings aren't.
Full guidance, safe patterns and decision rule: see `/src/Umbraco.Infrastructure/CLAUDE.md` → "Avoiding the SQL Server 2100-parameter limit".
### Known Limitations
1. **Circular Dependencies**: Avoided via `Lazy<T>` or event notifications
@@ -544,6 +552,14 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
---
## 9. Testing Practices
### Tests for a bug fix must fail before the fix
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
---
## Quick Reference
### Essential Commands
+4 -4
View File
@@ -49,15 +49,15 @@
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
<PackageVersion Include="Examine" Version="3.7.1" />
<PackageVersion Include="Examine.Core" Version="3.7.1" />
<PackageVersion Include="Examine" Version="3.8.0" />
<PackageVersion Include="Examine.Core" Version="3.8.0" />
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
<PackageVersion Include="MailKit" Version="4.16.0" />
<PackageVersion Include="Markdig" Version="1.1.3" />
<PackageVersion Include="Markdown" Version="2.2.1" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="MessagePack" Version="3.1.4" />
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
<PackageVersion Include="ncrontab" Version="3.4.0" />
@@ -95,4 +95,4 @@
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
</ItemGroup>
</Project>
</Project>
+109 -43
View File
@@ -45,7 +45,7 @@ parameters:
- name: integrationNonReleaseTestFilter
displayName: TestFilter used for non-release type builds
type: string
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
- name: integrationReleaseTestFilter
displayName: TestFilter used for release type builds
type: string
@@ -53,7 +53,7 @@ parameters:
- name: nonWindowsIntegrationNonReleaseTestFilter
displayName: TestFilter used for non-release type builds on non Windows agents
type: string
default: "--filter TestCategory!=LongRunning&TestCategory!=NonCritical"
default: "TestCategory!=LongRunning&TestCategory!=NonCritical"
- name: nonWindowsIntegrationReleaseTestFilter
displayName: TestFilter used for release type builds on non Windows agents
type: string
@@ -455,13 +455,13 @@ stages:
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
testRunTitle: Integration Tests SQLite - $(Agent.OS)
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
${{ else }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
# Integration Tests (SQL Server)
- job:
timeoutInMinutes: 180
@@ -569,13 +569,13 @@ stages:
projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
testRunTitle: Integration Tests SQL Server - $(Agent.OS)
${{ if and(eq(variables['Agent.OS'],'Windows_NT'), or(variables.releaseTestFilter, parameters.forceReleaseTestFilter)) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationReleaseTestFilter}}'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
${{ elseif eq(variables['Agent.OS'],'Windows_NT') }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.integrationNonReleaseTestFilter}}'
arguments: '--filter "$(testFilter) & ${{parameters.integrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
${{ elseif or(variables.releaseTestFilter, parameters.forceReleaseTestFilter) }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationReleaseTestFilter}}'
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
${{ else }}:
arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}'
arguments: '--filter "$(testFilter) & ${{parameters.nonWindowsIntegrationNonReleaseTestFilter}}" --configuration $(buildConfiguration) --no-build'
# Stop SQL Server
- pwsh: docker stop mssql
@@ -825,31 +825,74 @@ stages:
publishFeedCredentials: "MyGet - Umbraco Nightly"
${{ else }}:
publishFeedCredentials: "MyGet - Pre-releases"
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
- job:
displayName: Push to pre-release feed (npm)
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
- checkout: none
- download: current
artifact: npm
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
displayName: Push to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpersNpm
displayName: Push TestHelpers to pre-release feed (npm)
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: |
# Check if we are on a nightly build
if [ $isNightly = "False" ]; then
echo "##[debug]Prerelease build detected"
registry="https://www.myget.org/F/umbracoprereleases/npm/"
else
echo "##[debug]Nightly build detected"
registry="https://www.myget.org/F/umbraconightly/npm/"
fi
echo "@umbraco-cms:registry=$registry" >> .npmrc
env:
isNightly: ${{parameters.isNightly}}
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm (MyGet)
inputs:
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
displayName: Push test helpers to npm (MyGet)
${{ if eq(parameters.isNightly, true) }}:
registry: https://www.myget.org/F/umbraconightly/npm/
${{ else }}:
registry: https://www.myget.org/F/umbracoprereleases/npm/
- bash: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm (MyGet)
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Deploy_NuGet
displayName: NuGet release
@@ -862,10 +905,10 @@ stages:
- job: WaitForApproval
displayName: Wait for manual approval
pool: server
timeoutInMinutes: 4320 # 3 days
steps:
- task: ManualValidation@0
displayName: Manual approval to push to NuGet
timeoutInMinutes: 4320 # 3 days
inputs:
notifyUsers: ''
instructions: 'Approve to push the NuGet release.'
@@ -898,30 +941,53 @@ stages:
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
dependsOn:
- Deploy_NuGet
variables:
# `latest` for stable releases, `next` for prereleases.
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
jobs:
- job: Publish
displayName: Push to NPM
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm
registry: https://registry.npmjs.org/
- checkout: none
- download: current
artifact: npm
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm/.npmrc
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push to npm
npmTag: $(npmDistTag)
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push to npm
workingDirectory: $(Pipeline.Workspace)/npm
- job: PublishTestHelpers
displayName: Push Test Helpers to NPM
steps:
- template: templates/npm-publish.yml
parameters:
artifactName: npm-testhelpers
registry: https://registry.npmjs.org/
- checkout: none
- download: current
artifact: npm-testhelpers
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
displayName: Add scoped registry to .npmrc
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
customEndpoint: "NPM - Umbraco Backoffice"
displayName: Push test helpers to npm
npmTag: $(npmDistTag)
- script: |
# Setup temp npm project to load in defaults from the local .npmrc
npm init -y
# Find the first .tgz file in the current directory and publish it
files=( ./*.tgz )
npm publish "${files[0]}"
displayName: Push test helpers to npm
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
- stage: Upload_API_Docs
pool:
+3 -3
View File
@@ -4,11 +4,11 @@ pr: none
trigger: none
schedules:
- cron: '0 6 * * *'
displayName: Daily 6AM build (v18/dev)
- cron: '0 0 * * *'
displayName: Daily 0AM build (main)
branches:
include:
- v18/dev
- main
parameters:
- name: skipIntegrationTests
-28
View File
@@ -1,28 +0,0 @@
parameters:
- name: artifactName # "npm" or "npm-testhelpers"
type: string
- name: registry # scoped-registry URL to publish to
type: string
- name: customEndpoint # npmAuthenticate service connection(s)
type: string
- name: displayName # label for the publish step
type: string
- name: npmTag # dist-tag to publish under
type: string
default: latest
steps:
- checkout: none
- download: current
artifact: ${{ parameters.artifactName }}
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
displayName: Add scoped registry to .npmrc
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
- task: npmAuthenticate@0
displayName: Authenticate with npm
inputs:
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
customEndpoint: ${{ parameters.customEndpoint }}
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
displayName: ${{ parameters.displayName }}
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
@@ -0,0 +1,96 @@
# Visual Editor — Partial Re-render (Phase 3 remainder) — Design
**Status**: Implemented (spike passed 2026-06-11; see `2026-06-11-visual-editor-partial-rerender-plan.md`). Built via cache-node override + `IPublishedContentFactory` rather than a decorator — see the plan's "Deliberate deviation" note.
**Date**: 2026-06-11
**Author**: Rick Butterfield + Claude
**Scope**: The "Still to build" items of Phase 3 in `docs/plans/visual-page-builder.md` — server-side partial re-render with unsaved values, and client-side DOM patching. Block manipulation itself is already done.
**Relates to**: `docs/plans/visual-page-builder.md` §4.4 (original endpoint sketch), §2.3 (BlockPreview pattern); supersedes the isolated-region endpoint idea in §4.4 in favour of full-page render + client morph.
---
## Goal
Make partial re-render the **single, universal mechanism** for reflecting edits in the visual editor preview, retiring both the optimistic-text-only path and the save-and-full-reload path. Every edit — plain text, RTE/Markdown/media, block content/settings, and structural block add/delete/move/reorder — is reflected by re-rendering the page server-side with the workspace's unsaved values and morphing the live iframe DOM in place (no reload, scroll/selection preserved).
## Decisions locked
| Decision | Outcome |
|---|---|
| Trigger scope | **All** edit types route through re-render: block content/settings, block add/delete/move/reorder, RTE/Markdown/transformed properties, and plain text. |
| Feedback model | **Optimistic + authoritative**: instant optimistic `textContent` paint for plain text on keystroke; a debounced (~500ms) server re-render then replaces the region with true Razor output. Blocks/RTE show a subtle pending state (no meaningful optimistic paint) until the render returns. |
| Rendering approach | **A — full-page render + client DOM morph.** One endpoint renders the whole page via the existing preview path with unsaved values injected; guest morphs the live DOM. Chosen over isolated-region (B) because only a full-page render covers arbitrary-template property placement with guaranteed fidelity, and over hybrid (C) for single-path simplicity. |
| DOM patch | Bundle **morphdom** in the guest bundle; morph `<body>`, touching only changed nodes; preserves scroll. |
| Failure mode | **Keep last good DOM + quiet notice.** Leave current DOM untouched, log, transient non-blocking indicator; the workspace already holds the edit so the next successful render reconciles. Never silently swallow. |
| Save + SignalR | **Suppress self-reload, keep as multi-user net.** After a local save, a short-lived guard makes the editor ignore its own `refreshed` SignalR event (DOM already authoritative — no flicker). Refreshes not caused by this editor still reload. |
## Architecture & data flow
```
edit (property / block / structural)
→ element updates workspace value (source of truth) [+ optimistic textContent for plain text]
→ UmbVisualEditorRenderController: debounce ~500ms, latest-wins (AbortController cancels in-flight)
→ POST /umbraco/management/api/v1/visual-editor/render
body: { unique, culture?, segment?, values: [{ alias, value, culture?, segment? }] }
→ server:
EnsureUmbracoContext + force preview mode + VisualEditorPropertyTracker.Enable() for the render scope
base = DRAFT content from the published cache (preview read — same as the iframe shows)
wrap in PropertyOverridePublishedContent(unsaved values)
render the assigned template → HTML string (data-umb-* annotations emitted)
→ { html }
→ element posts umb:ve:render to the guest with the HTML
→ guest morphs <body> (morphdom) → re-runs initRegions() → restores selection highlight
```
The base is the **draft** content the iframe already renders (preview-mode cache read); the override layer is the workspace's even-newer unsaved edits on top.
## Server components (new)
| Unit | Project | Responsibility |
|---|---|---|
| Override-content builder (conversion) | `Umbraco.PublishedCache.HybridCache` (or a public seam exposed from it) | Produce an `IPublishedContent` representing the draft + unsaved overrides. **Approach proven by the spike** (and mirroring the in-tree `BlockElementService.BuildElementAsync`): for each overridden alias, run the editor-format value through `dataType.Editor.GetValueEditor().FromEditor(new ContentPropertyData(value, dataType.ConfigurationObject), null)` to get the source value; reuse the existing saved source values (`property.GetValue(published)`) for non-overridden aliases; assemble `PropertyData[]``ContentData``ContentCacheNode``IPublishedContentFactory.ToIPublishedContent(node, preview: true).CreateModel(...)`. Threads `Culture`/`Segment` onto `PropertyData` and sets `ContentData.CultureInfos` for variant content. **Not** a `GetProperty` decorator — a cache-node rebuild. (`IPublishedContentFactory` is `internal` to HybridCache, hence this unit lives there or a small public seam is added — resolved in the plan.) |
| `IVisualEditorRenderService` + impl | `Umbraco.Web.Common` | Renders a supplied `IPublishedContent` to an HTML string. Modeled on `TemplateRenderer` (`src/Umbraco.Web.Common/Templates/TemplateRenderer.cs`): build an `IPublishedRequest` via `IPublishedRouter`, `SetPublishedContent(overriddenContent)`, set culture/segment + template, swap onto `UmbracoContext.PublishedRequest`, render the template view to a `StringWriter`, restore. Forces preview mode + enables `VisualEditorPropertyTracker` for the render scope so annotations are emitted. RTE-embedded blocks render via the partial-view block engine, which this render context satisfies. |
| `RenderVisualEditorController` | `Umbraco.Cms.Api.Management` | `POST /umbraco/management/api/v1/visual-editor/render`, `[Authorize(Policy = BackOfficeAccess)]`. Ensures an `UmbracoContext`, resolves the draft content for `unique`, builds the override content from the request `values`, calls the render service, returns `{ html }`. |
**Value conversion — DE-RISKED by the spike (2026-06-11).** All three property kinds convert correctly via `IPublishedContentFactory.ToIPublishedContent`:
- **TextBox** — `FromEditor` → string source → published string. Clean.
- **Rich Text** — `FromEditor` → source JSON → `RteBlockRenderingValueConverter`; all link/url/image parsing happens at value-conversion time (no `IPublishedRequest` needed). RTE-*embedded blocks* additionally use the partial-view block engine at render time (covered by the full-page render context — smoke-test specifically).
- **Block List** — `FromEditor` source IS the block JSON; the converter resolves element types from the published content-type cache (no parent content / `IPublishedRequest` needed). Blocks need an `Expose` entry for the relevant culture/segment to surface.
Recommended primitive: reuse `IPublishedContentFactory` rather than hand-assembling per property. Variant content must populate `PropertyData` per culture/segment + `ContentData.CultureInfos`, and read-time resolution depends on the ambient `IVariationContextAccessor`.
## Client components
| Unit | Responsibility |
|---|---|
| `UmbVisualEditorRenderController` (new sibling, follows the SignalR/router/resolver extraction pattern) | Debounce (~500ms) + latest-wins cancellation via `AbortController`. Collects the active variant's current values from the workspace, calls the endpoint, posts `umb:ve:render` to the guest with the returned HTML. On failure: keep DOM, log, transient notice. Invoked from every mutation site (property submit, block submit, add/move/delete/reorder, and the debounced optimistic text input). |
| guest `injected.ts` | Bundle **morphdom**. Refactor the one-shot init (default outlines, drag-sort setup, add-button insertion, region discovery) into a re-runnable `initRegions()`. On `umb:ve:render`: morph `document.body` to the new HTML, then run `initRegions()` and restore the selection highlight. Delegated document-level listeners (click capture, mouseover) survive the morph; per-node styles/attributes are re-applied by `initRegions()`. |
| element SignalR (`visual-editor-signalr.controller.ts` + element) | Reintroduce a short-lived **suppress-self-reload** guard set when this editor saves, so the `refreshed` event for our own document key is ignored. Refreshes outside the guard window still reload (multi-user / external cache changes). |
## Error handling
- Render failure (network/500/timeout): keep last good DOM, log, show a transient non-blocking "preview out of date" indicator. The edit is already in the workspace; a later successful render reconciles. No silent swallow.
- Latest-wins: a newer edit aborts the in-flight render so stale HTML never overwrites newer DOM.
## Out of scope
- Render caching / output pooling beyond debounce + concurrency cap.
- Headless / Delivery-API rendering in the iframe.
- Surfacing validation state in the preview.
- Server-side sub-region extraction (full-page render + client morph already delivers partial DOM updates).
- Inline (`contenteditable`) editing — that is Phase 4 and now has its server-rendered source of truth from this phase.
## Testing
- **Backend integration test** (the riskiest, and testable C#, unlike the UI surface): the spike's throwaway test at `tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/VisualEditorConversionSpikeTests.cs` (uncommitted) is the basis — the plan's first task formalizes it into a real test of the override-content builder for TextBox, RTE, and Block List (assert converted-without-saving == save-then-read). A second test renders a seeded document through the render service and asserts the HTML reflects overridden values and carries `data-umb-*` annotations.
- **Frontend**: `npm run build` + `npm run lint` + manual smoke (no VE test harness exists; consistent with the prior phase).
## Spike outcome (2026-06-11) — PASSED
A throwaway integration test (`VisualEditorConversionSpikeTests`, uncommitted) booted Umbraco on SQLite, seeded a doc with TextBox + Rich Text + Block List, and proved that each property's editor-format value converts to the correct published value **without saving**, via `FromEditor` + `IPublishedContentFactory.ToIPublishedContent`. All 3 assertions passed (convert-without-saving == save-then-read). Findings folded into "Server components" above:
- Conversion primitive: `IPublishedContentFactory` (HybridCache, `internal`) — plan must resolve the access seam.
- Approach is a cache-node rebuild, **not** a `GetProperty` decorator (in-tree precedent: `BlockElementService`).
- Variants: thread `Culture`/`Segment` + `ContentData.CultureInfos`; read-time needs `IVariationContextAccessor`.
- Render-to-string is independently de-risked by the existing `TemplateRenderer`; RTE-embedded-block partials are the one spot needing the render context (not the value conversion).
No design fallback required — the approach is viable as chosen.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,85 @@
# Visual Editor Tidy-Up — Design
**Status**: Implemented (manual smoke pass pending)
**Date**: 2026-06-11
**Author**: Rick Butterfield + Claude
**Scope**: Tidy-up round on `feature/visual-editor` after merging `main` — no new feature phases.
**Relates to**: `docs/plans/visual-page-builder.md` (the feature plan; updated as part of this round)
---
## Decisions locked in this round
| Decision | Outcome |
|---|---|
| Architecture | **Embedded document-workspace view** is the current direction. The standalone-window evolution (plan doc §10, Open Q11) is **deferred**, not the next step. |
| Round scope | **Tidy-up only** — security, semantics, refactor, docs. No partial re-render API, no inline editing. |
| Editability semantics | **Strict opt-in everywhere** for document properties: a property is annotated/editable only when `appearance.editableInVisualEditor === true`. The frontend opt-out fallback is removed. |
| Block modal properties | **No filter**: the block editing modal shows all of the element type's content/settings properties. The `EditableInVisualEditor` setting governs document property annotation only. |
## Why
The branch is functionally far ahead of its plan doc (Phases 12 complete plus most block manipulation), but an audit found:
1. **Security**: the guest script (`src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts:540`) accepts `message` events with no `evt.origin` check, and posts with target `'*'`. The backoffice-side listener also lacks source/origin validation.
2. **Semantic mismatch**: backend tracking is strict opt-in (`PublishedContentExtensions.TrackVisualEditorAccess` checks `EditableInVisualEditor`), while the frontend had a conflicting "if none opt in, include all" fallback — dead code for properties, but confusing and wrong.
3. **Maintainability**: `document-workspace-view-visual-editor.element.ts` is 1,210 lines with ~11 responsibilities.
4. **Gap**: root-level empty Block Lists cannot offer "Add content" (container lacks a property-alias annotation; `injected.ts:1040` TODO).
5. **Stale docs**: `visual-page-builder.md` predates the `EditableInVisualEditor` setting and records "standalone window" as decided.
## Changes
### 1. Security hardening
**Guest script** (`injected.ts`):
- Derive `PARENT_ORIGIN` once: `document.referrer ? new URL(document.referrer).origin : window.location.origin`.
- Incoming handler: drop messages where `evt.origin !== PARENT_ORIGIN`.
- Outgoing: `window.parent.postMessage(msg, PARENT_ORIGIN)` instead of `'*'`.
- Referrer-based derivation keeps cross-origin dev (Vite 5173 → server 44339) working.
**Workspace view element**: the `message` listener accepts only events where `evt.source === iframe.contentWindow` **and** `evt.origin` equals the server origin from `UMB_SERVER_CONTEXT`.
### 2. Strict opt-in semantics
In the element's property-structure resolution:
- Remove the `anyExplicitlyEnabled` hybrid entirely.
- Document property METADATA stays unfiltered (it doubles as block-config lookup for `#getBlocksConfig`); enforcement is at the interaction points instead: `#onPropertyClicked` and the property modal `onSetup` both require `editableInVisualEditor === true` (defense-in-depth on top of server-side annotation gating).
- Remove the filter entirely from block content/settings structure resolution (blocks show all fields).
- Drop the `as { editableInVisualEditor?: boolean }` casts — the generated API types carry `appearance.editableInVisualEditor` natively; the resolver maps it onto `UmbVisualEditorPropertyInfo.editableInVisualEditor`.
Backend is already strict — no backend change.
### 3. Element refactor (extraction-only)
Extract from `document-workspace-view-visual-editor.element.ts` into sibling files; no behavior change:
| New file | Responsibility |
|---|---|
| `visual-editor-signalr.controller.ts` | `HubConnection` lifecycle, `refreshed` event, refresh-suppression guard |
| `visual-editor-property-structure.resolver.ts` | Document/block/settings property-structure resolution incl. composition-chain fetch and caching; `Map`-indexed by alias (replaces 6× O(n) `find()`); sole home of the opt-in filter |
| `visual-editor-message-router.ts` | Typed message-map routing of guest messages (replaces 7-case switch); performs the origin/source validation from §1 |
The element keeps iframe lifecycle, modal registrations, selection state and preview URL — target ≤ ~600 lines. Also: `Object.keys(pastedBlocks.layout)[0]``Object.values(pastedBlocks.layout)[0]` (line 972).
### 4. Root-level empty block lists
- `BlockListTemplateExtensions` passes the property alias to the partial via `ViewData` (alias-aware overloads; empty models no longer short-circuit so the partial can render an annotated empty container in preview mode).
- `Views/Partials/blocklist/default.cshtml` emits `data-umb-block-property="<alias>"` on the list container — a distinct attribute, because `data-umb-property` is the guest script's property-region selector and would turn the whole list into a clickable property region.
- `injected.ts` resolves the alias from the container for empty root-level lists and renders the existing "Add content" button via a new `umb:ve:block-add-to-property` message (closes the `injected.ts:1040` TODO).
### 5. Docs & polish
- XML docs: class-level summary on `VisualEditorPropertyTracker`; `<param>` tags on `VisualEditorGuestScript.GetScriptTag()`.
- `docs/plans/visual-page-builder.md`: refresh status header and phase statuses; close Open Q5 (setting shipped, strict opt-in); mark §10/Q11 standalone window **Deferred** with embedded view as current; update Appendix B attribute table.
## Out of scope
- Partial re-render API (Phase 3 remainder), inline editing (Phase 4), headless rendering, validation surfaced in preview, scroll retention.
- Moving the visual editor to its own package / lifting block-manipulation logic to library level — revisit with Phase 3.
- Automated tests for the visual editor (no harness exists for this surface yet; E2E coverage noted in the plan doc as future work).
## Verification
1. `npm run build` and `npm run lint` in `src/Umbraco.Web.UI.Client`.
2. `dotnet build umbraco.sln` — zero errors, no new warnings.
3. Manual smoke in the visual editor tab: property edit (flagged + unflagged property), block add/edit/settings/move/delete, empty root-level block list "Add content", save → SignalR refresh → selection restore, postMessage still works in dev (Vite) and built modes.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
# Visual Editor — Framework-Emitted Empty-Block Affordance — Design
**Status**: Implemented
**Date**: 2026-06-12
**Author**: Rick Butterfield + Claude
**Scope**: Move the "empty editable block property" visual-editor affordance (the annotated container that lets the guest offer an "Add content" button) out of per-view template code and into the framework block-rendering helpers, so it works automatically for every template — including custom ones — with zero template boilerplate.
**Supersedes**: the per-view empty-state edits to `blockgrid/blocklist/singleblock/default.cshtml` (sample site) and `EmbeddedResources/BlockGrid/default.cshtml`, plus the `PropertyAliasViewDataKey` ViewData plumbing in `BlockListTemplateExtensions`/`BlockGridTemplateExtensions`.
---
## Problem
The visual editor needs a DOM anchor for empty, editable block properties so the guest can render an "Add content" affordance (it has no blocks to attach inter-block "+" buttons to). The current implementation puts this in the Razor templates:
- `GetBlock{List,Grid}HtmlAsync` short-circuits empty models to `HtmlString.Empty`.
- Each `default.cshtml` was patched to read a `PropertyAliasViewDataKey` from ViewData and render an annotated empty `<div ... data-umb-block-property="{alias}">` in visual-editor mode.
This is unfriendly and incomplete:
- Every block template (block list, block grid, single block — default **and** any custom template) must carry framework annotation boilerplate.
- Custom templates that don't include it silently lose the feature.
- It contrasts with regular property annotation, which is fully automatic (`UmbracoViewPage` wraps editable property output in `data-umb-property` spans with no template code).
## Goal
Make the empty-block affordance **fully automatic**: no template code, working for the default templates and any custom template, gated on the property's `EditableInVisualEditor` opt-in and on visual-editor/preview mode. Revert all per-view edits and the ViewData plumbing.
## Why not the obvious alternatives
- **Emit it from `UmbracoViewPage` (like `data-umb-property`)**: the automatic span is only emitted when the property is accessed via the tracked `IPublishedContent.Value()` path; the block helpers read the value via `GetProperty().GetValue()`, which bypasses the tracker. Making block access reliably tracked and anchoring an affordance on an empty span touches the core annotation pipeline — bigger and riskier (this is the deferred "unify all property annotation" direction).
- **Emit HTML from the Core block model**: `BlockListModel`/`BlockGridModel` live in `Umbraco.Core`, which has no web/HTML concern — emitting annotation markup from the model crosses a layer boundary.
## Approach (chosen)
The block-rendering helpers in `Umbraco.Web.Common` are the web-layer choke point essentially all block rendering flows through. Move the empty-state emission there.
### Component 1 — Helpers emit the annotated container
In `BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, and the single-block rendering helper:
- When the model is **empty** AND `VisualEditorPropertyTracker.IsEnabled` AND the property's `PropertyType.EditableInVisualEditor` is `true`, return a minimal annotated container as an `HtmlString`:
- Block list: `<div class="umb-block-list" data-umb-block-property="{alias}"></div>`
- Block grid: `<div class="umb-block-grid" data-umb-block-property="{alias}"></div>` (with the existing `data-grid-columns`/`--umb-block-grid--grid-columns` styling, defaulting columns to `12`)
- Single block: an analogous annotated empty container (see Component 3)
- Otherwise return `HtmlString.Empty` exactly as today. Non-empty models render their partial unchanged.
The helper builds this small fixed container directly (no partial, no ViewData). The `PropertyAliasViewDataKey` constant, the `WithPropertyAlias` helper, and the alias-via-ViewData private overloads are **removed** from both extensions.
Gating predicate (shared intent across all three helpers): `model is empty && VisualEditorPropertyTracker.IsEnabled && propertyType?.EditableInVisualEditor == true`.
### Component 2 — Emission lives in the alias-bearing overloads only (no model metadata)
The helpers have three call styles:
| Overload | Has alias + editable flag? |
|---|---|
| `GetBlock*HtmlAsync(IPublishedContent content, string alias[, template])` | Yes — resolves the `IPublishedProperty` (`alias`, `PropertyType.EditableInVisualEditor`) |
| `GetBlock*HtmlAsync(IPublishedProperty property[, template])` | Yes — `property.Alias`, `property.PropertyType.EditableInVisualEditor` |
| `GetBlock*HtmlAsync(BlockListModel/BlockGridModel model[, template])` | **No** |
The empty-state container is emitted **only by the two alias-bearing overloads**, because they carry the alias and editable flag regardless of whether the value is empty.
**Why not "alias on the model" (rejected):** empty block values resolve to a process-wide **singleton** — the value creators return `BlockListModel.Empty` / `BlockGridModel.Empty` (`public static`), and an empty single block converts to `null`. There is no per-property instance to carry an alias for the empty case, and setting a mutable alias on the shared singleton would corrupt every empty block property on the site. The alias is also unavailable where the model is built (the value *creators* don't receive `IPublishedPropertyType` — only the *converters* do). So model metadata is out; **no changes to Core models, value creators, or converters.**
**Consequence for the model-only overload:** `GetBlock*HtmlAsync(Model.BlockProperty)` (model-only, including the bare ModelsBuilder property) keeps its current behaviour — empty renders nothing, no affordance. The alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")` / `(IPublishedProperty)`) is the documented, default pattern used by all sample templates (and `Home.cshtml` was aligned to it), so "fully automatic" holds for the standard pattern. The model-only gap is in the same class as fully hand-rolled rendering — see Out of scope.
### Component 3 — Single block
The single-block helper is `SingleBlockTemplateExtensions.GetBlockHtmlAsync`; an empty single-block property surfaces as a **null** `BlockListItem` (the helper already returns `HtmlString.Empty` for null). Emit an annotated empty container when the value is null/empty + `VisualEditorPropertyTracker.IsEnabled` + the property is `EditableInVisualEditor`.
Consistent with Component 2: annotation comes only from the **alias-bearing overloads**`GetBlockHtmlAsync(IPublishedProperty)` and `GetBlockHtmlAsync(IPublishedContent, alias)` — which expose `property.Alias` and `property.PropertyType.EditableInVisualEditor` even when `property.GetValue()` is null. The model-only `GetBlockHtmlAsync(BlockListItem? model)` overload, given a null model, has no alias and cannot annotate (documented gap; the sample/default and documented usage use the alias-bearing overloads).
"Add content" reuses the existing `umb:ve:block-add-to-property` message (single-block semantics: one block, `insertIndex 0`). The guest gains a single-block empty-container branch mirroring the list/grid ones (or a shared selector). Exact container markup + the guest branch are finalized in the plan.
### Component 4 — Guest + element (mostly unchanged)
- The guest already attaches the "Add content" placeholder to empty `.umb-block-list` / `.umb-block-grid` containers carrying `data-umb-block-property`, and the element's grid-aware add (`#resolveBlockSchemaAlias`) already produces the correct list/grid value shape. These are unchanged.
- The only guest addition is the single-block empty-container handling.
- The `data-umb-block-property` attribute and the `umb:ve:block-add-to-property` postMessage protocol are retained — the helper now emits the attribute that the templates previously emitted.
### Component 5 — Revert the per-view changes
Revert to original form (removing the empty-state boilerplate and ViewData reads):
- `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
- `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
- `src/Umbraco.Web.UI/Views/Partials/singleblock/default.cshtml` (unchanged from original — never modified, but confirm it needs no edit under the new mechanism)
- `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
`Home.cshtml`'s switch to the alias-aware overload (`GetBlockGridHtmlAsync(Model, "bodyText")`) may be **kept or reverted** — under Component 2 the model-only overload also works, so reverting it is safe; keeping it is harmless. The plan picks one (default: keep, as the alias-aware overload is the documented norm).
## Data flow (after)
```
template: @await Html.GetBlockGridHtmlAsync(Model, "bodyText") (or Model.BodyText, or an IPublishedProperty)
→ helper resolves model + property alias + EditableInVisualEditor
→ model non-empty? → render partial as today (unchanged)
→ model empty?
→ VisualEditorPropertyTracker.IsEnabled && EditableInVisualEditor?
→ return <div class="umb-block-grid" data-umb-block-property="bodyText"></div>
→ else HtmlString.Empty (production: nothing, as today)
→ guest sees the empty annotated container → renders "Add content" → umb:ve:block-add-to-property
→ element #onBlockAddToProperty → grid/list-aware value creation (unchanged)
```
## Error handling / edge cases
- Not in VE/preview, or property not editable, or model non-empty → byte-for-byte the same output as before this change (no behavioural change to production rendering).
- Property alias unknown on the model-only overload (metadata not populated, e.g. a model constructed outside the value creators) → no annotation (graceful: treated as "alias unknown", returns empty as today). Not silent in a harmful way — it just falls back to current behaviour.
- A custom template that hand-renders blocks without any `GetBlock*HtmlAsync` helper → no affordance. Documented as the one uncovered path (the helper is the documented rendering API).
## Testing
- **Backend (unit/integration)**: the block helpers return an annotated container for an empty editable block property when `VisualEditorPropertyTracker.IsEnabled`, and `HtmlString.Empty` when (a) the tracker is disabled, (b) the property is not `EditableInVisualEditor`, or (c) the model is non-empty. Cover all three overloads (content+alias, property, model-only) — the model-only case asserts the `PropertyAlias` metadata path.
- **Value-creator test**: the produced block model carries the correct `PropertyAlias` / `EditableInVisualEditor` metadata.
- **Frontend/guest**: `npm run build` + `npm run lint` + manual smoke (no VE guest test harness; consistent with the feature's established posture). Manual smoke covers empty list, empty grid, empty single block in the visual editor.
## Out of scope
- Unifying all property annotation under a single `data-umb-property` mechanism (the deferred Approach 2).
- Shipping a default block-list render template (block list intentionally ships none).
- Covering hand-rolled block rendering that bypasses the `GetBlock*HtmlAsync` helpers.
- Covering the **model-only** helper overload (`GetBlock*HtmlAsync(Model.BlockProperty)`): empty values resolve to the shared `.Empty` singleton (or `null` for single block), which has no per-property identity to annotate. Use the alias-bearing overload (`GetBlock*HtmlAsync(Model, "alias")`) — the documented default — to get the empty-state affordance.
## Implementation note
This change **reverts** the prior per-view empty-state commits and the ViewData plumbing in favour of the helper-based mechanism. Those commits remain in history as superseded steps; the revert is part of this work, not a separate cleanup.
@@ -0,0 +1,947 @@
# Visual Editor — Framework-Emitted Empty-Block Affordance — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the visual-editor empty-block "Add content" affordance fully automatic via the block-rendering helpers, removing the per-view template boilerplate and the ViewData plumbing.
**Architecture:** The block helpers (`BlockListTemplateExtensions`, `BlockGridTemplateExtensions`, `SingleBlockTemplateExtensions` in `Umbraco.Web.Common`) emit an annotated empty container `<div class="umb-block-{list,grid,single}" data-umb-block-property="{alias}">` themselves — but only from the **alias-bearing overloads** (which carry the alias + `PropertyType.EditableInVisualEditor` even when the value is empty), and only when `VisualEditorPropertyTracker.IsEnabled` and the property is editable-in-VE. A shared `BlockEmptyState` helper DRYs the gating + markup. The default views revert to their plain form, and the ViewData plumbing is deleted. No changes to Core models / value creators / converters.
**Tech Stack:** C# / ASP.NET Core Razor helpers (`Umbraco.Web.Common`), Razor views (`Umbraco.Web.UI`, embedded `Umbraco.Core`), TypeScript guest (`injected.ts`) + Lit element (backoffice client). Working dir for ALL tasks: `D:/CMS/Umbraco-CMS/.worktrees/feature-visual-editor`.
**Spec:** `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md`
**Standing instruction:** the user asked for **no commits yet**. Implement and verify each task; leave changes in the working tree **uncommitted**. The "Commit" steps below are written for completeness but are GATED — do not run them until the user approves committing. Report each task's diff for review instead.
**Verified facts:**
- Empty block values resolve to the shared singletons `BlockListModel.Empty` / `BlockGridModel.Empty`; an empty single block converts to `null`. Hence the alias can only come from the alias-bearing overloads, not the model. (No model/creator/converter changes.)
- `IPublishedPropertyType` exposes `string Alias` and `bool EditableInVisualEditor` (default `false`) — `src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs:27,52`.
- `VisualEditorPropertyTracker.IsEnabled` is a public static in `Umbraco.Cms.Core.Models.PublishedContent`.
- `SingleBlockValue : BlockValue<SingleBlockLayoutItem>` with `PropertyEditorAlias => Constants.PropertyEditors.Aliases.SingleBlock` — so single-block add reuses `addBlockToValue` with the single-block schema alias.
- The guest already has empty-container branches for `.umb-block-list` and `.umb-block-grid` reading `dataset.umbBlockProperty`; there is **no** single-block handling.
---
### Task 1: Shared empty-state helper
**Files:**
- Create: `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs`:
```csharp
using Microsoft.AspNetCore.Html;
using NUnit.Framework;
using System.IO;
using System.Text.Encodings.Web;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
[TestFixture]
public class BlockEmptyStateTests
{
[TearDown]
public void TearDown() => VisualEditorPropertyTracker.Disable();
private static string Render(IHtmlContent content)
{
using var writer = new StringWriter();
content.WriteTo(writer, HtmlEncoder.Default);
return writer.ToString();
}
[Test]
public void Returns_Annotated_Container_When_Enabled_And_Editable()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
var html = Render(result);
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
}
[Test]
public void Returns_Empty_When_Tracker_Disabled()
{
VisualEditorPropertyTracker.Disable();
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "bodyText", editableInVisualEditor: true);
Assert.That(Render(result), Is.Empty);
}
[Test]
public void Returns_Empty_When_Not_Editable()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = BlockEmptyState.Container("umb-block-grid", "bodyText", editableInVisualEditor: false);
Assert.That(Render(result), Is.Empty);
}
[Test]
public void Returns_Empty_When_Alias_Missing()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = BlockEmptyState.Container("umb-block-list", string.Empty, editableInVisualEditor: true);
Assert.That(Render(result), Is.Empty);
}
[Test]
public void Encodes_Alias_And_Class()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = BlockEmptyState.Container("umb-block-list", "a\"b", editableInVisualEditor: true);
var html = Render(result);
Assert.That(html, Does.Not.Contain("a\"b"));
Assert.That(html, Does.Contain("a&quot;b").Or.Contain("a&#x22;b"));
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
Expected: FAIL (build error — `BlockEmptyState` does not exist).
- [ ] **Step 3: Implement `BlockEmptyState`**
Create `src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs`:
```csharp
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Extensions;
/// <summary>
/// Produces the annotated empty container the visual editor uses to offer an "add content"
/// affordance on an empty, editable block property. Returns empty content outside the visual editor.
/// </summary>
internal static class BlockEmptyState
{
/// <summary>
/// Returns an annotated empty container (<c>&lt;div class="{cssClass}" data-umb-block-property="{alias}"&gt;</c>)
/// when the property is editable in the visual editor and the visual editor is active; otherwise empty content.
/// </summary>
public static IHtmlContent Container(string cssClass, string propertyAlias, bool editableInVisualEditor)
{
if (!editableInVisualEditor
|| string.IsNullOrEmpty(propertyAlias)
|| !VisualEditorPropertyTracker.IsEnabled)
{
return HtmlString.Empty;
}
var encodedClass = HtmlEncoder.Default.Encode(cssClass);
var encodedAlias = HtmlEncoder.Default.Encode(propertyAlias);
return new HtmlString($"<div class=\"{encodedClass}\" data-umb-block-property=\"{encodedAlias}\"></div>");
}
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockEmptyStateTests"`
Expected: PASS (5 passed).
- [ ] **Step 5: Commit (GATED — only if the user has approved committing)**
```bash
git add src/Umbraco.Web.Common/Extensions/BlockEmptyState.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockEmptyStateTests.cs
git commit -m "feat(visual-editor): shared empty-state container helper for block properties"
```
---
### Task 2: Block list helper emits the affordance; remove ViewData plumbing
**Files:**
- Modify: `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs`
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs`:
```csharp
using System.IO;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
[TestFixture]
public class BlockListTemplateExtensionsTests
{
[TearDown]
public void TearDown() => VisualEditorPropertyTracker.Disable();
private static string Render(IHtmlContent content)
{
using var writer = new StringWriter();
content.WriteTo(writer, HtmlEncoder.Default);
return writer.ToString();
}
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
{
var propertyType = new Mock<IPublishedPropertyType>();
propertyType.SetupGet(x => x.Alias).Returns(alias);
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
var property = new Mock<IPublishedProperty>();
property.SetupGet(x => x.Alias).Returns(alias);
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
property.Setup(x => x.GetValue(null, null)).Returns(BlockListModel.Empty);
return property.Object;
}
[Test]
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
var html = Render(result);
Assert.That(html, Does.Contain("class=\"umb-block-list\""));
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
}
[Test]
public async Task Empty_NonEditable_Property_Emits_Nothing()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
Assert.That(Render(result), Is.Empty);
}
[Test]
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
{
VisualEditorPropertyTracker.Disable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockListHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
Assert.That(Render(result), Is.Empty);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
Expected: FAIL — the current helper short-circuits empty to `HtmlString.Empty` (no container), so the first test fails.
- [ ] **Step 3: Rewrite `BlockListTemplateExtensions.cs`**
Replace the full contents of `src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs` with:
```csharp
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Extensions;
public static class BlockListTemplateExtensions
{
public const string DefaultFolder = "blocklist/";
public const string DefaultTemplate = "default";
#region Async
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
{
if (model?.Count == 0)
{
return HtmlString.Empty;
}
return await html.PartialAsync(DefaultFolderTemplate(template), model);
}
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> await GetBlockListHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
public static async Task<IHtmlContent> GetBlockListHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return await GetBlockListHtmlAsync(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
}
private static async Task<IHtmlContent> GetBlockListHtmlAsync(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null || model.Count == 0)
{
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
}
return await html.PartialAsync(DefaultFolderTemplate(template), model);
}
#endregion
#region Sync
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, BlockListModel? model, string template = DefaultTemplate)
{
if (model?.Count == 0)
{
return HtmlString.Empty;
}
return html.Partial(DefaultFolderTemplate(template), model);
}
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> GetBlockListHtml(html, contentItem, propertyAlias, DefaultTemplate);
public static IHtmlContent GetBlockListHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return GetBlockListHtml(html, property.GetValue() as BlockListModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
}
private static IHtmlContent GetBlockListHtml(IHtmlHelper html, BlockListModel? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null || model.Count == 0)
{
return BlockEmptyState.Container("umb-block-list", propertyAlias, editableInVisualEditor);
}
return html.Partial(DefaultFolderTemplate(template), model);
}
#endregion
private static string DefaultFolderTemplate(string template) => $"{DefaultFolder}{template}";
private static IPublishedProperty GetRequiredProperty(IPublishedContent contentItem, string propertyAlias)
{
ArgumentNullException.ThrowIfNull(propertyAlias);
if (string.IsNullOrWhiteSpace(propertyAlias))
{
throw new ArgumentException(
"Value can't be empty or consist only of white-space characters.",
nameof(propertyAlias));
}
IPublishedProperty? property = contentItem.GetProperty(propertyAlias);
if (property == null)
{
throw new InvalidOperationException("No property type found with alias " + propertyAlias);
}
return property;
}
}
```
This removes `PropertyAliasViewDataKey`, `WithPropertyAlias`, the `Microsoft.AspNetCore.Mvc.ViewFeatures` using, and the old alias-via-ViewData private overloads.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockListTemplateExtensionsTests"`
Expected: PASS (3 passed).
- [ ] **Step 5: Build Web.Common**
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
Expected: 0 errors.
- [ ] **Step 6: Commit (GATED)**
```bash
git add src/Umbraco.Web.Common/Extensions/BlockListTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockListTemplateExtensionsTests.cs
git commit -m "feat(visual-editor): block list helper emits empty-state affordance, drop ViewData plumbing"
```
---
### Task 3: Block grid helper emits the affordance; remove ViewData plumbing
**Files:**
- Modify: `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs`:
```csharp
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
[TestFixture]
public class BlockGridTemplateExtensionsTests
{
[TearDown]
public void TearDown() => VisualEditorPropertyTracker.Disable();
private static string Render(IHtmlContent content)
{
using var writer = new StringWriter();
content.WriteTo(writer, HtmlEncoder.Default);
return writer.ToString();
}
private static IPublishedProperty EmptyEditableProperty(string alias, bool editable)
{
var emptyGrid = new BlockGridModel(new List<BlockGridItem>(), null);
var propertyType = new Mock<IPublishedPropertyType>();
propertyType.SetupGet(x => x.Alias).Returns(alias);
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
var property = new Mock<IPublishedProperty>();
property.SetupGet(x => x.Alias).Returns(alias);
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
property.Setup(x => x.GetValue(null, null)).Returns(emptyGrid);
return property.Object;
}
[Test]
public async Task Empty_Editable_Property_In_VisualEditor_Emits_Annotated_Container()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
var html = Render(result);
Assert.That(html, Does.Contain("class=\"umb-block-grid\""));
Assert.That(html, Does.Contain("data-umb-block-property=\"bodyText\""));
}
[Test]
public async Task Empty_NonEditable_Property_Emits_Nothing()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: false));
Assert.That(Render(result), Is.Empty);
}
[Test]
public async Task Empty_Property_Outside_VisualEditor_Emits_Nothing()
{
VisualEditorPropertyTracker.Disable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockGridHtmlAsync(EmptyEditableProperty("bodyText", editable: true));
Assert.That(Render(result), Is.Empty);
}
}
```
(Note: `new BlockGridModel(new List<BlockGridItem>(), null)` is used instead of `BlockGridModel.Empty` because `Empty` has `Count == 0` and either works; the explicit list keeps the test independent of the singleton.)
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
Expected: FAIL (no container emitted by current helper).
- [ ] **Step 3: Edit `BlockGridTemplateExtensions.cs`**
In `src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs`:
(a) Remove the `using Microsoft.AspNetCore.Mvc.ViewFeatures;` line.
(b) Remove the `PropertyAliasViewDataKey` const + its XML doc (lines 20-24).
(c) Replace the async property/content overloads + private method (lines 51-66) — change the property overloads to pass the alias **and** editable flag, and rewrite the private method to emit the empty-state:
```csharp
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> await GetBlockGridHtmlAsync(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> await GetBlockGridHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
public static async Task<IHtmlContent> GetBlockGridHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
return await GetBlockGridHtmlAsync(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
}
private static async Task<IHtmlContent> GetBlockGridHtmlAsync(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null || model.Count == 0)
{
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
}
return await html.PartialAsync(DefaultFolderTemplate(template), model);
}
```
(d) Mirror the same change in the sync region (lines 104-118):
```csharp
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> GetBlockGridHtml(html, property.GetValue() as BlockGridModel, template, property.Alias, property.PropertyType.EditableInVisualEditor);
/// <inheritdoc cref="GetBlockGridHtmlAsync(Microsoft.AspNetCore.Mvc.Rendering.IHtmlHelper,Umbraco.Cms.Core.Models.Blocks.BlockGridModel?,string)"/>
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> GetBlockGridHtml(html, contentItem, propertyAlias, DefaultTemplate);
public static IHtmlContent GetBlockGridHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty prop = GetRequiredProperty(contentItem, propertyAlias);
return GetBlockGridHtml(html, prop.GetValue() as BlockGridModel, template, prop.Alias, prop.PropertyType.EditableInVisualEditor);
}
private static IHtmlContent GetBlockGridHtml(IHtmlHelper html, BlockGridModel? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null || model.Count == 0)
{
return BlockEmptyState.Container("umb-block-grid", propertyAlias, editableInVisualEditor);
}
return html.Partial(DefaultFolderTemplate(template), model);
}
```
(e) Remove the now-unused `WithPropertyAlias` private method (lines 139-140). Leave `GetBlockGridItemsHtmlAsync`/`GetBlockGridItemAreasHtmlAsync`/etc. and `GetRequiredProperty` unchanged. The model-only `GetBlockGridHtmlAsync(BlockGridModel? model, ...)` overload (lines 41-49) keeps its `model?.Count == 0 → HtmlString.Empty` form unchanged.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~BlockGridTemplateExtensionsTests"`
Expected: PASS (3 passed).
- [ ] **Step 5: Build Web.Common**
Run: `dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj`
Expected: 0 errors.
- [ ] **Step 6: Commit (GATED)**
```bash
git add src/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/BlockGridTemplateExtensionsTests.cs
git commit -m "feat(visual-editor): block grid helper emits empty-state affordance, drop ViewData plumbing"
```
---
### Task 4: Single block helper emits the affordance
**Files:**
- Modify: `src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs`
- Test: `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`
The single-block value is a `BlockListItem?`; empty = `null`. The alias-bearing overloads have the property even when the value is null.
- [ ] **Step 1: Write the failing test**
Create `tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs`:
```csharp
using System.IO;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Models.Blocks;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Common.Extensions;
[TestFixture]
public class SingleBlockTemplateExtensionsTests
{
[TearDown]
public void TearDown() => VisualEditorPropertyTracker.Disable();
private static string Render(IHtmlContent content)
{
using var writer = new StringWriter();
content.WriteTo(writer, HtmlEncoder.Default);
return writer.ToString();
}
private static IPublishedProperty NullEditableProperty(string alias, bool editable)
{
var propertyType = new Mock<IPublishedPropertyType>();
propertyType.SetupGet(x => x.Alias).Returns(alias);
propertyType.SetupGet(x => x.EditableInVisualEditor).Returns(editable);
var property = new Mock<IPublishedProperty>();
property.SetupGet(x => x.Alias).Returns(alias);
property.SetupGet(x => x.PropertyType).Returns(propertyType.Object);
property.Setup(x => x.GetValue(null, null)).Returns((object?)null);
return property.Object;
}
[Test]
public async Task Empty_Editable_Single_Block_In_VisualEditor_Emits_Annotated_Container()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
var html = Render(result);
Assert.That(html, Does.Contain("class=\"umb-single-block\""));
Assert.That(html, Does.Contain("data-umb-block-property=\"hero\""));
}
[Test]
public async Task Empty_NonEditable_Single_Block_Emits_Nothing()
{
VisualEditorPropertyTracker.Enable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: false));
Assert.That(Render(result), Is.Empty);
}
[Test]
public async Task Empty_Single_Block_Outside_VisualEditor_Emits_Nothing()
{
VisualEditorPropertyTracker.Disable();
IHtmlContent result = await Mock.Of<IHtmlHelper>().GetBlockHtmlAsync(NullEditableProperty("hero", editable: true));
Assert.That(Render(result), Is.Empty);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
Expected: FAIL (current helper returns `HtmlString.Empty` for null model).
- [ ] **Step 3: Edit `SingleBlockTemplateExtensions.cs`**
Change the alias-bearing overloads to pass the alias + editable flag through to a private method that emits the empty-state. The model-only overloads keep their `model is null → HtmlString.Empty` behaviour.
Replace the async property/content overloads (lines 27-37) with:
```csharp
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> await GetBlockHtmlAsync(html, contentItem, propertyAlias, DefaultTemplate);
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
}
private static async Task<IHtmlContent> GetBlockHtmlAsync(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null)
{
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
}
return await html.PartialAsync(DefaultFolderTemplate(template), model);
}
```
Replace the sync property/content overloads (lines 52-62) with:
```csharp
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias)
=> GetBlockHtml(html, contentItem, propertyAlias, DefaultTemplate);
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedContent contentItem, string propertyAlias, string template)
{
IPublishedProperty property = GetRequiredProperty(contentItem, propertyAlias);
return GetBlockHtml(html, property.GetValue() as BlockListItem, template, property.Alias, property.PropertyType.EditableInVisualEditor);
}
private static IHtmlContent GetBlockHtml(IHtmlHelper html, BlockListItem? model, string template, string propertyAlias, bool editableInVisualEditor)
{
if (model is null)
{
return BlockEmptyState.Container("umb-single-block", propertyAlias, editableInVisualEditor);
}
return html.Partial(DefaultFolderTemplate(template), model);
}
```
Leave the model-only `GetBlockHtmlAsync(BlockListItem? model, ...)` / `GetBlockHtml(BlockListItem? model, ...)` overloads (lines 17-25, 42-50), `SingleBlockPartialWithFallback`, `DefaultFolderTemplate`, and `GetRequiredProperty` unchanged.
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~SingleBlockTemplateExtensionsTests"`
Expected: PASS (3 passed).
- [ ] **Step 5: Build + commit (GATED)**
```bash
dotnet build src/Umbraco.Web.Common/Umbraco.Web.Common.csproj
git add src/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensions.cs tests/Umbraco.Tests.UnitTests/Umbraco.Web.Common/Extensions/SingleBlockTemplateExtensionsTests.cs
git commit -m "feat(visual-editor): single block helper emits empty-state affordance"
```
---
### Task 5: Revert the views to their plain form
**Files:**
- Modify: `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml`
- Modify: `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml`
- Modify: `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml`
These no longer carry empty-state logic — the helper handles it. (The `singleblock/default.cshtml` was never modified and stays as-is: the helper now handles the empty/null case before the partial is invoked, so the partial only ever renders a non-null block.)
- [ ] **Step 1: Revert `blocklist/default.cshtml`**
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml` with:
```razor
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockListModel>
@{
if (Model?.Any() != true) { return; }
}
<div class="umb-block-list">
@foreach (var block in Model)
{
if (block?.ContentKey == null) { continue; }
var data = block.Content;
<div data-umb-block-key="@block.ContentKey" data-umb-content-type="@data.ContentType.Alias">
@await Html.PartialAsync("blocklist/Components/" + data.ContentType.Alias, block)
</div>
}
</div>
```
(Note: the per-block `data-umb-block-key`/`data-umb-content-type` annotations on populated blocks are retained — they were present before the empty-state work and are needed for selecting existing blocks. Only the empty-state `data-umb-block-property` + ViewData read are removed.)
- [ ] **Step 2: Revert `blockgrid/default.cshtml`**
Replace the full contents of `src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml` with:
```razor
@using Umbraco.Extensions
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
@{
if (Model?.Any() != true) { return; }
var gridColumns = Model.GridColumns?.ToString() ?? "12";
}
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
@await Html.GetBlockGridItemsHtmlAsync(Model)
</div>
```
- [ ] **Step 3: Revert embedded `BlockGrid/default.cshtml`**
Replace the full contents of `src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml` with the identical plain form:
```razor
@using Umbraco.Extensions
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage<Umbraco.Cms.Core.Models.Blocks.BlockGridModel>
@{
if (Model?.Any() != true) { return; }
var gridColumns = Model.GridColumns?.ToString() ?? "12";
}
<div class="umb-block-grid" data-grid-columns="@(gridColumns)" style="--umb-block-grid--grid-columns: @(gridColumns);">
@await Html.GetBlockGridItemsHtmlAsync(Model)
</div>
```
- [ ] **Step 4: Confirm no remaining references to the removed ViewData keys**
Run: `grep -rn "PropertyAliasViewDataKey\|umbBlockListPropertyAlias\|umbBlockGridPropertyAlias" src/`
Expected: zero hits (the consts were removed in Tasks 2-3 and the views no longer read them).
- [ ] **Step 5: Build Web.UI (validates compile; Razor is runtime-compiled)**
Run: `dotnet build src/Umbraco.Web.UI/Umbraco.Web.UI.csproj`
Expected: 0 errors. (Stop any running dev instance first to avoid DLL file-locks.)
- [ ] **Step 6: Commit (GATED)**
```bash
git add src/Umbraco.Web.UI/Views/Partials/blocklist/default.cshtml src/Umbraco.Web.UI/Views/Partials/blockgrid/default.cshtml src/Umbraco.Core/EmbeddedResources/BlockGrid/default.cshtml
git commit -m "refactor(visual-editor): revert block view empty-state boilerplate (now framework-emitted)"
```
---
### Task 6: Guest — single-block empty-container branch
**Files:**
- Modify: `src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts`
The guest already handles empty `.umb-block-list` and `.umb-block-grid` containers (unchanged — the helper now emits the same `data-umb-block-property` markup). Add a parallel branch for the single-block container class `umb-single-block`.
- [ ] **Step 1: Add the single-block empty-container branch**
In `insertAddButtons()`, immediately after the existing empty `.umb-block-grid` branch (the block that does `document.querySelectorAll<HTMLElement>('.umb-block-grid').forEach(...)`), add:
```typescript
// Empty single block at root level. The container carries data-umb-block-property
// (emitted by the single block helper in visual-editor mode).
document.querySelectorAll<HTMLElement>('.umb-single-block').forEach((single) => {
if (single.querySelector(BLOCK_SELECTOR)) return; // Has a block
if (single.querySelector(`[${ADD_BTN_ATTR}]`)) return; // Already handled
const propertyAlias = single.dataset.umbBlockProperty || '';
if (!propertyAlias) return;
single.appendChild(
createEmptyPlaceholder(() => {
send({ type: 'umb:ve:block-add-to-property', propertyAlias, insertIndex: 0 });
}),
);
});
```
Also update the file-header doc comment line for `data-umb-block-property` to read: `Property alias on a block list, block grid, or single block container (empty-state block creation)`.
- [ ] **Step 2: Build the client**
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
Expected: tsc exits 0. (Allow up to 600000ms.)
- [ ] **Step 3: Commit (GATED)**
```bash
git add src/Umbraco.Web.UI.Client/src/apps/visual-editor/injected.ts
git commit -m "feat(visual-editor): single block empty-state add-content affordance (guest)"
```
---
### Task 7: Element — single-block-aware add
**Files:**
- Modify: `src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts`
When the guest sends `umb:ve:block-add-to-property` for a single-block property, the element must create a single-block-shaped value (layout key `Umbraco.SingleBlock`). Today `#resolveBlockSchemaAlias` only maps grid vs list; extend it for single block so `addBlockToValue` writes the right layout key.
- [ ] **Step 1: Confirm the single-block client constants**
Run: `grep -rn "PROPERTY_EDITOR_SCHEMA_ALIAS\|PROPERTY_EDITOR_UI_ALIAS" src/Umbraco.Web.UI.Client/src/packages/block/block-single/`
Expected: find the exported constants for the single block editor — the schema alias (value `Umbraco.SingleBlock`) and the UI alias (value `Umb.PropertyEditorUi.BlockSingle` or similar). Note their exact exported names and the import path (`@umbraco-cms/backoffice/block-single`). If the names differ from those used below, substitute the real names.
- [ ] **Step 2: Extend `#resolveBlockSchemaAlias`**
Add the import (next to the existing block-grid import):
```typescript
import {
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS,
UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS,
} from '@umbraco-cms/backoffice/block-single';
```
Replace `#resolveBlockSchemaAlias` with:
```typescript
#resolveBlockSchemaAlias(propertyAlias: string): string {
const editorUiAlias = this.#structures.getDocumentProperty(propertyAlias)?.editorUiAlias ?? '';
if (editorUiAlias === UMB_BLOCK_GRID_PROPERTY_EDITOR_UI_ALIAS) {
return UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS;
}
if (editorUiAlias === UMB_BLOCK_SINGLE_PROPERTY_EDITOR_UI_ALIAS) {
return UMB_BLOCK_SINGLE_PROPERTY_EDITOR_SCHEMA_ALIAS;
}
return UMB_BLOCK_LIST_PROPERTY_EDITOR_SCHEMA_ALIAS;
}
```
(`addBlockToValue` keys its grid-specific layout logic on `UMB_BLOCK_GRID_PROPERTY_EDITOR_SCHEMA_ALIAS`; for the single-block alias it falls through to the plain list-shaped layout item, which matches `SingleBlockValue`'s `BlockValue<SingleBlockLayoutItem>` structure — one block under the `Umbraco.SingleBlock` layout key, no columnSpan/rowSpan.)
- [ ] **Step 3: Build the client**
Run: `cd src/Umbraco.Web.UI.Client && npm run build`
Expected: tsc exits 0. (Allow up to 600000ms.) If the single-block constant names differ, fix the import to the real names found in Step 1.
- [ ] **Step 4: Commit (GATED)**
```bash
git add src/Umbraco.Web.UI.Client/src/packages/documents/documents/workspace/views/visual-editor/document-workspace-view-visual-editor.element.ts
git commit -m "feat(visual-editor): single-block-aware add for empty single block properties"
```
---
### Task 8: Final verification + mark spec implemented
- [ ] **Step 1: Unit tests**
Run: `dotnet test tests/Umbraco.Tests.UnitTests --filter "FullyQualifiedName~TemplateExtensionsTests|FullyQualifiedName~BlockEmptyStateTests"`
Expected: all helper + empty-state tests pass.
- [ ] **Step 2: Full client build + lint**
```bash
cd src/Umbraco.Web.UI.Client
npm run build
npm run lint
```
Expected: build exits 0; lint reports no NEW errors in the visual-editor files (the `umb:ve:*` keys are already lint-exempt).
- [ ] **Step 3: Full solution build**
Run: `dotnet build umbraco.sln`
Expected: 0 errors (pre-existing StyleCop warnings out of scope).
- [ ] **Step 4: Manual smoke** (run the site, backoffice at https://localhost:44339/umbraco)
1. Empty editable block **list** property → preview shows the annotated empty container with an "Add content" button; clicking it adds a block.
2. Empty editable block **grid** property (e.g. Blogpost `bodyText`) → same.
3. Empty editable **single block** property → same; clicking adds exactly one block.
4. A **non-editable** empty block property → renders nothing, no affordance.
5. A custom template that renders a block property via `@Html.GetBlock*HtmlAsync(Model, "alias")` → affordance appears with **no template code** for the empty state.
6. Non-empty block properties render unchanged.
- [ ] **Step 5: Update the design doc status**
In `docs/plans/2026-06-12-visual-editor-block-empty-state-design.md` replace:
```markdown
**Status**: Approved design, pending implementation plan
```
with:
```markdown
**Status**: Implemented
```
- [ ] **Step 6: Commit (GATED)**
```bash
git add docs/plans/2026-06-12-visual-editor-block-empty-state-design.md
git commit -m "docs(visual-editor): mark framework-emitted empty-block affordance implemented"
```
---
## Self-review notes
- **Spec coverage:** Component 1 (helper emits container) → Tasks 2-4 + the `BlockEmptyState` helper (Task 1); Component 2 (alias-bearing overloads only, no model metadata) → Tasks 2-4 pass alias + `EditableInVisualEditor` from the property; Component 3 (single block) → Tasks 4, 6, 7; Component 4 (guest/element) → Tasks 6-7; Component 5 (revert views) → Task 5; Testing → unit tests in Tasks 1-4 + manual in Task 8.
- **Verify-at-execution (not placeholders):** the single-block client constant names (Task 7 Step 1) — exact exported names confirmed by grep before use.
- **No model/creator/converter changes** — consistent with the singleton finding.
- **Commits are GATED** per the user's "no commits yet" instruction — execute and review; commit only on approval.
File diff suppressed because it is too large Load Diff
@@ -53,11 +53,14 @@ public class SearchDataTypeItemController : DatatypeItemControllerBase
return Ok(new PagedModel<DataTypeItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<IDataType> dataTypes = await _dataTypeService.GetAllAsync(keys);
IEnumerable<IDataType> orderedDataTypes = OrderByRequestedIds(dataTypes, keys);
var result = new PagedModel<DataTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(dataTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IDataType, DataTypeItemResponseModel>(orderedDataTypes),
Total = searchResult.Total,
};
return Ok(result);
@@ -61,8 +61,9 @@ public class SearchElementItemController : ElementItemControllerBase
.GetAll(UmbracoObjectTypes.Element, keys)
.OfType<IElementEntitySlim>()
.ToArray();
List<IElementEntitySlim> orderedElements = OrderByRequestedIds(elements, keys);
ElementItemResponseModel[] items = await Task.WhenAll(elements.Select(_elementPresentationFactory.CreateItemResponseModelAsync));
ElementItemResponseModel[] items = await Task.WhenAll(orderedElements.Select(_elementPresentationFactory.CreateItemResponseModelAsync));
return Ok(
new PagedModel<ElementItemResponseModel>
@@ -54,11 +54,14 @@ public class SearchMediaTypeItemController : MediaTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MediaTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray().EmptyNull());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMediaType> mediaTypes = _mediaTypeService.GetMany(keys.EmptyNull());
IEnumerable<IMediaType> orderedMediaTypes = OrderByRequestedIds(mediaTypes, keys);
var result = new PagedModel<MediaTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(mediaTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMediaType, MediaTypeItemResponseModel>(orderedMediaTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -32,6 +32,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for member type items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter member type items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{MemberTypeItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<MemberTypeItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchMemberTypeItemController : MemberTypeItemControllerBase
return Task.FromResult<IActionResult>(Ok(new PagedModel<MemberTypeItemResponseModel> { Total = searchResult.Total }));
}
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(item => item.Key).ToArray();
IEnumerable<IMemberType> memberTypes = _memberTypeService.GetMany(keys);
IEnumerable<IMemberType> orderedMemberTypes = OrderByRequestedIds(memberTypes, keys);
var result = new PagedModel<MemberTypeItemResponseModel>
{
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(memberTypes),
Total = searchResult.Total
Items = _mapper.MapEnumerable<IMemberType, MemberTypeItemResponseModel>(orderedMemberTypes),
Total = searchResult.Total,
};
return Task.FromResult<IActionResult>(Ok(result));
@@ -2,6 +2,7 @@ using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
@@ -32,11 +33,13 @@ public class DeleteByKeyRedirectUrlManagementController : RedirectUrlManagementC
[MapToApiVersion("1.0")]
[HttpDelete("{id:guid}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Deletes a redirect URL.")]
[EndpointDescription("Deletes a redirect URL identified by the provided Id.")]
public Task<IActionResult> DeleteByKey(CancellationToken cancellationToken, Guid id)
{
_redirectUrlService.Delete(id);
return Task.FromResult<IActionResult>(Ok());
RedirectUrlOperationStatus status = _redirectUrlService.DeleteWithStatus(id);
return Task.FromResult(RedirectUrlOperationStatusResult(status));
}
}
@@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
@@ -14,4 +16,31 @@ namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
public class RedirectUrlManagementControllerBase : ManagementApiControllerBase
{
/// <summary>
/// Maps a <see cref="RedirectUrlOperationStatus"/> to an appropriate <see cref="IActionResult"/>.
/// </summary>
/// <param name="status">The operation status to map.</param>
/// <returns>An <see cref="IActionResult"/> describing the outcome of the operation.</returns>
protected IActionResult RedirectUrlOperationStatusResult(RedirectUrlOperationStatus status) =>
OperationStatusResult(status, problemDetailsBuilder => status switch
{
RedirectUrlOperationStatus.Success => Ok(),
RedirectUrlOperationStatus.NotFound => NotFound(problemDetailsBuilder
.WithTitle("The redirect URL could not be found")
.Build()),
RedirectUrlOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder
.WithTitle("Cancelled by notification")
.WithDetail("A notification handler prevented the redirect URL operation.")
.Build()),
RedirectUrlOperationStatus.Unknown => StatusCode(
StatusCodes.Status500InternalServerError,
problemDetailsBuilder
.WithTitle("Unknown error. Please see the log for more details.")
.Build()),
_ => StatusCode(
StatusCodes.Status500InternalServerError,
problemDetailsBuilder
.WithTitle("Unknown redirect URL operation status.")
.Build()),
});
}
@@ -8,67 +8,38 @@ using Umbraco.Cms.Core.Security;
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
/// <summary>
/// Controller for setting the redirect URL tracking status.
/// Controller for setting the redirect URL tracking status. Retained for backwards compatibility only;
/// the endpoint no longer modifies any configuration.
/// </summary>
[ApiVersion("1.0")]
[Obsolete("This controller is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public class SetStatusRedirectUrlManagementController : RedirectUrlManagementControllerBase
{
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
private readonly IConfigManipulator _configManipulator;
/// <summary>
/// Initializes a new instance of the <see cref="SetStatusRedirectUrlManagementController"/> class.
/// </summary>
/// <param name="backOfficeSecurityAccessor">The back office security accessor.</param>
/// <param name="configManipulator">The configuration manipulator.</param>
/// <param name="backOfficeSecurityAccessor">Ignored. Retained for binary compatibility.</param>
/// <param name="configManipulator">Ignored. Retained for binary compatibility.</param>
public SetStatusRedirectUrlManagementController(
#pragma warning disable IDE0060 // Remove unused parameter
IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
IConfigManipulator configManipulator)
#pragma warning restore IDE0060 // Remove unused parameter
{
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
_configManipulator = configManipulator;
}
// TODO: Consider if we should even allow this, or only allow using the appsettings
// We generally don't want to edit the appsettings from our code.
// But maybe there is a valid use case for doing it on the fly.
/// <summary>
/// Sets the redirect URL tracking status.
/// Deprecated. Returns an OK response without modifying any configuration. To toggle redirect URL tracking,
/// set the <c>Umbraco:CMS:WebRouting:DisableRedirectUrlTracking</c> configuration key instead.
/// </summary>
/// <param name="cancellationToken">The cancellation token for the HTTP request.</param>
/// <param name="status">The redirect status to set.</param>
/// <returns>An OK result if successful.</returns>
/// <param name="status">The redirect status (ignored).</param>
/// <returns>An OK result.</returns>
[HttpPost("status")]
[EndpointSummary("Sets the redirect URL tracking status.")]
[EndpointDescription("Updates the redirect URL tracking configuration according to the provided status.")]
[EndpointSummary("Deprecated. No longer changes the redirect URL tracking status.")]
[EndpointDescription("This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.")]
[MapToApiVersion("1.0")]
public async Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
{
// TODO: uncomment this when auth is implemented.
// var userIsAdmin = _backOfficeSecurityAccessor.BackOfficeSecurity?.CurrentUser?.IsAdmin();
// if (userIsAdmin is null or false)
// {
// return Unauthorized();
// }
var enable = status switch
{
RedirectStatus.Enabled => true,
RedirectStatus.Disabled => false,
_ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown redirect status")
};
// For now I'm not gonna change this to limit breaking, but it's weird to have a "disabled" switch,
// since you're essentially negating the boolean from the get go,
// it's much easier to reason with enabled = false == disabled.
await _configManipulator.SaveDisableRedirectUrlTrackingAsync(!enable);
// Taken from the existing implementation in RedirectUrlManagementController
// TODO this is ridiculous, but we need to ensure the configuration is reloaded, before this request is ended.
// otherwise we can read the old value in GetEnableState.
// The value is equal to JsonConfigurationSource.ReloadDelay
Thread.Sleep(250);
return Ok();
}
[Obsolete("This endpoint is deprecated and no longer modifies the configuration. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public Task<IActionResult> SetStatus(CancellationToken cancellationToken, [FromQuery] RedirectStatus status)
=> Task.FromResult<IActionResult>(Ok());
}
@@ -32,6 +32,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
_mapper = mapper;
}
/// <summary>
/// Searches for template items matching the specified query, with support for pagination.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="query">The search query used to filter template items.</param>
/// <param name="skip">The number of items to skip before starting to collect the result set (used for pagination).</param>
/// <param name="take">The maximum number of items to return in the result set (used for pagination).</param>
/// <returns>A task representing the asynchronous operation. The task result contains an <see cref="IActionResult"/> with a <see cref="PagedModel{TemplateItemResponseModel}"/> containing the search results.</returns>
[HttpGet("search")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedModel<TemplateItemResponseModel>), StatusCodes.Status200OK)]
@@ -45,11 +53,14 @@ public class SearchTemplateItemController : TemplateItemControllerBase
return Ok(new PagedModel<TemplateItemResponseModel> { Total = searchResult.Total });
}
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(searchResult.Items.Select(item => item.Key).ToArray());
Guid[] keys = searchResult.Items.Select(x => x.Key).ToArray();
IEnumerable<ITemplate> templates = await _templateService.GetAllAsync(keys);
IEnumerable<ITemplate> orderedTemplates = OrderByRequestedIds(templates, keys);
var result = new PagedModel<TemplateItemResponseModel>
{
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(templates),
Total = searchResult.Total
Items = _mapper.MapEnumerable<ITemplate, TemplateItemResponseModel>(orderedTemplates),
Total = searchResult.Total,
};
return Ok(result);
@@ -0,0 +1,53 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Templates;
namespace Umbraco.Cms.Api.Management.Controllers.VisualEditor;
/// <summary>
/// Renders a document's template with the visual editor's unsaved values for live preview.
/// </summary>
[ApiVersion("1.0")]
public class RenderVisualEditorController : VisualEditorControllerBase
{
private readonly IVisualEditorRenderService _renderService;
/// <summary>
/// Initializes a new instance of the <see cref="RenderVisualEditorController"/> class.
/// </summary>
/// <param name="renderService">The <see cref="IVisualEditorRenderService"/> used to render document templates with visual editor overrides.</param>
public RenderVisualEditorController(IVisualEditorRenderService renderService)
=> _renderService = renderService;
/// <summary>
/// Renders a document's template with the unsaved property values supplied by the visual editor.
/// </summary>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <param name="requestModel">The model containing the document key, culture, segment, and property value overrides to render.</param>
/// <returns>
/// An <see cref="IActionResult"/> containing a <see cref="VisualEditorRenderResponseModel"/> with the rendered HTML on success.
/// </returns>
[HttpPost("render")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(VisualEditorRenderResponseModel), StatusCodes.Status200OK)]
[EndpointSummary("Renders a document with unsaved visual editor values.")]
public async Task<IActionResult> Render(
CancellationToken cancellationToken,
VisualEditorRenderRequestModel requestModel)
{
var overrides = requestModel.Values
.Select(v => new VisualEditorPropertyOverride(v.Alias, v.Value, v.Culture, v.Segment))
.ToList();
var html = await _renderService.RenderAsync(
requestModel.Unique,
requestModel.Culture,
requestModel.Segment,
overrides);
return Ok(new VisualEditorRenderResponseModel { Html = html });
}
}
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.VisualEditor;
/// <summary>
/// Base controller for visual editor management API endpoints.
/// </summary>
[VersionedApiBackOfficeRoute("visual-editor")]
[ApiExplorerSettings(GroupName = "Visual Editor")]
[Authorize(Policy = AuthorizationPolicies.BackOfficeAccess)]
public abstract class VisualEditorControllerBase : ManagementApiControllerBase
{
}
@@ -5,6 +5,7 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Hosting;
using Umbraco.Cms.Web.Common.Middleware;
namespace Umbraco.Extensions;
@@ -68,6 +69,10 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IBackOfficeEnabledMarker, BackOfficeEnabledMarker>();
builder.Services.AddUnique<IBackOfficePathGenerator, UmbracoBackOfficePathGenerator>();
// Registered here rather than in AddWebComponents because the middleware depends on
// IBackOfficePathGenerator (registered just above). DI scope validation would otherwise
// fail in Delivery-only/Website-only bootstraps that never call AddBackOffice().
builder.Services.AddSingleton<UmbracoBackOfficeCacheHeadersMiddleware>();
builder.Services.AddUnique<IPhysicalFileSystem>(factory =>
{
var path = "~/";
@@ -118,7 +118,7 @@ internal abstract class ContentTypeEditingPresentationFactory<TContentType>
{
Alias = property.Alias,
Appearance =
new ContentTypeEditingModels.PropertyTypeAppearance { LabelOnTop = property.Appearance.LabelOnTop },
new ContentTypeEditingModels.PropertyTypeAppearance { LabelOnTop = property.Appearance.LabelOnTop, EditableInVisualEditor = property.Appearance.EditableInVisualEditor },
Name = property.Name,
Validation = new ContentTypeEditingModels.PropertyTypeValidation
{
@@ -1,5 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.DataType;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Serialization;
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
private readonly TimeProvider _timeProvider;
private readonly ILogger<DataTypePresentationFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
/// <param name="logger">The logger.</param>
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
TimeProvider timeProvider,
ILogger<DataTypePresentationFactory> logger)
{
_dataTypeContainerService = dataTypeContainerService;
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
/// </summary>
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
public DataTypePresentationFactory(
IDataTypeContainerService dataTypeContainerService,
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
TimeProvider timeProvider)
: this(
dataTypeContainerService,
propertyEditorCollection,
dataValueEditorFactory,
configurationEditorJsonSerializer,
timeProvider,
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
{
}
/// <inheritdoc />
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
dataType.Key = requestModel.Id.Value;
}
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
}
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
{
try
{
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
return parent is null
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
}
/// <inheritdoc/>
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
{
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
}
IDataType dataType = (IDataType)current.DeepClone();
var dataType = (IDataType)current.DeepClone();
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
dataType.Name = requestModel.Name;
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
{
var configurationObject = editor.GetConfigurationEditor()
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
if (configurationObject is IConfigureValueType configureValueType)
// Only editors whose configuration object implements IConfigureValueType derive their storage
// type from the configuration. Building the typed configuration object can throw for editors
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
// must not fail the save, so fall back to the value editor's value type in that case.
try
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
is IConfigureValueType configureValueType)
{
return ValueTypes.ToStorageType(configureValueType.ValueType);
}
}
catch (Exception)
{
// Configuration editors are third-party and can throw anything when the stored configuration
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
// rather than failing the save, but log so the misconfiguration remains observable.
_logger.LogError(
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
editor.Alias);
}
var valueType = editor.GetValueEditor().ValueType;
@@ -49,7 +49,8 @@ public abstract class ContentTypeMapDefinition<TContentType, TPropertyTypeModel,
},
Appearance = new PropertyTypeAppearance
{
LabelOnTop = propertyType.LabelOnTop
LabelOnTop = propertyType.LabelOnTop,
EditableInVisualEditor = propertyType.EditableInVisualEditor,
}
})
.ToArray();
+143 -3
View File
@@ -28924,8 +28924,8 @@
"tags": [
"Redirect Management"
],
"summary": "Sets the redirect URL tracking status.",
"description": "Updates the redirect URL tracking configuration according to the provided status.",
"summary": "Deprecated. No longer changes the redirect URL tracking status.",
"description": "This endpoint is deprecated and no longer modifies the configuration. To toggle redirect URL tracking, set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead.",
"operationId": "PostRedirectManagementStatus",
"parameters": [
{
@@ -38519,6 +38519,76 @@
]
}
},
"/umbraco/management/api/v1/visual-editor/render": {
"post": {
"tags": [
"Visual Editor"
],
"summary": "Renders a document with unsaved visual editor values.",
"operationId": "PostVisualEditorRender",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VisualEditorRenderRequestModel"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
}
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/VisualEditorRenderResponseModel"
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
},
"403": {
"description": "The authenticated user does not have access to this resource",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
}
}
}
}
}
},
"security": [
{
"Backoffice-User": []
}
]
}
},
"/umbraco/management/api/v1/item/webhook": {
"get": {
"tags": [
@@ -49208,12 +49278,16 @@
},
"PropertyTypeAppearanceModel": {
"required": [
"labelOnTop"
"labelOnTop",
"editableInVisualEditor"
],
"type": "object",
"properties": {
"labelOnTop": {
"type": "boolean"
},
"editableInVisualEditor": {
"type": "boolean"
}
}
},
@@ -53134,6 +53208,72 @@
}
}
},
"VisualEditorPropertyValueModel": {
"required": [
"alias"
],
"type": "object",
"properties": {
"alias": {
"type": "string"
},
"value": {},
"culture": {
"type": [
"null",
"string"
]
},
"segment": {
"type": [
"null",
"string"
]
}
}
},
"VisualEditorRenderRequestModel": {
"required": [
"unique",
"values"
],
"type": "object",
"properties": {
"unique": {
"type": "string",
"format": "uuid"
},
"culture": {
"type": [
"null",
"string"
]
},
"segment": {
"type": [
"null",
"string"
]
},
"values": {
"type": "array",
"items": {
"$ref": "#/components/schemas/VisualEditorPropertyValueModel"
}
}
}
},
"VisualEditorRenderResponseModel": {
"required": [
"html"
],
"type": "object",
"properties": {
"html": {
"type": "string"
}
}
},
"WebhookEventModel": {
"required": [
"eventName",
@@ -73,6 +73,6 @@ public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
Controller = ControllerExtensions.GetControllerName<BackOfficeDefaultController>(),
Action = nameof(BackOfficeDefaultController.Index),
},
constraints: new { slug = @"^(section|preview|upgrade|install|oauth_complete|logout|error).*$" });
constraints: new { slug = @"^(section|preview|visual-editor|upgrade|install|oauth_complete|logout|error).*$" });
}
}
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
/// Gets or sets a value indicating whether the label for the property type is displayed above the input.
/// </summary>
public bool LabelOnTop { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
/// </summary>
public bool EditableInVisualEditor { get; set; }
}
@@ -0,0 +1,19 @@
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
/// <summary>
/// A single unsaved property value submitted for a visual editor preview render.
/// </summary>
public class VisualEditorPropertyValueModel
{
/// <summary>Gets or sets the property alias.</summary>
public required string Alias { get; set; }
/// <summary>Gets or sets the editor-format value (raw string for simple editors, JSON for complex editors).</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the culture this value applies to, or <c>null</c> for invariant.</summary>
public string? Culture { get; set; }
/// <summary>Gets or sets the segment this value applies to, or <c>null</c> for none.</summary>
public string? Segment { get; set; }
}
@@ -0,0 +1,19 @@
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
/// <summary>
/// Request to render a document's template with unsaved visual editor values overlaid.
/// </summary>
public class VisualEditorRenderRequestModel
{
/// <summary>Gets or sets the document key to render.</summary>
public Guid Unique { get; set; }
/// <summary>Gets or sets the culture to render, or <c>null</c> for the default/invariant.</summary>
public string? Culture { get; set; }
/// <summary>Gets or sets the segment to render, or <c>null</c> for none.</summary>
public string? Segment { get; set; }
/// <summary>Gets or sets the unsaved property values to overlay onto the draft content.</summary>
public IEnumerable<VisualEditorPropertyValueModel> Values { get; set; } = [];
}
@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
/// <summary>
/// The rendered HTML for a visual editor preview render request.
/// </summary>
public class VisualEditorRenderResponseModel
{
/// <summary>Gets or sets the rendered page HTML.</summary>
public required string Html { get; set; }
}
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
1. **Migration Provider** - Executes SQLite-specific migrations
2. **Migration Provider Setup** - Configures DbContext to use SQLite
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
### Folder Structure
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
├── EFCoreSqliteComposer.cs # DI registration
├── SqliteMigrationProvider.cs # IMigrationProvider impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
```
### Relationship with Parent Project
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
### SqliteMigrationProviderSetup (line 11-14)
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
`SqliteRetryingExecutionStrategy` (see below). Invoked from
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
### SqliteRetryingExecutionStrategy
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
---
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
| File | Purpose |
|------|---------|
| `SqliteMigrationProvider.cs` | Migration execution |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
| `EFCoreSqliteComposer.cs` | DI registration |
| `Migrations/*.cs` | Migration files |
@@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Umbraco.Cms.Core;
using Umbraco.Cms.Persistence.EFCore.Migrations;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
/// <inheritdoc />
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
{
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
builder.UseSqlite(connectionString, x =>
{
x.MigrationsAssembly(GetType().Assembly.FullName);
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
// for the rationale — long-running migrations or schema-modifying operations can
// briefly lock the database in a way that surfaces as a hard error to concurrent
// EF Core readers (notably OpenIddict token validation). See issue #22939.
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
});
}
}
@@ -0,0 +1,71 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore.Storage;
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
/// <summary>
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
/// </summary>
/// <remarks>
/// <para>
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
/// </para>
/// <para>
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
/// default exponential backoff and re-uses its inherited
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
/// </para>
/// <para>
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
/// so EF Core's retry budget is the only buffer.
/// </para>
/// </remarks>
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
{
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
/// </summary>
/// <param name="dependencies">Parameter object containing service dependencies.</param>
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
public SqliteRetryingExecutionStrategy(
ExecutionStrategyDependencies dependencies,
int maxRetryCount,
TimeSpan maxRetryDelay)
: base(dependencies, maxRetryCount, maxRetryDelay)
{
}
/// <inheritdoc />
protected override bool ShouldRetryOn(Exception exception)
{
// EF Core wraps provider exceptions, so walk the inner-exception chain.
for (Exception? current = exception; current is not null; current = current.InnerException)
{
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
{
return true;
}
}
return false;
}
}
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
}
}
catch (SqliteException ex) when (IsBusyOrLocked(ex))
catch (SqliteException ex) when (ex.IsBusyOrLocked())
{
throw new DistributedWriteLockTimeoutException(LockId);
}
});
}
private static bool IsBusyOrLocked(SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
}
@@ -0,0 +1,26 @@
using Microsoft.Data.Sqlite;
using SQLitePCL;
namespace Umbraco.Cms.Persistence.EFCore;
/// <summary>
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
/// </summary>
/// <remarks>
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
/// duplication is intentional — keeps the layering clean.
/// </remarks>
public static class SqliteExceptionExtensions
{
/// <summary>
/// Determines if the SQLite exception is a BUSY or LOCKED error.
/// </summary>
/// <param name="ex">The SQLite exception to check.</param>
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
public static bool IsBusyOrLocked(this SqliteException ex) =>
ex.SqliteErrorCode
is raw.SQLITE_BUSY
or raw.SQLITE_LOCKED
or raw.SQLITE_LOCKED_SHAREDCACHE;
}
+2
View File
@@ -306,6 +306,8 @@ public class MyEntityCacheRefresher : CacheRefresherBase<MyEntityCacheRefresher>
- `Attempt.Succeed(value)` / `Attempt.Fail<T>()`
- `Attempt<Content, ContentEditingOperationStatus>` - typed result with status
> Writing or reviewing a query with a `WHERE IN` on a runtime-sized collection? See "Avoiding the SQL Server 2100-parameter limit" in `/src/Umbraco.Infrastructure/CLAUDE.md` — that's where the full helper list (`Constants.Sql.MaxParameterCount`, `InGroupsOf`, NPoco's `FetchByGroups`) and the decision rules live.
### Configuration
Configuration models in `/Configuration/Models`:
@@ -467,20 +467,6 @@ public static class DistributedCacheExtensions
#endregion
#region ElementContainerCacheRefresher
/// <summary>
/// Invalidates the id/key map for the specified deleted element containers (folders).
/// </summary>
/// <param name="dc">The distributed cache.</param>
/// <param name="deletedContainers">The element containers that were deleted.</param>
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
=> dc.RefreshByPayload(
ElementContainerCacheRefresher.UniqueId,
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
#endregion
#region Published Snapshot
/// <summary>
@@ -1,43 +0,0 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
/// </summary>
/// <remarks>
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
/// itself, so without this handler the container's stale id/key mapping survives until the next app
/// restart (see #23072).
/// </remarks>
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
{
private readonly DistributedCache _distributedCache;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
/// </summary>
/// <param name="distributedCache">The distributed cache.</param>
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
=> _distributedCache = distributedCache;
/// <inheritdoc />
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
{
EntityContainer[] elementContainers = entities
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
.ToArray();
if (elementContainers.Length == 0)
{
return;
}
_distributedCache.RemoveElementContainerCache(elementContainers);
}
}
+10 -1
View File
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
}
// Ensure key is removed from set when evicted from cache
return options.RegisterPostEvictionCallback((key, _, _, _) =>
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
{
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
{
return;
}
try
{
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
@@ -1,109 +0,0 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Core.Cache;
/// <summary>
/// Provides cache refresh functionality for element containers (folders).
/// </summary>
/// <remarks>
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
/// </remarks>
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
{
private readonly IIdKeyMap _idKeyMap;
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
/// </summary>
public ElementContainerCacheRefresher(
AppCaches appCaches,
IJsonSerializer serializer,
IIdKeyMap idKeyMap,
IEventAggregator eventAggregator,
ICacheRefresherNotificationFactory factory)
: base(appCaches, serializer, eventAggregator, factory)
=> _idKeyMap = idKeyMap;
#region Json
/// <summary>
/// Represents a JSON-serializable payload identifying an element container that changed.
/// </summary>
public class JsonPayload
{
/// <summary>
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
/// </summary>
/// <param name="id">The unique integer identifier for the container.</param>
/// <param name="key">The unique GUID key associated with the container.</param>
public JsonPayload(int id, Guid key)
{
Id = id;
Key = key;
}
/// <summary>
/// Gets the unique integer identifier for the container.
/// </summary>
public int Id { get; }
/// <summary>
/// Gets the unique GUID key associated with the container.
/// </summary>
public Guid Key { get; }
}
#endregion
#region Define
/// <summary>
/// Represents a unique identifier for the cache refresher.
/// </summary>
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
/// <inheritdoc/>
public override Guid RefresherUniqueId => UniqueId;
/// <inheritdoc/>
public override string Name => "Element Container Cache Refresher";
#endregion
#region Refresher
/// <inheritdoc/>
public override void Refresh(JsonPayload[] payloads)
{
foreach (JsonPayload payload in payloads)
{
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
_idKeyMap.ClearCache(payload.Id);
}
base.Refresh(payloads);
}
// These events should never trigger. Everything should be PAYLOAD/JSON.
/// <inheritdoc/>
public override void RefreshAll() => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(int id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Refresh(Guid id) => throw new NotSupportedException();
/// <inheritdoc/>
public override void Remove(int id) => throw new NotSupportedException();
#endregion
}
@@ -36,6 +36,7 @@ public interface IConfigManipulator
/// </summary>
/// <param name="disable">The value to save.</param>
/// <returns></returns>
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
Task SaveDisableRedirectUrlTrackingAsync(bool disable);
/// <summary>
@@ -316,6 +316,8 @@ public static class PublishedContentExtensions
{
IPublishedProperty? property = content.GetProperty(alias);
TrackVisualEditorAccess(property, alias, content.Key);
// if we have a property, and it has a value, return that value
if (property != null && property.HasValue(culture, segment))
{
@@ -356,6 +358,8 @@ public static class PublishedContentExtensions
{
IPublishedProperty? property = content.GetProperty(alias);
TrackVisualEditorAccess(property, alias, content.Key);
// if we have a property, and it has a value, return that value
if (property != null && property.HasValue(culture, segment))
{
@@ -373,6 +377,23 @@ public static class PublishedContentExtensions
return property == null ? default : property.Value<T>(publishedValueFallback, culture, segment, fallback);
}
/// <summary>
/// Records a visual editor property access for property types
/// that have been marked as editable in the visual editor.
/// </summary>
private static void TrackVisualEditorAccess(IPublishedProperty? property, string alias, Guid contentKey)
{
if (property is null || !VisualEditorPropertyTracker.IsEnabled)
{
return;
}
if (property.PropertyType.EditableInVisualEditor)
{
VisualEditorPropertyTracker.RecordAccess(alias, contentKey);
}
}
#endregion
#region IsSomething: misc.
@@ -730,6 +730,10 @@ public static partial class StringExtensions
/// </summary>
/// <param name="fileName">The file name to convert.</param>
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
/// <remarks>
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
/// keep the two implementations in sync.
/// </remarks>
public static string ToFriendlyName(this string fileName)
{
// strip the file extension
@@ -106,7 +106,7 @@ public interface IHostingEnvironment
/// content root are the same, however
/// in netcore the web root is /www therefore this will Map to a physical path within www.
/// </remarks>
[Obsolete("Please use the MapPathWebRoot extension method on an instance of IWebHostEnvironment instead")]
[Obsolete("Please use the MapPathWebRoot extension method on an instance of IWebHostEnvironment instead. Scheduled for removal in Umbraco 20.")]
string MapPathWebRoot(string path);
/// <summary>
@@ -118,7 +118,7 @@ public interface IHostingEnvironment
/// in netcore the web root is /www therefore this will Map to a physical path within www.
/// </remarks>
[Obsolete(
"Please use the MapPathContentRoot extension method on an instance of IHostEnvironment (or IWebHostEnvironment) instead")]
"Please use the MapPathContentRoot extension method on an instance of IHostEnvironment (or IWebHostEnvironment) instead. Scheduled for removal in Umbraco 20.")]
string MapPathContentRoot(string path);
/// <summary>
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
/// Gets or sets a value indicating whether the label should be displayed above the property editor.
/// </summary>
public bool LabelOnTop { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
/// </summary>
public bool EditableInVisualEditor { get; set; }
}
+5
View File
@@ -58,6 +58,11 @@ public interface IPropertyType : IEntity, IRememberBeingDirty
/// </summary>
bool LabelOnTop { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
/// </summary>
bool EditableInVisualEditor { get; set; }
/// <summary>
/// Gets of sets the sort order of the property type.
/// </summary>
+9
View File
@@ -21,6 +21,7 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
private Guid _dataTypeKey;
private string? _description;
private bool _labelOnTop;
private bool _editableInVisualEditor;
private bool _mandatory;
private string? _mandatoryMessage;
private string _name;
@@ -225,6 +226,14 @@ public class PropertyType : EntityBase, IPropertyType, IEquatable<PropertyType>
set => SetPropertyValueAndDetectChanges(value, ref _labelOnTop, nameof(LabelOnTop));
}
/// <inheritdoc />
[DataMember]
public bool EditableInVisualEditor
{
get => _editableInVisualEditor;
set => SetPropertyValueAndDetectChanges(value, ref _editableInVisualEditor, nameof(EditableInVisualEditor));
}
/// <inheritdoc />
[DataMember]
public int SortOrder
@@ -46,6 +46,11 @@ public interface IPublishedPropertyType
/// </remarks>
bool IsUserProperty { get; }
/// <summary>
/// Gets a value indicating whether this property type is editable in the visual editor.
/// </summary>
bool EditableInVisualEditor => false;
/// <summary>
/// Gets the content variations of the property type.
/// </summary>
@@ -37,6 +37,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
: this(propertyType.Alias, propertyType.DataTypeId, true, propertyType.Variations, propertyValueConverters, publishedModelFactory, factory)
{
ContentType = contentType ?? throw new ArgumentNullException(nameof(contentType));
EditableInVisualEditor = propertyType.EditableInVisualEditor;
}
/// <summary>
@@ -94,6 +95,9 @@ namespace Umbraco.Cms.Core.Models.PublishedContent
/// <inheritdoc />
public bool IsUserProperty { get; }
/// <inheritdoc />
public bool EditableInVisualEditor { get; }
/// <inheritdoc />
public ContentVariation Variations { get; }
@@ -0,0 +1,66 @@
namespace Umbraco.Cms.Core.Models.PublishedContent;
/// <summary>
/// Tracks property accesses during Razor rendering so that the visual editor
/// can automatically wrap property output with annotation attributes.
///
/// <para>
/// When <c>@Model.Title</c> or <c>@Model.Value("title")</c> is evaluated in a Razor view,
/// the <c>Value()</c> extension method records the property alias and content key here.
/// When Razor subsequently calls <c>Write()</c>, the recorded access is consumed and the output
/// is wrapped with <c>data-umb-property</c> attributes.
/// </para>
/// </summary>
public static class VisualEditorPropertyTracker
{
private static readonly AsyncLocal<PropertyAccess?> _lastAccess = new();
private static readonly AsyncLocal<bool> _enabled = new();
/// <summary>
/// Enables tracking for the current async context.
/// Should be called when the request is in visual edit / preview mode.
/// </summary>
public static void Enable() => _enabled.Value = true;
/// <summary>
/// Disables tracking for the current async context. Pair with <see cref="Enable"/> in a finally block.
/// </summary>
public static void Disable() => _enabled.Value = false;
/// <summary>
/// Whether tracking is currently enabled for this async context.
/// </summary>
public static bool IsEnabled => _enabled.Value;
/// <summary>
/// Records a property access. Called from <c>Value()</c> / <c>Value&lt;T&gt;()</c> extension methods.
/// </summary>
public static void RecordAccess(string alias, Guid contentKey)
{
if (_enabled.Value)
{
_lastAccess.Value = new PropertyAccess(alias, contentKey);
}
}
/// <summary>
/// Consumes the last recorded access, returning it and clearing the state.
/// </summary>
public static PropertyAccess? ConsumeAccess()
{
PropertyAccess? access = _lastAccess.Value;
_lastAccess.Value = null;
return access;
}
/// <summary>
/// Clears any pending recorded access without consuming it.
/// </summary>
public static void Clear()
=> _lastAccess.Value = null;
/// <summary>
/// Represents a recorded property access.
/// </summary>
public readonly record struct PropertyAccess(string Alias, Guid ContentKey);
}
@@ -24,9 +24,4 @@ public enum TaggableObjectTypes
/// Represents member entities (user accounts).
/// </summary>
Member,
/// <summary>
/// Represents element entities.
/// </summary>
Element,
}
@@ -1,19 +0,0 @@
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// A notification that is used to trigger the Element Container Cache Refresher.
/// </summary>
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
/// </summary>
/// <param name="messageObject">The refresher payload.</param>
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
: base(messageObject, messageType)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published after one or more redirect URLs have been deleted.
/// </summary>
public class RedirectUrlDeletedNotification : DeletedNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with a single redirect URL.
/// </summary>
/// <param name="target">The redirect URL that was deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletedNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with multiple redirect URLs.
/// </summary>
/// <param name="target">The redirect URLs that were deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,34 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published before one or more redirect URLs are deleted.
/// </summary>
/// <remarks>
/// This notification is cancelable, allowing handlers to prevent the delete operation
/// by setting <see cref="ICancelableNotification.Cancel" /> to <c>true</c>.
/// </remarks>
public class RedirectUrlDeletingNotification : DeletingNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with a single redirect URL.
/// </summary>
/// <param name="target">The redirect URL being deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletingNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with multiple redirect URLs.
/// </summary>
/// <param name="target">The redirect URLs being deleted.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlDeletingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published after a redirect URL has been saved.
/// </summary>
public class RedirectUrlSavedNotification : SavedNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
/// </summary>
/// <param name="target">The redirect URL that was saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavedNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
/// </summary>
/// <param name="target">The redirect URLs that were saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,30 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Notifications;
/// <summary>
/// Notification published before a redirect URL is saved.
/// </summary>
public class RedirectUrlSavingNotification : SavingNotification<IRedirectUrl>
{
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
/// </summary>
/// <param name="target">The redirect URL being saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavingNotification(IRedirectUrl target, EventMessages messages)
: base(target, messages)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
/// </summary>
/// <param name="target">The redirect URLs being saved.</param>
/// <param name="messages">The event messages collection.</param>
public RedirectUrlSavingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
: base(target, messages)
{
}
}
@@ -0,0 +1,25 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.PublishedCache;
/// <summary>
/// Builds an <see cref="IPublishedContent"/> for the visual editor preview: the requested document's
/// draft content with a set of unsaved property values overlaid on top, converted to their published form.
/// </summary>
public interface IVisualEditorContentFactory
{
/// <summary>
/// Resolves the draft content for <paramref name="documentKey"/> and returns a preview
/// <see cref="IPublishedContent"/> whose overridden aliases yield the converted unsaved values.
/// Returns <c>null</c> if the document does not exist.
/// </summary>
/// <param name="documentKey">The key of the document whose draft content will be used as the base.</param>
/// <param name="overrides">The unsaved property values to overlay on top of the draft content.</param>
/// <returns>
/// A preview <see cref="IPublishedContent"/> with the overrides applied,
/// or <c>null</c> if the document cannot be resolved.
/// </returns>
Task<IPublishedContent?> CreateWithOverridesAsync(
Guid documentKey,
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
}
@@ -0,0 +1,13 @@
namespace Umbraco.Cms.Core.PublishedCache;
/// <summary>
/// A single unsaved property value to overlay onto draft content when rendering the visual editor preview.
/// </summary>
/// <param name="Alias">The property alias to override.</param>
/// <param name="EditorValue">
/// The editor-format value as held by the backoffice workspace. Complex editors (rich text, block list)
/// expect their serialized JSON; plain editors (e.g. text box) expect the raw value.
/// </param>
/// <param name="Culture">The culture the override applies to, or <c>null</c> for invariant.</param>
/// <param name="Segment">The segment the override applies to, or <c>null</c> for none.</param>
public readonly record struct VisualEditorPropertyOverride(string Alias, object? EditorValue, string? Culture, string? Segment);
@@ -821,6 +821,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
propertyType.Description = property.Description;
propertyType.SortOrder = property.SortOrder;
propertyType.LabelOnTop = property.Appearance.LabelOnTop;
propertyType.EditableInVisualEditor = property.Appearance.EditableInVisualEditor;
propertyType.PropertyGroupId = propertyGroup is null
? null
@@ -1,5 +1,5 @@
using System.Threading.Tasks;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -15,26 +15,105 @@ public interface IRedirectUrlService : IService
/// <param name="contentKey">The content unique key.</param>
/// <param name="culture">The culture.</param>
/// <remarks>Is a proper Umbraco route eg /path/to/foo or 123/path/tofoo.</remarks>
[Obsolete("Use RegisterWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Register(string url, Guid contentKey, string? culture = null);
/// <summary>
/// Registers a redirect URL.
/// </summary>
/// <param name="oldUrl">The previous Umbraco URL route the redirect is being created from.</param>
/// <param name="contentKey">The content unique key.</param>
/// <param name="culture">The culture.</param>
/// <returns>
/// An <see cref="Attempt{TResult,TStatus}" /> containing the registered redirect URL on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation, and rename this back to "Register" when the obsolete Register overload is removed.
Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
{
#pragma warning disable CS0618 // Type or member is obsolete
Register(oldUrl, contentKey, culture);
#pragma warning restore CS0618 // Type or member is obsolete
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, null);
}
/// <summary>
/// Deletes all redirect URLs for a given content.
/// </summary>
/// <param name="contentKey">The content unique key.</param>
[Obsolete("Use DeleteContentRedirectUrlsWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void DeleteContentRedirectUrls(Guid contentKey);
/// <summary>
/// Deletes all redirect URLs for a given content, returning the operation status.
/// </summary>
/// <param name="contentKey">The content unique key.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete DeleteContentRedirectUrls overload is removed.
RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
{
#pragma warning disable CS0618 // Type or member is obsolete
DeleteContentRedirectUrls(contentKey);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes a redirect URL.
/// </summary>
/// <param name="redirectUrl">The redirect URL to delete.</param>
[Obsolete("Use DeleteWithStatus(IRedirectUrl) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Delete(IRedirectUrl redirectUrl);
/// <summary>
/// Deletes a redirect URL, returning the operation status.
/// </summary>
/// <param name="redirectUrl">The redirect URL to delete.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete Delete(IRedirectUrl) overload is removed.
RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
{
#pragma warning disable CS0618 // Type or member is obsolete
Delete(redirectUrl);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes a redirect URL.
/// </summary>
/// <param name="id">The redirect URL identifier.</param>
[Obsolete("Use DeleteWithStatus(Guid) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
void Delete(Guid id);
/// <summary>
/// Deletes a redirect URL by its identifier, returning the operation status.
/// </summary>
/// <param name="id">The redirect URL identifier.</param>
/// <returns>
/// <see cref="RedirectUrlOperationStatus.Success" /> on success,
/// <see cref="RedirectUrlOperationStatus.NotFound" /> if no redirect URL with the given identifier exists, or
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
/// canceled the operation.
/// </returns>
// TODO (V20): Remove the default implementation when the obsolete Delete(Guid) overload is removed.
RedirectUrlOperationStatus DeleteWithStatus(Guid id)
{
#pragma warning disable CS0618 // Type or member is obsolete
Delete(id);
#pragma warning restore CS0618 // Type or member is obsolete
return RedirectUrlOperationStatus.Success;
}
/// <summary>
/// Deletes all redirect URLs.
/// </summary>
-18
View File
@@ -54,18 +54,6 @@ public interface ITagService : IService
/// </summary>
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
/// <summary>
/// Gets all elements tagged with any tag in the specified group.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
/// <summary>
/// Gets all elements tagged with the specified tag.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags.
/// </summary>
@@ -112,12 +100,6 @@ public interface ITagService : IService
/// </summary>
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
/// <summary>
/// Gets all element tags.
/// </summary>
// TODO (V19): Remove the default implementation from this interface.
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
/// <summary>
/// Gets all tags attached to an entity via a property.
/// </summary>
@@ -0,0 +1,27 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
/// <summary>
/// Represents the status of a redirect URL operation.
/// </summary>
public enum RedirectUrlOperationStatus
{
/// <summary>
/// The operation completed successfully.
/// </summary>
Success,
/// <summary>
/// The operation was cancelled by a notification handler.
/// </summary>
CancelledByNotification,
/// <summary>
/// The operation failed because the redirect URL could not be found.
/// </summary>
NotFound,
/// <summary>
/// An unknown error occurred during the operation.
/// </summary>
Unknown,
}
+103 -5
View File
@@ -1,8 +1,10 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -25,45 +27,141 @@ internal sealed class RedirectUrlService : RepositoryService, IRedirectUrlServic
_redirectUrlRepository = redirectUrlRepository;
/// <inheritdoc/>
[Obsolete("Use RegisterWithStatus instead. Scheduled for removal in Umbraco 20.")]
public void Register(string url, Guid contentKey, string? culture = null)
=> RegisterWithStatus(url, contentKey, culture);
/// <inheritdoc/>
public Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture);
IRedirectUrl? redir = _redirectUrlRepository.Get(oldUrl, contentKey, culture);
if (redir != null)
{
redir.CreateDateUtc = DateTime.UtcNow;
}
else
{
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture };
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = oldUrl, ContentKey = contentKey, Culture = culture };
}
// Use a detached EventMessages instance so a handler cancelling the save does not surface a
// notification in the backoffice. Redirect creation is a silent side-effect of publishing, so a
// cancellation is not something the editor triggered or can act on - unlike deletion (an explicit
// editor action), where the sibling methods deliberately use EventMessagesFactory.Get() instead.
var eventMessages = new EventMessages();
var savingNotification = new RedirectUrlSavingNotification(redir, eventMessages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return Attempt.FailWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.CancelledByNotification, redir);
}
_redirectUrlRepository.Save(redir);
scope.Notifications.Publish(new RedirectUrlSavedNotification(redir, eventMessages)
.WithStateFrom(savingNotification));
scope.Complete();
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, redir);
}
/// <inheritdoc/>
public void Delete(IRedirectUrl redirectUrl)
[Obsolete("Use DeleteWithStatus(IRedirectUrl) instead. Scheduled for removal in Umbraco 20.")]
public void Delete(IRedirectUrl redirectUrl) => DeleteWithStatus(redirectUrl);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.Delete(redirectUrl);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
public void Delete(Guid id)
[Obsolete("Use DeleteWithStatus(Guid) instead. Scheduled for removal in Umbraco 20.")]
public void Delete(Guid id) => DeleteWithStatus(id);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteWithStatus(Guid id)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl? redirectUrl = _redirectUrlRepository.Get(id);
if (redirectUrl is null)
{
scope.Complete();
return RedirectUrlOperationStatus.NotFound;
}
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.Delete(id);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
public void DeleteContentRedirectUrls(Guid contentKey)
[Obsolete("Use DeleteContentRedirectUrlsWithStatus instead. Scheduled for removal in Umbraco 20.")]
public void DeleteContentRedirectUrls(Guid contentKey) => DeleteContentRedirectUrlsWithStatus(contentKey);
/// <inheritdoc/>
public RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IRedirectUrl[] redirectUrls = _redirectUrlRepository.GetContentUrls(contentKey).ToArray();
if (redirectUrls.Length == 0)
{
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
EventMessages eventMessages = EventMessagesFactory.Get();
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrls, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return RedirectUrlOperationStatus.CancelledByNotification;
}
_redirectUrlRepository.DeleteContentUrls(contentKey);
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrls, eventMessages)
.WithStateFrom(deletingNotification));
scope.Complete();
return RedirectUrlOperationStatus.Success;
}
/// <inheritdoc/>
-27
View File
@@ -101,24 +101,6 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
{
@@ -180,15 +162,6 @@ public class TagService : RepositoryService, ITagService
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
{
using (ScopeProvider.CreateCoreScope(autoComplete: true))
{
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
}
}
/// <inheritdoc />
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
{
@@ -0,0 +1,26 @@
using Umbraco.Cms.Core.PublishedCache;
namespace Umbraco.Cms.Core.Templates;
/// <summary>
/// Renders a document's assigned template to an HTML string using the visual editor's unsaved values,
/// with property-access tracking enabled so the output carries <c>data-umb-*</c> annotations.
/// </summary>
public interface IVisualEditorRenderService
{
/// <summary>
/// Renders the document identified by <paramref name="documentKey"/> with the supplied unsaved
/// <paramref name="overrides"/> overlaid. Returns the rendered HTML, or an empty string if the
/// document or its template cannot be resolved.
/// </summary>
/// <param name="documentKey">The key of the document to render.</param>
/// <param name="culture">The culture to render, or <c>null</c> for the default/invariant.</param>
/// <param name="segment">The segment to render, or <c>null</c> for none.</param>
/// <param name="overrides">The unsaved editor values to overlay onto the draft content.</param>
/// <returns>The rendered page HTML, or an empty string if the document or template is unavailable.</returns>
Task<string> RenderAsync(
Guid documentKey,
string? culture,
string? segment,
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
}
@@ -54,7 +54,7 @@ public static class UmbracoBuilderExtensions
.DeliveryApiContentIndexName)
.ConfigureOptions<ConfigureIndexOptions>();
services.AddSingleton<IApplicationRoot, UmbracoApplicationRoot>();
services.AddSingleton<IApplicationRoot>(sp => ActivatorUtilities.CreateInstance<UmbracoApplicationRoot>(sp));
services.AddSingleton<ILockFactory, UmbracoLockFactory>();
services.AddSingleton<ConfigurationEnabledDirectoryFactory>();
@@ -55,7 +55,9 @@ public class LuceneIndexDiagnostics : IIndexDiagnostics
Directory luceneDir = Index.GetLuceneDirectory();
var d = new Dictionary<string, object?>
{
#pragma warning disable CS0618 // CommitCount is obsolete and reported unused, but retained to avoid any risk of change to existing behaviour. Remove this entry when a future Examine upgrade removes the underlying field.
[nameof(UmbracoExamineIndex.CommitCount)] = Index.CommitCount,
#pragma warning restore CS0618
[nameof(UmbracoExamineIndex.DefaultAnalyzer)] = Index.DefaultAnalyzer.GetType().Name,
["LuceneDirectory"] = luceneDir.GetType().Name
};
@@ -1,6 +1,10 @@
using Examine;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Hosting;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Extensions;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Infrastructure.Examine;
@@ -9,14 +13,24 @@ namespace Umbraco.Cms.Infrastructure.Examine;
/// </summary>
public class UmbracoApplicationRoot : IApplicationRoot
{
private readonly IHostingEnvironment _hostingEnvironment;
private readonly IHostEnvironment _hostEnvironment;
// TODO (V20): Remove this obsolete constructor and the [ActivatorUtilitiesConstructor] attribute below.
// Also revert the registration in UmbracoBuilderExtensions to:
// services.AddSingleton<IApplicationRoot, UmbracoApplicationRoot>();
// (the factory form is required so [ActivatorUtilitiesConstructor] is honored).
[Obsolete("Use the constructor accepting IHostEnvironment. Scheduled for removal in Umbraco 20.")]
public UmbracoApplicationRoot(IHostingEnvironment hostingEnvironment)
=> _hostingEnvironment = hostingEnvironment;
: this(StaticServiceProvider.Instance.GetRequiredService<IHostEnvironment>())
{
}
[ActivatorUtilitiesConstructor]
public UmbracoApplicationRoot(IHostEnvironment hostEnvironment)
=> _hostEnvironment = hostEnvironment;
public DirectoryInfo ApplicationRoot
=> new(
Path.Combine(
_hostingEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData),
"ExamineIndexes"));
=> new(Path.Combine(
_hostEnvironment.MapPathContentRoot(Constants.SystemDirectories.TempData),
"ExamineIndexes"));
}
@@ -1,5 +1,9 @@
using Examine;
using Examine.Lucene;
using Examine.Lucene.Directories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.DependencyInjection;
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
namespace Umbraco.Cms.Infrastructure.Examine
@@ -9,11 +13,25 @@ namespace Umbraco.Cms.Infrastructure.Examine
/// </summary>
public class UmbracoTempEnvFileSystemDirectoryFactory : FileSystemDirectoryFactory
{
[Obsolete("Use the constructor accepting IOptionsMonitor<LuceneDirectoryIndexOptions>. Scheduled for removal in Umbraco 20.")]
public UmbracoTempEnvFileSystemDirectoryFactory(
IApplicationIdentifier applicationIdentifier,
ILockFactory lockFactory,
IHostingEnvironment hostingEnvironment)
: base(new DirectoryInfo(GetTempPath(applicationIdentifier, hostingEnvironment)), lockFactory)
: this(
applicationIdentifier,
lockFactory,
hostingEnvironment,
StaticServiceProvider.Instance.GetRequiredService<IOptionsMonitor<LuceneDirectoryIndexOptions>>())
{
}
public UmbracoTempEnvFileSystemDirectoryFactory(
IApplicationIdentifier applicationIdentifier,
ILockFactory lockFactory,
IHostingEnvironment hostingEnvironment,
IOptionsMonitor<LuceneDirectoryIndexOptions> indexOptions)
: base(new DirectoryInfo(GetTempPath(applicationIdentifier, hostingEnvironment)), lockFactory, indexOptions)
{
}
+51
View File
@@ -384,6 +384,57 @@ using (ICoreScope scope = ScopeProvider.CreateCoreScope())
3. **Lazy loading outside scope** - NPoco relationships must load within scope
4. **Large migrations** - Split into multiple steps if > 1000 lines
5. **Repository logic in services** - Keep repos thin, logic in services
6. **Unbatched `WHERE IN` on user-sized collections** - See "Avoiding the SQL Server 2100-parameter limit" below
### Avoiding the SQL Server 2100-parameter limit
SQL Server caps a single statement at 2100 parameters. When an `IN` clause is built from a collection sized by user data, that cap can be hit — and the symptom is a runtime `SqlException` (error 8003) on customer installs that nobody hit in dev.
**The constant and helpers**:
- `Constants.Sql.MaxParameterCount = 2000` (in `Umbraco.Core`, `Constants-Sql.cs`) — the ceiling we target (2100 minus headroom for joined predicates already in the SQL).
- `IEnumerable<T>.InGroupsOf(groupSize)` (in `Umbraco.Core`, `Extensions/EnumerableExtensions.cs`) — extension method to batch a collection.
- `Database.FetchByGroups<TResult, TSource>(source, groupSize, sqlFactory)` (in `Umbraco.Infrastructure`, `Persistence/NPocoDatabaseExtensions.cs`) — NPoco helper that batches a fetch.
**The safe patterns** (use one of these any time the collection size is user-driven):
```csharp
// Pattern 1: batch a DeleteMany / Execute / Fetch by looping.
foreach (IEnumerable<int> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Database.DeleteMany<FooDto>().Where(x => group.Contains(x.Id)).Execute();
}
// Pattern 2: batched fetch with NPoco helper.
List<FooDto> dtos = Database.FetchByGroups<FooDto, int>(
ids,
Constants.Sql.MaxParameterCount,
batch => Sql().Select<FooDto>().From<FooDto>().WhereIn<FooDto>(x => x.Id, batch));
// Pattern 3: reserve headroom for other parameters in the same statement.
foreach (IEnumerable<int> group in entityIds.InGroupsOf(Constants.Sql.MaxParameterCount - userGroupIds.Length))
{
// statement uses entityIds + userGroupIds, so subtract the other predicate's parameter count from the budget
}
```
**Decision rule when writing or reviewing a `WHERE IN`-style query**:
Look at what drives the size of the collection feeding the `IN`. Ask: *could this realistically exceed 2000 on a large install?* Risky drivers — batch any query backed by these:
- All content / media / member nodes (or descendants of a deep tree).
- A product of two scaling dimensions, e.g. `documents × languages`, `properties × versions`, `relations × endpoints`.
- Configuration-tunable batch sizes (`CacheSettings.DocumentSeedBatchSize`, `NuCacheSettings.SqlPageSize`, etc.). The default may be safe but the customer can raise it.
- Anything that scans property data, version history, relations, or audit logs across many nodes.
Safe drivers — don't bother batching:
- Languages / content types / member groups / user groups — bounded by install configuration, typically <100.
- "Per single content item" collections — properties on one document, versions of one document, tokens for one external login.
- IDs supplied directly by a user action through the UI (picker selections, bulk actions on a page of results).
If you're not sure, batch — the cost is one loop and an `IEnumerable<T>` allocation per batch; the cost of being wrong is a SqlException on a customer's biggest site.
**For new public APIs** that take an `IEnumerable<int>`/`IEnumerable<Guid>` and feed it into a query, batch internally even if no current caller is large — package authors and future callers will not know about the 2000-limit ceiling.
**Don't** rely on `if (ids.Length > MaxParameterCount) throw` as a substitute for batching. Throwing only moves the problem; the caller has no obvious way to recover and will most likely just fail in production.
---
@@ -104,6 +104,7 @@ internal sealed class JsonConfigManipulator : IConfigManipulator
}
/// <inheritdoc />
[Obsolete("This method is no longer used by Umbraco. Set the Umbraco:CMS:WebRouting:DisableRedirectUrlTracking configuration key instead. Scheduled for removal in Umbraco 19.")]
public async Task SaveDisableRedirectUrlTrackingAsync(bool disable)
=> await CreateOrUpdateConfigValueAsync(DisableRedirectUrlTrackingPath, disable);
@@ -466,7 +466,6 @@ public static partial class UmbracoBuilderExtensions
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
;
// add notification handlers for auditing
@@ -52,8 +52,8 @@ internal sealed class DatabaseDataCreator
},
new()
{
Name = "Find all logs that are from the namespace 'Umbraco.Core'",
Query = "StartsWith(SourceContext, 'Umbraco.Core')",
Name = "Find all logs that are within the namespace 'Umbraco.Cms'",
Query = "StartsWith(SourceContext, 'Umbraco.Cms')",
},
new()
{
@@ -94,6 +94,7 @@ public partial class UmbracoPlan : MigrationPlan
To<V_17_3_0.PopulateSortableValueForDatePropertyData>("{6748CB56-CC16-49F0-BA91-B8ECE31BF456}");
// To 17.4.0
To<V_17_4_0.AddEditableInVisualEditorToPropertyType>("{C3D4E5F6-A7B8-49C0-D1E2-F3A4B5C6D7E8}");
To<V_17_4_0.AddContentVersionDateIndex>("{D4E5F6A7-B8C9-4D0E-A1F2-3B4C5D6E7F80}");
To<V_17_4_0.AddDimensionsToSvg>("{72970B86-59D8-403C-B322-FFF43F9DB199}");
To<V_17_4_0.AddExternalMemberTables>("{D7E8F9A0-B1C2-4D3E-A5F6-7890ABCDEF12}");
@@ -0,0 +1,41 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_4_0;
/// <summary>
/// Migration to add the editableInVisualEditor column to the cmsPropertyType table.
/// </summary>
public class AddEditableInVisualEditorToPropertyType : AsyncMigrationBase
{
/// <summary>
/// Initializes a new instance of the <see cref="AddEditableInVisualEditorToPropertyType"/> class.
/// </summary>
/// <param name="context">The migration context.</param>
public AddEditableInVisualEditorToPropertyType(IMigrationContext context)
: base(context)
{
}
/// <inheritdoc/>
protected override async Task MigrateAsync()
{
if (TableExists(Constants.DatabaseSchema.Tables.PropertyType) is false)
{
return;
}
const string columnName = "editableInVisualEditor";
var hasColumn = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database)
.Any(c =>
c.TableName == Constants.DatabaseSchema.Tables.PropertyType &&
c.ColumnName == columnName);
if (hasColumn)
{
return;
}
AddColumn<PropertyTypeDto>(Constants.DatabaseSchema.Tables.PropertyType, columnName);
}
}
@@ -7,6 +7,7 @@ namespace Umbraco.Cms.Infrastructure.Notifications
/// <summary>
/// Notification that is raised when a recurring background job is triggered or executed.
/// </summary>
// TODO (V19): Mark this class as abstract.
public class RecurringBackgroundJobNotification : ObjectNotification<IRecurringBackgroundJob>
{
/// <summary>
@@ -114,6 +114,13 @@ internal class PropertyTypeDto
[Constraint(Default = "0")]
public bool LabelOnTop { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
/// </summary>
[Column("editableInVisualEditor")]
[Constraint(Default = "0")]
public bool EditableInVisualEditor { get; set; }
/// <summary>
/// Gets or sets the variation flags for the property type, indicating whether the property supports culture, segment, or invariant variations.
/// The value corresponds to the <c>ContentVariation</c> enum.
@@ -90,6 +90,12 @@ internal sealed class PropertyTypeReadOnlyDto
[Column("labelOnTop")]
public bool LabelOnTop { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
/// </summary>
[Column("editableInVisualEditor")]
public bool EditableInVisualEditor { get; set; }
/* cmsMemberType */
/// <summary>
@@ -48,6 +48,7 @@ internal static class PropertyGroupFactory
UniqueId = propertyType.Key,
Variations = (byte)propertyType.Variations,
LabelOnTop = propertyType.LabelOnTop,
EditableInVisualEditor = propertyType.EditableInVisualEditor,
};
if (groupId != default)
@@ -34,6 +34,7 @@ public sealed class PropertyTypeMapper : BaseMapper
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExp), nameof(PropertyTypeDto.ValidationRegExp));
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExpMessage), nameof(PropertyTypeDto.ValidationRegExpMessage));
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.LabelOnTop), nameof(PropertyTypeDto.LabelOnTop));
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.EditableInVisualEditor), nameof(PropertyTypeDto.EditableInVisualEditor));
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.PropertyEditorAlias), nameof(DataTypeDto.EditorAlias));
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.ValueStorageType), nameof(DataTypeDto.DbType));
}
@@ -440,6 +440,7 @@ internal sealed class ContentTypeCommonRepository : IContentTypeCommonRepository
ValidationRegExpMessage = dto.ValidationRegExpMessage,
Variations = (ContentVariation)dto.Variations,
LabelOnTop = dto.LabelOnTop,
EditableInVisualEditor = dto.EditableInVisualEditor,
};
}
}
@@ -249,14 +249,24 @@ internal sealed class RedirectUrlRepository : EntityRepositoryBase<Guid, IRedire
protected override IEnumerable<IRedirectUrl> PerformGetAll(params Guid[]? ids)
{
if (ids?.Length > Constants.Sql.MaxParameterCount)
if (ids is null || ids.Length == 0)
{
throw new NotSupportedException(
$"This repository does not support more than {Constants.Sql.MaxParameterCount} ids.");
return Database.Fetch<RedirectUrlDto>(GetBaseQuery(false))
.WhereNotNull()
.Select(Map)
.WhereNotNull();
}
// Batch the WhereIn fetch so we never exceed SQL Server's 2100 parameter limit.
// EntityRepositoryBase.GetMany already groups IDs, but we keep the batching here as
// a defensive measure for safety and consistency at the repository boundary.
var dtos = new List<RedirectUrlDto>(ids.Length);
foreach (IEnumerable<Guid> group in ids.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, group);
dtos.AddRange(Database.Fetch<RedirectUrlDto>(sql));
}
Sql<ISqlContext> sql = GetBaseQuery(false).WhereIn<RedirectUrlDto>(x => x.Id, ids);
List<RedirectUrlDto> dtos = Database.Fetch<RedirectUrlDto>(sql);
return dtos.WhereNotNull().Select(Map).WhereNotNull();
}
@@ -647,8 +647,6 @@ ON (tagset.tag = {cmsTags}.tag AND tagset.{group} = {cmsTags}.{group} AND COALES
return Constants.ObjectTypes.Media;
case TaggableObjectTypes.Member:
return Constants.ObjectTypes.Member;
case TaggableObjectTypes.Element:
return Constants.ObjectTypes.Element;
default:
throw new ArgumentOutOfRangeException(nameof(type));
}
@@ -75,6 +75,7 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement
[
sx.ColumnWithAlias("x", "otherId", "nodeId"),
sx.ColumnWithAlias("n", "uniqueId", "nodeKey"),
sx.ColumnWithAlias("n", "text", "nodeName"),
sx.ColumnWithAlias("n", "nodeObjectType", "nodeObjectType"),
$"COALESCE({sx.ColumnWithAlias("d", "published")}, {sx.ColumnWithAlias("e", "published")}) AS nodePublished",
sx.ColumnWithAlias("ctn", "uniqueId", "contentTypeKey"),
@@ -309,7 +309,7 @@ internal sealed class RedirectTracker : IRedirectTracker
// to the original. We resolve this by removing any existing redirect that points to the new route.
RemoveSelfReferencingRedirect(contentKey, newRoute);
_redirectUrlService.Register(oldRoute, contentKey, culture);
_redirectUrlService.RegisterWithStatus(oldRoute, contentKey, culture);
}
catch (Exception ex)
{
@@ -341,7 +341,7 @@ internal sealed class RedirectTracker : IRedirectTracker
{
if (redirectUrl.Url == route)
{
_redirectUrlService.Delete(redirectUrl.Key);
_redirectUrlService.DeleteWithStatus(redirectUrl.Key);
}
}
}
@@ -122,11 +122,30 @@ internal sealed class IndexedEntitySearchService : IIndexedEntitySearchService
.Where(key => key != Guid.Empty)
.ToArray();
// EntityService.GetAll returns entities in database (not Lucene score) order, which
// would discard the relevance ranking. Re-order to match the search result sequence.
IEnumerable<IEntitySlim> orderedItems;
if (keys.Length > 0)
{
var keyOrder = new Dictionary<Guid, int>(keys.Length);
for (var i = 0; i < keys.Length; i++)
{
keyOrder.TryAdd(keys[i], i);
}
orderedItems = _entityService
.GetAll(objectType, keys)
.OrderBy(entity => keyOrder.TryGetValue(entity.Key, out var index) ? index : int.MaxValue)
.ToArray();
}
else
{
orderedItems = [];
}
return Task.FromResult(new PagedModel<IEntitySlim>
{
Items = keys.Any()
? _entityService.GetAll(objectType, keys)
: Enumerable.Empty<IEntitySlim>(),
Items = orderedItems,
Total = totalFound
});
}
@@ -47,30 +47,21 @@ public class LogViewerRepository : LogViewerRepositoryBase
var filesForCurrentDay = Directory.GetFiles(_loggingConfiguration.LogDirectory, filesToFind);
// Foreach file we find - open it
// Foreach file we find - open it. Any failure reading a single file (open error,
// unrecoverable parse error, etc.) should not prevent the remaining files for the
// day or date range from being read.
foreach (var filePath in filesForCurrentDay)
{
// Open log file & add contents to the log collection
// Which we then use LINQ to page over
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
try
{
using (var stream = new StreamReader(fs))
{
var reader = new LogEventReader(stream);
while (TryRead(reader, out LogEvent? evt))
{
// We may get a null if log line is malformed
if (evt == null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
}
ReadLogFile(filePath, logFilter, logs);
}
catch (Exception ex)
{
_logger.LogWarning(
ex,
"Skipped log file {FilePath} after a file-level error; the file may be inaccessible or unreadable.",
filePath);
}
}
}
@@ -88,6 +79,63 @@ public class LogViewerRepository : LogViewerRepositoryBase
}).ToArray();
}
private void ReadLogFile(string filePath, ILogFilter logFilter, List<LogEvent> logs)
{
using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var stream = new StreamReader(fs);
var reader = new LogEventReader(stream);
var errorCount = 0;
Exception? firstError = null;
while (true)
{
LogEvent? evt;
try
{
if (!reader.TryRead(out evt))
{
break;
}
}
catch (Exception ex) when (ex is Newtonsoft.Json.JsonException or InvalidDataException)
{
// Serilog.Formatting.Compact.Reader uses Newtonsoft.Json internally and surfaces
// its exceptions (Umbraco's own serialization is on System.Text.Json, but that
// doesn't apply here — we have to catch what the reader actually throws).
// JsonException covers parse failures (e.g. an unterminated string in a truncated
// entry); InvalidDataException covers structurally-valid JSON that isn't a valid
// Serilog Compact event. Either way the offending line has been consumed from the
// underlying StreamReader and the next TryRead call advances. Anything else
// (IOException, decoder failures, etc.) is propagated to the file-level catch in
// GetLogs so we don't risk a tight loop or silently swallow a more serious failure.
errorCount++;
firstError ??= ex;
continue;
}
// LogEventReader may return true with a null event for a benign skip.
if (evt is null)
{
continue;
}
if (logFilter.TakeLogEvent(evt))
{
logs.Add(evt);
}
}
if (errorCount > 0)
{
_logger.LogWarning(
firstError,
"Encountered {ErrorCount} unreadable line(s) while reading log file {FilePath}. The file may contain partially-written or corrupt entries; affected lines were skipped.",
errorCount,
filePath);
}
}
private IReadOnlyDictionary<string, string?> MapLogMessageProperties(IReadOnlyDictionary<string, LogEventPropertyValue>? properties)
{
var result = new Dictionary<string, string?>();
@@ -121,21 +169,4 @@ public class LogViewerRepository : LogViewerRepositoryBase
}
private static string GetSearchPattern(DateTime day) => $"*{day:yyyyMMdd}*.json";
private bool TryRead(LogEventReader reader, out LogEvent? evt)
{
try
{
return reader.TryRead(out evt);
}
catch (Exception ex)
{
// As we are reading/streaming one line at a time in the JSON file
// Thus we can not report the line number, as it will always be 1
_logger.LogError(ex, "Unable to parse a line in the JSON log file");
evt = null;
return true;
}
}
}
@@ -52,6 +52,7 @@ public static class UmbracoBuilderExtensions
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
builder.Services.AddSingleton<ICacheNodeFactory, CacheNodeFactory>();
builder.Services.AddSingleton<IVisualEditorContentFactory, VisualEditorContentFactory>();
builder.Services.AddSingleton<ICacheManager, CacheManager>();
builder.Services.AddSingleton<IDatabaseCacheRebuilder, DatabaseCacheRebuilder>();
builder.Services.AddSingleton<IDeferredCacheRebuildService, DeferredCacheRebuildService>();
@@ -317,21 +317,28 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetDocumentSourcesAsync(IEnumerable<Guid> keys, bool preview = false)
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Batch the WHERE IN to stay within SQL Server's parameter limit.
// The configurable document seed batch size is applied upstream; this method only enforces MaxParameterCount.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlContentSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
dtos = dtos
var filtered = dtos
.Where(x => x is not null)
.Where(x => preview || ((x.PubDataRaw is not null || x.PubData is not null) && (!x.Published || x.PubName is not null)))
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document);
return dtos
return filtered
.Select(x => CreateContentNodeKit(x, serializer, preview))
.OfType<ContentCacheNode>();
}
@@ -496,20 +503,27 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <inheritdoc/>
public async Task<IEnumerable<ContentCacheNode>> GetMediaSourcesAsync(IEnumerable<Guid> keys)
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, keys)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
// Batch the WHERE IN by Constants.Sql.MaxParameterCount so callers configuring
// CacheSettings.MediaSeedBatchSize above that limit do not hit SQL Server's 2100 parameter limit.
Guid[] keysArray = keys as Guid[] ?? keys.ToArray();
var dtos = new List<ContentSourceDto>(keysArray.Length);
foreach (IEnumerable<Guid> group in keysArray.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext>? sql = SqlMediaSourcesSelect()
.Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media))
.WhereIn<NodeDto>(x => x.UniqueId, group)
.Append(SqlOrderByLevelIdSortOrder(SqlContext));
List<ContentSourceDto> dtos = await Database.FetchAsync<ContentSourceDto>(sql);
dtos.AddRange(await Database.FetchAsync<ContentSourceDto>(sql));
}
dtos = dtos
var filtered = dtos
.Where(x => x is not null)
.ToList();
IContentCacheDataSerializer serializer =
_contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media);
return dtos
return filtered
.Select(x => CreateMediaNodeKit(x, serializer));
}
@@ -729,107 +743,135 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// </summary>
private List<CacheRebuildPublishableContentDto> GetDocumentMetadataForNodes(List<int> nodeIds)
{
// Query content metadata with both edit and published version info
// Query content metadata with both edit and published version info.
// Uses nested join pattern to ensure we only get the published ContentVersion
// (where a DocumentVersionDto with Published=true exists)
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// (where a DocumentVersionDto with Published=true exists).
// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
var results = new List<CacheRebuildPublishableContentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<DocumentDto>(x => x.Published)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "EditVersionId"),
x => Alias(x.Text, "EditName"),
x => Alias(x.VersionDate, "EditVersionDate"),
x => Alias(x.UserId, "EditWriterId"))
.AndSelect<ContentVersionDto>(
"pcv",
x => Alias(x.Id, "PublishedVersionId"),
x => Alias(x.Text, "PublishedName"),
x => Alias(x.VersionDate, "PublishedVersionDate"),
x => Alias(x.UserId, "PublishedWriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<DocumentDto>().On<NodeDto, DocumentDto>((n, d) => n.NodeId == d.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true
// This ensures pcv only includes rows where there's a published DocumentVersion
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
// Nested join: ContentVersionDto "pcv" INNER JOIN DocumentVersionDto "pdv" ON published=true.
// This ensures pcv only includes rows where there's a published DocumentVersion.
.LeftJoin<ContentVersionDto>(
j => j.InnerJoin<DocumentVersionDto>("pdv")
.On<ContentVersionDto, DocumentVersionDto>(
(left, right) => left.Id == right.Id && right.Published == true, "pcv", "pdv"),
"pcv")
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
.On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId, aliasRight: "pcv")
.WhereIn<NodeDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildPublishableContentDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildPublishableContentDto>(sql));
}
return results;
}
/// <summary>
/// Gets property data for the specified node IDs using efficient JOIN on nodeId.
/// This avoids the expensive WHERE IN on versionId that causes index scans.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildPropertyDto> GetPropertyDataForNodes(List<int> nodeIds)
{
// JOIN through nodeId → versionId path for efficient query plan
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildPropertyDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
// JOIN through nodeId → versionId path for efficient query plan.
Sql<ISqlContext> sql = Sql()
.Select<PropertyDataDto>(
x => x.VersionId,
x => x.LanguageId,
x => x.Segment,
x => x.IntegerValue,
x => x.DecimalValue,
x => x.DateValue,
x => x.VarcharValue,
x => x.TextValue)
.AndSelect<PropertyTypeDto>(x => Alias(x.Alias, "PropertyAlias"))
.From<PropertyDataDto>()
.InnerJoin<PropertyTypeDto>().On<PropertyDataDto, PropertyTypeDto>((pd, pt) => pd.PropertyTypeId == pt.Id)
.InnerJoin<ContentVersionDto>().On<PropertyDataDto, ContentVersionDto>((pd, cv) => pd.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildPropertyDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildPropertyDto>(sql));
}
return results;
}
/// <summary>
/// Gets culture variation data for the specified node IDs.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildCultureDto> GetCultureDataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<ContentVersionCultureVariationDto>(x => x.VersionId, x => x.Name, x => x.UpdateDate)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<ContentVersionCultureVariationDto>()
.InnerJoin<LanguageDto>().On<ContentVersionCultureVariationDto, LanguageDto>((cv, l) => cv.LanguageId == l.Id)
.InnerJoin<ContentVersionDto>().On<ContentVersionCultureVariationDto, ContentVersionDto>((ccv, cv) => ccv.VersionId == cv.Id)
.WhereIn<ContentVersionDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildCultureDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildCultureDto>(sql));
}
return results;
}
/// <summary>
/// Gets document culture variation data (edited status per culture) for the specified node IDs. Used for documents.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildPublishableCultureDto> GetDocumentCultureDataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildPublishableCultureDto>();
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<DocumentCultureVariationDto>(x => x.NodeId, x => x.Edited)
.AndSelect<LanguageDto>(x => Alias(x.IsoCode, "IsoCode"))
.From<DocumentCultureVariationDto>()
.InnerJoin<LanguageDto>().On<DocumentCultureVariationDto, LanguageDto>((dcv, l) => dcv.LanguageId == l.Id)
.WhereIn<DocumentCultureVariationDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildPublishableCultureDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildPublishableCultureDto>(sql));
}
return results;
}
/// <summary>
@@ -1358,31 +1400,38 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe
/// <summary>
/// Gets content metadata for the specified node IDs using efficient JOIN. Used for media and members.
/// Batched on nodeIds so a NuCacheSettings.SqlPageSize larger than MaxParameterCount still works.
/// </summary>
private List<CacheRebuildContentDto> GetContentMetadataForNodes(List<int> nodeIds)
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, nodeIds);
var results = new List<CacheRebuildContentDto>(nodeIds.Count);
foreach (IEnumerable<int> group in nodeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
Sql<ISqlContext> sql = Sql()
.Select<NodeDto>(
x => x.NodeId,
x => x.UniqueId,
x => x.Text,
x => x.Path,
x => x.Level,
x => x.ParentId,
x => x.SortOrder,
x => x.CreateDate,
x => Alias(x.UserId, "CreatorId"))
.AndSelect<ContentDto>(x => x.ContentTypeId)
.AndSelect<ContentVersionDto>(
x => Alias(x.Id, "VersionId"),
x => Alias(x.VersionDate, "VersionDate"),
x => Alias(x.UserId, "WriterId"))
.From<NodeDto>()
.InnerJoin<ContentDto>().On<NodeDto, ContentDto>((n, c) => n.NodeId == c.NodeId)
.InnerJoin<ContentVersionDto>().On<NodeDto, ContentVersionDto>((n, cv) => n.NodeId == cv.NodeId && cv.Current)
.WhereIn<NodeDto>(x => x.NodeId, group);
return Database.Fetch<CacheRebuildContentDto>(sql);
results.AddRange(Database.Fetch<CacheRebuildContentDto>(sql));
}
return results;
}
/// <summary>
@@ -0,0 +1,144 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
using Umbraco.Extensions;
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
internal sealed class VisualEditorContentFactory : IVisualEditorContentFactory
{
private readonly IIdKeyMap _idKeyMap;
private readonly IContentService _contentService;
private readonly IDataTypeService _dataTypeService;
private readonly ICacheNodeFactory _cacheNodeFactory;
private readonly IPublishedContentFactory _publishedContentFactory;
private readonly IJsonSerializer _jsonSerializer;
private readonly IPublishedModelFactory _publishedModelFactory;
public VisualEditorContentFactory(
IIdKeyMap idKeyMap,
IContentService contentService,
IDataTypeService dataTypeService,
ICacheNodeFactory cacheNodeFactory,
IPublishedContentFactory publishedContentFactory,
IJsonSerializer jsonSerializer,
IPublishedModelFactory publishedModelFactory)
{
_idKeyMap = idKeyMap;
_contentService = contentService;
_dataTypeService = dataTypeService;
_cacheNodeFactory = cacheNodeFactory;
_publishedContentFactory = publishedContentFactory;
_jsonSerializer = jsonSerializer;
_publishedModelFactory = publishedModelFactory;
}
public async Task<IPublishedContent?> CreateWithOverridesAsync(
Guid documentKey,
IReadOnlyCollection<VisualEditorPropertyOverride> overrides)
{
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document);
if (idAttempt.Success is false)
{
return null;
}
IContent? content = _contentService.GetById(idAttempt.Result);
if (content is null)
{
return null;
}
ContentCacheNode baseNode = _cacheNodeFactory.ToContentCacheNode(content, preview: true);
if (baseNode.Data is null)
{
return null;
}
var properties = new Dictionary<string, PropertyData[]>(baseNode.Data.Properties);
foreach (VisualEditorPropertyOverride @override in overrides)
{
(IDataValueEditor valueEditor, object? configuration)? resolved = await ResolveEditorAsync(content, @override);
if (resolved is null)
{
continue;
}
var editorValue = @override.EditorValue is string s ? s : _jsonSerializer.Serialize(@override.EditorValue);
object? source = resolved.Value.valueEditor.FromEditor(
new ContentPropertyData(editorValue, resolved.Value.configuration),
null);
string overrideCulture = @override.Culture ?? string.Empty;
string overrideSegment = @override.Segment ?? string.Empty;
var overrideEntry = new PropertyData
{
Culture = overrideCulture,
Segment = overrideSegment,
Value = source,
};
if (properties.TryGetValue(@override.Alias, out PropertyData[]? existing))
{
PropertyData[] merged = existing
.Where(p => !(p.Culture == overrideCulture && p.Segment == overrideSegment))
.Append(overrideEntry)
.ToArray();
properties[@override.Alias] = merged;
}
else
{
properties[@override.Alias] = [overrideEntry];
}
}
#pragma warning disable CS0618 // ContentData ctor obsolete usage mirrored from the cache node source
var overriddenNode = new ContentCacheNode
{
Id = baseNode.Id,
Key = baseNode.Key,
SortOrder = baseNode.SortOrder,
CreateDate = baseNode.CreateDate,
CreatorId = baseNode.CreatorId,
ContentTypeId = baseNode.ContentTypeId,
IsDraft = true,
Data = new ContentData(
name: baseNode.Data.Name,
urlSegment: baseNode.Data.UrlSegment,
versionId: baseNode.Data.VersionId,
versionDate: baseNode.Data.VersionDate,
writerId: baseNode.Data.WriterId,
templateId: baseNode.Data.TemplateId,
published: baseNode.Data.Published,
properties: properties,
cultureInfos: baseNode.Data.CultureInfos),
};
#pragma warning restore CS0618
return _publishedContentFactory.ToIPublishedContent(overriddenNode, preview: true).CreateModel(_publishedModelFactory);
}
private async Task<(IDataValueEditor, object? configuration)?> ResolveEditorAsync(IContent content, VisualEditorPropertyOverride @override)
{
IProperty? property = content.Properties[@override.Alias];
if (property is null)
{
return null;
}
IDataType? dataType = await _dataTypeService.GetAsync(property.PropertyType.DataTypeKey);
if (dataType?.Editor is null)
{
return null;
}
return (dataType.Editor.GetValueEditor(), dataType.ConfigurationObject);
}
}
@@ -77,6 +77,8 @@ public class UmbracoApplicationBuilder : IUmbracoApplicationBuilder, IUmbracoEnd
// Only use backoffice rewrites if backoffice is enabled
if (ApplicationServices.GetService<IBackOfficeEnabledMarker>() is not null)
{
// Must run before the rewriter so the cache-bust hash is still present on the request path.
AppBuilder.UseUmbracoBackOfficeCacheHeaders();
AppBuilder.UseUmbracoBackOfficeRewrites();
}
+9 -1
View File
@@ -62,7 +62,8 @@ Umbraco.Web.Common/
│ └── UmbracoPublishedContentCultureProvider.cs
├── Middleware/
│ ├── BootFailedMiddleware.cs # Startup failure handling (81 lines)
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
── PreviewAuthenticationMiddleware.cs # Preview mode auth (84 lines)
│ └── UmbracoBackOfficeCacheHeadersMiddleware.cs # Cache-Control on cache-busted backoffice asset path
├── Routing/
│ ├── IAreaRoutes.cs # Area routing interface
│ ├── IRoutableDocumentFilter.cs # Content routing filter
@@ -256,6 +257,8 @@ ASP.NET Core Identity sign-in manager for members.
### Middleware
**Convention**: middleware lives in `Middleware/` as a class implementing `IMiddleware`, registered as a singleton next to its dependencies' registration (generic middleware in `AddWebComponents`; feature-specific middleware where the feature's services are added, e.g. backoffice middleware in `AddBackOfficeCore`), and wired into the pipeline via `app.UseMiddleware<TMiddleware>()`. Companion `IApplicationBuilder` extension methods are thin one-line `UseMiddleware<T>()` wrappers — inline `builder.Use(async …)` lambdas bypass DI and are harder to test; `CspNonceExtensions` and `Web.UI/WebApplicationExtensions` are tiny pre-existing exceptions, not a precedent for new work.
**BootFailedMiddleware** (lines 17-81):
- Intercepts requests when `RuntimeLevel == BootFailed`
- Debug mode: Rethrows exception for stack trace
@@ -266,6 +269,11 @@ ASP.NET Core Identity sign-in manager for members.
- Skips client-side requests and backoffice paths
- Uses `IPreviewService.TryGetPreviewClaimsIdentityAsync()`
**UmbracoBackOfficeCacheHeadersMiddleware**:
- Sets `Cache-Control: public, max-age=31536000, immutable` on responses under the cache-busted backoffice asset prefix (`/umbraco/backoffice/<hash>/…`); `no-cache` in debug mode
- Runs before `UseUmbracoBackOfficeRewrites` so the original (hash-bearing) path can be matched
- Non-destructive: uses `Response.OnStarting` + `ContainsKey` guard so any consumer override wins
---
## 4. Routing

Some files were not shown because too many files have changed in this diff Show More