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,
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ internal sealed class RedirectTracker : IRedirectTracker
|
||||
// to the original. We resolve this by removing any existing redirect that points to the new route.
|
||||
RemoveSelfReferencingRedirect(contentKey, newRoute);
|
||||
|
||||
_redirectUrlService.Register(oldRoute, contentKey, culture);
|
||||
_redirectUrlService.RegisterWithStatus(oldRoute, contentKey, culture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -341,7 +341,7 @@ internal sealed class RedirectTracker : IRedirectTracker
|
||||
{
|
||||
if (redirectUrl.Url == route)
|
||||
{
|
||||
_redirectUrlService.Delete(redirectUrl.Key);
|
||||
_redirectUrlService.DeleteWithStatus(redirectUrl.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ public static class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IDomainCacheService, DomainCacheService>();
|
||||
builder.Services.AddSingleton<IPublishedContentFactory, PublishedContentFactory>();
|
||||
builder.Services.AddSingleton<ICacheNodeFactory, CacheNodeFactory>();
|
||||
builder.Services.AddSingleton<IVisualEditorContentFactory, VisualEditorContentFactory>();
|
||||
builder.Services.AddSingleton<ICacheManager, CacheManager>();
|
||||
builder.Services.AddSingleton<IDatabaseCacheRebuilder, DatabaseCacheRebuilder>();
|
||||
builder.Services.AddSingleton<IDeferredCacheRebuildService, DeferredCacheRebuildService>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Concurrent;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Cache;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
@@ -9,23 +9,41 @@ using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.Changes;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IDomainCacheService" />, providing an in-memory cache of the configured <see cref="Domain" />s.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The cache is lazily populated from the database on first access and kept up to date in response to domain
|
||||
/// cache refresher notifications. It is registered as a singleton, so a single instance serves all requests.
|
||||
/// </remarks>
|
||||
public class DomainCacheService : IDomainCacheService
|
||||
{
|
||||
private readonly IDomainService _domainService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
private readonly ConcurrentDictionary<int, Domain> _domains;
|
||||
private bool _initialized = false;
|
||||
private readonly Lock _initializationLock = new();
|
||||
|
||||
// Both fields are written under _initializationLock but read on the hot path (request routing) without
|
||||
// it. Marking them volatile makes those lock-free reads acquire-reads, so a reader is guaranteed to see
|
||||
// the fully populated dictionary and the completed-initialization flag together, never a stale or
|
||||
// half-published value. This is required for correctness on weak memory models such as ARM; on x86/x64
|
||||
// ordinary reads already have acquire semantics, but we cannot rely on that.
|
||||
private volatile ConcurrentDictionary<int, Domain> _domains = new();
|
||||
private volatile bool _initialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DomainCacheService" /> class.
|
||||
/// </summary>
|
||||
/// <param name="domainService">The service used to load domains from the database.</param>
|
||||
/// <param name="coreScopeProvider">The provider used to create scopes for database access.</param>
|
||||
public DomainCacheService(IDomainService domainService, ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_domainService = domainService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
_domains = new ConcurrentDictionary<int, Domain>();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAll(bool includeWildcards)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
@@ -34,22 +52,38 @@ public class DomainCacheService : IDomainCacheService
|
||||
: _domains.Select(x => x.Value).OrderBy(x => x.SortOrder);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the domains on first access, ensuring the cache is populated before any caller reads from it.
|
||||
/// </summary>
|
||||
private void InitializeIfMissing()
|
||||
{
|
||||
// Lazy, on-demand initialization triggered by the first request to reach the cache.
|
||||
// The flag must only be set to true *after* the domains have been loaded and published.
|
||||
// Setting it beforehand creates a window where a concurrent caller observes _initialized == true,
|
||||
// skips loading, and reads an empty domain cache. On a multi-site setup that empties domain
|
||||
// resolution, causing every site to fall back to the first root node (see ContentFinderByUrlNew).
|
||||
// The double-checked lock ensures a single load while concurrent readers block until it completes.
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_initialized = true;
|
||||
LoadDomains();
|
||||
|
||||
lock (_initializationLock)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LoadDomains();
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Domain> GetAssigned(int documentId, bool includeWildcards = false)
|
||||
{
|
||||
InitializeIfMissing();
|
||||
// probably this could be optimized with an index
|
||||
// but then we'd need a custom DomainStore of some sort
|
||||
IEnumerable<Domain> list = _domains.Values.Where(x => x.ContentId == documentId);
|
||||
if (includeWildcards == false)
|
||||
{
|
||||
@@ -66,6 +100,7 @@ public class DomainCacheService : IDomainCacheService
|
||||
return documentId > 0 && GetAssigned(documentId, includeWildcards).Any();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Refresh(DomainCacheRefresher.JsonPayload[] payloads)
|
||||
{
|
||||
foreach (DomainCacheRefresher.JsonPayload payload in payloads)
|
||||
@@ -102,20 +137,23 @@ public class DomainCacheService : IDomainCacheService
|
||||
continue; // anomaly
|
||||
}
|
||||
|
||||
var newDomain = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
|
||||
// Feels wierd to use key and oldvalue, but we're using neither when updating.
|
||||
_domains.AddOrUpdate(
|
||||
domain.Id,
|
||||
new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder),
|
||||
(key, oldValue) => newDomain);
|
||||
_domains[domain.Id] = new Domain(domain.Id, domain.DomainName, domain.RootContentId.Value, culture, domain.IsWildcard, domain.SortOrder);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the configured domains from the database into a fresh dictionary and atomically swaps it in
|
||||
/// as the current cache.
|
||||
/// </summary>
|
||||
private void LoadDomains()
|
||||
{
|
||||
// Build the replacement set in a local dictionary and publish it with a single write to the
|
||||
// (volatile) _domains field. A reader never observes a partially populated cache during a RefreshAll
|
||||
// rebuild, and the published set contains exactly the current domains (any removed since the last
|
||||
// load are absent).
|
||||
var newDomains = new ConcurrentDictionary<int, Domain>();
|
||||
using (ICoreScope scope = _coreScopeProvider.CreateCoreScope())
|
||||
{
|
||||
scope.ReadLock(Constants.Locks.Domains);
|
||||
@@ -124,11 +162,11 @@ public class DomainCacheService : IDomainCacheService
|
||||
.Where(x => x.RootContentId.HasValue && x.LanguageIsoCode.IsNullOrWhiteSpace() == false)
|
||||
.Select(x => new Domain(x.Id, x.DomainName, x.RootContentId!.Value, x.LanguageIsoCode!, x.IsWildcard, x.SortOrder)))
|
||||
{
|
||||
_domains.AddOrUpdate(domain.Id, domain, (key, oldValue) => domain);
|
||||
newDomains[domain.Id] = domain;
|
||||
}
|
||||
scope.Complete();
|
||||
}
|
||||
|
||||
|
||||
_domains = newDomains;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Infrastructure.HybridCache.Factories;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.HybridCache.Services;
|
||||
|
||||
internal sealed class VisualEditorContentFactory : IVisualEditorContentFactory
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
private readonly IContentService _contentService;
|
||||
private readonly IDataTypeService _dataTypeService;
|
||||
private readonly ICacheNodeFactory _cacheNodeFactory;
|
||||
private readonly IPublishedContentFactory _publishedContentFactory;
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly IPublishedModelFactory _publishedModelFactory;
|
||||
|
||||
public VisualEditorContentFactory(
|
||||
IIdKeyMap idKeyMap,
|
||||
IContentService contentService,
|
||||
IDataTypeService dataTypeService,
|
||||
ICacheNodeFactory cacheNodeFactory,
|
||||
IPublishedContentFactory publishedContentFactory,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IPublishedModelFactory publishedModelFactory)
|
||||
{
|
||||
_idKeyMap = idKeyMap;
|
||||
_contentService = contentService;
|
||||
_dataTypeService = dataTypeService;
|
||||
_cacheNodeFactory = cacheNodeFactory;
|
||||
_publishedContentFactory = publishedContentFactory;
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_publishedModelFactory = publishedModelFactory;
|
||||
}
|
||||
|
||||
public async Task<IPublishedContent?> CreateWithOverridesAsync(
|
||||
Guid documentKey,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides)
|
||||
{
|
||||
Attempt<int> idAttempt = _idKeyMap.GetIdForKey(documentKey, UmbracoObjectTypes.Document);
|
||||
if (idAttempt.Success is false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IContent? content = _contentService.GetById(idAttempt.Result);
|
||||
if (content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ContentCacheNode baseNode = _cacheNodeFactory.ToContentCacheNode(content, preview: true);
|
||||
if (baseNode.Data is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var properties = new Dictionary<string, PropertyData[]>(baseNode.Data.Properties);
|
||||
|
||||
foreach (VisualEditorPropertyOverride @override in overrides)
|
||||
{
|
||||
(IDataValueEditor valueEditor, object? configuration)? resolved = await ResolveEditorAsync(content, @override);
|
||||
if (resolved is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var editorValue = @override.EditorValue is string s ? s : _jsonSerializer.Serialize(@override.EditorValue);
|
||||
object? source = resolved.Value.valueEditor.FromEditor(
|
||||
new ContentPropertyData(editorValue, resolved.Value.configuration),
|
||||
null);
|
||||
|
||||
string overrideCulture = @override.Culture ?? string.Empty;
|
||||
string overrideSegment = @override.Segment ?? string.Empty;
|
||||
|
||||
var overrideEntry = new PropertyData
|
||||
{
|
||||
Culture = overrideCulture,
|
||||
Segment = overrideSegment,
|
||||
Value = source,
|
||||
};
|
||||
|
||||
if (properties.TryGetValue(@override.Alias, out PropertyData[]? existing))
|
||||
{
|
||||
PropertyData[] merged = existing
|
||||
.Where(p => !(p.Culture == overrideCulture && p.Segment == overrideSegment))
|
||||
.Append(overrideEntry)
|
||||
.ToArray();
|
||||
properties[@override.Alias] = merged;
|
||||
}
|
||||
else
|
||||
{
|
||||
properties[@override.Alias] = [overrideEntry];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618 // ContentData ctor obsolete usage mirrored from the cache node source
|
||||
var overriddenNode = new ContentCacheNode
|
||||
{
|
||||
Id = baseNode.Id,
|
||||
Key = baseNode.Key,
|
||||
SortOrder = baseNode.SortOrder,
|
||||
CreateDate = baseNode.CreateDate,
|
||||
CreatorId = baseNode.CreatorId,
|
||||
ContentTypeId = baseNode.ContentTypeId,
|
||||
IsDraft = true,
|
||||
Data = new ContentData(
|
||||
name: baseNode.Data.Name,
|
||||
urlSegment: baseNode.Data.UrlSegment,
|
||||
versionId: baseNode.Data.VersionId,
|
||||
versionDate: baseNode.Data.VersionDate,
|
||||
writerId: baseNode.Data.WriterId,
|
||||
templateId: baseNode.Data.TemplateId,
|
||||
published: baseNode.Data.Published,
|
||||
properties: properties,
|
||||
cultureInfos: baseNode.Data.CultureInfos),
|
||||
};
|
||||
#pragma warning restore CS0618
|
||||
|
||||
return _publishedContentFactory.ToIPublishedContent(overriddenNode, preview: true).CreateModel(_publishedModelFactory);
|
||||
}
|
||||
|
||||
private async Task<(IDataValueEditor, object? configuration)?> ResolveEditorAsync(IContent content, VisualEditorPropertyOverride @override)
|
||||
{
|
||||
IProperty? property = content.Properties[@override.Alias];
|
||||
if (property is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
IDataType? dataType = await _dataTypeService.GetAsync(property.PropertyType.DataTypeKey);
|
||||
if (dataType?.Editor is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (dataType.Editor.GetValueEditor(), dataType.ConfigurationObject);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.DataProtection.Infrastructure;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
@@ -55,6 +56,7 @@ using Umbraco.Cms.Web.Common.Preview;
|
||||
using Umbraco.Cms.Web.Common.Profiler;
|
||||
using Umbraco.Cms.Web.Common.Repositories;
|
||||
using Umbraco.Cms.Web.Common.Security;
|
||||
using Umbraco.Cms.Web.Common.TagHelpers;
|
||||
using Umbraco.Cms.Web.Common.Templates;
|
||||
using Umbraco.Cms.Web.Common.UmbracoContext;
|
||||
using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment;
|
||||
@@ -335,10 +337,13 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<BootFailedMiddleware>();
|
||||
builder.Services.AddSingleton<ProtectRecycleBinMediaMiddleware>();
|
||||
|
||||
builder.Services.AddScoped<ITagHelperComponent, VisualEditorScriptTagHelperComponent>();
|
||||
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck<UmbracoReadinessHealthCheck>("umbraco-ready", tags: [UmbracoReadinessHealthCheck.ReadyTag]);
|
||||
|
||||
builder.Services.AddUnique<ITemplateRenderer, TemplateRenderer>();
|
||||
builder.Services.AddTransient<IVisualEditorRenderService, VisualEditorRenderService>();
|
||||
builder.Services.AddUnique<IPublicAccessChecker, PublicAccessChecker>();
|
||||
|
||||
builder.Services.AddSingleton<ContentModelBinder>();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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>");
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public static class BlockGridTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return await html.PartialAsync(DefaultFolderTemplate(template), model);
|
||||
@@ -43,7 +43,7 @@ public static class BlockGridTemplateExtensions
|
||||
|
||||
/// <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);
|
||||
=> 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)
|
||||
@@ -52,7 +52,17 @@ public static class BlockGridTemplateExtensions
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockGridItemsHtmlAsync(this IHtmlHelper html, IEnumerable<BlockGridItem> items, string template = DefaultItemsTemplate)
|
||||
@@ -84,7 +94,7 @@ public static class BlockGridTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
return HtmlString.Empty;
|
||||
}
|
||||
|
||||
return html.Partial(DefaultFolderTemplate(template), model);
|
||||
@@ -92,7 +102,7 @@ public static class BlockGridTemplateExtensions
|
||||
|
||||
/// <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);
|
||||
=> 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)
|
||||
@@ -101,7 +111,17 @@ public static class BlockGridTemplateExtensions
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockGridItemsHtml(this IHtmlHelper html, IEnumerable<BlockGridItem> items, string template = DefaultItemsTemplate)
|
||||
|
||||
@@ -16,14 +16,14 @@ public static class BlockListTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
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);
|
||||
=> 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);
|
||||
@@ -31,8 +31,19 @@ public static class BlockListTemplateExtensions
|
||||
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);
|
||||
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
|
||||
@@ -41,14 +52,14 @@ public static class BlockListTemplateExtensions
|
||||
{
|
||||
if (model?.Count == 0)
|
||||
{
|
||||
return new HtmlString(string.Empty);
|
||||
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);
|
||||
=> 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);
|
||||
@@ -56,7 +67,17 @@ public static class BlockListTemplateExtensions
|
||||
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);
|
||||
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
|
||||
|
||||
@@ -25,7 +25,7 @@ public static class SingleBlockTemplateExtensions
|
||||
}
|
||||
|
||||
public static async Task<IHtmlContent> GetBlockHtmlAsync(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> await GetBlockHtmlAsync(html, property.GetValue() as BlockListItem, template);
|
||||
=> 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);
|
||||
@@ -33,8 +33,19 @@ public static class SingleBlockTemplateExtensions
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sync
|
||||
@@ -50,7 +61,7 @@ public static class SingleBlockTemplateExtensions
|
||||
}
|
||||
|
||||
public static IHtmlContent GetBlockHtml(this IHtmlHelper html, IPublishedProperty property, string template = DefaultTemplate)
|
||||
=> GetBlockHtml(html, property.GetValue() as BlockListItem, template);
|
||||
=> 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);
|
||||
@@ -58,7 +69,17 @@ public static class SingleBlockTemplateExtensions
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
public static string SingleBlockPartialWithFallback(this IHtmlHelper html, string template, string fallbackTemplate)
|
||||
|
||||
@@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.Preview;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -55,6 +56,10 @@ public class PreviewAuthenticationMiddleware : IMiddleware
|
||||
|
||||
if (isPreview)
|
||||
{
|
||||
// Enable visual editor property tracking for this request.
|
||||
// This allows UmbracoViewPage.Write() to automatically annotate property output.
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
|
||||
Attempt<ClaimsIdentity> backOfficeIdentityAttempt = await _previewService.TryGetPreviewClaimsIdentityAsync();
|
||||
|
||||
if (backOfficeIdentityAttempt.Success)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Cms.Web.Common.Hosting;
|
||||
using Umbraco.Cms.Web.Common.Views;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.TagHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Injects the visual editor guest script into the <c><body></c> tag when in preview mode.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses the standard ASP.NET Core <see cref="ITagHelperComponent"/> extensibility point to append
|
||||
/// the guest script to the body tag. The script runs inside the preview iframe and communicates
|
||||
/// with the backoffice via postMessage.
|
||||
/// </remarks>
|
||||
internal sealed class VisualEditorScriptTagHelperComponent : TagHelperComponent
|
||||
{
|
||||
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
|
||||
private readonly ICspNonceService _cspNonceService;
|
||||
private readonly IBackOfficePathGenerator _backOfficePathGenerator;
|
||||
|
||||
public VisualEditorScriptTagHelperComponent(
|
||||
IUmbracoContextAccessor umbracoContextAccessor,
|
||||
ICspNonceService cspNonceService,
|
||||
IBackOfficePathGenerator backOfficePathGenerator)
|
||||
{
|
||||
_umbracoContextAccessor = umbracoContextAccessor;
|
||||
_cspNonceService = cspNonceService;
|
||||
_backOfficePathGenerator = backOfficePathGenerator;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run after other tag helper components (e.g. the preview badge).
|
||||
/// </summary>
|
||||
public override int Order => 100;
|
||||
|
||||
public override void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
if (!string.Equals(output.TagName, "body", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext)
|
||||
|| !umbracoContext.InPreviewMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var nonce = _cspNonceService.GetNonce();
|
||||
var backOfficeAssetsPath = _backOfficePathGenerator.BackOfficeAssetsPath;
|
||||
output.PostContent.AppendHtml(VisualEditorGuestScript.GetScriptTag(nonce, backOfficeAssetsPath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.AspNetCore.Mvc.ViewEngines;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
using Umbraco.Cms.Core.Routing;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Templates;
|
||||
using Umbraco.Cms.Core.Web;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Web.Common.Templates;
|
||||
|
||||
internal sealed class VisualEditorRenderService : IVisualEditorRenderService
|
||||
{
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
private readonly IPublishedRouter _publishedRouter;
|
||||
private readonly ITemplateService _templateService;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ICompositeViewEngine _viewEngine;
|
||||
private readonly IModelMetadataProvider _modelMetadataProvider;
|
||||
private readonly ITempDataDictionaryFactory _tempDataDictionaryFactory;
|
||||
private readonly IVisualEditorContentFactory _contentFactory;
|
||||
private readonly ILogger<VisualEditorRenderService> _logger;
|
||||
|
||||
public VisualEditorRenderService(
|
||||
IUmbracoContextFactory umbracoContextFactory,
|
||||
IPublishedRouter publishedRouter,
|
||||
ITemplateService templateService,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
ICompositeViewEngine viewEngine,
|
||||
IModelMetadataProvider modelMetadataProvider,
|
||||
ITempDataDictionaryFactory tempDataDictionaryFactory,
|
||||
IVisualEditorContentFactory contentFactory,
|
||||
ILogger<VisualEditorRenderService> logger)
|
||||
{
|
||||
_umbracoContextFactory = umbracoContextFactory;
|
||||
_publishedRouter = publishedRouter;
|
||||
_templateService = templateService;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_viewEngine = viewEngine;
|
||||
_modelMetadataProvider = modelMetadataProvider;
|
||||
_tempDataDictionaryFactory = tempDataDictionaryFactory;
|
||||
_contentFactory = contentFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> RenderAsync(
|
||||
Guid documentKey,
|
||||
string? culture,
|
||||
string? segment,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides)
|
||||
{
|
||||
using UmbracoContextReference contextReference = _umbracoContextFactory.EnsureUmbracoContext();
|
||||
IUmbracoContext umbracoContext = contextReference.UmbracoContext;
|
||||
|
||||
IPublishedContent? content = await _contentFactory.CreateWithOverridesAsync(documentKey, overrides);
|
||||
if (content?.TemplateId is null)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: no draft content or template for document {DocumentKey}.", documentKey);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
ITemplate? template = await _templateService.GetAsync(content.TemplateId.Value);
|
||||
if (template is null)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: template {TemplateId} not found for document {DocumentKey}.", content.TemplateId.Value, documentKey);
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
IPublishedRequestBuilder requestBuilder = await _publishedRouter.CreateRequestAsync(umbracoContext.CleanedUmbracoUrl);
|
||||
requestBuilder.SetCulture(culture);
|
||||
requestBuilder.SetSegment(segment);
|
||||
requestBuilder.SetPublishedContent(content);
|
||||
requestBuilder.SetTemplate(template);
|
||||
IPublishedRequest request = requestBuilder.Build();
|
||||
|
||||
IPublishedRequest? oldRequest = umbracoContext.PublishedRequest;
|
||||
VisualEditorPropertyTracker.Enable();
|
||||
try
|
||||
{
|
||||
umbracoContext.PublishedRequest = request;
|
||||
return ExecuteTemplateRendering(request);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisualEditorPropertyTracker.Disable();
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
umbracoContext.PublishedRequest = oldRequest;
|
||||
}
|
||||
}
|
||||
|
||||
private string ExecuteTemplateRendering(IPublishedRequest request)
|
||||
{
|
||||
HttpContext httpContext = _httpContextAccessor.GetRequiredHttpContext();
|
||||
|
||||
// isMainPage is set to true here to ensure ViewStart(s) found in the view hierarchy are rendered
|
||||
ViewEngineResult viewResult = _viewEngine.GetView(null, $"~/Views/{request.GetTemplateAlias()}.cshtml", true);
|
||||
if (viewResult.Success is false)
|
||||
{
|
||||
_logger.LogWarning("Visual editor render skipped: view for template alias {TemplateAlias} not found.", request.GetTemplateAlias());
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var viewData = new ViewDataDictionary(_modelMetadataProvider, new ModelStateDictionary())
|
||||
{
|
||||
Model = request.PublishedContent,
|
||||
};
|
||||
|
||||
using var writer = new StringWriter();
|
||||
var viewContext = new ViewContext(
|
||||
new ActionContext(httpContext, httpContext.GetRouteData(), new ControllerActionDescriptor()),
|
||||
viewResult.View,
|
||||
viewData,
|
||||
_tempDataDictionaryFactory.GetTempData(httpContext),
|
||||
writer,
|
||||
new HtmlHelperOptions());
|
||||
|
||||
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
|
||||
return writer.GetStringBuilder().ToString();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ public abstract class UmbracoViewPage : UmbracoViewPage<IPublishedContent>
|
||||
public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
{
|
||||
private UmbracoHelper? _helper;
|
||||
private int _attributeDepth;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Umbraco helper.
|
||||
@@ -108,10 +109,64 @@ public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(object? value)
|
||||
{
|
||||
if (TryWriteVisualEditorAnnotation(value, () => WriteCore(value)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteCore(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// In .NET 10+, Razor resolves <c>Write(stringValue)</c> to this overload rather than
|
||||
/// <see cref="Write(object?)"/>. Both must participate in visual editor annotation.
|
||||
/// </remarks>
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (TryWriteVisualEditorAnnotation(value, () => base.Write(value)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
base.Write(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks for a tracked property access and wraps the output with visual editor annotation attributes.
|
||||
/// Returns true if annotation was applied (and the value was written), false otherwise.
|
||||
/// </summary>
|
||||
private bool TryWriteVisualEditorAnnotation(object? value, Action writeValue)
|
||||
{
|
||||
var propertyAccess = VisualEditorPropertyTracker.ConsumeAccess();
|
||||
|
||||
if (propertyAccess.HasValue
|
||||
&& value is not null
|
||||
&& _attributeDepth == 0
|
||||
&& ((UmbracoContext?.InPreviewMode ?? false) || VisualEditorPropertyTracker.IsEnabled))
|
||||
{
|
||||
var access = propertyAccess.Value;
|
||||
var escapedAlias = System.Web.HttpUtility.HtmlAttributeEncode(access.Alias);
|
||||
base.WriteLiteral($"<span data-umb-property=\"{escapedAlias}\" data-umb-content-key=\"{access.ContentKey}\">");
|
||||
|
||||
writeValue();
|
||||
|
||||
base.WriteLiteral("</span>");
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core write logic for the object overload, shared by annotated and non-annotated paths.
|
||||
/// </summary>
|
||||
private void WriteCore(object? value)
|
||||
{
|
||||
if (value is IHtmlEncodedString htmlEncodedString)
|
||||
{
|
||||
WriteLiteral(htmlEncodedString.ToHtmlString());
|
||||
base.WriteLiteral(htmlEncodedString.ToHtmlString());
|
||||
}
|
||||
else if (value is TagHelperOutput tagHelperOutput)
|
||||
{
|
||||
@@ -124,6 +179,40 @@ public abstract class UmbracoViewPage<TModel> : RazorPage<TModel>
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void BeginWriteAttribute(string name, string prefix, int prefixOffset, string suffix, int suffixOffset, int attributeValuesCount)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
_attributeDepth++;
|
||||
base.BeginWriteAttribute(name, prefix, prefixOffset, suffix, suffixOffset, attributeValuesCount);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void EndWriteAttribute()
|
||||
{
|
||||
_attributeDepth--;
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.EndWriteAttribute();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void WriteLiteral(object? value)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.WriteLiteral(value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// In .NET 10+, Razor resolves <c>WriteLiteral(stringValue)</c> to this overload.
|
||||
/// Both must participate in visual editor tracker clearing.
|
||||
/// </remarks>
|
||||
public override void WriteLiteral(string? value)
|
||||
{
|
||||
VisualEditorPropertyTracker.Clear();
|
||||
base.WriteLiteral(value);
|
||||
}
|
||||
|
||||
public void WriteUmbracoContent(TagHelperOutput tagHelperOutput)
|
||||
{
|
||||
// filter / add preview banner
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Umbraco.Cms.Web.Common.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Generates the script tag for the visual editor guest script.
|
||||
/// The script is built from <c>src/apps/visual-editor/injected.ts</c> in the backoffice client
|
||||
/// and served from the backoffice static assets under <c>apps/visual-editor/injected.js</c>.
|
||||
/// </summary>
|
||||
internal static class VisualEditorGuestScript
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the script tag referencing the visual editor guest script asset.
|
||||
/// </summary>
|
||||
/// <param name="nonce">The CSP nonce for the current request, or <c>null</c>/empty when CSP is not in use.</param>
|
||||
/// <param name="backOfficePath">The (cache-busted) backoffice assets path the script is served under.</param>
|
||||
/// <returns>The <c><script></c> tag markup.</returns>
|
||||
public static string GetScriptTag(string? nonce, string backOfficePath = "/umbraco/backoffice")
|
||||
{
|
||||
var nonceAttr = string.IsNullOrEmpty(nonce) ? string.Empty : $" nonce=\"{nonce}\"";
|
||||
return $"<script data-umb-visual-editor src=\"{backOfficePath}/apps/visual-editor/injected.js\"{nonceAttr}></script>";
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -99,6 +100,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -123,6 +125,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -147,6 +150,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -167,6 +171,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -187,6 +192,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -207,6 +213,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,6 +234,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -247,6 +255,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -267,6 +276,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -287,6 +297,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -307,6 +318,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -327,6 +339,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -347,6 +360,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -367,6 +381,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -387,6 +402,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -407,6 +423,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -427,6 +444,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -447,6 +465,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -467,6 +486,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -487,6 +507,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -507,6 +528,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -527,6 +549,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -547,6 +570,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -567,6 +591,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -587,6 +612,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -607,6 +633,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -627,6 +654,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -647,6 +675,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -667,6 +696,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -687,6 +717,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -707,6 +738,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -727,6 +759,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -751,6 +784,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -771,6 +805,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -791,6 +826,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -811,6 +847,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -831,6 +868,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -888,6 +926,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -908,6 +947,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -969,6 +1009,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -989,6 +1030,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1009,6 +1051,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1029,6 +1072,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: true,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1049,6 +1093,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1143,6 +1188,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1200,6 +1246,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1220,6 +1267,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1240,6 +1288,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1304,6 +1353,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1371,6 +1421,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1431,6 +1482,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1451,6 +1503,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1543,6 +1596,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1563,6 +1617,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1620,6 +1675,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1677,6 +1733,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1734,6 +1791,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1791,6 +1849,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1900,6 +1959,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1957,6 +2017,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1977,6 +2038,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -2034,6 +2096,7 @@ export const data: Array<UmbMockDocumentTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -48,6 +49,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,6 +70,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -121,6 +124,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -174,6 +178,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -227,6 +232,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -280,6 +286,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -333,6 +340,7 @@ export const data: Array<UmbMockMediaTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -28,6 +28,7 @@ export const data: Array<UmbMockMemberTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -81,6 +82,7 @@ export const data: Array<UmbMockMemberTypeModel> = [
|
||||
},
|
||||
appearance: {
|
||||
labelOnTop: false,
|
||||
editableInVisualEditor: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
+21
-6
@@ -11,10 +11,12 @@
|
||||
"workspaces": [
|
||||
"./src/libs/*",
|
||||
"./src/packages/*",
|
||||
"./src/external/*"
|
||||
"./src/external/*",
|
||||
"./src/apps/visual-editor"
|
||||
],
|
||||
"dependencies": {
|
||||
"element-internals-polyfill": "^3.0.2"
|
||||
"element-internals-polyfill": "^3.0.2",
|
||||
"morphdom": "^2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
@@ -3962,10 +3964,14 @@
|
||||
"resolved": "src/packages/webhook",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-cms/visual-editor": {
|
||||
"resolved": "src/apps/visual-editor",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@umbraco-ui/uui": {
|
||||
"version": "2.0.0-rc.1",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.1.tgz",
|
||||
"integrity": "sha512-qhKsTl11hq82GQWZEq/2f/D1UePjfN9DLet7MqastvEKFdwdgHM9cCu+2f3mlOcX/OHj5XiUwLVFoJanRgfJZg==",
|
||||
"version": "2.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@umbraco-ui/uui/-/uui-2.0.0-rc.2.tgz",
|
||||
"integrity": "sha512-/eRw8byM1zysUF61VrM3MQzZnvuZHuerhiW13eR5vPAxvWT1I5n/jeh+qTKKd3mgZi/rvAV1fnRS05ipqu2hMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"culori": "^4.0.2",
|
||||
@@ -11073,6 +11079,12 @@
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/morphdom": {
|
||||
"version": "2.7.8",
|
||||
"resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.8.tgz",
|
||||
"integrity": "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -16390,6 +16402,9 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"src/apps/visual-editor": {
|
||||
"name": "@umbraco-cms/visual-editor"
|
||||
},
|
||||
"src/external/dompurify": {
|
||||
"name": "@umbraco-backoffice/dompurify",
|
||||
"dependencies": {
|
||||
@@ -16447,7 +16462,7 @@
|
||||
"src/external/uui": {
|
||||
"name": "@umbraco-backoffice/uui",
|
||||
"dependencies": {
|
||||
"@umbraco-ui/uui": "^2.0.0-rc.1"
|
||||
"@umbraco-ui/uui": "^2.0.0-rc.2"
|
||||
}
|
||||
},
|
||||
"src/libs/class-api": {
|
||||
|
||||
@@ -171,7 +171,8 @@
|
||||
"workspaces": [
|
||||
"./src/libs/*",
|
||||
"./src/packages/*",
|
||||
"./src/external/*"
|
||||
"./src/external/*",
|
||||
"./src/apps/visual-editor"
|
||||
],
|
||||
"scripts": {
|
||||
"backoffice:test:e2e": "npx playwright test",
|
||||
@@ -232,7 +233,8 @@
|
||||
"npm": ">=10.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-internals-polyfill": "^3.0.2"
|
||||
"element-internals-polyfill": "^3.0.2",
|
||||
"morphdom": "^2.7.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.29.0",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user