Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1afa84e78b | ||
|
|
48455449dd | ||
|
|
acc21b44f3 | ||
|
|
31ff6c5f68 | ||
|
|
daba4f8eea | ||
|
|
38dfc75a7e | ||
|
|
aa7df42ffc | ||
|
|
3cac997b12 | ||
|
|
85d0e32b18 | ||
|
|
689495d9cc | ||
|
|
f274f42548 | ||
|
|
653c36c8fe | ||
|
|
a301384ed6 | ||
|
|
5be1f2fcb6 | ||
|
|
b0857c33ff | ||
|
|
7d5ad4feb2 | ||
|
|
0abe9f6c7c | ||
|
|
32c974a987 | ||
|
|
2946d81334 | ||
|
|
2fca102591 | ||
|
|
245915bba9 | ||
|
|
4cdaf0d829 | ||
|
|
a5fd7413ed | ||
|
|
91ff3f2299 | ||
|
|
6b960030f5 | ||
|
|
fb4d86639b | ||
|
|
6189177ecc | ||
|
|
80c0e8e8b6 | ||
|
|
9a45da142e | ||
|
|
f81452a062 | ||
|
|
1529eb9c6f | ||
|
|
b3ca6a6f3f | ||
|
|
c8e9345fe9 | ||
|
|
ba33517148 | ||
|
|
30c809e269 | ||
|
|
d4db0d2079 | ||
|
|
9683871c6a | ||
|
|
c7904ea013 | ||
|
|
d629bb8654 | ||
|
|
6b22d5558b | ||
|
|
de61491093 | ||
|
|
deafc20db9 | ||
|
|
28cdbe5317 | ||
|
|
6d1487ef4e | ||
|
|
46aa184e10 | ||
|
|
3dbd4baefe | ||
|
|
c1ba303fdc | ||
|
|
fa5dd209c1 | ||
|
|
4cc4acee62 | ||
|
|
62663d9573 | ||
|
|
b582f9d2ef | ||
|
|
a3424eb40d | ||
|
|
68d03ae7c4 | ||
|
|
1dbcf1037a | ||
|
|
27909ed18e | ||
|
|
91c9b79926 | ||
|
|
ad90db8b38 | ||
|
|
8e3b821a55 | ||
|
|
5ade6ae6ec | ||
|
|
7ca8d3d872 | ||
|
|
8ac989c4e3 | ||
|
|
fceb2f421f | ||
|
|
54827c4c92 | ||
|
|
3913a61b74 | ||
|
|
245bc336a5 | ||
|
|
c5efd65e23 | ||
|
|
cb9ac904f6 | ||
|
|
cc38e5724b | ||
|
|
90bedcd42e | ||
|
|
c54189aa90 | ||
|
|
88ec0a248f | ||
|
|
7158aec145 | ||
|
|
214fd03241 | ||
|
|
a3f65b2920 | ||
|
|
787f66be3d | ||
|
|
cd23fc75c5 | ||
|
|
230b5db528 | ||
|
|
2372c40056 | ||
|
|
75d2b0129d | ||
|
|
b410e060b6 | ||
|
|
3e22733081 | ||
|
|
a86777a8f2 | ||
|
|
232077e820 | ||
|
|
4e815d7e9d | ||
|
|
943d1eeccd | ||
|
|
b3666dad8b | ||
|
|
318843793f | ||
|
|
0adb5d21f5 | ||
|
|
28a403361e | ||
|
|
446a3795b7 | ||
|
|
3f0e0747fa | ||
|
|
f3471e961f | ||
|
|
8e6a791de0 | ||
|
|
5ffea3152b | ||
|
|
2043ff1dbd | ||
|
|
fd6e6a1a69 | ||
|
|
6c8aee07ed | ||
|
|
59ffe94525 | ||
|
|
ae0638af8b | ||
|
|
39f30d267c | ||
|
|
e4457715ee | ||
|
|
788a7cc4d5 | ||
|
|
996c6ff3fd | ||
|
|
d88785c3b4 | ||
|
|
d02b7c64a0 | ||
|
|
a8bab716b2 | ||
|
|
8b1bec2fa0 | ||
|
|
9a33b7ad7f | ||
|
|
99e25ccbd4 | ||
|
|
eef319e252 | ||
|
|
4f121da113 | ||
|
|
4bdbe274e6 | ||
|
|
3859202126 | ||
|
|
7b6828a804 | ||
|
|
3094d552e2 | ||
|
|
3d50062211 | ||
|
|
1037f8f8f2 | ||
|
|
b8607ad624 | ||
|
|
5889c061d8 |
+4
-12
@@ -70,18 +70,6 @@ trim_trailing_whitespace = true
|
||||
[*.less]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
##########################################
|
||||
# File Header (Uncomment to support file headers)
|
||||
# https://docs.microsoft.com/visualstudio/ide/reference/add-file-header
|
||||
##########################################
|
||||
|
||||
# [*.{cs,csx,cake,vb,vbx}]
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
|
||||
# SA1636: File header copyright text should match
|
||||
# Justification: .editorconfig supports file headers. If this is changed to a value other than "none", a stylecop.json file will need to added to the project.
|
||||
# dotnet_diagnostic.SA1636.severity = none
|
||||
|
||||
##########################################
|
||||
# .NET Language Conventions
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions
|
||||
@@ -136,6 +124,10 @@ dotnet_code_quality_unused_parameters = all:warning
|
||||
dotnet_style_operator_placement_when_wrapping = end_of_line
|
||||
# https://github.com/dotnet/roslyn/pull/40070
|
||||
dotnet_style_prefer_simplified_interpolation = true:warning
|
||||
# File header preferences
|
||||
file_header_template = Copyright (c) Umbraco.\nSee LICENSE for more details.
|
||||
dotnet_diagnostic.SA1633.severity = none # Suppressed until we decide to enforce it
|
||||
dotnet_diagnostic.SA1636.severity = none # Suppressed since we are using StyleCop
|
||||
|
||||
# C# Code Style Settings
|
||||
# https://docs.microsoft.com/visualstudio/ide/editorconfig-language-conventions#c-code-style-settings
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
name: "SonarQube Cloud - Analysis"
|
||||
|
||||
# This workflow runs the full SonarCloud analysis with the SONAR_TOKEN secret.
|
||||
# It is skipped for fork PRs since secrets are not available in that context.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "v*/dev"
|
||||
- "v*/main"
|
||||
- "release/*"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
SONAR_PROJECT_KEY: umbraco_Umbraco-CMS
|
||||
SONAR_ORGANIZATION: umbraco
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Build and analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET from global.json
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.sonar/cache
|
||||
key: ${{ runner.os }}-sonar
|
||||
restore-keys: ${{ runner.os }}-sonar
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
dotnet tool install --global dotnet-sonarscanner
|
||||
dotnet tool install --global dotnet-coverage
|
||||
|
||||
- name: Load sonar params
|
||||
run: echo "SONARQUBE_SCANNER_PARAMS=$(jq -c . .github/workflows/sonarcloud/sonar-params.json)" >> $GITHUB_ENV
|
||||
|
||||
- name: Begin analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: |
|
||||
dotnet-sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.token="$SONAR_TOKEN"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore umbraco.sln
|
||||
|
||||
- name: Build solution
|
||||
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: |
|
||||
dotnet-coverage collect \
|
||||
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
|
||||
--output TestResults/coverage.xml \
|
||||
--output-format xml
|
||||
|
||||
- name: End analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sonar.cs.vscoveragexml.reportsPaths": "TestResults/coverage.xml",
|
||||
"sonar.inclusions": "src/**,templates/**,tools/**,tests/**,.github/**,build/**",
|
||||
"sonar.exclusions": "**/bin/**,**/obj/**,**/node_modules/**,**/lang/*.ts,**/mocks/**,**/wwwroot/**,**/dist-cms/**,**/*.generated.cs,src/Umbraco.Web.UI/umbraco/**,src/Umbraco.Cms.Persistence.EFCore.*/Migrations/**,src/Umbraco.Web.UI.Client/src/packages/core/backend-api/**,**/.nuget/**",
|
||||
"sonar.test.inclusions": "tests/**,**/*.test.ts,**/*.spec.ts",
|
||||
"sonar.typescript.tsconfigPaths": "src/Umbraco.Web.UI.Client/tsconfig.json,src/Umbraco.Web.UI.Client/tsconfig.node.json,src/Umbraco.Web.UI.Login/tsconfig.json"
|
||||
}
|
||||
@@ -120,4 +120,8 @@ trace.zip
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
.worktrees
|
||||
.playwright-mcp/
|
||||
|
||||
# SonarQube local analysis cache
|
||||
.sonarqube/
|
||||
|
||||
@@ -48,7 +48,6 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = sug
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = suggestion
|
||||
dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = suggestion
|
||||
|
||||
dotnet_diagnostic.SA1636.severity = none # SA1636: File header copyright text should match
|
||||
dotnet_diagnostic.SA1101.severity = none # PrefixLocalCallsWithThis - stylecop appears to be ignoring dotnet_style_qualification_for_*
|
||||
dotnet_diagnostic.SA1309.severity = none # FieldNamesMustNotBeginWithUnderscore
|
||||
|
||||
|
||||
@@ -64,4 +64,14 @@
|
||||
</_ProjectReferencesWithVersions>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Workaround for https://github.com/umbraco/Umbraco-CMS/issues/23018
|
||||
Due to the amount of XML documentation in this solution, the OpenAPI XML documentation source generator produces
|
||||
too many lines of code causing a StackOverflowException when running on IIS. For that reason we disable the analyzer.
|
||||
See https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/openapi-comments?view=aspnetcore-10.0#disabling-xml-documentation-support -->
|
||||
<Target Name="DisableCompileTimeOpenApiXmlGenerator" BeforeTargets="CoreCompile" Condition="'$(IsPackable)' != 'false' or '$(IsTestProject)' == 'true'">
|
||||
<ItemGroup>
|
||||
<Analyzer Remove="@(Analyzer)" Condition="'%(Filename)' == 'Microsoft.AspNetCore.OpenApi.SourceGenerators'" />
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
|
||||
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
|
||||
<PackageVersion Include="Dazinator.Extensions.FileProviders" Version="2.0.0" />
|
||||
<PackageVersion Include="Examine" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.7.1" />
|
||||
<PackageVersion Include="Examine" Version="3.8.0" />
|
||||
<PackageVersion Include="Examine.Core" Version="3.8.0" />
|
||||
<PackageVersion Include="HtmlAgilityPack" Version="1.12.4" />
|
||||
<PackageVersion Include="JsonPatch.Net" Version="3.3.0" />
|
||||
<PackageVersion Include="K4os.Compression.LZ4" Version="1.3.8" />
|
||||
@@ -95,4 +95,4 @@
|
||||
<!-- TODO: Remove this pinned dependency when Examine updates its Microsoft.AspNetCore.DataProtection reference. -->
|
||||
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.7" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -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 1–2 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"b").Or.Contain("a"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><div class="{cssClass}" data-umb-block-property="{alias}"></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
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.Json.Schema;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
@@ -341,6 +343,12 @@ public sealed class ContentTypeSchemaTransformer : IOpenApiSchemaTransformer, IO
|
||||
|
||||
var schemaId = GetSchemaId(jsonTypeInfo);
|
||||
|
||||
// Types that produce 'true' in JSON Schema (unconstrained: JsonNode, object, custom-converter types) should be inline {} rather than named components.
|
||||
if (jsonTypeInfo.Kind == JsonTypeInfoKind.None && jsonTypeInfo.GetJsonSchemaAsNode().GetValueKind() == JsonValueKind.True)
|
||||
{
|
||||
return new OpenApiSchema();
|
||||
}
|
||||
|
||||
// If this is one of the types we handle, and we already started generating it, return a placeholder
|
||||
// to avoid circular reference issues.
|
||||
// In the document transformer, these placeholders will be replaced with the actual schemas.
|
||||
|
||||
+5
-2
@@ -2,6 +2,7 @@ using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
|
||||
@@ -32,11 +33,13 @@ public class DeleteByKeyRedirectUrlManagementController : RedirectUrlManagementC
|
||||
[MapToApiVersion("1.0")]
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Deletes a redirect URL.")]
|
||||
[EndpointDescription("Deletes a redirect URL identified by the provided Id.")]
|
||||
public Task<IActionResult> DeleteByKey(CancellationToken cancellationToken, Guid id)
|
||||
{
|
||||
_redirectUrlService.Delete(id);
|
||||
return Task.FromResult<IActionResult>(Ok());
|
||||
RedirectUrlOperationStatus status = _redirectUrlService.DeleteWithStatus(id);
|
||||
return Task.FromResult(RedirectUrlOperationStatusResult(status));
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Routing;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
@@ -14,4 +16,31 @@ namespace Umbraco.Cms.Api.Management.Controllers.RedirectUrlManagement;
|
||||
[Authorize(Policy = AuthorizationPolicies.SectionAccessContent)]
|
||||
public class RedirectUrlManagementControllerBase : ManagementApiControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps a <see cref="RedirectUrlOperationStatus"/> to an appropriate <see cref="IActionResult"/>.
|
||||
/// </summary>
|
||||
/// <param name="status">The operation status to map.</param>
|
||||
/// <returns>An <see cref="IActionResult"/> describing the outcome of the operation.</returns>
|
||||
protected IActionResult RedirectUrlOperationStatusResult(RedirectUrlOperationStatus status) =>
|
||||
OperationStatusResult(status, problemDetailsBuilder => status switch
|
||||
{
|
||||
RedirectUrlOperationStatus.Success => Ok(),
|
||||
RedirectUrlOperationStatus.NotFound => NotFound(problemDetailsBuilder
|
||||
.WithTitle("The redirect URL could not be found")
|
||||
.Build()),
|
||||
RedirectUrlOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder
|
||||
.WithTitle("Cancelled by notification")
|
||||
.WithDetail("A notification handler prevented the redirect URL operation.")
|
||||
.Build()),
|
||||
RedirectUrlOperationStatus.Unknown => StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
problemDetailsBuilder
|
||||
.WithTitle("Unknown error. Please see the log for more details.")
|
||||
.Build()),
|
||||
_ => StatusCode(
|
||||
StatusCodes.Status500InternalServerError,
|
||||
problemDetailsBuilder
|
||||
.WithTitle("Unknown redirect URL operation status.")
|
||||
.Build()),
|
||||
});
|
||||
}
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ public class CreateTemporaryFileController : TemporaryFileControllerBase
|
||||
|
||||
[HttpPost("")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Creates a temporary file.")]
|
||||
|
||||
+53
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.DataType;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.PropertyEditors;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
@@ -16,6 +19,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
private readonly IDataValueEditorFactory _dataValueEditorFactory;
|
||||
private readonly IConfigurationEditorJsonSerializer _configurationEditorJsonSerializer;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DataTypePresentationFactory> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
@@ -25,18 +29,46 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DataTypePresentationFactory> logger)
|
||||
{
|
||||
_dataTypeContainerService = dataTypeContainerService;
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_dataValueEditorFactory = dataValueEditorFactory;
|
||||
_configurationEditorJsonSerializer = configurationEditorJsonSerializer;
|
||||
_timeProvider = timeProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DataTypePresentationFactory"/> class, which is responsible for creating data type presentation models.
|
||||
/// </summary>
|
||||
/// <param name="dataTypeContainerService">Service used to manage data type containers.</param>
|
||||
/// <param name="propertyEditorCollection">A collection containing all available property editors.</param>
|
||||
/// <param name="dataValueEditorFactory">Factory for creating data value editors.</param>
|
||||
/// <param name="configurationEditorJsonSerializer">Serializer for configuration editor JSON data.</param>
|
||||
/// <param name="timeProvider">Provides the current time for time-dependent operations.</param>
|
||||
[Obsolete("Please use the constructor that takes all parameters. Scheduled for removal in Umbraco 19.")]
|
||||
public DataTypePresentationFactory(
|
||||
IDataTypeContainerService dataTypeContainerService,
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IConfigurationEditorJsonSerializer configurationEditorJsonSerializer,
|
||||
TimeProvider timeProvider)
|
||||
: this(
|
||||
dataTypeContainerService,
|
||||
propertyEditorCollection,
|
||||
dataValueEditorFactory,
|
||||
configurationEditorJsonSerializer,
|
||||
timeProvider,
|
||||
StaticServiceProvider.Instance.GetRequiredService<ILogger<DataTypePresentationFactory>>())
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -72,7 +104,6 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
dataType.Key = requestModel.Id.Value;
|
||||
}
|
||||
|
||||
|
||||
return Attempt.SucceedWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.Success, dataType);
|
||||
}
|
||||
|
||||
@@ -82,7 +113,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
var parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
EntityContainer? parent = await _dataTypeContainerService.GetAsync(requestModel.Parent.Id);
|
||||
|
||||
return parent is null
|
||||
? Attempt.FailWithStatus(DataTypeOperationStatus.ParentNotFound, 0)
|
||||
@@ -97,6 +128,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Attempt.SucceedWithStatus(DataTypeOperationStatus.Success, Constants.System.Root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<Attempt<IDataType, DataTypeOperationStatus>> CreateAsync(UpdateDataTypeRequestModel requestModel, IDataType current)
|
||||
{
|
||||
if (!_propertyEditorCollection.TryGet(requestModel.EditorAlias, out IDataEditor? editor))
|
||||
@@ -104,7 +136,7 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
return Task.FromResult(Attempt.FailWithStatus<IDataType, DataTypeOperationStatus>(DataTypeOperationStatus.PropertyEditorNotFound, new DataType(new VoidEditor(_dataValueEditorFactory), _configurationEditorJsonSerializer) ));
|
||||
}
|
||||
|
||||
IDataType dataType = (IDataType)current.DeepClone();
|
||||
var dataType = (IDataType)current.DeepClone();
|
||||
|
||||
IDictionary<string, object> configurationData = MapConfigurationData(requestModel, editor);
|
||||
dataType.Name = requestModel.Name;
|
||||
@@ -119,12 +151,26 @@ public class DataTypePresentationFactory : IDataTypePresentationFactory
|
||||
|
||||
private ValueStorageType GetEditorValueStorageType(IDataEditor editor, IDictionary<string, object> configurationData)
|
||||
{
|
||||
var configurationObject = editor.GetConfigurationEditor()
|
||||
.ToConfigurationObject(configurationData, _configurationEditorJsonSerializer);
|
||||
|
||||
if (configurationObject is IConfigureValueType configureValueType)
|
||||
// Only editors whose configuration object implements IConfigureValueType derive their storage
|
||||
// type from the configuration. Building the typed configuration object can throw for editors
|
||||
// whose stored configuration doesn't cleanly deserialize into their configuration type; that
|
||||
// must not fail the save, so fall back to the value editor's value type in that case.
|
||||
try
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
if (editor.GetConfigurationEditor().ToConfigurationObject(configurationData, _configurationEditorJsonSerializer)
|
||||
is IConfigureValueType configureValueType)
|
||||
{
|
||||
return ValueTypes.ToStorageType(configureValueType.ValueType);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Configuration editors are third-party and can throw anything when the stored configuration
|
||||
// doesn't deserialize into their configuration type. Fall back to the value editor's value type
|
||||
// rather than failing the save, but log so the misconfiguration remains observable.
|
||||
_logger.LogError(
|
||||
"Could not build the configuration object for editor {EditorAlias} to determine its value storage type; falling back to the value editor's value type.",
|
||||
editor.Alias);
|
||||
}
|
||||
|
||||
var valueType = editor.GetValueEditor().ValueType;
|
||||
|
||||
@@ -49,7 +49,8 @@ public abstract class ContentTypeMapDefinition<TContentType, TPropertyTypeModel,
|
||||
},
|
||||
Appearance = new PropertyTypeAppearance
|
||||
{
|
||||
LabelOnTop = propertyType.LabelOnTop
|
||||
LabelOnTop = propertyType.LabelOnTop,
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor,
|
||||
}
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
+142
-2
@@ -33430,7 +33430,7 @@
|
||||
"operationId": "PostTemporaryFile",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/x-www-form-urlencoded": {
|
||||
"multipart/form-data": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -38519,6 +38519,76 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/visual-editor/render": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Visual Editor"
|
||||
],
|
||||
"summary": "Renders a document with unsaved visual editor values.",
|
||||
"operationId": "PostVisualEditorRender",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VisualEditorRenderRequestModel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VisualEditorRenderResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "The resource is protected and requires an authentication token"
|
||||
},
|
||||
"403": {
|
||||
"description": "The authenticated user does not have access to this resource",
|
||||
"headers": {
|
||||
"Umb-Notifications": {
|
||||
"description": "The list of notifications produced during the request.",
|
||||
"schema": {
|
||||
"type": [
|
||||
"null",
|
||||
"array"
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/NotificationHeaderModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"Backoffice-User": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/umbraco/management/api/v1/item/webhook": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -49208,12 +49278,16 @@
|
||||
},
|
||||
"PropertyTypeAppearanceModel": {
|
||||
"required": [
|
||||
"labelOnTop"
|
||||
"labelOnTop",
|
||||
"editableInVisualEditor"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"labelOnTop": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"editableInVisualEditor": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -53134,6 +53208,72 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorPropertyValueModel": {
|
||||
"required": [
|
||||
"alias"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {},
|
||||
"culture": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"segment": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorRenderRequestModel": {
|
||||
"required": [
|
||||
"unique",
|
||||
"values"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"unique": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"culture": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"segment": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"values": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/VisualEditorPropertyValueModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisualEditorRenderResponseModel": {
|
||||
"required": [
|
||||
"html"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"html": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WebhookEventModel": {
|
||||
"required": [
|
||||
"eventName",
|
||||
|
||||
@@ -73,6 +73,6 @@ public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
|
||||
Controller = ControllerExtensions.GetControllerName<BackOfficeDefaultController>(),
|
||||
Action = nameof(BackOfficeDefaultController.Index),
|
||||
},
|
||||
constraints: new { slug = @"^(section|preview|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
constraints: new { slug = @"^(section|preview|visual-editor|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
|
||||
/// Gets or sets a value indicating whether the label for the property type is displayed above the input.
|
||||
/// </summary>
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
}
|
||||
|
||||
+19
@@ -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; }
|
||||
}
|
||||
+19
@@ -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; } = [];
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.VisualEditor;
|
||||
|
||||
/// <summary>
|
||||
/// The rendered HTML for a visual editor preview render request.
|
||||
/// </summary>
|
||||
public class VisualEditorRenderResponseModel
|
||||
{
|
||||
/// <summary>Gets or sets the rendered page HTML.</summary>
|
||||
public required string Html { get; set; }
|
||||
}
|
||||
@@ -15,8 +15,9 @@ SQLite-specific EF Core provider for Umbraco CMS. Contains SQLite migrations and
|
||||
This is a thin provider project that implements SQLite-specific functionality for the EF Core persistence layer:
|
||||
|
||||
1. **Migration Provider** - Executes SQLite-specific migrations
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite
|
||||
2. **Migration Provider Setup** - Configures DbContext to use SQLite (incl. transient-error retry)
|
||||
3. **Migrations** - SQLite-specific migration files for OpenIddict tables
|
||||
4. **Retrying Execution Strategy** - Retries transient SQLite lock errors on EF Core operations
|
||||
|
||||
### Folder Structure
|
||||
|
||||
@@ -30,7 +31,8 @@ Umbraco.Cms.Persistence.EFCore.Sqlite/
|
||||
│ └── UmbracoDbContextModelSnapshot.cs # Current model state
|
||||
├── EFCoreSqliteComposer.cs # DI registration
|
||||
├── SqliteMigrationProvider.cs # IMigrationProvider impl
|
||||
└── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
├── SqliteMigrationProviderSetup.cs # IMigrationProviderSetup impl
|
||||
└── SqliteRetryingExecutionStrategy.cs # IExecutionStrategy for transient lock errors
|
||||
```
|
||||
|
||||
### Relationship with Parent Project
|
||||
@@ -65,7 +67,19 @@ Registers `IMigrationProvider` and `IMigrationProviderSetup` for SQLite.
|
||||
|
||||
### SqliteMigrationProviderSetup (line 11-14)
|
||||
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite` and migrations assembly.
|
||||
Configures `DbContextOptionsBuilder` with `UseSqlite`, the migrations assembly, and the
|
||||
`SqliteRetryingExecutionStrategy` (see below). Invoked from
|
||||
`UmbracoDbContext.ConfigureOptions` for every `UmbracoDbContext` instance, so all EF Core
|
||||
access to the Umbraco database (including OpenIddict's token store) inherits the retry.
|
||||
|
||||
### SqliteRetryingExecutionStrategy
|
||||
|
||||
Custom `Microsoft.EntityFrameworkCore.Storage.ExecutionStrategy` that retries on transient
|
||||
SQLite errors (`SQLITE_BUSY`, `SQLITE_LOCKED`) using `SqliteExceptionExtensions.IsBusyOrLocked`
|
||||
from the parent project. Defaults inherit `ExecutionStrategy.DefaultMaxRetryCount` (6) and
|
||||
`ExecutionStrategy.DefaultMaxDelay` (30s), giving a ~56-second retry budget — see the class's
|
||||
XML doc for the rationale and the unattended-upgrade escape hatch for very long migrations.
|
||||
Added to resolve issue #22939 (OpenIddict token reads failing during long migrations).
|
||||
|
||||
---
|
||||
|
||||
@@ -122,7 +136,8 @@ All tables prefixed with `umbraco`:
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SqliteMigrationProvider.cs` | Migration execution |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration |
|
||||
| `SqliteMigrationProviderSetup.cs` | DbContext configuration (UseSqlite + retry strategy) |
|
||||
| `SqliteRetryingExecutionStrategy.cs` | Retry on transient SQLite BUSY/LOCKED errors |
|
||||
| `EFCoreSqliteComposer.cs` | DI registration |
|
||||
| `Migrations/*.cs` | Migration files |
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Persistence.EFCore.Migrations;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
@@ -15,6 +14,15 @@ public class SqliteMigrationProviderSetup : IMigrationProviderSetup
|
||||
/// <inheritdoc />
|
||||
public void Setup(DbContextOptionsBuilder builder, string? connectionString)
|
||||
{
|
||||
builder.UseSqlite(connectionString, x => x.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
builder.UseSqlite(connectionString, x =>
|
||||
{
|
||||
x.MigrationsAssembly(GetType().Assembly.FullName);
|
||||
|
||||
// Retry transient SQLite errors (BUSY / LOCKED). See SqliteRetryingExecutionStrategy
|
||||
// for the rationale — long-running migrations or schema-modifying operations can
|
||||
// briefly lock the database in a way that surfaces as a hard error to concurrent
|
||||
// EF Core readers (notably OpenIddict token validation). See issue #22939.
|
||||
x.ExecutionStrategy(deps => new SqliteRetryingExecutionStrategy(deps));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// EF Core execution strategy that retries on transient SQLite errors (BUSY / LOCKED).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// SQLite serialises writers at the database level, and schema-modifying statements briefly
|
||||
/// block readers — even in WAL mode. Without retries, concurrent EF Core reads (for example
|
||||
/// OpenIddict's token validation against <c>umbracoOpenIddictTokens</c>) surface those
|
||||
/// transient locks as <see cref="SqliteException"/> and fail the caller's request.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Microsoft does not ship a built-in execution strategy for SQLite (only the SQL Server
|
||||
/// equivalent), so we provide this one. It piggy-backs on <see cref="ExecutionStrategy"/>'s
|
||||
/// default exponential backoff and re-uses its inherited
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxRetryCount"/> (6) and
|
||||
/// <see cref="ExecutionStrategy.DefaultMaxDelay"/> (30 seconds), which produce a delay
|
||||
/// schedule of roughly 0s, 1s, 3s, 7s, 15s, 30s — a ~56-second retry window.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On top of those EF Core delays, <c>SQLITE_BUSY</c> (error 5) is also retried internally
|
||||
/// by Microsoft.Data.Sqlite for up to the connection's <c>Default Timeout</c> (30 seconds
|
||||
/// by default) per attempt. <c>SQLITE_LOCKED</c> (error 6) is not — it returns immediately,
|
||||
/// so EF Core's retry budget is the only buffer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public class SqliteRetryingExecutionStrategy : ExecutionStrategy
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class
|
||||
/// with default retry settings inherited from <see cref="ExecutionStrategy"/>.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
public SqliteRetryingExecutionStrategy(ExecutionStrategyDependencies dependencies)
|
||||
: this(dependencies, DefaultMaxRetryCount, DefaultMaxDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqliteRetryingExecutionStrategy"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dependencies">Parameter object containing service dependencies.</param>
|
||||
/// <param name="maxRetryCount">The maximum number of retry attempts.</param>
|
||||
/// <param name="maxRetryDelay">The maximum delay between retries.</param>
|
||||
public SqliteRetryingExecutionStrategy(
|
||||
ExecutionStrategyDependencies dependencies,
|
||||
int maxRetryCount,
|
||||
TimeSpan maxRetryDelay)
|
||||
: base(dependencies, maxRetryCount, maxRetryDelay)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool ShouldRetryOn(Exception exception)
|
||||
{
|
||||
// EF Core wraps provider exceptions, so walk the inner-exception chain.
|
||||
for (Exception? current = exception; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is SqliteException sqlite && sqlite.IsBusyOrLocked())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+1
-7
@@ -184,17 +184,11 @@ internal sealed class SqliteEFCoreDistributedLockingMechanism<T> : IDistributedL
|
||||
throw new ArgumentException($"LockObject with id={LockId} does not exist.");
|
||||
}
|
||||
}
|
||||
catch (SqliteException ex) when (IsBusyOrLocked(ex))
|
||||
catch (SqliteException ex) when (ex.IsBusyOrLocked())
|
||||
{
|
||||
throw new DistributedWriteLockTimeoutException(LockId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static bool IsBusyOrLocked(SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using SQLitePCL;
|
||||
|
||||
namespace Umbraco.Cms.Persistence.EFCore;
|
||||
|
||||
/// <summary>
|
||||
/// SQLite-specific exception helpers for code running on the EF Core persistence stack.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A parallel helper exists at <c>Umbraco.Cms.Persistence.Sqlite.Services.SqliteExceptionExtensions</c>
|
||||
/// for the NPoco stack. Both stacks are independent (neither references the other) so the small
|
||||
/// duplication is intentional — keeps the layering clean.
|
||||
/// </remarks>
|
||||
public static class SqliteExceptionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if the SQLite exception is a BUSY or LOCKED error.
|
||||
/// </summary>
|
||||
/// <param name="ex">The SQLite exception to check.</param>
|
||||
/// <returns><c>true</c> if the error is BUSY, LOCKED, or LOCKED_SHAREDCACHE; otherwise <c>false</c>.</returns>
|
||||
public static bool IsBusyOrLocked(this SqliteException ex) =>
|
||||
ex.SqliteErrorCode
|
||||
is raw.SQLITE_BUSY
|
||||
or raw.SQLITE_LOCKED
|
||||
or raw.SQLITE_LOCKED_SHAREDCACHE;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementContainerUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementCopy : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementDelete : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementMove : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementNew : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementPublish : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementRollback : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -21,7 +21,7 @@ public class ActionElementUpdate : IAction
|
||||
public string Alias => ActionAlias;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShowInNotifier => true;
|
||||
public bool ShowInNotifier => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanBePermissionAssigned => true;
|
||||
|
||||
@@ -368,8 +368,17 @@ public class ObjectCacheAppCache : IAppPolicyCache, IDisposable
|
||||
}
|
||||
|
||||
// Ensure key is removed from set when evicted from cache
|
||||
return options.RegisterPostEvictionCallback((key, _, _, _) =>
|
||||
return options.RegisterPostEvictionCallback((key, _, reason, _) =>
|
||||
{
|
||||
// Removed and Replaced evictions don't need pruning here: the Remove/Clear call sites already
|
||||
// prune the tracking set synchronously under the write lock, and a Replaced key still has a
|
||||
// live entry (the synchronous Set re-added it). Pruning here instead runs on a background
|
||||
// thread and races with that re-add, dropping a key whose entry is still cached. (#23064)
|
||||
if (reason is EvictionReason.Removed or EvictionReason.Replaced)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_locker.TryEnterWriteLock(_writeLockTimeout) is false)
|
||||
|
||||
@@ -405,7 +405,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -454,7 +454,8 @@
|
||||
<key alias="httpsCheckConfigurationRectifyNotPossible">Mae gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i 'false' yn eich ffeil appSettings.json. Unwaith y byddwch yn cyrchu'r wefan hon gan ddefnyddio'r cynllun HTTPS, dylid gosod hwnnw i 'true'.</key>
|
||||
<key alias="httpsCheckConfigurationCheckResult">Mae'r gosodiad ap 'Umbraco:CMS:Global:UseHttps' wedi'i osod i '%0%' yn eich ffeil appSettings.json, mae eich cwcis %1% wedi'u marcio'n ddiogel.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue">Mae gosodiad yr ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod i <strong>%0%</strong>.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod, felly bydd URL y rhaglen yn cael ei ganfod yn awtomatig o geisiadau sy'n dod i mewn. Argymhellir ei osod yn benodol.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[Nid yw gosodiad ap 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' wedi'i osod ac mae canfod URL y rhaglen yn awtomatig wedi'i analluogi (mae 'Umbraco:CMS:WebRouting:ApplicationUrlDetection' yn 'None'). Ni fydd nodweddion sydd angen URL absoliwt, fel e-byst ailosod cyfrinair a gwahoddiadau, yn gweithio. Gosodwch URL y rhaglen yn benodol, neu galluogwch ganfod yn awtomatig.]]></key>
|
||||
<key alias="smtpMailSettingsNotFound">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp'.</key>
|
||||
<key alias="smtpMailSettingsHostNotConfigured">Nid oedd modd dod o hyd i'r ffurfweddiad 'Umbraco:CMS:Global:Smtp:Host'.</key>
|
||||
<key alias="smtpMailSettingsConnectionFail">Methwyd cyrraedd y gweinydd SMTP a ffurfweddwyd gyda gwesteiwr '%0%' a phorth '%1%'. Gwiriwch i sicrhau bod y gosodiadau SMTP yn y ffurfweddiad 'Umbraco:CMS:Global:Smtp' yn gywir.</key>
|
||||
|
||||
@@ -463,7 +463,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -452,7 +452,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is set to <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set, so the application URL will be auto-detected from incoming requests. Setting it explicitly is recommended.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[The appSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' is not set and application URL auto-detection is disabled ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' is 'None'). Features that require an absolute URL, such as password reset and invitation emails, will not work. Set the application URL explicitly, or enable auto-detection.]]></key>
|
||||
<key alias="clickJackingCheckHeaderFound">
|
||||
<![CDATA[The header or meta-tag <strong>X-Frame-Options</strong> used to control whether a site can be IFRAMEd by another was found.]]></key>
|
||||
<key alias="clickJackingCheckHeaderNotFound">
|
||||
|
||||
@@ -403,7 +403,8 @@
|
||||
0: Comma delimitted list of failed folder paths
|
||||
-->
|
||||
<key alias="umbracoApplicationUrlCheckResultTrue"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' je postavljen na <strong>%0%</strong>.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse">AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen.</key>
|
||||
<key alias="umbracoApplicationUrlCheckResultFalse"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, pa će se URL aplikacije automatski otkriti iz dolaznih zahtjeva. Preporučuje se da ga postavite izričito.]]></key>
|
||||
<key alias="umbracoApplicationUrlCheckResultError"><![CDATA[AppSetting 'Umbraco:CMS:WebRouting:UmbracoApplicationUrl' nije postavljen, a automatsko otkrivanje URL-a aplikacije je onemogućeno ('Umbraco:CMS:WebRouting:ApplicationUrlDetection' je 'None'). Značajke koje zahtijevaju apsolutni URL, poput e-pošte za poništavanje lozinke i pozivnica, neće raditi. Postavite URL aplikacije izričito ili omogućite automatsko otkrivanje.]]></key>
|
||||
<!-- The following key get these tokens passed in:
|
||||
0: Comma delimitted list of headers found
|
||||
-->
|
||||
|
||||
@@ -316,6 +316,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -356,6 +358,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -373,6 +377,23 @@ public static class PublishedContentExtensions
|
||||
return property == null ? default : property.Value<T>(publishedValueFallback, culture, segment, fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a visual editor property access for property types
|
||||
/// that have been marked as editable in the visual editor.
|
||||
/// </summary>
|
||||
private static void TrackVisualEditorAccess(IPublishedProperty? property, string alias, Guid contentKey)
|
||||
{
|
||||
if (property is null || !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType.EditableInVisualEditor)
|
||||
{
|
||||
VisualEditorPropertyTracker.RecordAccess(alias, contentKey);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsSomething: misc.
|
||||
|
||||
@@ -730,6 +730,10 @@ public static partial class StringExtensions
|
||||
/// </summary>
|
||||
/// <param name="fileName">The file name to convert.</param>
|
||||
/// <returns>A friendly name with the extension stripped, underscores and dashes converted to spaces, and title case applied.</returns>
|
||||
/// <remarks>
|
||||
/// Mirrored client-side in <c>src/Umbraco.Web.UI.Client/src/packages/media/media/utils/to-friendly-name.function.ts</c>;
|
||||
/// keep the two implementations in sync.
|
||||
/// </remarks>
|
||||
public static string ToFriendlyName(this string fileName)
|
||||
{
|
||||
// strip the file extension
|
||||
|
||||
@@ -44,28 +44,34 @@ public class UmbracoApplicationUrlCheck : HealthCheck
|
||||
|
||||
private HealthCheckStatus CheckUmbracoApplicationUrl()
|
||||
{
|
||||
var url = _webRoutingSettings.CurrentValue.UmbracoApplicationUrl;
|
||||
WebRoutingSettings settings = _webRoutingSettings.CurrentValue;
|
||||
var url = settings.UmbracoApplicationUrl;
|
||||
|
||||
string resultMessage;
|
||||
StatusResultType resultType;
|
||||
var success = false;
|
||||
|
||||
if (url.IsNullOrWhiteSpace())
|
||||
if (url.IsNullOrWhiteSpace() is false)
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", [url]);
|
||||
resultType = StatusResultType.Success;
|
||||
}
|
||||
else if (settings.ApplicationUrlDetection == ApplicationUrlDetection.None)
|
||||
{
|
||||
// No explicit URL and auto-detection is disabled, so the application URL can never be established.
|
||||
// Features that require an absolute URL (e.g. password reset and invitation emails) will not work.
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultError");
|
||||
resultType = StatusResultType.Error;
|
||||
}
|
||||
else
|
||||
{
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultTrue", new[] { url });
|
||||
resultType = StatusResultType.Success;
|
||||
success = true;
|
||||
resultMessage = _textService.Localize("healthcheck", "umbracoApplicationUrlCheckResultFalse");
|
||||
resultType = StatusResultType.Warning;
|
||||
}
|
||||
|
||||
return new HealthCheckStatus(resultMessage)
|
||||
{
|
||||
ResultType = resultType,
|
||||
ReadMoreLink = success
|
||||
ReadMoreLink = resultType == StatusResultType.Success
|
||||
? null
|
||||
: Constants.HealthChecks.DocumentationLinks.Security.UmbracoApplicationUrlCheck,
|
||||
};
|
||||
|
||||
@@ -64,8 +64,4 @@ public class BlockGridLayoutItem : BlockLayoutItemBase
|
||||
/// <inheritdoc />
|
||||
public override bool ReferencesSetting(Guid key)
|
||||
=> SettingsKey == key || Areas.Any(area => area.ContainsSetting(key));
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IEnumerable<IBlockLayoutItem> GetContainedLayouts()
|
||||
=> Areas.SelectMany(area => area.Items);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,12 @@ namespace Umbraco.Cms.Core.Models.Blocks;
|
||||
/// </summary>
|
||||
public abstract class BlockLayoutItemBase : IBlockLayoutItem
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Guid Key { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid ContentKey { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid? SettingsKey { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsExternalContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlockLayoutItemBase" /> class.
|
||||
/// </summary>
|
||||
@@ -50,7 +44,4 @@ public abstract class BlockLayoutItemBase : IBlockLayoutItem
|
||||
/// <inheritdoc />
|
||||
public virtual bool ReferencesSetting(Guid key)
|
||||
=> SettingsKey == key;
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual IEnumerable<IBlockLayoutItem> GetContainedLayouts() => [];
|
||||
}
|
||||
|
||||
@@ -8,18 +8,6 @@ namespace Umbraco.Cms.Core.Models.Blocks;
|
||||
/// </summary>
|
||||
public interface IBlockLayoutItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the layout item key.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The layout item key.
|
||||
/// </value>
|
||||
/// <remarks>
|
||||
/// Uniquely identifies a layout item. Previously the <see cref="ContentKey"/> could be used for this, but
|
||||
/// with reusable elements, the same <see cref="ContentKey"/> can appear multiple times in one layout.
|
||||
/// </remarks>
|
||||
public Guid Key { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content key.
|
||||
/// </summary>
|
||||
@@ -36,11 +24,6 @@ public interface IBlockLayoutItem
|
||||
/// </value>
|
||||
public Guid? SettingsKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the content source is local or originates from the element service.
|
||||
/// </summary>
|
||||
public bool IsExternalContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this layout item references the specified content key.
|
||||
/// </summary>
|
||||
@@ -58,10 +41,4 @@ public interface IBlockLayoutItem
|
||||
/// <c>true</c> if this layout item references the specified settings key; otherwise, <c>false</c>.
|
||||
/// </returns>
|
||||
public bool ReferencesSetting(Guid key) => SettingsKey == key;
|
||||
|
||||
/// <summary>
|
||||
/// Returns any nested layouts for this layout (e.g. area layouts for the Block Grid).
|
||||
/// </summary>
|
||||
/// <returns>The nested layouts.</returns>
|
||||
public IEnumerable<IBlockLayoutItem> GetContainedLayouts();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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<T>()</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);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published after one or more redirect URLs have been deleted.
|
||||
/// </summary>
|
||||
public class RedirectUrlDeletedNotification : DeletedNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with a single redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL that was deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletedNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletedNotification" /> class with multiple redirect URLs.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs that were deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published before one or more redirect URLs are deleted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This notification is cancelable, allowing handlers to prevent the delete operation
|
||||
/// by setting <see cref="ICancelableNotification.Cancel" /> to <c>true</c>.
|
||||
/// </remarks>
|
||||
public class RedirectUrlDeletingNotification : DeletingNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with a single redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL being deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletingNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlDeletingNotification" /> class with multiple redirect URLs.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs being deleted.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlDeletingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published after a redirect URL has been saved.
|
||||
/// </summary>
|
||||
public class RedirectUrlSavedNotification : SavedNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL that was saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavedNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavedNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs that were saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavedNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Notification published before a redirect URL is saved.
|
||||
/// </summary>
|
||||
public class RedirectUrlSavingNotification : SavingNotification<IRedirectUrl>
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URL being saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavingNotification(IRedirectUrl target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedirectUrlSavingNotification" /> class.
|
||||
/// </summary>
|
||||
/// <param name="target">The redirect URLs being saved.</param>
|
||||
/// <param name="messages">The event messages collection.</param>
|
||||
public RedirectUrlSavingNotification(IEnumerable<IRedirectUrl> target, EventMessages messages)
|
||||
: base(target, messages)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property index value factory specifically for block list properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This marker interface allows for specialized indexing of block list content.
|
||||
/// </remarks>
|
||||
public interface IBlockListPropertyIndexValueFactory : IPropertyIndexValueFactory
|
||||
{
|
||||
}
|
||||
+4
-3
@@ -1,11 +1,12 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property index value factory specifically for block grid properties.
|
||||
/// Represents a property index value factory specifically for block-based property values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This marker interface allows for specialized indexing of block grid content.
|
||||
/// This marker interface allows for specialized indexing of block content,
|
||||
/// such as Block List, Block Grid, and Rich Text block values.
|
||||
/// </remarks>
|
||||
public interface IBlockGridPropertyIndexValueFactory : IPropertyIndexValueFactory
|
||||
public interface IBlockValuePropertyIndexValueFactory : IPropertyIndexValueFactory
|
||||
{
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a property index value factory specifically for single block properties.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This marker interface allows for specialized indexing of single block content.
|
||||
/// </remarks>
|
||||
public interface ISingleBlockPropertyIndexValueFactory : IPropertyIndexValueFactory
|
||||
{
|
||||
}
|
||||
@@ -3,17 +3,7 @@ using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// A service for converting <see cref="BlockItemData"/> into <see cref="IPublishedElement"/>.
|
||||
/// </summary>
|
||||
public interface IBlockElementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IPublishedElement"/> instance from <see cref="BlockItemData"/>.
|
||||
/// </summary>
|
||||
/// <param name="owner">The <see cref="IPublishedElement"/> that contains the block property which is the origin to the <see cref="BlockItemData"/>.</param>
|
||||
/// <param name="blockItemData">The <see cref="BlockItemData"/> containing the data to convert into an <see cref="IPublishedElement"/>.</param>
|
||||
/// <param name="preview">Whether to perform the conversion for preview.</param>
|
||||
/// <returns>The created <see cref="IPublishedElement"/>, or null if an element could not be created from the <see cref="BlockItemData"/>.</returns>
|
||||
Task<IPublishedElement?> BuildElementAsync(IPublishedElement owner, BlockItemData blockItemData, bool? preview = null);
|
||||
Task<IPublishedElement?> BuildElementAsync(BlockItemData blockItemData, bool? preview = null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="IPublishedContent"/> for the visual editor preview: the requested document's
|
||||
/// draft content with a set of unsaved property values overlaid on top, converted to their published form.
|
||||
/// </summary>
|
||||
public interface IVisualEditorContentFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the draft content for <paramref name="documentKey"/> and returns a preview
|
||||
/// <see cref="IPublishedContent"/> whose overridden aliases yield the converted unsaved values.
|
||||
/// Returns <c>null</c> if the document does not exist.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The key of the document whose draft content will be used as the base.</param>
|
||||
/// <param name="overrides">The unsaved property values to overlay on top of the draft content.</param>
|
||||
/// <returns>
|
||||
/// A preview <see cref="IPublishedContent"/> with the overrides applied,
|
||||
/// or <c>null</c> if the document cannot be resolved.
|
||||
/// </returns>
|
||||
Task<IPublishedContent?> CreateWithOverridesAsync(
|
||||
Guid documentKey,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// A single unsaved property value to overlay onto draft content when rendering the visual editor preview.
|
||||
/// </summary>
|
||||
/// <param name="Alias">The property alias to override.</param>
|
||||
/// <param name="EditorValue">
|
||||
/// The editor-format value as held by the backoffice workspace. Complex editors (rich text, block list)
|
||||
/// expect their serialized JSON; plain editors (e.g. text box) expect the raw value.
|
||||
/// </param>
|
||||
/// <param name="Culture">The culture the override applies to, or <c>null</c> for invariant.</param>
|
||||
/// <param name="Segment">The segment the override applies to, or <c>null</c> for none.</param>
|
||||
public readonly record struct VisualEditorPropertyOverride(string Alias, object? EditorValue, string? Culture, string? Segment);
|
||||
@@ -821,6 +821,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
propertyType.Description = property.Description;
|
||||
propertyType.SortOrder = property.SortOrder;
|
||||
propertyType.LabelOnTop = property.Appearance.LabelOnTop;
|
||||
propertyType.EditableInVisualEditor = property.Appearance.EditableInVisualEditor;
|
||||
|
||||
propertyType.PropertyGroupId = propertyGroup is null
|
||||
? null
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
@@ -15,26 +15,105 @@ public interface IRedirectUrlService : IService
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <param name="culture">The culture.</param>
|
||||
/// <remarks>Is a proper Umbraco route eg /path/to/foo or 123/path/tofoo.</remarks>
|
||||
[Obsolete("Use RegisterWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Register(string url, Guid contentKey, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="oldUrl">The previous Umbraco URL route the redirect is being created from.</param>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <param name="culture">The culture.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="Attempt{TResult,TStatus}" /> containing the registered redirect URL on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation, and rename this back to "Register" when the obsolete Register overload is removed.
|
||||
Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Register(oldUrl, contentKey, culture);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs for a given content.
|
||||
/// </summary>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
[Obsolete("Use DeleteContentRedirectUrlsWithStatus to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void DeleteContentRedirectUrls(Guid contentKey);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs for a given content, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="contentKey">The content unique key.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete DeleteContentRedirectUrls overload is removed.
|
||||
RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
DeleteContentRedirectUrls(contentKey);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="redirectUrl">The redirect URL to delete.</param>
|
||||
[Obsolete("Use DeleteWithStatus(IRedirectUrl) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Delete(IRedirectUrl redirectUrl);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="redirectUrl">The redirect URL to delete.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete Delete(IRedirectUrl) overload is removed.
|
||||
RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Delete(redirectUrl);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL.
|
||||
/// </summary>
|
||||
/// <param name="id">The redirect URL identifier.</param>
|
||||
[Obsolete("Use DeleteWithStatus(Guid) to support cancellation via notifications. Scheduled for removal in Umbraco 20.")]
|
||||
void Delete(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a redirect URL by its identifier, returning the operation status.
|
||||
/// </summary>
|
||||
/// <param name="id">The redirect URL identifier.</param>
|
||||
/// <returns>
|
||||
/// <see cref="RedirectUrlOperationStatus.Success" /> on success,
|
||||
/// <see cref="RedirectUrlOperationStatus.NotFound" /> if no redirect URL with the given identifier exists, or
|
||||
/// <see cref="RedirectUrlOperationStatus.CancelledByNotification" /> if a notification handler
|
||||
/// canceled the operation.
|
||||
/// </returns>
|
||||
// TODO (V20): Remove the default implementation when the obsolete Delete(Guid) overload is removed.
|
||||
RedirectUrlOperationStatus DeleteWithStatus(Guid id)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Delete(id);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all redirect URLs.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the status of a redirect URL operation.
|
||||
/// </summary>
|
||||
public enum RedirectUrlOperationStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// The operation completed successfully.
|
||||
/// </summary>
|
||||
Success,
|
||||
|
||||
/// <summary>
|
||||
/// The operation was cancelled by a notification handler.
|
||||
/// </summary>
|
||||
CancelledByNotification,
|
||||
|
||||
/// <summary>
|
||||
/// The operation failed because the redirect URL could not be found.
|
||||
/// </summary>
|
||||
NotFound,
|
||||
|
||||
/// <summary>
|
||||
/// An unknown error occurred during the operation.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
@@ -25,45 +27,141 @@ internal sealed class RedirectUrlService : RepositoryService, IRedirectUrlServic
|
||||
_redirectUrlRepository = redirectUrlRepository;
|
||||
|
||||
/// <inheritdoc/>
|
||||
[Obsolete("Use RegisterWithStatus instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Register(string url, Guid contentKey, string? culture = null)
|
||||
=> RegisterWithStatus(url, contentKey, culture);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Attempt<IRedirectUrl?, RedirectUrlOperationStatus> RegisterWithStatus(string oldUrl, Guid contentKey, string? culture = null)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
IRedirectUrl? redir = _redirectUrlRepository.Get(url, contentKey, culture);
|
||||
IRedirectUrl? redir = _redirectUrlRepository.Get(oldUrl, contentKey, culture);
|
||||
if (redir != null)
|
||||
{
|
||||
redir.CreateDateUtc = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = url, ContentKey = contentKey, Culture = culture };
|
||||
redir = new RedirectUrl { Key = Guid.NewGuid(), Url = oldUrl, ContentKey = contentKey, Culture = culture };
|
||||
}
|
||||
|
||||
// Use a detached EventMessages instance so a handler cancelling the save does not surface a
|
||||
// notification in the backoffice. Redirect creation is a silent side-effect of publishing, so a
|
||||
// cancellation is not something the editor triggered or can act on - unlike deletion (an explicit
|
||||
// editor action), where the sibling methods deliberately use EventMessagesFactory.Get() instead.
|
||||
var eventMessages = new EventMessages();
|
||||
var savingNotification = new RedirectUrlSavingNotification(redir, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return Attempt.FailWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.CancelledByNotification, redir);
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Save(redir);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlSavedNotification(redir, eventMessages)
|
||||
.WithStateFrom(savingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return Attempt.SucceedWithStatus<IRedirectUrl?, RedirectUrlOperationStatus>(RedirectUrlOperationStatus.Success, redir);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(IRedirectUrl redirectUrl)
|
||||
[Obsolete("Use DeleteWithStatus(IRedirectUrl) instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Delete(IRedirectUrl redirectUrl) => DeleteWithStatus(redirectUrl);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteWithStatus(IRedirectUrl redirectUrl)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Delete(redirectUrl);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Guid id)
|
||||
[Obsolete("Use DeleteWithStatus(Guid) instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void Delete(Guid id) => DeleteWithStatus(id);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteWithStatus(Guid id)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
IRedirectUrl? redirectUrl = _redirectUrlRepository.Get(id);
|
||||
|
||||
if (redirectUrl is null)
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrl, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.Delete(id);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrl, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DeleteContentRedirectUrls(Guid contentKey)
|
||||
[Obsolete("Use DeleteContentRedirectUrlsWithStatus instead. Scheduled for removal in Umbraco 20.")]
|
||||
public void DeleteContentRedirectUrls(Guid contentKey) => DeleteContentRedirectUrlsWithStatus(contentKey);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public RedirectUrlOperationStatus DeleteContentRedirectUrlsWithStatus(Guid contentKey)
|
||||
{
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
|
||||
IRedirectUrl[] redirectUrls = _redirectUrlRepository.GetContentUrls(contentKey).ToArray();
|
||||
|
||||
if (redirectUrls.Length == 0)
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
EventMessages eventMessages = EventMessagesFactory.Get();
|
||||
var deletingNotification = new RedirectUrlDeletingNotification(redirectUrls, eventMessages);
|
||||
|
||||
if (scope.Notifications.PublishCancelable(deletingNotification))
|
||||
{
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
_redirectUrlRepository.DeleteContentUrls(contentKey);
|
||||
|
||||
scope.Notifications.Publish(new RedirectUrlDeletedNotification(redirectUrls, eventMessages)
|
||||
.WithStateFrom(deletingNotification));
|
||||
|
||||
scope.Complete();
|
||||
return RedirectUrlOperationStatus.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+13
-2
@@ -84,8 +84,19 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(serverAddress))
|
||||
{
|
||||
_logger.LogWarning("No umbracoApplicationUrl for service (yet), skip.");
|
||||
return Task.CompletedTask;
|
||||
// No application URL is known yet: either detection is off (WebRouting:ApplicationUrlDetection is
|
||||
// None with no UmbracoApplicationUrl set), or detection is on but no request has been served yet.
|
||||
// Register with the machine name as a placeholder so server-role election can still proceed (uniqueness
|
||||
// comes from the server identity, not this address). If a URL is later detected from a request, the next
|
||||
// touch overwrites the placeholder.
|
||||
serverAddress = Environment.MachineName;
|
||||
_logger.LogDebug(
|
||||
"No application URL available; registering server with placeholder address {ServerAddress}.",
|
||||
serverAddress);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
try
|
||||
|
||||
@@ -285,9 +285,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
/// <returns>The same <see cref="Umbraco.Cms.Core.DependencyInjection.IUmbracoBuilder"/> instance so that multiple calls can be chained.</returns>
|
||||
public static IUmbracoBuilder AddPropertyIndexValueFactories(this IUmbracoBuilder builder)
|
||||
{
|
||||
builder.Services.AddSingleton<IBlockListPropertyIndexValueFactory, BlockListPropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<IBlockGridPropertyIndexValueFactory, BlockGridPropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<ISingleBlockPropertyIndexValueFactory, SingleBlockPropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<IBlockValuePropertyIndexValueFactory, BlockValuePropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<ITagPropertyIndexValueFactory, TagPropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<IRichTextPropertyIndexValueFactory, RichTextPropertyIndexValueFactory>();
|
||||
builder.Services.AddSingleton<IDateOnlyPropertyIndexValueFactory, DateOnlyPropertyIndexValueFactory>();
|
||||
|
||||
@@ -94,6 +94,7 @@ public partial class UmbracoPlan : MigrationPlan
|
||||
To<V_17_3_0.PopulateSortableValueForDatePropertyData>("{6748CB56-CC16-49F0-BA91-B8ECE31BF456}");
|
||||
|
||||
// To 17.4.0
|
||||
To<V_17_4_0.AddEditableInVisualEditorToPropertyType>("{C3D4E5F6-A7B8-49C0-D1E2-F3A4B5C6D7E8}");
|
||||
To<V_17_4_0.AddContentVersionDateIndex>("{D4E5F6A7-B8C9-4D0E-A1F2-3B4C5D6E7F80}");
|
||||
To<V_17_4_0.AddDimensionsToSvg>("{72970B86-59D8-403C-B322-FFF43F9DB199}");
|
||||
To<V_17_4_0.AddExternalMemberTables>("{D7E8F9A0-B1C2-4D3E-A5F6-7890ABCDEF12}");
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Infrastructure.Persistence.Dtos;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_17_4_0;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to add the editableInVisualEditor column to the cmsPropertyType table.
|
||||
/// </summary>
|
||||
public class AddEditableInVisualEditorToPropertyType : AsyncMigrationBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AddEditableInVisualEditorToPropertyType"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The migration context.</param>
|
||||
public AddEditableInVisualEditorToPropertyType(IMigrationContext context)
|
||||
: base(context)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override async Task MigrateAsync()
|
||||
{
|
||||
if (TableExists(Constants.DatabaseSchema.Tables.PropertyType) is false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string columnName = "editableInVisualEditor";
|
||||
var hasColumn = Context.SqlContext.SqlSyntax.GetColumnsInSchema(Context.Database)
|
||||
.Any(c =>
|
||||
c.TableName == Constants.DatabaseSchema.Tables.PropertyType &&
|
||||
c.ColumnName == columnName);
|
||||
|
||||
if (hasColumn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddColumn<PropertyTypeDto>(Constants.DatabaseSchema.Tables.PropertyType, columnName);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public class MigrateSingleBlockList : AsyncMigrationBase
|
||||
SingleBlockListConfigurationCache blockListConfigurationCache,
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IIOHelper ioHelper,
|
||||
ISingleBlockPropertyIndexValueFactory blockValuePropertyIndexValueFactory,
|
||||
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory,
|
||||
IBlockEditorElementTypeCache elementTypeCache,
|
||||
AppCaches appCaches)
|
||||
: base(context)
|
||||
|
||||
@@ -114,6 +114,13 @@ internal class PropertyTypeDto
|
||||
[Constraint(Default = "0")]
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
[Column("editableInVisualEditor")]
|
||||
[Constraint(Default = "0")]
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the variation flags for the property type, indicating whether the property supports culture, segment, or invariant variations.
|
||||
/// The value corresponds to the <c>ContentVariation</c> enum.
|
||||
|
||||
@@ -90,6 +90,12 @@ internal sealed class PropertyTypeReadOnlyDto
|
||||
[Column("labelOnTop")]
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
[Column("editableInVisualEditor")]
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
|
||||
/* cmsMemberType */
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -48,6 +48,7 @@ internal static class PropertyGroupFactory
|
||||
UniqueId = propertyType.Key,
|
||||
Variations = (byte)propertyType.Variations,
|
||||
LabelOnTop = propertyType.LabelOnTop,
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor,
|
||||
};
|
||||
|
||||
if (groupId != default)
|
||||
|
||||
@@ -34,6 +34,7 @@ public sealed class PropertyTypeMapper : BaseMapper
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExp), nameof(PropertyTypeDto.ValidationRegExp));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.ValidationRegExpMessage), nameof(PropertyTypeDto.ValidationRegExpMessage));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.LabelOnTop), nameof(PropertyTypeDto.LabelOnTop));
|
||||
DefineMap<PropertyType, PropertyTypeDto>(nameof(PropertyType.EditableInVisualEditor), nameof(PropertyTypeDto.EditableInVisualEditor));
|
||||
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.PropertyEditorAlias), nameof(DataTypeDto.EditorAlias));
|
||||
DefineMap<PropertyType, DataTypeDto>(nameof(PropertyType.ValueStorageType), nameof(DataTypeDto.DbType));
|
||||
}
|
||||
|
||||
+1
@@ -440,6 +440,7 @@ internal sealed class ContentTypeCommonRepository : IContentTypeCommonRepository
|
||||
ValidationRegExpMessage = dto.ValidationRegExpMessage,
|
||||
Variations = (ContentVariation)dto.Variations,
|
||||
LabelOnTop = dto.LabelOnTop,
|
||||
EditableInVisualEditor = dto.EditableInVisualEditor,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Persistence.Repositories;
|
||||
using Umbraco.Cms.Infrastructure.Scoping;
|
||||
|
||||
@@ -34,4 +35,27 @@ internal sealed class ElementContainerRepository : EntityContainerRepository, IE
|
||||
cacheSyncService)
|
||||
{
|
||||
}
|
||||
|
||||
protected override void PersistDeletedItem(EntityContainer entity)
|
||||
{
|
||||
if (entity == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(entity));
|
||||
}
|
||||
|
||||
// Element containers can be referenced as start nodes on individual users (umbracoUserStartNode)
|
||||
// and on user groups (umbracoUserGroup.startElementId). Both reference umbracoNode.id via FK,
|
||||
// so we must clear those references before deleting the underlying node.
|
||||
var args = new { id = entity.Id };
|
||||
Database.Execute(
|
||||
$"DELETE FROM {QuoteTableName(Constants.DatabaseSchema.Tables.UserStartNode)} WHERE {QuoteColumnName("startNode")} = @id",
|
||||
args);
|
||||
Database.Execute(
|
||||
$@"UPDATE {QuoteTableName(Constants.DatabaseSchema.Tables.UserGroup)}
|
||||
SET {QuoteColumnName("startElementId")} = NULL
|
||||
WHERE {QuoteColumnName("startElementId")} = @id",
|
||||
args);
|
||||
|
||||
base.PersistDeletedItem(entity);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-42
@@ -110,10 +110,10 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
|
||||
|
||||
private void TraverseObject(JsonObject obj)
|
||||
{
|
||||
// we'll assume that the object is a data representation of a block based editor if it contains "contentData", "settingsData" and "layout".
|
||||
if (obj["contentData"] is JsonArray contentData && obj["settingsData"] is JsonArray settingsData && obj["layout"] is JsonObject layoutData)
|
||||
// we'll assume that the object is a data representation of a block based editor if it contains "contentData" and "settingsData".
|
||||
if (obj["contentData"] is JsonArray contentData && obj["settingsData"] is JsonArray settingsData)
|
||||
{
|
||||
ParseKeys(contentData, settingsData, layoutData);
|
||||
ParseKeys(contentData, settingsData);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,46 +123,12 @@ public abstract class BlockEditorPropertyNotificationHandlerBase<TBlockLayoutIte
|
||||
}
|
||||
}
|
||||
|
||||
private void ParseKeys(JsonArray contentData, JsonArray settingsData, JsonObject layoutData)
|
||||
private void ParseKeys(JsonArray contentData, JsonArray settingsData)
|
||||
{
|
||||
// recurse a JSON object to find all contained block editor layouts
|
||||
List<JsonObject> GetLayoutItemsRecursively(JsonObject jsonObject)
|
||||
{
|
||||
var layoutItems = new List<JsonObject>();
|
||||
if (jsonObject.ContainsKey("key") && jsonObject.ContainsKey("contentKey"))
|
||||
{
|
||||
// assume it's a layout if it has "key" and "contentKey"
|
||||
layoutItems.Add(jsonObject);
|
||||
}
|
||||
|
||||
foreach (JsonNode property in jsonObject.Select(v => v.Value).WhereNotNull())
|
||||
{
|
||||
IEnumerable<JsonObject> childrenToRecurse = property is JsonObject jsonObjectChild
|
||||
? [jsonObjectChild]
|
||||
: property is JsonArray jsonArrayChild
|
||||
? jsonArrayChild.OfType<JsonObject>()
|
||||
: [];
|
||||
layoutItems.AddRange(childrenToRecurse.SelectMany(GetLayoutItemsRecursively));
|
||||
}
|
||||
|
||||
return layoutItems;
|
||||
}
|
||||
|
||||
// grab keys applicable for replacement from all the layouts - that is:
|
||||
// - the key of the layout itself ("key").
|
||||
// - the key of the content item ("contentKey").
|
||||
// - ONLY for local content; do NOT replace content item keys for shared content.
|
||||
// - the key of the settings item ("settingsKey") if present.
|
||||
List<JsonObject> layoutItems = GetLayoutItemsRecursively(layoutData);
|
||||
var keys = layoutItems.SelectMany(layoutItem => new[]
|
||||
{
|
||||
layoutItem["key"]?.GetValue<string>(),
|
||||
layoutItem["isExternalContent"]?.GetValue<bool>() is not true
|
||||
? layoutItem["contentKey"]?.GetValue<string>()
|
||||
: null,
|
||||
layoutItem["settingsKey"]?.GetValue<string>(),
|
||||
})
|
||||
.WhereNotNull()
|
||||
// grab all keys from the objects of contentData and settingsData
|
||||
var keys = contentData.Select(c => c?["key"])
|
||||
.Union(settingsData.Select(s => s?["key"]))
|
||||
.Select(keyToken => keyToken?.GetValue<string>().NullOrWhiteSpaceAsNull())
|
||||
.ToArray();
|
||||
|
||||
// the following is solely for avoiding functionality wise breakage. we should consider removing it eventually, but for the time being it's harmless.
|
||||
|
||||
@@ -127,7 +127,7 @@ public abstract class BlockEditorPropertyValueEditor<TValue, TLayout> : BlockVal
|
||||
}
|
||||
|
||||
private static bool IsBlockEditorDataEmpty([NotNullWhen(false)] BlockEditorData<TValue, TLayout>? editorData)
|
||||
=> editorData is null || editorData.BlockValue.Layout.Count == 0;
|
||||
=> editorData is null || editorData.BlockValue.ContentData.Count == 0;
|
||||
|
||||
// We don't throw on error here because we want to be able to parse what we can, even if some of the data is invalid. In cases where migrating
|
||||
// from nested content to blocks, we don't want to trigger a fatal error for retrieving references, as this isn't vital to the operation.
|
||||
|
||||
@@ -63,20 +63,13 @@ public class BlockEditorValues<TValue, TLayout>
|
||||
|
||||
private BlockEditorData<TValue, TLayout>? Clean(BlockEditorData<TValue, TLayout> blockEditorData)
|
||||
{
|
||||
if (blockEditorData.BlockValue.Layout.Count == 0)
|
||||
if (blockEditorData.BlockValue.ContentData.Count == 0)
|
||||
{
|
||||
// if there's no content ensure there's no settings too
|
||||
blockEditorData.BlockValue.SettingsData.Clear();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (blockEditorData.BlockValue.ContentData.Count == 0
|
||||
&& blockEditorData.BlockValue.SettingsData.Count == 0)
|
||||
{
|
||||
// no local content or settings; the block editor must contain only global elements
|
||||
return blockEditorData;
|
||||
}
|
||||
|
||||
var contentTypePropertyTypes = new Dictionary<string, Dictionary<string, IPropertyType>>();
|
||||
|
||||
// filter out any content that isn't referenced in the layout references
|
||||
|
||||
@@ -26,7 +26,7 @@ public class BlockGridPropertyEditor : BlockGridPropertyEditorBase
|
||||
public BlockGridPropertyEditor(
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IIOHelper ioHelper,
|
||||
IBlockGridPropertyIndexValueFactory blockValuePropertyIndexValueFactory)
|
||||
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
|
||||
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory)
|
||||
=> _ioHelper = ioHelper;
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
/// </summary>
|
||||
public abstract class BlockGridPropertyEditorBase : DataEditor, IValueSchemaProvider
|
||||
{
|
||||
private readonly IBlockGridPropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
|
||||
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
|
||||
|
||||
protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockGridPropertyIndexValueFactory blockValuePropertyIndexValueFactory)
|
||||
protected BlockGridPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
_blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal sealed class BlockGridPropertyIndexValueFactory
|
||||
: BlockValuePropertyIndexValueFactoryBase<BlockGridValue>, IBlockGridPropertyIndexValueFactory
|
||||
{
|
||||
public BlockGridPropertyIndexValueFactory(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IElementService elementService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IOptionsMonitor<IndexingSettings> indexingSettings)
|
||||
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IEnumerable<RawDataItem> GetDataItems(BlockGridValue input, bool published)
|
||||
=> GetDataItems(input.GetLayouts() ?? [], input.ContentData, input.Expose, published);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public class BlockListPropertyEditor : BlockListPropertyEditorBase
|
||||
public BlockListPropertyEditor(
|
||||
IDataValueEditorFactory dataValueEditorFactory,
|
||||
IIOHelper ioHelper,
|
||||
IBlockListPropertyIndexValueFactory blockValuePropertyIndexValueFactory,
|
||||
IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory,
|
||||
IJsonSerializer jsonSerializer)
|
||||
: base(dataValueEditorFactory, blockValuePropertyIndexValueFactory, jsonSerializer)
|
||||
=> _ioHelper = ioHelper;
|
||||
|
||||
@@ -21,13 +21,13 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
/// </summary>
|
||||
public abstract class BlockListPropertyEditorBase : DataEditor, IValueSchemaProvider
|
||||
{
|
||||
private readonly IBlockListPropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
|
||||
private readonly IBlockValuePropertyIndexValueFactory _blockValuePropertyIndexValueFactory;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlockListPropertyEditorBase"/> class.
|
||||
/// </summary>
|
||||
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockListPropertyIndexValueFactory blockValuePropertyIndexValueFactory, IJsonSerializer jsonSerializer)
|
||||
protected BlockListPropertyEditorBase(IDataValueEditorFactory dataValueEditorFactory, IBlockValuePropertyIndexValueFactory blockValuePropertyIndexValueFactory, IJsonSerializer jsonSerializer)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
_blockValuePropertyIndexValueFactory = blockValuePropertyIndexValueFactory;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal sealed class BlockListPropertyIndexValueFactory
|
||||
: BlockValuePropertyIndexValueFactoryBase<BlockListValue>, IBlockListPropertyIndexValueFactory
|
||||
{
|
||||
public BlockListPropertyIndexValueFactory(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IElementService elementService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IOptionsMonitor<IndexingSettings> indexingSettings)
|
||||
: base(propertyEditorCollection, elementService, jsonSerializer, indexingSettings)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IEnumerable<RawDataItem> GetDataItems(BlockListValue input, bool published)
|
||||
=> GetDataItems(input.GetLayouts() ?? [], input.ContentData, input.Expose, published);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
internal sealed class BlockValuePropertyIndexValueFactory :
|
||||
BlockValuePropertyIndexValueFactoryBase<BlockValuePropertyIndexValueFactory.IndexValueFactoryBlockValue>,
|
||||
IBlockValuePropertyIndexValueFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BlockValuePropertyIndexValueFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="propertyEditorCollection">The <see cref="PropertyEditorCollection"/> containing available property editors.</param>
|
||||
/// <param name="jsonSerializer">The <see cref="IJsonSerializer"/> used for serializing and deserializing JSON values.</param>
|
||||
/// <param name="indexingSettings">The <see cref="IOptionsMonitor{IndexingSettings}"/> providing access to indexing configuration settings.</param>
|
||||
public BlockValuePropertyIndexValueFactory(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IOptionsMonitor<IndexingSettings> indexingSettings)
|
||||
: base(propertyEditorCollection, jsonSerializer, indexingSettings)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IEnumerable<RawDataItem> GetDataItems(IndexValueFactoryBlockValue input, bool published)
|
||||
=> GetDataItems(input.ContentData, input.Expose, published);
|
||||
|
||||
// we only care about the content data when extracting values for indexing - not the layouts nor the settings
|
||||
internal sealed class IndexValueFactoryBlockValue
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of content block item data.
|
||||
/// </summary>
|
||||
public List<BlockItemData> ContentData { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of <see cref="BlockItemVariation"/> instances that should be exposed by the index value factory.
|
||||
/// </summary>
|
||||
public List<BlockItemVariation> Expose { get; set; } = new();
|
||||
}
|
||||
}
|
||||
+22
-63
@@ -4,7 +4,6 @@ using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Blocks;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.Examine;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
@@ -13,17 +12,14 @@ namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : JsonPropertyIndexValueFactoryBase<TSerialized>
|
||||
{
|
||||
private readonly PropertyEditorCollection _propertyEditorCollection;
|
||||
private readonly IElementService _elementService;
|
||||
|
||||
protected BlockValuePropertyIndexValueFactoryBase(
|
||||
PropertyEditorCollection propertyEditorCollection,
|
||||
IElementService elementService,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IOptionsMonitor<IndexingSettings> indexingSettings)
|
||||
: base(jsonSerializer, indexingSettings)
|
||||
{
|
||||
_propertyEditorCollection = propertyEditorCollection;
|
||||
_elementService = elementService;
|
||||
}
|
||||
|
||||
protected override IEnumerable<IndexValue> Handle(
|
||||
@@ -110,74 +106,37 @@ internal abstract class BlockValuePropertyIndexValueFactoryBase<TSerialized> : J
|
||||
/// <summary>
|
||||
/// Unwraps block item data as data items.
|
||||
/// </summary>
|
||||
protected IEnumerable<RawDataItem> GetDataItems(IEnumerable<IBlockLayoutItem> layouts, IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
|
||||
protected IEnumerable<RawDataItem> GetDataItems(IList<BlockItemData> contentData, IList<BlockItemVariation> expose, bool published)
|
||||
{
|
||||
List<RawDataItem> indexData;
|
||||
if (published is false)
|
||||
{
|
||||
indexData = contentData.Select(ToRawData).ToList();
|
||||
return contentData.Select(ToRawData);
|
||||
}
|
||||
else
|
||||
|
||||
var indexData = new List<RawDataItem>();
|
||||
foreach (BlockItemData blockItemData in contentData)
|
||||
{
|
||||
indexData = new();
|
||||
foreach (BlockItemData blockItemData in contentData)
|
||||
var exposedCultures = expose
|
||||
.Where(e => e.ContentKey == blockItemData.Key)
|
||||
.Select(e => e.Culture)
|
||||
.ToArray();
|
||||
|
||||
if (exposedCultures.Any() is false)
|
||||
{
|
||||
var exposedCultures = expose
|
||||
.Where(e => e.ContentKey == blockItemData.Key)
|
||||
.Select(e => e.Culture)
|
||||
.ToArray();
|
||||
|
||||
if (exposedCultures.Any() is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exposedCultures.Contains(null)
|
||||
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
|
||||
{
|
||||
indexData.Add(ToRawData(blockItemData));
|
||||
continue;
|
||||
}
|
||||
|
||||
indexData.Add(
|
||||
ToRawData(
|
||||
blockItemData.ContentTypeKey,
|
||||
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
IBlockLayoutItem[] layoutsAsArray = layouts as IBlockLayoutItem[] ?? layouts.ToArray();
|
||||
if (exposedCultures.Contains(null)
|
||||
|| exposedCultures.ContainsAll(blockItemData.Values.Select(v => v.Culture)))
|
||||
{
|
||||
indexData.Add(ToRawData(blockItemData));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the shared element keys from all layouts.
|
||||
// NOTE: While the Grid areas are modeled to contain areas within areas, in reality it cannot be configured as
|
||||
// such, so this "top-level aggregation" of shared content keys works in effect.
|
||||
Guid[] sharedElementKeys = layoutsAsArray
|
||||
.Union(layoutsAsArray.SelectMany(l => l.GetContainedLayouts()))
|
||||
.Where(l => l.IsExternalContent)
|
||||
.Select(l => l.ContentKey)
|
||||
.ToArray();
|
||||
|
||||
if (sharedElementKeys.Length > 0)
|
||||
{
|
||||
IEnumerable<IElement> elements = _elementService.GetByIds(sharedElementKeys);
|
||||
indexData.AddRange(
|
||||
elements.Select(element => new RawDataItem
|
||||
{
|
||||
ContentTypeKey = element.ContentType.Key,
|
||||
Properties = element
|
||||
.Properties
|
||||
.SelectMany(property => property
|
||||
.Values
|
||||
.Select(value => new RawPropertyData
|
||||
{
|
||||
Alias = property.Alias,
|
||||
Culture = value.Culture,
|
||||
Value = published
|
||||
? value.PublishedValue
|
||||
: value.EditedValue,
|
||||
}))
|
||||
.ToArray(),
|
||||
}));
|
||||
indexData.Add(
|
||||
ToRawData(
|
||||
blockItemData.ContentTypeKey,
|
||||
blockItemData.Values.Where(value => value.Culture is null || exposedCultures.Contains(value.Culture))));
|
||||
}
|
||||
|
||||
return indexData;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user