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
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
419 changed files with 13593 additions and 11814 deletions
-135
View File
@@ -1,135 +0,0 @@
---
name: umb-release-notes
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
argument-hint: <version> <path-to-generated-notes-file>
---
# Umbraco CMS - Improve Release Notes
Takes a file of auto-generated GitHub release notes and produces an improved version that:
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
2. **Is well-categorized** — every PR sits under the most appropriate heading.
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
The result is written to a **new** file alongside the input, so the user can diff the two.
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
## Arguments
`$ARGUMENTS` contains two values:
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
If either is missing, ask the user once for the missing value, then proceed.
## Prerequisites
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
## Procedure
### 1. Read the input notes
Read the input file. Note its structure — it is GitHub's generated format:
- A leading HTML comment (`<!-- Release notes generated ... -->`).
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
### 2. Fetch every labelled PR
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
--json number,title,author,labels,mergedAt \
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
```
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
```bash
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
```
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
### 3. Reconcile
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
```bash
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
```
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
### 4. Categorize every PR
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
| Heading | What goes here | Primary signal |
|---|---|---|
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
| `### 🧑‍💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
**Rules:**
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
### 5. Remove purely-internal noise
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
- Branch/merge maintenance ("Fix main branch after merge issue").
- CI/pipeline fixes that don't change the product.
- Reverts of changes that never shipped in a release.
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
### 6. Write the output
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
### 7. Report
Give a concise summary:
- Count of PRs added (with their numbers), and which categories they landed in.
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
- Every entry removed, with the one-line reason.
- Any borderline calls the user may want to override.
- The output file path.
## Verification
Before reporting done, confirm:
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
- No PR appears under more than one heading.
- Notable and Breaking sections are byte-for-byte unchanged from the input.
- The header comment, New Contributors, and Full Changelog lines are intact.
+2 -20
View File
@@ -23,7 +23,7 @@ env:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
@@ -38,12 +38,6 @@ jobs:
- name: Setup .NET from global.json
uses: actions/setup-dotnet@v5
- name: Setup Java 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
- name: Cache SonarQube packages
uses: actions/cache@v5
with:
@@ -66,8 +60,7 @@ jobs:
dotnet-sonarscanner begin \
/k:"$SONAR_PROJECT_KEY" \
/o:"$SONAR_ORGANIZATION" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.scanner.skipJreProvisioning=true
/d:sonar.token="$SONAR_TOKEN"
- name: Restore
run: dotnet restore umbraco.sln
@@ -76,23 +69,12 @@ jobs:
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
id: tests
continue-on-error: true
run: |
dotnet-coverage collect \
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
--output TestResults/coverage.xml \
--output-format xml
- name: Warn on test failure
if: steps.tests.outcome == 'failure'
run: |
if [ -f TestResults/coverage.xml ]; then
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
else
echo "::warning::Unit tests failed and no coverage data was collected"
fi
- name: End analysis
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
+1
View File
@@ -120,6 +120,7 @@ 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
-2
View File
@@ -558,8 +558,6 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
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.
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
---
## Quick Reference
+1 -1
View File
@@ -40,7 +40,7 @@
<!-- Package Validation -->
<PropertyGroup>
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
<EnablePackageValidation>true</EnablePackageValidation>
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
+1 -1
View File
@@ -57,7 +57,7 @@
<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" />
+98 -32
View File
@@ -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
@@ -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:
-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
@@ -59,7 +59,6 @@ public static class UmbracoBuilderApiExtensions
string? jsonOptionsName = null)
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
{
apiName = apiName.ToLowerInvariant();
builder.Services.AddOpenApi(apiName);
builder.Services.ConfigureOptions<TConfigureOptions>();
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Api.Common.Attributes;
using Umbraco.Cms.Api.Common.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Common.OpenApi;
@@ -116,20 +116,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
/// <param name="builder">The Umbraco builder to register services against.</param>
internal void Build(IUmbracoBuilder builder)
{
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
builder.Services.AddOpenApi(
lowercasedDocumentName,
DocumentName,
options =>
{
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
options.ShouldInclude = apiDescription =>
apiDescription.ActionDescriptor.EndpointMetadata
?.OfType<MapToApiAttribute>()
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
?? false;
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
@@ -166,12 +158,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
if (_includedInUi)
{
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
}
if (_httpJsonOptionsFactory is not null)
{
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
}
}
}
@@ -1,100 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the root-level documents by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenAtRootDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level documents by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level documents by a field.")]
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,102 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Actions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
/// <summary>
/// Provides an API endpoint for sorting the children of a document by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenDocumentController : DocumentControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IContentEditingService _contentEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="contentEditingService">Service for editing and managing content.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
public SortChildrenDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_contentEditingService = contentEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child documents of the specified parent document by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a document by a field.")]
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
AuthorizationPolicies.ContentPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Document,
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
AuthorizationPolicies.ContentPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
requestModel.Culture,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,98 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the root-level media items by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenAtRootMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenAtRootMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the root-level media items by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
/// </returns>
[HttpPut("root/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[EndpointSummary("Sorts the root-level media items by a field.")]
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.Root(),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
parentKey: null,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
null,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(result);
}
}
@@ -1,100 +0,0 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Security.Authorization;
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Controllers.Media;
/// <summary>
/// Provides an API endpoint for sorting the children of a media item by a system field.
/// </summary>
[ApiVersion("1.0")]
public class SortChildrenMediaController : MediaControllerBase
{
private readonly IAuthorizationService _authorizationService;
private readonly IMediaEditingService _mediaEditingService;
private readonly IEntityService _entityService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
/// <summary>
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
/// </summary>
/// <param name="authorizationService">Service used to authorize user actions.</param>
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
/// <param name="entityService">Service used to resolve the children to authorize.</param>
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
public SortChildrenMediaController(
IAuthorizationService authorizationService,
IMediaEditingService mediaEditingService,
IEntityService entityService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_authorizationService = authorizationService;
_mediaEditingService = mediaEditingService;
_entityService = entityService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
/// <summary>
/// Sorts the child media items of the specified parent media item by a system field.
/// </summary>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
/// <param name="requestModel">The field to sort by and the sort direction.</param>
/// <returns>
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
/// </returns>
[HttpPut("{id:guid}/sort-children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[EndpointSummary("Sorts the children of a media item by a field.")]
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
{
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
User,
MediaPermissionResource.WithKeys(id),
AuthorizationPolicies.MediaPermissionByResource);
if (!authorizationResult.Succeeded)
{
return Forbidden();
}
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
_authorizationService,
_entityService,
User,
id,
UmbracoObjectTypes.Media,
childKeys => MediaPermissionResource.WithKeys(childKeys),
AuthorizationPolicies.MediaPermissionByResource);
if (!childrenAuthorized)
{
return Forbidden();
}
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
id,
requestModel.Field,
requestModel.Direction,
CurrentUserKey(_backOfficeSecurityAccessor));
return result == ContentEditingOperationStatus.Success
? Ok()
: ContentEditingOperationStatusResult(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
{
}
@@ -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
{
@@ -49,7 +49,8 @@ public abstract class ContentTypeMapDefinition<TContentType, TPropertyTypeModel,
},
Appearance = new PropertyTypeAppearance
{
LabelOnTop = propertyType.LabelOnTop
LabelOnTop = propertyType.LabelOnTop,
EditableInVisualEditor = propertyType.EditableInVisualEditor,
}
})
.ToArray();
File diff suppressed because it is too large Load Diff
@@ -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).*$" });
}
}
@@ -1,64 +0,0 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Security.Authorization;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Security.Authorization;
/// <summary>
/// Authorizes permissions on all direct children of a node.
/// </summary>
internal static class AllChildrenAuthorizer
{
/// <summary>
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
/// </summary>
/// <param name="authorizationService">The authorization service.</param>
/// <param name="entityService">The entity service used to resolve the children.</param>
/// <param name="user">The current user.</param>
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
/// <param name="objectType">The object type of the children (and parent).</param>
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
/// <param name="policy">The authorization policy to apply.</param>
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
public static async Task<bool> IsAuthorizedForChildrenAsync(
IAuthorizationService authorizationService,
IEntityService entityService,
ClaimsPrincipal user,
Guid? parentKey,
UmbracoObjectTypes objectType,
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
string policy)
{
const int pageSize = 500;
var page = 0;
long total;
do
{
Guid[] childKeys = entityService
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
.Select(child => child.Key)
.ToArray();
if (childKeys.Length > 0)
{
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
user,
resourceFactory(childKeys),
policy);
if (authorizationResult.Succeeded is false)
{
return false;
}
}
page++;
}
while (page * pageSize < total);
return true;
}
}
@@ -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; }
}
@@ -1,21 +0,0 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Base request model for sorting the children of a node by a system field.
/// </summary>
public abstract class SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the system field to sort the children by.
/// The create and update dates are node-level (not culture-specific).
/// </summary>
public required ContentSortField Field { get; init; }
/// <summary>
/// Gets or sets the direction to sort in.
/// </summary>
public required Direction Direction { get; init; }
}
@@ -1,16 +0,0 @@
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a document by a system field.
/// </summary>
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
/// <summary>
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
/// </summary>
public string? Culture { get; init; }
}
@@ -1,9 +0,0 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
/// <summary>
/// Request model for sorting the children of a media item by a system field.
/// Media items do not vary by culture, so no culture is accepted.
/// </summary>
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
{
}
@@ -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; }
}
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
private int? _skipver;
private RoslynCompiler? _roslynCompiler;
private ModelsBuilderSettings _config;
private volatile bool _disposedValue;
private bool _disposedValue;
public InMemoryModelFactory(
Lazy<UmbracoServices> umbracoServices,
@@ -280,34 +280,25 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
}
}
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
// requests can still reach this point. Bail out with the current models rather than touching
// the disposed lock. The catch below covers the small window where disposal happens after this
// check but before (or while) the lock is acquired.
if (_disposedValue)
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
return _infos;
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
try
{
// don't use an upgradeable lock here because only 1 thread at a time could enter it
try
{
_locker.EnterReadLock();
if (_hasModels)
{
return _infos;
}
}
finally
{
if (_locker.IsReadLockHeld)
{
_locker.ExitReadLock();
}
}
_locker.EnterUpgradeableReadLock();
if (_hasModels)
@@ -368,12 +359,6 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
return _infos;
}
catch (ObjectDisposedException ex)
{
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
return _infos;
}
finally
{
if (_locker.IsWriteLockHeld)
@@ -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);
}
}
@@ -18,14 +18,5 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
/// <inheritdoc />
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
{
_distributedCache.RemoveLanguageCache(entities);
// User groups cache their allowed language ids, so a deleted language must be evicted from
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
// language without a query, and language deletion is rare enough that a full refresh is fine.
_distributedCache.RefreshAllUserGroupCache();
}
=> _distributedCache.RemoveLanguageCache(entities);
}
@@ -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
}
@@ -16,11 +16,6 @@ public class ContentSettings
/// </summary>
internal const bool StaticResolveUrlsFromTextString = false;
/// <summary>
/// The default value for whether sorting children by a field fires per-item notifications.
/// </summary>
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
/// <summary>
/// The default preview badge markup template.
/// </summary>
@@ -114,18 +109,6 @@ public class ContentSettings
[DefaultValue(StaticResolveUrlsFromTextString)]
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
/// <summary>
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
/// per-item save/sort notifications (and therefore webhooks).
/// </summary>
/// <remarks>
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
/// (and webhooks), accepting the additional performance cost on nodes with many children.
/// </remarks>
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
/// <summary>
/// Gets or sets a value for the collection of error pages.
/// </summary>
@@ -30,17 +30,6 @@ public class DatabaseServerMessengerSettings
/// </summary>
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// The default timeout for a single synchronization operation.
/// </summary>
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
/// <see cref="SyncTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
/// <summary>
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
/// cold-boots (rebuilds its caches).
@@ -66,13 +55,4 @@ public class DatabaseServerMessengerSettings
/// </summary>
[DefaultValue(StaticTimeBetweenPruneOperations)]
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
/// <summary>
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticSyncTimeout)]
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
}
@@ -20,17 +20,6 @@ public class DatabaseServerRegistrarSettings
/// </summary>
internal const string StaticStaleServerTimeout = "00:02:00";
/// <summary>
/// The default timeout for a single server touch operation.
/// </summary>
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
/// <summary>
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
/// <see cref="TouchTimeout" /> is configured.
/// </summary>
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
/// <summary>
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
/// </summary>
@@ -42,13 +31,4 @@ public class DatabaseServerRegistrarSettings
/// </summary>
[DefaultValue(StaticStaleServerTimeout)]
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
/// <summary>
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
/// </summary>
[DefaultValue(StaticTouchTimeout)]
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
}
@@ -20,12 +20,5 @@ public class IndexingSettings
/// <summary>
/// Gets or sets a value for how many items to index at a time.
/// </summary>
/// <remarks>
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
/// during a rebuild.
/// </remarks>
[DefaultValue(StaticBatchSize)]
public int BatchSize { get; set; } = StaticBatchSize;
}
@@ -32,11 +32,6 @@ public class LoggingSettings
/// </summary>
internal const string StaticFileNameFormatArguments = "MachineName";
/// <summary>
/// The default mode for enriching log events with a session identifier.
/// </summary>
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
/// <summary>
/// Gets or sets a value for the maximum age of a log file.
/// </summary>
@@ -75,16 +70,4 @@ public class LoggingSettings
/// </remarks>
[DefaultValue(StaticFileNameFormatArguments)]
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
/// <summary>
/// Gets or sets a value determining how log events are enriched with a session identifier.
/// </summary>
/// <remarks>
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
/// blocking session-store load that resolving the actual session id incurs per request when the session is
/// backed by an <c>IDistributedCache</c>.
/// </remarks>
[DefaultValue(StaticSessionIdLogging)]
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
}
@@ -1,33 +0,0 @@
using System.ComponentModel;
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Settings for scheduled publishing.
/// </summary>
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
public class ScheduledPublishingSettings
{
private const string StaticPeriod = "00:01:00";
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
/// <summary>
/// Gets or sets a value for how often scheduled publishing runs.
/// </summary>
[DefaultValue(StaticPeriod)]
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
/// <summary>
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
/// based on when the previous run completed.
/// </summary>
/// <remarks>
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
/// whole-minute periods this is indistinguishable from local time at the second level.
/// </remarks>
[DefaultValue(StaticAlignToClock)]
public bool AlignToClock { get; set; } = StaticAlignToClock;
}
@@ -1,29 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
namespace Umbraco.Cms.Core.Configuration.Models;
/// <summary>
/// Determines how request logging enriches log events with a session identifier.
/// </summary>
public enum SessionIdLoggingMode
{
/// <summary>
/// Do not enrich log events with a session identifier.
/// </summary>
None = 0,
/// <summary>
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
/// </summary>
SessionId,
/// <summary>
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
/// a distributed-cache round-trip.
/// </summary>
CookieHash,
}
@@ -1,43 +0,0 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using Microsoft.Extensions.Options;
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
/// <summary>
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
/// </summary>
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
{
/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
{
if (options.Period <= TimeSpan.Zero)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
}
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
{
return ValidateOptionsResult.Fail(
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
}
return ValidateOptionsResult.Success;
}
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
{
var totalSeconds = period.TotalSeconds;
// Must be a positive, whole number of seconds (no sub-second component).
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
{
return false;
}
return 3600 % (long)totalSeconds == 0;
}
}
@@ -291,11 +291,6 @@ public static partial class Constants
/// </summary>
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
/// <summary>
/// The configuration key for scheduled publishing settings.
/// </summary>
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
/// <summary>
/// The configuration key for backoffice token cookie settings.
/// </summary>
@@ -57,7 +57,6 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
// Register configuration sections.
builder
@@ -101,7 +100,6 @@ public static partial class UmbracoBuilderExtensions
.AddUmbracoOptions<CacheSettings>()
.AddUmbracoOptions<SystemDateMigrationSettings>()
.AddUmbracoOptions<DistributedJobSettings>()
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
.AddUmbracoOptions<WebsiteSettings>()
.AddUmbracoOptions<SignalRSettings>();
@@ -402,7 +402,6 @@
<key alias="invalidMediaType">The chosen media type is invalid.</key>
<key alias="invalidContentType">The chosen content is of invalid type.</key>
<key alias="missingContent">The chosen content does not exist.</key>
<key alias="missingMedia">The chosen media does not exist.</key>
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
@@ -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.
@@ -1,22 +0,0 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
/// <summary>
/// Represents a system field that a node's children can be sorted by.
/// </summary>
public enum ContentSortField
{
/// <summary>
/// Sort by the node's name.
/// </summary>
Name,
/// <summary>
/// Sort by the date the node was created.
/// </summary>
CreateDate,
/// <summary>
/// Sort by the date the node was last updated.
/// </summary>
UpdateDate,
}
@@ -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)
{
}
}
@@ -16,19 +16,6 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
/// </summary>
int RecycleBinId { get; }
/// <summary>
/// Updates the sort order of the specified nodes so that each node's sort order matches its
/// position in the supplied (already ordered) collection, in a single set-based update.
/// </summary>
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
/// <remarks>
/// This persists the sort order directly and does not load the entities or fire any notifications;
/// callers are responsible for any required cache refresh and auditing.
/// </remarks>
// TODO (V19): Remove the default implementation.
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
=> throw new NotImplementedException();
/// <summary>
/// Gets versions.
/// </summary>
@@ -1,34 +0,0 @@
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
/// </summary>
internal static class AllowedContentTypeKeysParser
{
/// <summary>
/// Parses the configured value into the set of allowed content type keys.
/// </summary>
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
public static HashSet<Guid> Parse(string? configValue)
{
if (configValue.IsNullOrWhiteSpace())
{
return [];
}
var result = new HashSet<Guid>();
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
{
if (Guid.TryParse(entry, out Guid guid))
{
result.Add(guid);
}
}
return result;
}
}
@@ -8,10 +8,4 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
}
@@ -1,16 +1,12 @@
// Copyright (c) Umbraco.
// See LICENSE for more details.
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
@@ -74,21 +70,13 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
/// <param name="contentService">The content service.</param>
/// <param name="localizedTextService">The localized text service.</param>
public ContentPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
ILocalizedTextService localizedTextService)
DataEditorAttribute attribute)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
}
/// <inheritdoc />
@@ -146,61 +134,4 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
return guidUdi.Guid;
}
}
/// <summary>
/// Validates that the selected content matches the allowed content types configured for the property editor.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="contentService">The content service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
: ITypedValidator<string, ContentPickerConfiguration>
{
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
string? value,
ContentPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (string.IsNullOrEmpty(value) ||
configuration is null ||
Guid.TryParse(value, out Guid id) is false)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all content types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
Guid? key = contentService.GetById(id)?.ContentType?.Key;
scope.Complete();
if (key is null)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"missingContent"),
["value"])];
}
if (allowedContentTypeKeys.Contains(key.Value) is false)
{
return [new ValidationResult(
localizedTextService.Localize(
"validation",
"invalidObjectType"),
["value"])];
}
return [];
}
}
}
@@ -8,32 +8,4 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
/// <inheritdoc />
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
public bool IgnoreUserStartNodes { get; set; }
/// <summary>
/// Gets or sets the validation limits for the number of elements allowed.
/// </summary>
[ConfigurationField("validationLimit")]
public NumberRange? ValidationLimit { get; set; }
/// <summary>
/// Gets or sets the content type filter for allowed selections.
/// </summary>
[ConfigurationField("allowedContentTypes")]
public string? AllowedContentTypeIds { get; set; }
/// <summary>
/// Represents a numeric range with optional minimum and maximum values.
/// </summary>
public class NumberRange
{
/// <summary>
/// Gets or sets the minimum value of the range.
/// </summary>
public int? Min { get; set; }
/// <summary>
/// Gets or sets the maximum value of the range.
/// </summary>
public int? Max { get; set; }
}
}
@@ -1,19 +1,13 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Editors;
using Umbraco.Cms.Core.Models.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Serialization;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Strings;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.PropertyEditors;
/// <summary>
/// Element picker property editor that stores element keys.
/// Element picker property editor that stores element keys
/// </summary>
[DataEditor(
Constants.PropertyEditors.Aliases.ElementPicker,
@@ -23,11 +17,6 @@ public class ElementPickerPropertyEditor : DataEditor
{
private readonly IIOHelper _ioHelper;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
/// </summary>
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
/// <param name="ioHelper">The IO helper.</param>
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
: base(dataValueEditorFactory)
{
@@ -39,44 +28,21 @@ public class ElementPickerPropertyEditor : DataEditor
protected override IConfigurationEditor CreateConfigurationEditor() =>
new ElementPickerConfigurationEditor(_ioHelper);
/// <inheritdoc/>
protected override IDataValueEditor CreateValueEditor() =>
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
/// <summary>
/// Provides the value editor for the element picker property editor.
/// </summary>
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
{
private readonly IJsonSerializer _jsonSerializer;
/// <summary>
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
/// </summary>
/// <param name="shortStringHelper">The short string helper.</param>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="ioHelper">The IO helper.</param>
/// <param name="attribute">The data editor attribute.</param>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public ElementPickerPropertyValueEditor(
IShortStringHelper shortStringHelper,
IJsonSerializer jsonSerializer,
IIOHelper ioHelper,
DataEditorAttribute attribute,
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
DataEditorAttribute attribute)
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
{
_jsonSerializer = jsonSerializer;
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
new MinMaxValidator(localizedTextService),
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
}
=> _jsonSerializer = jsonSerializer;
/// <inheritdoc/>
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
{
var asString = value as string ?? value?.ToString();
@@ -97,144 +63,4 @@ public class ElementPickerPropertyEditor : DataEditor
}
}
}
/// <summary>
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
/// </summary>
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
/// <summary>
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
public MinMaxValidator(ILocalizedTextService localizedTextService)
=> _localizedTextService = localizedTextService;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
var validationResults = new List<ValidationResult>();
if (configuration is null || configuration.ValidationLimit is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesShort",
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
["value"]));
}
if (value is null)
{
return validationResults;
}
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
{
validationResults.Add(new ValidationResult(
_localizedTextService.Localize(
"validation",
"entriesExceed",
[max.ToString(), (value.Count - max).ToString()]),
["value"]));
}
return validationResults;
}
}
/// <summary>
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
/// </summary>
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
private readonly IElementService _elementService;
private readonly ICoreScopeProvider _coreScopeProvider;
/// <summary>
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
/// </summary>
/// <param name="localizedTextService">The localized text service.</param>
/// <param name="elementService">The element service.</param>
/// <param name="coreScopeProvider">The core scope provider.</param>
public AllowedTypeValidator(
ILocalizedTextService localizedTextService,
IElementService elementService,
ICoreScopeProvider coreScopeProvider)
{
_localizedTextService = localizedTextService;
_elementService = elementService;
_coreScopeProvider = coreScopeProvider;
}
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
List<string>? value,
ElementPickerConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext)
{
if (value is null || value.Count == 0 || configuration is null)
{
return [];
}
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
// No filter configured — all element types are allowed.
if (allowedContentTypeKeys.Count == 0)
{
return [];
}
Guid[] elementIds = value
.Where(v => Guid.TryParse(v, out _))
.Select(Guid.Parse)
.Distinct()
.ToArray();
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
scope.Complete();
// Compare against the distinct requested keys (not the raw value count, which may include
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
if (elements.Length != elementIds.Length)
{
return [
new ValidationResult(
_localizedTextService.Localize("validation", "missingContent"),
["value"])
];
}
foreach (IElement element in elements)
{
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
{
return
[
new ValidationResult(
_localizedTextService.Localize("validation", "invalidObjectType"),
["value"])
];
}
}
return [];
}
}
}
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
/// <summary>
/// Validates the min/max configuration for the entity data picker property editor.
/// </summary>
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
{
private readonly ILocalizedTextService _localizedTextService;
@@ -9,13 +9,17 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
public interface ITypedJsonValidator<TValue, TConfiguration>
{
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
// TODO (V20): remove together with this interface.
new IEnumerable<ValidationResult> Validate(
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The deserialized value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
public abstract IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
@@ -1,31 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// A validator that operates on an already-typed value and configuration.
/// <remarks>
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
/// </remarks>
/// </summary>
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
public interface ITypedValidator<TValue, TConfiguration>
{
/// <summary>
/// Validates the specified value against the configuration.
/// </summary>
/// <param name="value">The typed value to validate.</param>
/// <param name="configuration">The data type configuration.</param>
/// <param name="valueType">The value type.</param>
/// <param name="validationContext">The property validation context.</param>
/// <returns>A collection of validation results.</returns>
IEnumerable<ValidationResult> Validate(
TValue? value,
TConfiguration? configuration,
string? valueType,
PropertyValidationContext validationContext);
}
@@ -6,47 +6,26 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
/// </para>
/// <para>
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
/// </para>
/// <para>
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
/// this one deserializes, the other casts.
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// </summary>
/// <typeparam name="TValue">The type of the expected value.</typeparam>
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
where TValue : class
{
private readonly IJsonSerializer _jsonSerializer;
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="jsonSerializer">The JSON serializer.</param>
/// <param name="validators">The collection of validators to run.</param>
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
{
_jsonSerializer = jsonSerializer;
_validators = validators;
@@ -72,7 +51,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
return validationResults;
}
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
{
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
}
@@ -1,60 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models.Validation;
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
/// <summary>
/// <para>
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
/// </para>
/// <para>
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
/// (value is a <c>List&lt;string&gt;</c>, since the backoffice JSON object converter resolves an array of scalars into a typed list).
/// </para>
/// <para>
/// When the editor value is instead raw JSON that must be deserialized into <typeparamref name="TValue"/> before validation —
/// typically an array of complex objects, such as a media picker storing crop data — use <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
/// instead. That is the only difference between the two runners: this one casts, the other deserializes.
/// </para>
/// </summary>
/// <typeparam name="TValue">The type of the expected value.</typeparam>
/// <typeparam name="TConfiguration">The type of the expected configuration.</typeparam>
/// <seealso cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
public class TypedValidatorRunner<TValue, TConfiguration> : IValueValidator
where TValue : class
{
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
/// <summary>
/// Initializes a new instance of the <see cref="TypedValidatorRunner{TValue, TConfiguration}"/> class.
/// </summary>
/// <param name="validators">The collection of validators to run.</param>
public TypedValidatorRunner(params ITypedValidator<TValue, TConfiguration>[] validators)
=> _validators = validators;
/// <inheritdoc/>
public IEnumerable<ValidationResult> Validate(
object? value,
string? valueType,
object? dataTypeConfiguration,
PropertyValidationContext validationContext)
{
if (dataTypeConfiguration is not TConfiguration configuration)
{
return [];
}
if (value is not null and not TValue)
{
return [];
}
var typedValue = value as TValue;
return _validators
.SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext))
.ToList();
}
}
@@ -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);
@@ -103,19 +103,11 @@ internal sealed class ContentEditingService
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
ContentCreateModel createModel,
Guid userKey)
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidateCulturesAndPropertiesAsync(
=> await ValidateCulturesAndPropertiesAsync(
createModel,
createModel.ContentTypeKey,
createModel.Variants.Select(variant => variant.Culture),
userKey);
}
/// <inheritdoc />
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
@@ -219,15 +211,6 @@ internal sealed class ContentEditingService
Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
private async Task<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
{
if (templateKey == null)
@@ -275,8 +258,8 @@ internal sealed class ContentEditingService
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
/// <inheritdoc />
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
@@ -285,13 +268,6 @@ internal sealed class ContentEditingService
return OperationResultToOperationStatus(result);
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
{
try
@@ -500,10 +500,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
{
// these are the only result states currently expected from the invoked IContentService operations
OperationResultType.Success => ContentEditingOperationStatus.Success,
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
@@ -665,25 +661,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return filteredContentTypes.Any();
}
/// <summary>
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
/// actually created. This allows the validation endpoints to be consistent with creation.
/// </summary>
/// <param name="createModel">The content creation model.</param>
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
{
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
if (contentType is null)
{
return ContentEditingOperationStatus.ContentTypeNotFound;
}
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
return operationStatus;
}
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
{
if (contentType.VariesByCulture())
@@ -90,10 +90,9 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
/// <param name="parentId">The parent identifier.</param>
/// <param name="pageIndex">The zero-based page index.</param>
/// <param name="pageSize">The page size.</param>
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
/// <param name="total">The total number of children.</param>
/// <returns>The paged children.</returns>
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
/// <summary>
/// Handles the sorting operation asynchronously.
@@ -116,7 +115,16 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.NotFound;
}
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
var children = new List<TContent>((int)total);
children.AddRange(page);
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
children.AddRange(page);
}
try
{
@@ -134,102 +142,4 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
return ContentEditingOperationStatus.SortingInvalid;
}
}
/// <summary>
/// Handles sorting a parent's children by a system field asynchronously.
/// </summary>
/// <param name="parentKey">The optional parent key.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The user key performing the operation.</param>
/// <returns>The operation status.</returns>
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
Guid? parentKey,
ContentSortField field,
Direction direction,
string? culture,
Guid userKey)
{
var contentId = parentKey.HasValue
? ContentService.GetById(parentKey.Value)?.Id
: Constants.System.Root;
if (contentId.HasValue is false)
{
return ContentEditingOperationStatus.NotFound;
}
Ordering ordering = BuildOrdering(field, direction, culture);
// The database does the ordering (matching the list view and the order shown in the sort UI).
if (ContentSettings.SortChildrenByFieldFiresNotifications)
{
// Opt-in path: load the children and persist via the standard sort, firing per-item
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
if (orderedChildren.Count == 0)
{
return ContentEditingOperationStatus.Success;
}
return Sort(orderedChildren, await GetUserIdAsync(userKey));
}
// Default path: persist the resulting order with a single set-based update and a branch cache
// refresh, without loading every child or firing per-item notifications.
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
if (orderedChildIds.Count == 0)
{
// Nothing to sort - the order is trivially correct.
return ContentEditingOperationStatus.Success;
}
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
}
/// <summary>
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
/// the children or firing per-item notifications.
/// </summary>
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
/// <param name="userId">The user performing the operation.</param>
/// <returns>The operation status.</returns>
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
=> LoadAllChildren(contentId, ordering, child => child.Id);
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
=> LoadAllChildren(contentId, ordering, child => child);
// Pages through all children, projecting each page with the selector so callers that only need a
// lightweight value (e.g. the id) don't retain every loaded child.
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
{
const int pageSize = 500;
var pageNumber = 0;
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
var results = new List<TResult>((int)total);
results.AddRange(page.Select(selector));
while (pageNumber * pageSize < total)
{
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
results.AddRange(page.Select(selector));
}
return results;
}
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
=> field switch
{
// Name is variant - the culture selects the variant name to order by (invariant content and media
// ignore it). Create and update dates are node-level, so the culture does not apply.
ContentSortField.Name => Ordering.By("name", direction, culture),
ContentSortField.CreateDate => Ordering.By("createDate", direction),
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
};
}
+1 -44
View File
@@ -1652,13 +1652,7 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
{
scope.WriteLock(Constants.Locks.ContentTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
// and saving those directly would wipe the template and property data (#23120).
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
scope.Complete();
return ret;
}
@@ -1696,43 +1690,6 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
}
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.ContentTree);
_documentRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IContent[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IContent? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
{
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
@@ -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
@@ -95,18 +95,6 @@ public interface IContentEditingService
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
/// <param name="userKey">The unique identifier of the user performing the action.</param>
/// <returns>The operation status indicating success or failure.</returns>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Deletes a content item whether it is in the recycle bin or not.
/// </summary>
@@ -418,22 +418,6 @@ public interface IContentService : IPublishableContentService<IContent>
/// <returns>The operation result.</returns>
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
#endregion
#region Publish Document
@@ -118,18 +118,6 @@ public interface IMediaEditingService
/// </returns>
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
/// <summary>
/// Sorts the children of a parent by a system field.
/// </summary>
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
/// <param name="field">The system field to sort the children by.</param>
/// <param name="direction">The direction to sort in.</param>
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
/// <returns>The operation status indicating the operation outcome.</returns>
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
/// <summary>
/// Permanently deletes a media item from the recycle bin.
/// </summary>
@@ -338,22 +338,6 @@ public interface IMediaService : IContentServiceBase<IMedia>
/// <returns>True if sorting succeeded, otherwise False</returns>
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
/// <summary>
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
/// as the new sort order, in a single set-based update.
/// </summary>
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
/// <param name="userId">The identifier of the user performing the action.</param>
/// <returns>The operation result.</returns>
/// <remarks>
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
/// </remarks>
// TODO (V19): Remove the default implementation.
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
=> throw new NotImplementedException();
/// <summary>
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
/// that this Media should based on.
-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>
@@ -87,15 +87,7 @@ internal sealed class MediaEditingService
/// <inheritdoc />
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
{
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
}
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
}
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
/// <inheritdoc />
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
@@ -177,13 +169,6 @@ internal sealed class MediaEditingService
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
=> await HandleSortAsync(parentKey, sortingModels, userKey);
/// <inheritdoc />
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
// Media never varies by culture, so children are always ordered by the invariant name.
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
/// <inheritdoc />
protected override IMedia New(string name, int parentId, IMediaType mediaType)
=> new Models.Media(name, parentId, mediaType);
@@ -206,8 +191,8 @@ internal sealed class MediaEditingService
=> ContentService.Delete(media, userId).Result;
/// <inheritdoc />
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
/// <inheritdoc />
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
@@ -218,13 +203,6 @@ internal sealed class MediaEditingService
: ContentEditingOperationStatus.CancelledByNotification;
}
/// <inheritdoc />
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
{
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
return OperationResultToOperationStatus(result);
}
/// <summary>
/// Saves a media item to the repository.
/// </summary>
-46
View File
@@ -1309,15 +1309,6 @@ namespace Umbraco.Cms.Core.Services
{
scope.WriteLock(Constants.Locks.MediaTree);
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
// partially-loaded media (e.g. without property data), and saving those directly would
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
itemsA = itemsA
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
.WhereNotNull()
.ToArray();
var savingNotification = new MediaSavingNotification(itemsA, messages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
@@ -1356,43 +1347,6 @@ namespace Umbraco.Cms.Core.Services
}
/// <inheritdoc />
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
{
EventMessages evtMsgs = EventMessagesFactory.Get();
if (orderedChildIds.Count == 0)
{
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
scope.WriteLock(Constants.Locks.MediaTree);
_mediaRepository.UpdateSortOrder(orderedChildIds);
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
if (parentId == Constants.System.Root)
{
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
}
else
{
IMedia? parent = GetById(parentId);
if (parent is not null)
{
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
}
}
Audit(AuditType.Sort, userId, parentId);
scope.Complete();
return OperationResult.Succeed(evtMsgs);
}
/// <summary>
/// Checks the data integrity of the media tree and optionally fixes detected issues.
/// </summary>
@@ -803,8 +803,6 @@ public class RelationService : RepositoryService, IRelationService
UmbracoObjectTypes.MemberType,
UmbracoObjectTypes.DataType,
UmbracoObjectTypes.MemberGroup,
UmbracoObjectTypes.Element,
UmbracoObjectTypes.ElementContainer,
UmbracoObjectTypes.ROOT,
UmbracoObjectTypes.RecycleBin,
];
-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);
}
@@ -1,4 +1,4 @@
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
/// <summary>
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
@@ -16,19 +16,6 @@ public interface IDistributedBackgroundJob
/// </summary>
TimeSpan Period { get; }
/// <summary>
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
/// </summary>
/// <remarks>
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
/// or every N seconds) rather than at <c>LastRun + Period</c>.
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
/// </remarks>
bool AlignToClock => false;
/// <summary>
/// Run the job.
/// </summary>
@@ -2,9 +2,7 @@
// See LICENSE for more details.
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services;
@@ -25,10 +23,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
public string Name => "ScheduledPublishingJob";
/// <inheritdoc />
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
/// <inheritdoc />
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
public TimeSpan Period => TimeSpan.FromMinutes(1);
private readonly IContentService _contentService;
@@ -38,7 +33,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
private readonly TimeProvider _timeProvider;
private readonly IServerMessenger _serverMessenger;
private readonly IUmbracoContextFactory _umbracoContextFactory;
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
/// <summary>
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
@@ -50,8 +44,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
ILogger<ScheduledPublishingJob> logger,
IServerMessenger serverMessenger,
ICoreScopeProvider scopeProvider,
TimeProvider timeProvider,
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
TimeProvider timeProvider)
{
_contentService = contentService;
_elementService = elementService;
@@ -60,7 +53,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
_serverMessenger = serverMessenger;
_scopeProvider = scopeProvider;
_timeProvider = timeProvider;
_scheduledPublishingSettings = scheduledPublishingSettings;
}
/// <inheritdoc />
@@ -4,6 +4,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
@@ -25,8 +26,6 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
private readonly ILogger<InstructionProcessJob> _logger;
private readonly IServerMessenger _messenger;
private readonly TimeSpan _syncTimeout;
private Task? _inFlightSync;
/// <summary>
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
@@ -42,78 +41,27 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
{
_messenger = messenger;
_logger = logger;
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
}
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
{
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
{
return configuredSyncTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
configuredSyncTimeout,
DatabaseServerMessengerSettings.DefaultSyncTimeout);
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
}
/// <summary>
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
/// Logs an error if the synchronization fails, but always completes the task.
/// </summary>
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
/// <returns>
/// A task representing the asynchronous operation.
/// A completed task representing the asynchronous operation.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
public override Task RunJobAsync(CancellationToken cancellationToken)
{
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
if (_inFlightSync is { IsCompleted: false })
{
return;
}
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
//
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
_inFlightSync = syncTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = syncTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
_logger.LogDebug("Synchronized cache instructions.");
_messenger.Sync();
}
catch (TimeoutException)
{
_logger.LogError(
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
_syncTimeout);
}
catch (Exception e) when (e is not OperationCanceledException)
catch (Exception e)
{
_logger.LogError(e, "Failed (will repeat).");
}
return Task.CompletedTask;
}
}
@@ -33,8 +33,6 @@ public class TouchServerJob : RecurringBackgroundJobBase
private readonly IServerRoleAccessor _serverRoleAccessor;
private readonly IDisposable? _onChangeRegistration;
private GlobalSettings _globalSettings;
private TimeSpan _touchTimeout;
private Task? _inFlightTouch;
/// <summary>
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
@@ -57,13 +55,11 @@ public class TouchServerJob : RecurringBackgroundJobBase
_logger = logger;
_globalSettings = globalSettings.CurrentValue;
_serverRoleAccessor = serverRoleAccessor;
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
_onChangeRegistration = globalSettings.OnChange(x =>
{
_globalSettings = x;
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
});
}
@@ -75,23 +71,14 @@ public class TouchServerJob : RecurringBackgroundJobBase
/// <returns>
/// A completed task when the job has finished running.
/// </returns>
public override async Task RunJobAsync(CancellationToken cancellationToken)
public override Task RunJobAsync(CancellationToken cancellationToken)
{
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
// since all it's used for is to allow the ElectedServerRoleAccessor
// to figure out what role a given server has, so we just stop this task.
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
{
return;
}
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
// every interval until it recovers.
if (_inFlightTouch is { IsCompleted: false })
{
return;
return Task.CompletedTask;
}
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
@@ -112,56 +99,18 @@ public class TouchServerJob : RecurringBackgroundJobBase
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
}
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
// thread pool and bound the wait so the loop survives and keeps touching.
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
_inFlightTouch = touchTask;
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
_ = touchTask.ContinueWith(
static t => _ = t.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
try
{
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
_serverRegistrationService.TouchServer(
serverAddress,
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
}
catch (TimeoutException)
{
_logger.LogError(
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
_touchTimeout);
}
catch (Exception ex) when (ex is not OperationCanceledException)
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update server record in database.");
}
}
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
// is allowed as an explicit opt-out that restores the unbounded wait.
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
{
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
{
return configuredTouchTimeout;
}
_logger.LogWarning(
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
configuredTouchTimeout,
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
return Task.CompletedTask;
}
/// <inheritdoc />
@@ -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
@@ -91,7 +91,7 @@ public static partial class UmbracoBuilderExtensions
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
return builder;
}
@@ -170,7 +170,13 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
pageIndex++;
}
@@ -210,7 +216,12 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
}
}
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
pageIndex++;
}
@@ -49,7 +49,13 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
Constants.System.Root,
descendants =>
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
{
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
foreach (IIndex index in indexes)
{
index.IndexItems(valueSets);
}
});
}
public override bool IsRegistered(IIndex index)
@@ -107,7 +107,11 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
{
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
}
pageIndex++;
}
@@ -41,7 +41,11 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
{
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
// ReSharper disable once PossibleMultipleEnumeration
foreach (IIndex index in indexes)
{
index.IndexItems(_valueSetBuilder.GetValueSets(members));
}
pageIndex++;
}
@@ -1,66 +0,0 @@
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
namespace Umbraco.Cms.Infrastructure.Examine;
/// <summary>
/// Handles how the indexes are rebuilt after startup.
/// </summary>
/// <remarks>
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
/// If it is a cold boot, they are all rebuilt.
/// </remarks>
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
{
// The notification is published again on restart, but the indexes only need to be
// considered for rebuilding once per application lifetime.
private static int _hasRun;
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
private readonly IIndexRebuilder _indexRebuilder;
private readonly IRuntimeState _runtimeState;
/// <summary>
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
/// </summary>
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
public RebuildOnStartedHandler(
ISyncBootStateAccessor syncBootStateAccessor,
IIndexRebuilder indexRebuilder,
IRuntimeState runtimeState)
{
_syncBootStateAccessor = syncBootStateAccessor;
_indexRebuilder = indexRebuilder;
_runtimeState = runtimeState;
}
/// <summary>
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
/// </summary>
/// <param name="notification">The notification.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
{
if (_runtimeState.Level != RuntimeLevel.Run)
{
return;
}
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
{
return;
}
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
// if it's not a cold boot, only rebuild empty ones
await _indexRebuilder.RebuildIndexesAsync(
bootState != SyncBootState.ColdBoot,
TimeSpan.FromMinutes(1));
}
}

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