Compare commits
@@ -1,135 +0,0 @@
|
||||
---
|
||||
name: umb-release-notes
|
||||
description: Improve a set of auto-generated GitHub release notes for an Umbraco CMS release. Cross-checks the notes against every PR carrying the release label, adds any that are missing, re-files every PR under the most appropriate category, and strips purely-internal entries. Use whenever the user asks to tidy up, improve, complete, or recategorize release notes for a given version, or mentions a release-notes text file plus a version number.
|
||||
argument-hint: <version> <path-to-generated-notes-file>
|
||||
---
|
||||
|
||||
# Umbraco CMS - Improve Release Notes
|
||||
|
||||
Takes a file of auto-generated GitHub release notes and produces an improved version that:
|
||||
|
||||
1. **Is complete** — every merged PR carrying the `release/<version>` label appears.
|
||||
2. **Is well-categorized** — every PR sits under the most appropriate heading.
|
||||
3. **Is free of noise** — purely-internal entries of no value to a reader are removed.
|
||||
|
||||
The result is written to a **new** file alongside the input, so the user can diff the two.
|
||||
|
||||
**Run autonomously.** Do NOT use `AskUserQuestion` once the required arguments (version and input file path) are available — only ask if one of them is missing from `$ARGUMENTS` and cannot be inferred (see Arguments). Beyond that, make the categorization calls yourself using the rules below; if a handful are genuinely borderline, place them anyway and note the borderline ones in your closing summary so the user can override.
|
||||
|
||||
## Arguments
|
||||
|
||||
`$ARGUMENTS` contains two values:
|
||||
|
||||
1. **Version** — e.g. `17.5.0`, `18.1.0`. The GitHub label to search is `release/<version>` (so version `17.5.0` → label `release/17.5.0`).
|
||||
2. **Input file path** — full path to the text file holding the auto-generated notes (e.g. `C:\Temp\release-17.5.0-rc.md`).
|
||||
|
||||
If either is missing, ask the user once for the missing value, then proceed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Run `gh auth status`. If it fails, tell the user to authenticate `gh` (e.g. `gh auth login`) and stop — the skill needs the GitHub CLI to query PRs. The repo is always `umbraco/Umbraco-CMS`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Read the input notes
|
||||
|
||||
Read the input file. Note its structure — it is GitHub's generated format:
|
||||
|
||||
- A leading HTML comment (`<!-- Release notes generated ... -->`).
|
||||
- A `## What's Changed` heading followed by `### <emoji> <Category>` sub-headings, each with `* <title> by @<author> in <url>` bullets.
|
||||
- A trailing `## New Contributors` section and a `**Full Changelog**: ...` line.
|
||||
|
||||
Extract the set of PR numbers already present (parse the `/pull/<number>` from each bullet). Preserve each existing bullet's **exact text** (title, author, URL) when you re-emit it — only its category placement may change.
|
||||
|
||||
### 2. Fetch every labelled PR
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 \
|
||||
--json number,title,author,labels,mergedAt \
|
||||
--jq '.[] | select(.mergedAt != null) | "\(.number)\t\(.author.login)\t\([.labels[].name] | join(", "))\t\(.title)"' | sort -n
|
||||
```
|
||||
|
||||
This is the authoritative list of what the release *should* contain. Each row gives number, author, labels, title.
|
||||
|
||||
**Guard against silent truncation.** `gh pr list` caps at `--limit` without warning, so a large release could drop the overflow and the skill would still look "complete". Count the returned rows and compare against the limit:
|
||||
|
||||
```bash
|
||||
gh pr list --repo umbraco/Umbraco-CMS --label "release/<version>" --state closed --limit 1000 --json number --jq 'length'
|
||||
```
|
||||
|
||||
If this equals 1000, the limit was hit — raise `--limit` and re-fetch before continuing. Do **not** proceed on a truncated list.
|
||||
|
||||
### 3. Reconcile
|
||||
|
||||
- **Missing labelled PRs** (labelled but not in the input file): these must be **added**. Build a bullet as `* <title> by @<author> in https://github.com/umbraco/Umbraco-CMS/pull/<number>`.
|
||||
- **Author handle.** `<author>` in the template is the raw `.author.login` value — the bullet supplies the leading `@`, so do not prepend another. `gh`'s `.author.login` already returns bot accounts with the `[bot]` suffix as part of the login — Dependabot comes back as `dependabot[bot]`, not `dependabot` or `app/dependabot` (the `app/` form only appears in git committer metadata and CODEOWNERS, never in `gh`'s JSON). So the login is already in the right shape; use it verbatim (e.g. `.author.login` of `dependabot[bot]` renders as `@dependabot[bot]`, matching what GitHub's generator wrote for the existing bullets). The only thing to guard against is accidentally stripping or altering the `[bot]` suffix.
|
||||
- **PRs in the file but not labelled**: keep them. The generated notes span a commit range (see the `Full Changelog` compare link), so they legitimately include backports / earlier-version PRs that lack the current label. For any of these you need to categorize, fetch its labels with:
|
||||
|
||||
```bash
|
||||
gh pr view <number> --repo umbraco/Umbraco-CMS --json number,title,labels \
|
||||
--jq '"\(.number)\t\([.labels[].name] | join(", "))\t\(.title)"'
|
||||
```
|
||||
|
||||
Do **not** invent or alter the `New Contributors` section — carry it over verbatim. You cannot reliably recompute first-time contributors, so leave it as the generator produced it (mention this in the summary).
|
||||
|
||||
### 4. Categorize every PR
|
||||
|
||||
Use exactly these headings, in this order. Omit any heading that ends up with no entries.
|
||||
|
||||
| Heading | What goes here | Primary signal |
|
||||
|---|---|---|
|
||||
| `### 🙌 Notable Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/notable` |
|
||||
| `### 💥 Breaking Changes` | **Don't recategorize existing entries.** Label-driven — but still add any missing PR carrying this label here. | label `category/breaking` |
|
||||
| `### 📦 Dependencies` | Dependency bumps | label `dependencies`; or dependabot author |
|
||||
| `### 🚀 New Features` | New user- or developer-facing capability | label `type/feature` / `category/feature`; or title introduces/adds a genuinely new capability |
|
||||
| `### 🚤 Performance` | Performance improvements | label `category/performance`; or `Performance:` title prefix |
|
||||
| `### 🌈 Accessibility Improvements` | A11y improvements (labels, contrast, keyboard) | label `category/accessibility` / `accessibility`; or clear a11y intent (e.g. "improve contrast", "missing labels") |
|
||||
| `### 🐛 Bug Fixes` | Fixes to broken/incorrect behaviour | default for anything describing a fix |
|
||||
| `### 🧪 Testing` | Test additions/changes only | label `category/test-automation` / `area/test`; or `E2E`/`QA`/"acceptance tests"/"unit test coverage"/"add tests" titles |
|
||||
| `### 🛡️ Code Quality, Documentation and Refactoring` | Refactors, deprecations, API tidy-ups, XML/MD documentation, knowledge-base (`MD`) updates | label `category/refactor`; or titles about refactoring, deprecating, renaming, documenting, constants extraction, MD/CLAUDE.md content |
|
||||
| `### 🧑💻 Developer Experience` | Things that improve the experience of developers building on or contributing to Umbraco — dev tooling, build/watch ergonomics, test mocks/harnesses, backoffice dev utilities | `Developer Experience` title prefix; dev tooling; mock/harness changes |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Notable and Breaking are off-limits for recategorization** — never move a PR that is *already in the input file* into or out of these sections; they are driven purely by their labels and the generator placed them correctly. This does **not** exempt them from completeness: a PR discovered as missing in step 3 that carries `category/notable` or `category/breaking` must still be **added** under the matching section.
|
||||
- Label signals beat title wording, except a `Performance:`/`Developer Experience:` title prefix is decisive for its section.
|
||||
- A PR with both `type/feature` and `category/refactor` whose title clearly describes a refactor (e.g. "swap relative imports", "re-export type") belongs under Code Quality, not New Features.
|
||||
- "Add ... tests"/"unit test coverage" → Testing, even if it also touches docs. If a PR adds XML documentation *and* tests, lead with where the title's emphasis lies (documentation → Code Quality; test coverage → Testing).
|
||||
- When a PR is genuinely 50/50, pick the more reader-useful heading and list it in your closing summary as borderline.
|
||||
|
||||
### 5. Remove purely-internal noise
|
||||
|
||||
Drop entries that have **no value to anyone reading release notes** — pure repository plumbing with no shipped impact. Examples:
|
||||
|
||||
- Branch/merge maintenance ("Fix main branch after merge issue").
|
||||
- CI/pipeline fixes that don't change the product.
|
||||
- Reverts of changes that never shipped in a release.
|
||||
|
||||
**Keep** anything that ships in the product or genuinely helps developers building on Umbraco — that includes documentation/MD updates, dev tooling, and test mocks (those go to Code Quality or Developer Experience, they are *not* noise). When unsure whether something is noise, keep it and flag it in the summary rather than silently dropping it. List every removal in your closing summary.
|
||||
|
||||
### 6. Write the output
|
||||
|
||||
Write to a new file in the **same folder** as the input, named by appending ` - with updates` before the extension:
|
||||
|
||||
- Input `C:\Temp\release-17.5.0-rc.md` → Output `C:\Temp\release-17.5.0-rc - with updates.md`
|
||||
|
||||
Preserve the leading HTML comment, the `## What's Changed` heading, the `## New Contributors` section, and the `**Full Changelog**` line exactly. Only the `### <category>` groupings and their bullets change.
|
||||
|
||||
### 7. Report
|
||||
|
||||
Give a concise summary:
|
||||
|
||||
- Count of PRs added (with their numbers), and which categories they landed in.
|
||||
- Notable recategorizations (PRs moved out of the catch-all Bug Fixes into Features/Performance/Testing/etc.).
|
||||
- Every entry removed, with the one-line reason.
|
||||
- Any borderline calls the user may want to override.
|
||||
- The output file path.
|
||||
|
||||
## Verification
|
||||
|
||||
Before reporting done, confirm:
|
||||
|
||||
- Every PR number from step 2 is present in the output (except any you deliberately removed in step 5 — and those must be in the removal list).
|
||||
- No PR appears under more than one heading.
|
||||
- Notable and Breaking sections are byte-for-byte unchanged from the input.
|
||||
- The header comment, New Contributors, and Full Changelog lines are intact.
|
||||
@@ -23,7 +23,7 @@ env:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
@@ -38,12 +38,6 @@ jobs:
|
||||
- name: Setup .NET from global.json
|
||||
uses: actions/setup-dotnet@v5
|
||||
|
||||
- name: Setup Java 21
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Cache SonarQube packages
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
@@ -66,8 +60,7 @@ jobs:
|
||||
dotnet-sonarscanner begin \
|
||||
/k:"$SONAR_PROJECT_KEY" \
|
||||
/o:"$SONAR_ORGANIZATION" \
|
||||
/d:sonar.token="$SONAR_TOKEN" \
|
||||
/d:sonar.scanner.skipJreProvisioning=true
|
||||
/d:sonar.token="$SONAR_TOKEN"
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore umbraco.sln
|
||||
@@ -76,23 +69,12 @@ jobs:
|
||||
run: GITHUB_ENV=/dev/null dotnet build umbraco.sln --no-restore -clp:ErrorsOnly # prevent sonar MSBuild integration from writing malformed values to $GITHUB_ENV
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
id: tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
dotnet-coverage collect \
|
||||
"dotnet test tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj --no-build" \
|
||||
--output TestResults/coverage.xml \
|
||||
--output-format xml
|
||||
|
||||
- name: Warn on test failure
|
||||
if: steps.tests.outcome == 'failure'
|
||||
run: |
|
||||
if [ -f TestResults/coverage.xml ]; then
|
||||
echo "::warning::Unit tests failed - SonarCloud analysis will proceed with the collected coverage data"
|
||||
else
|
||||
echo "::warning::Unit tests failed and no coverage data was collected"
|
||||
fi
|
||||
|
||||
- name: End analysis
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -120,6 +120,7 @@ trace.zip
|
||||
/tests/Umbraco.Tests.Integration/appsettings-schema.*.json
|
||||
/tests/Umbraco.Tests.Integration/umbraco-package-schema.json
|
||||
/src/Umbraco.Cms/appsettings-schema.json
|
||||
.worktrees
|
||||
.playwright-mcp/
|
||||
|
||||
# SonarQube local analysis cache
|
||||
|
||||
@@ -558,8 +558,6 @@ Allowed, but cheap to write and cheaper to leave behind. Keep them short and tra
|
||||
|
||||
Verify any test you add for a bug fix actually catches the bug: either write the failing test first (TDD), or temporarily revert the production change and confirm the test fails before re-applying. A test that passes both ways proves nothing. Watch for coincidental passes — default seed/sort orders can make a buggy path produce the right answer for the test's specific inputs; construct inputs so the broken and fixed behaviours give visibly different results.
|
||||
|
||||
For integration tests that exercise caching or cache refreshers, see `tests/Umbraco.Tests.Integration/CLAUDE.md` — the harness disables caching by default, which can produce false greens.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<!-- Package Validation -->
|
||||
<PropertyGroup>
|
||||
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
|
||||
<EnablePackageValidation>true</EnablePackageValidation>
|
||||
<EnablePackageValidation>false</EnablePackageValidation> <!-- TODO (V18): Set to true once this version is released. -->
|
||||
<PackageValidationBaselineVersion>18.0.0</PackageValidationBaselineVersion>
|
||||
<EnableStrictModeForCompatibleFrameworksInPackage>true</EnableStrictModeForCompatibleFrameworksInPackage>
|
||||
<EnableStrictModeForCompatibleTfms>true</EnableStrictModeForCompatibleTfms>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||
<PackageVersion Include="Markdig" Version="1.1.3" />
|
||||
<PackageVersion Include="Markdown" Version="2.2.1" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.7" />
|
||||
<PackageVersion Include="MessagePack" Version="3.1.4" />
|
||||
<PackageVersion Include="MiniProfiler.AspNetCore.Mvc" Version="4.5.4" />
|
||||
<PackageVersion Include="MiniProfiler.Shared" Version="4.5.4" />
|
||||
<PackageVersion Include="ncrontab" Version="3.4.0" />
|
||||
|
||||
+98
-32
@@ -825,31 +825,74 @@ stages:
|
||||
publishFeedCredentials: "MyGet - Umbraco Nightly"
|
||||
${{ else }}:
|
||||
publishFeedCredentials: "MyGet - Pre-releases"
|
||||
# Pre-release/nightly feeds: keep the `latest` dist-tag default (no `next` split).
|
||||
- job:
|
||||
displayName: Push to pre-release feed (npm)
|
||||
steps:
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm/.npmrc"
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
displayName: Push to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
- job: PublishTestHelpersNpm
|
||||
displayName: Push TestHelpers to pre-release feed (npm)
|
||||
steps:
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: |
|
||||
# Check if we are on a nightly build
|
||||
if [ $isNightly = "False" ]; then
|
||||
echo "##[debug]Prerelease build detected"
|
||||
registry="https://www.myget.org/F/umbracoprereleases/npm/"
|
||||
else
|
||||
echo "##[debug]Nightly build detected"
|
||||
registry="https://www.myget.org/F/umbraconightly/npm/"
|
||||
fi
|
||||
echo "@umbraco-cms:registry=$registry" >> .npmrc
|
||||
env:
|
||||
isNightly: ${{parameters.isNightly}}
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm (MyGet)
|
||||
inputs:
|
||||
workingFile: "$(Pipeline.Workspace)/npm-testhelpers/.npmrc"
|
||||
customEndpoint: "MyGet (npm) - Umbracoprereleases, MyGet (npm) - Umbraconightly"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
${{ if eq(parameters.isNightly, true) }}:
|
||||
registry: https://www.myget.org/F/umbraconightly/npm/
|
||||
${{ else }}:
|
||||
registry: https://www.myget.org/F/umbracoprereleases/npm/
|
||||
- bash: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm (MyGet)
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
|
||||
- stage: Deploy_NuGet
|
||||
displayName: NuGet release
|
||||
@@ -898,30 +941,53 @@ stages:
|
||||
condition: and(in(dependencies.Deploy_NuGet.result, 'Succeeded', 'SucceededWithIssues'), or(eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True'), ${{parameters.npmDeploy}}))
|
||||
dependsOn:
|
||||
- Deploy_NuGet
|
||||
variables:
|
||||
# `latest` for stable releases, `next` for prereleases.
|
||||
npmDistTag: $[ iif(eq(stageDependencies.Build.A.outputs['build.NBGV_PrereleaseVersionNoLeadingHyphen'], ''), 'latest', 'next') ]
|
||||
jobs:
|
||||
- job: Publish
|
||||
displayName: Push to NPM
|
||||
steps:
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm
|
||||
registry: https://registry.npmjs.org/
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm/.npmrc
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
displayName: Push to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm
|
||||
- job: PublishTestHelpers
|
||||
displayName: Push Test Helpers to NPM
|
||||
steps:
|
||||
- template: templates/npm-publish.yml
|
||||
parameters:
|
||||
artifactName: npm-testhelpers
|
||||
registry: https://registry.npmjs.org/
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: npm-testhelpers
|
||||
- bash: echo "@umbraco-cms:registry=https://registry.npmjs.org" >> .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
displayName: Add scoped registry to .npmrc
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/npm-testhelpers/.npmrc
|
||||
customEndpoint: "NPM - Umbraco Backoffice"
|
||||
displayName: Push test helpers to npm
|
||||
npmTag: $(npmDistTag)
|
||||
- script: |
|
||||
# Setup temp npm project to load in defaults from the local .npmrc
|
||||
npm init -y
|
||||
|
||||
# Find the first .tgz file in the current directory and publish it
|
||||
files=( ./*.tgz )
|
||||
npm publish "${files[0]}"
|
||||
displayName: Push test helpers to npm
|
||||
workingDirectory: $(Pipeline.Workspace)/npm-testhelpers
|
||||
|
||||
- stage: Upload_API_Docs
|
||||
pool:
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
parameters:
|
||||
- name: artifactName # "npm" or "npm-testhelpers"
|
||||
type: string
|
||||
- name: registry # scoped-registry URL to publish to
|
||||
type: string
|
||||
- name: customEndpoint # npmAuthenticate service connection(s)
|
||||
type: string
|
||||
- name: displayName # label for the publish step
|
||||
type: string
|
||||
- name: npmTag # dist-tag to publish under
|
||||
type: string
|
||||
default: latest
|
||||
|
||||
steps:
|
||||
- checkout: none
|
||||
- download: current
|
||||
artifact: ${{ parameters.artifactName }}
|
||||
- script: npm config set @umbraco-cms:registry ${{ parameters.registry }} --location=project
|
||||
displayName: Add scoped registry to .npmrc
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
- task: npmAuthenticate@0
|
||||
displayName: Authenticate with npm
|
||||
inputs:
|
||||
workingFile: $(Pipeline.Workspace)/${{ parameters.artifactName }}/.npmrc
|
||||
customEndpoint: ${{ parameters.customEndpoint }}
|
||||
- script: npm publish *.tgz --tag ${{ parameters.npmTag }}
|
||||
displayName: ${{ parameters.displayName }}
|
||||
workingDirectory: $(Pipeline.Workspace)/${{ parameters.artifactName }}
|
||||
@@ -0,0 +1,96 @@
|
||||
# Visual Editor — Partial Re-render (Phase 3 remainder) — Design
|
||||
|
||||
**Status**: Implemented (spike passed 2026-06-11; see `2026-06-11-visual-editor-partial-rerender-plan.md`). Built via cache-node override + `IPublishedContentFactory` rather than a decorator — see the plan's "Deliberate deviation" note.
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: The "Still to build" items of Phase 3 in `docs/plans/visual-page-builder.md` — server-side partial re-render with unsaved values, and client-side DOM patching. Block manipulation itself is already done.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` §4.4 (original endpoint sketch), §2.3 (BlockPreview pattern); supersedes the isolated-region endpoint idea in §4.4 in favour of full-page render + client morph.
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Make partial re-render the **single, universal mechanism** for reflecting edits in the visual editor preview, retiring both the optimistic-text-only path and the save-and-full-reload path. Every edit — plain text, RTE/Markdown/media, block content/settings, and structural block add/delete/move/reorder — is reflected by re-rendering the page server-side with the workspace's unsaved values and morphing the live iframe DOM in place (no reload, scroll/selection preserved).
|
||||
|
||||
## Decisions locked
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Trigger scope | **All** edit types route through re-render: block content/settings, block add/delete/move/reorder, RTE/Markdown/transformed properties, and plain text. |
|
||||
| Feedback model | **Optimistic + authoritative**: instant optimistic `textContent` paint for plain text on keystroke; a debounced (~500ms) server re-render then replaces the region with true Razor output. Blocks/RTE show a subtle pending state (no meaningful optimistic paint) until the render returns. |
|
||||
| Rendering approach | **A — full-page render + client DOM morph.** One endpoint renders the whole page via the existing preview path with unsaved values injected; guest morphs the live DOM. Chosen over isolated-region (B) because only a full-page render covers arbitrary-template property placement with guaranteed fidelity, and over hybrid (C) for single-path simplicity. |
|
||||
| DOM patch | Bundle **morphdom** in the guest bundle; morph `<body>`, touching only changed nodes; preserves scroll. |
|
||||
| Failure mode | **Keep last good DOM + quiet notice.** Leave current DOM untouched, log, transient non-blocking indicator; the workspace already holds the edit so the next successful render reconciles. Never silently swallow. |
|
||||
| Save + SignalR | **Suppress self-reload, keep as multi-user net.** After a local save, a short-lived guard makes the editor ignore its own `refreshed` SignalR event (DOM already authoritative — no flicker). Refreshes not caused by this editor still reload. |
|
||||
|
||||
## Architecture & data flow
|
||||
|
||||
```
|
||||
edit (property / block / structural)
|
||||
→ element updates workspace value (source of truth) [+ optimistic textContent for plain text]
|
||||
→ UmbVisualEditorRenderController: debounce ~500ms, latest-wins (AbortController cancels in-flight)
|
||||
→ POST /umbraco/management/api/v1/visual-editor/render
|
||||
body: { unique, culture?, segment?, values: [{ alias, value, culture?, segment? }] }
|
||||
→ server:
|
||||
EnsureUmbracoContext + force preview mode + VisualEditorPropertyTracker.Enable() for the render scope
|
||||
base = DRAFT content from the published cache (preview read — same as the iframe shows)
|
||||
wrap in PropertyOverridePublishedContent(unsaved values)
|
||||
render the assigned template → HTML string (data-umb-* annotations emitted)
|
||||
→ { html }
|
||||
→ element posts umb:ve:render to the guest with the HTML
|
||||
→ guest morphs <body> (morphdom) → re-runs initRegions() → restores selection highlight
|
||||
```
|
||||
|
||||
The base is the **draft** content the iframe already renders (preview-mode cache read); the override layer is the workspace's even-newer unsaved edits on top.
|
||||
|
||||
## Server components (new)
|
||||
|
||||
| Unit | Project | Responsibility |
|
||||
|---|---|---|
|
||||
| Override-content builder (conversion) | `Umbraco.PublishedCache.HybridCache` (or a public seam exposed from it) | Produce an `IPublishedContent` representing the draft + unsaved overrides. **Approach proven by the spike** (and mirroring the in-tree `BlockElementService.BuildElementAsync`): for each overridden alias, run the editor-format value through `dataType.Editor.GetValueEditor().FromEditor(new ContentPropertyData(value, dataType.ConfigurationObject), null)` to get the source value; reuse the existing saved source values (`property.GetValue(published)`) for non-overridden aliases; assemble `PropertyData[]` → `ContentData` → `ContentCacheNode` → `IPublishedContentFactory.ToIPublishedContent(node, preview: true).CreateModel(...)`. Threads `Culture`/`Segment` onto `PropertyData` and sets `ContentData.CultureInfos` for variant content. **Not** a `GetProperty` decorator — a cache-node rebuild. (`IPublishedContentFactory` is `internal` to HybridCache, hence this unit lives there or a small public seam is added — resolved in the plan.) |
|
||||
| `IVisualEditorRenderService` + impl | `Umbraco.Web.Common` | Renders a supplied `IPublishedContent` to an HTML string. Modeled on `TemplateRenderer` (`src/Umbraco.Web.Common/Templates/TemplateRenderer.cs`): build an `IPublishedRequest` via `IPublishedRouter`, `SetPublishedContent(overriddenContent)`, set culture/segment + template, swap onto `UmbracoContext.PublishedRequest`, render the template view to a `StringWriter`, restore. Forces preview mode + enables `VisualEditorPropertyTracker` for the render scope so annotations are emitted. RTE-embedded blocks render via the partial-view block engine, which this render context satisfies. |
|
||||
| `RenderVisualEditorController` | `Umbraco.Cms.Api.Management` | `POST /umbraco/management/api/v1/visual-editor/render`, `[Authorize(Policy = BackOfficeAccess)]`. Ensures an `UmbracoContext`, resolves the draft content for `unique`, builds the override content from the request `values`, calls the render service, returns `{ html }`. |
|
||||
|
||||
**Value conversion — DE-RISKED by the spike (2026-06-11).** All three property kinds convert correctly via `IPublishedContentFactory.ToIPublishedContent`:
|
||||
- **TextBox** — `FromEditor` → string source → published string. Clean.
|
||||
- **Rich Text** — `FromEditor` → source JSON → `RteBlockRenderingValueConverter`; all link/url/image parsing happens at value-conversion time (no `IPublishedRequest` needed). RTE-*embedded blocks* additionally use the partial-view block engine at render time (covered by the full-page render context — smoke-test specifically).
|
||||
- **Block List** — `FromEditor` source IS the block JSON; the converter resolves element types from the published content-type cache (no parent content / `IPublishedRequest` needed). Blocks need an `Expose` entry for the relevant culture/segment to surface.
|
||||
|
||||
Recommended primitive: reuse `IPublishedContentFactory` rather than hand-assembling per property. Variant content must populate `PropertyData` per culture/segment + `ContentData.CultureInfos`, and read-time resolution depends on the ambient `IVariationContextAccessor`.
|
||||
|
||||
## Client components
|
||||
|
||||
| Unit | Responsibility |
|
||||
|---|---|
|
||||
| `UmbVisualEditorRenderController` (new sibling, follows the SignalR/router/resolver extraction pattern) | Debounce (~500ms) + latest-wins cancellation via `AbortController`. Collects the active variant's current values from the workspace, calls the endpoint, posts `umb:ve:render` to the guest with the returned HTML. On failure: keep DOM, log, transient notice. Invoked from every mutation site (property submit, block submit, add/move/delete/reorder, and the debounced optimistic text input). |
|
||||
| guest `injected.ts` | Bundle **morphdom**. Refactor the one-shot init (default outlines, drag-sort setup, add-button insertion, region discovery) into a re-runnable `initRegions()`. On `umb:ve:render`: morph `document.body` to the new HTML, then run `initRegions()` and restore the selection highlight. Delegated document-level listeners (click capture, mouseover) survive the morph; per-node styles/attributes are re-applied by `initRegions()`. |
|
||||
| element SignalR (`visual-editor-signalr.controller.ts` + element) | Reintroduce a short-lived **suppress-self-reload** guard set when this editor saves, so the `refreshed` event for our own document key is ignored. Refreshes outside the guard window still reload (multi-user / external cache changes). |
|
||||
|
||||
## Error handling
|
||||
|
||||
- Render failure (network/500/timeout): keep last good DOM, log, show a transient non-blocking "preview out of date" indicator. The edit is already in the workspace; a later successful render reconciles. No silent swallow.
|
||||
- Latest-wins: a newer edit aborts the in-flight render so stale HTML never overwrites newer DOM.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Render caching / output pooling beyond debounce + concurrency cap.
|
||||
- Headless / Delivery-API rendering in the iframe.
|
||||
- Surfacing validation state in the preview.
|
||||
- Server-side sub-region extraction (full-page render + client morph already delivers partial DOM updates).
|
||||
- Inline (`contenteditable`) editing — that is Phase 4 and now has its server-rendered source of truth from this phase.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Backend integration test** (the riskiest, and testable C#, unlike the UI surface): the spike's throwaway test at `tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/PropertyEditors/VisualEditorConversionSpikeTests.cs` (uncommitted) is the basis — the plan's first task formalizes it into a real test of the override-content builder for TextBox, RTE, and Block List (assert converted-without-saving == save-then-read). A second test renders a seeded document through the render service and asserts the HTML reflects overridden values and carries `data-umb-*` annotations.
|
||||
- **Frontend**: `npm run build` + `npm run lint` + manual smoke (no VE test harness exists; consistent with the prior phase).
|
||||
|
||||
## Spike outcome (2026-06-11) — PASSED
|
||||
|
||||
A throwaway integration test (`VisualEditorConversionSpikeTests`, uncommitted) booted Umbraco on SQLite, seeded a doc with TextBox + Rich Text + Block List, and proved that each property's editor-format value converts to the correct published value **without saving**, via `FromEditor` + `IPublishedContentFactory.ToIPublishedContent`. All 3 assertions passed (convert-without-saving == save-then-read). Findings folded into "Server components" above:
|
||||
|
||||
- Conversion primitive: `IPublishedContentFactory` (HybridCache, `internal`) — plan must resolve the access seam.
|
||||
- Approach is a cache-node rebuild, **not** a `GetProperty` decorator (in-tree precedent: `BlockElementService`).
|
||||
- Variants: thread `Culture`/`Segment` + `ContentData.CultureInfos`; read-time needs `IVariationContextAccessor`.
|
||||
- Render-to-string is independently de-risked by the existing `TemplateRenderer`; RTE-embedded-block partials are the one spot needing the render context (not the value conversion).
|
||||
|
||||
No design fallback required — the approach is viable as chosen.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
# Visual Editor Tidy-Up — Design
|
||||
|
||||
**Status**: Implemented (manual smoke pass pending)
|
||||
**Date**: 2026-06-11
|
||||
**Author**: Rick Butterfield + Claude
|
||||
**Scope**: Tidy-up round on `feature/visual-editor` after merging `main` — no new feature phases.
|
||||
**Relates to**: `docs/plans/visual-page-builder.md` (the feature plan; updated as part of this round)
|
||||
|
||||
---
|
||||
|
||||
## Decisions locked in this round
|
||||
|
||||
| Decision | Outcome |
|
||||
|---|---|
|
||||
| Architecture | **Embedded document-workspace view** is the current direction. The standalone-window evolution (plan doc §10, Open Q11) is **deferred**, not the next step. |
|
||||
| Round scope | **Tidy-up only** — security, semantics, refactor, docs. No partial re-render API, no inline editing. |
|
||||
| Editability semantics | **Strict opt-in everywhere** for document properties: a property is annotated/editable only when `appearance.editableInVisualEditor === true`. The frontend opt-out fallback is removed. |
|
||||
| Block modal properties | **No filter**: the block editing modal shows all of the element type's content/settings properties. The `EditableInVisualEditor` setting governs document property annotation only. |
|
||||
|
||||
## Why
|
||||
|
||||
The branch is functionally far ahead of its plan doc (Phases 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
@@ -59,7 +59,6 @@ public static class UmbracoBuilderApiExtensions
|
||||
string? jsonOptionsName = null)
|
||||
where TConfigureOptions : ConfigureUmbracoOpenApiOptionsBase
|
||||
{
|
||||
apiName = apiName.ToLowerInvariant();
|
||||
builder.Services.AddOpenApi(apiName);
|
||||
builder.Services.ConfigureOptions<TConfigureOptions>();
|
||||
builder.Services.AddOpenApiDocumentToUi(apiName, apiTitle);
|
||||
|
||||
@@ -2,9 +2,9 @@ using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Api.Common.Attributes;
|
||||
using Umbraco.Cms.Api.Common.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Common.OpenApi;
|
||||
|
||||
@@ -116,20 +116,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
/// <param name="builder">The Umbraco builder to register services against.</param>
|
||||
internal void Build(IUmbracoBuilder builder)
|
||||
{
|
||||
// AddOpenApi lowercases the document name when registering its keyed services (https://github.com/dotnet/aspnetcore/blob/v10.0.9/src/OpenApi/src/Extensions/OpenApiServiceCollectionExtensions.cs#L64),
|
||||
// so we must normalise here to keep AddOpenApiDocumentToUi and ReplaceOpenApiSchemaService in sync.
|
||||
string lowercasedDocumentName = DocumentName.ToLowerInvariant();
|
||||
|
||||
builder.Services.AddOpenApi(
|
||||
lowercasedDocumentName,
|
||||
DocumentName,
|
||||
options =>
|
||||
{
|
||||
// ShouldInclude matches [MapToApi] case-insensitively to align with how documents are registered.
|
||||
options.ShouldInclude = apiDescription =>
|
||||
apiDescription.ActionDescriptor.EndpointMetadata
|
||||
?.OfType<MapToApiAttribute>()
|
||||
.Any(a => a.ApiName.Equals(DocumentName, StringComparison.OrdinalIgnoreCase))
|
||||
?? false;
|
||||
apiDescription.ActionDescriptor.HasMapToApiAttribute(DocumentName);
|
||||
|
||||
options.CreateSchemaReferenceId = UmbracoSchemaIdGenerator.CreateSchemaReferenceId;
|
||||
|
||||
@@ -166,12 +158,12 @@ public sealed class BackOfficeOpenApiDocumentBuilder
|
||||
|
||||
if (_includedInUi)
|
||||
{
|
||||
builder.Services.AddOpenApiDocumentToUi(lowercasedDocumentName, _uiTitle ?? _title ?? DocumentName);
|
||||
builder.Services.AddOpenApiDocumentToUi(DocumentName, _uiTitle ?? _title);
|
||||
}
|
||||
|
||||
if (_httpJsonOptionsFactory is not null)
|
||||
{
|
||||
builder.Services.ReplaceOpenApiSchemaService(lowercasedDocumentName, _httpJsonOptionsFactory);
|
||||
builder.Services.ReplaceOpenApiSchemaService(DocumentName, _httpJsonOptionsFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level documents by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenAtRootDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level documents by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level documents by a field.")]
|
||||
[EndpointDescription("Sorts the root-level documents by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so documents that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, (Guid?)null),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Actions;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Document;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenDocumentController : DocumentControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IContentEditingService _contentEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenDocumentController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="contentEditingService">Service for editing and managing content.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for back office security context.</param>
|
||||
public SortChildrenDocumentController(
|
||||
IAuthorizationService authorizationService,
|
||||
IContentEditingService contentEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_contentEditingService = contentEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child documents of the specified parent document by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent document whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent document does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a document by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent document by a system field in the given direction. When sorting by name, an optional culture selects the variant name; the culture is not validated, so children that do not vary by it (or an unrecognised culture) fall back to the invariant name.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortDocumentChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
ContentPermissionResource.WithKeys(ActionSort.ActionLetter, id),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Document,
|
||||
childKeys => ContentPermissionResource.WithKeys(ActionSort.ActionLetter, childKeys),
|
||||
AuthorizationPolicies.ContentPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _contentEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
requestModel.Culture,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the root-level media items by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenAtRootMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenAtRootMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenAtRootMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the root-level media items by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds or <c>400 Bad Request</c> if the field is not recognised.
|
||||
/// </returns>
|
||||
[HttpPut("root/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[EndpointSummary("Sorts the root-level media items by a field.")]
|
||||
[EndpointDescription("Sorts the root-level media items by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.Root(),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
parentKey: null,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
null,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
using Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Services.OperationStatus;
|
||||
using Umbraco.Cms.Web.Common.Authorization;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Controllers.Media;
|
||||
|
||||
/// <summary>
|
||||
/// Provides an API endpoint for sorting the children of a media item by a system field.
|
||||
/// </summary>
|
||||
[ApiVersion("1.0")]
|
||||
public class SortChildrenMediaController : MediaControllerBase
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IMediaEditingService _mediaEditingService;
|
||||
private readonly IEntityService _entityService;
|
||||
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SortChildrenMediaController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">Service used to authorize user actions.</param>
|
||||
/// <param name="mediaEditingService">Service responsible for editing and sorting media items.</param>
|
||||
/// <param name="entityService">Service used to resolve the children to authorize.</param>
|
||||
/// <param name="backOfficeSecurityAccessor">Accessor for backoffice security context.</param>
|
||||
public SortChildrenMediaController(
|
||||
IAuthorizationService authorizationService,
|
||||
IMediaEditingService mediaEditingService,
|
||||
IEntityService entityService,
|
||||
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_mediaEditingService = mediaEditingService;
|
||||
_entityService = entityService;
|
||||
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the child media items of the specified parent media item by a system field.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to observe while waiting for the task to complete.</param>
|
||||
/// <param name="id">The unique identifier of the parent media item whose children should be sorted.</param>
|
||||
/// <param name="requestModel">The field to sort by and the sort direction.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IActionResult"/> indicating the outcome of the operation:
|
||||
/// returns <c>200 OK</c> if sorting succeeds, <c>400 Bad Request</c> if the field is not recognised, or <c>404 Not Found</c> if the parent media item does not exist.
|
||||
/// </returns>
|
||||
[HttpPut("{id:guid}/sort-children")]
|
||||
[MapToApiVersion("1.0")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[EndpointSummary("Sorts the children of a media item by a field.")]
|
||||
[EndpointDescription("Sorts the children of the specified parent media item by a system field in the given direction.")]
|
||||
public async Task<IActionResult> SortChildren(CancellationToken cancellationToken, Guid id, SortMediaChildrenByFieldRequestModel requestModel)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync(
|
||||
User,
|
||||
MediaPermissionResource.WithKeys(id),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!authorizationResult.Succeeded)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
var childrenAuthorized = await AllChildrenAuthorizer.IsAuthorizedForChildrenAsync(
|
||||
_authorizationService,
|
||||
_entityService,
|
||||
User,
|
||||
id,
|
||||
UmbracoObjectTypes.Media,
|
||||
childKeys => MediaPermissionResource.WithKeys(childKeys),
|
||||
AuthorizationPolicies.MediaPermissionByResource);
|
||||
|
||||
if (!childrenAuthorized)
|
||||
{
|
||||
return Forbidden();
|
||||
}
|
||||
|
||||
ContentEditingOperationStatus result = await _mediaEditingService.SortByFieldAsync(
|
||||
id,
|
||||
requestModel.Field,
|
||||
requestModel.Direction,
|
||||
CurrentUserKey(_backOfficeSecurityAccessor));
|
||||
|
||||
return result == ContentEditingOperationStatus.Success
|
||||
? Ok()
|
||||
: ContentEditingOperationStatusResult(result);
|
||||
}
|
||||
}
|
||||
+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
|
||||
{
|
||||
|
||||
@@ -49,7 +49,8 @@ public abstract class ContentTypeMapDefinition<TContentType, TPropertyTypeModel,
|
||||
},
|
||||
Appearance = new PropertyTypeAppearance
|
||||
{
|
||||
LabelOnTop = propertyType.LabelOnTop
|
||||
LabelOnTop = propertyType.LabelOnTop,
|
||||
EditableInVisualEditor = propertyType.EditableInVisualEditor,
|
||||
}
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
+820
-803
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,6 @@ public sealed class BackOfficeAreaRoutes : SignalRRoutesBase, IAreaRoutes
|
||||
Controller = ControllerExtensions.GetControllerName<BackOfficeDefaultController>(),
|
||||
Action = nameof(BackOfficeDefaultController.Index),
|
||||
},
|
||||
constraints: new { slug = @"^(section|preview|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
constraints: new { slug = @"^(section|preview|visual-editor|upgrade|install|oauth_complete|logout|error).*$" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Security.Authorization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.Security.Authorization;
|
||||
|
||||
/// <summary>
|
||||
/// Authorizes permissions on all direct children of a node.
|
||||
/// </summary>
|
||||
internal static class AllChildrenAuthorizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether the user is authorized for every direct child of the given parent (or the root).
|
||||
/// </summary>
|
||||
/// <param name="authorizationService">The authorization service.</param>
|
||||
/// <param name="entityService">The entity service used to resolve the children.</param>
|
||||
/// <param name="user">The current user.</param>
|
||||
/// <param name="parentKey">The parent key, or <c>null</c> to authorize the root-level children.</param>
|
||||
/// <param name="objectType">The object type of the children (and parent).</param>
|
||||
/// <param name="resourceFactory">Builds the permission resource to authorize a batch of child keys against.</param>
|
||||
/// <param name="policy">The authorization policy to apply.</param>
|
||||
/// <returns><c>true</c> if the user is authorized against all children; otherwise <c>false</c>.</returns>
|
||||
public static async Task<bool> IsAuthorizedForChildrenAsync(
|
||||
IAuthorizationService authorizationService,
|
||||
IEntityService entityService,
|
||||
ClaimsPrincipal user,
|
||||
Guid? parentKey,
|
||||
UmbracoObjectTypes objectType,
|
||||
Func<IEnumerable<Guid>, IPermissionResource> resourceFactory,
|
||||
string policy)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var page = 0;
|
||||
long total;
|
||||
do
|
||||
{
|
||||
Guid[] childKeys = entityService
|
||||
.GetPagedChildren(parentKey, [objectType], objectType, page * pageSize, pageSize, out total)
|
||||
.Select(child => child.Key)
|
||||
.ToArray();
|
||||
|
||||
if (childKeys.Length > 0)
|
||||
{
|
||||
AuthorizationResult authorizationResult = await authorizationService.AuthorizeResourceAsync(
|
||||
user,
|
||||
resourceFactory(childKeys),
|
||||
policy);
|
||||
|
||||
if (authorizationResult.Succeeded is false)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
page++;
|
||||
}
|
||||
while (page * pageSize < total);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
|
||||
/// Gets or sets a value indicating whether the label for the property type is displayed above the input.
|
||||
/// </summary>
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
}
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Base request model for sorting the children of a node by a system field.
|
||||
/// </summary>
|
||||
public abstract class SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the system field to sort the children by.
|
||||
/// The create and update dates are node-level (not culture-specific).
|
||||
/// </summary>
|
||||
public required ContentSortField Field { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the direction to sort in.
|
||||
/// </summary>
|
||||
public required Direction Direction { get; init; }
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
using Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a document by a system field.
|
||||
/// </summary>
|
||||
public class SortDocumentChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the culture whose variant name to sort by, or <c>null</c> to sort by the invariant name.
|
||||
/// Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a document that
|
||||
/// does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.
|
||||
/// </summary>
|
||||
public string? Culture { get; init; }
|
||||
}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
namespace Umbraco.Cms.Api.Management.ViewModels.Sorting;
|
||||
|
||||
/// <summary>
|
||||
/// Request model for sorting the children of a media item by a system field.
|
||||
/// Media items do not vary by culture, so no culture is accepted.
|
||||
/// </summary>
|
||||
public class SortMediaChildrenByFieldRequestModel : SortChildrenByFieldRequestModelBase
|
||||
{
|
||||
}
|
||||
+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; }
|
||||
}
|
||||
@@ -59,7 +59,7 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
private int? _skipver;
|
||||
private RoslynCompiler? _roslynCompiler;
|
||||
private ModelsBuilderSettings _config;
|
||||
private volatile bool _disposedValue;
|
||||
private bool _disposedValue;
|
||||
|
||||
public InMemoryModelFactory(
|
||||
Lazy<UmbracoServices> umbracoServices,
|
||||
@@ -280,34 +280,25 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
}
|
||||
}
|
||||
|
||||
// The factory is disposed on application shutdown (via IRegisteredObject.Stop), but in-flight
|
||||
// requests can still reach this point. Bail out with the current models rather than touching
|
||||
// the disposed lock. The catch below covers the small window where disposal happens after this
|
||||
// check but before (or while) the lock is acquired.
|
||||
if (_disposedValue)
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
{
|
||||
return _infos;
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// don't use an upgradeable lock here because only 1 thread at a time could enter it
|
||||
try
|
||||
{
|
||||
_locker.EnterReadLock();
|
||||
if (_hasModels)
|
||||
{
|
||||
return _infos;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsReadLockHeld)
|
||||
{
|
||||
_locker.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
_locker.EnterUpgradeableReadLock();
|
||||
|
||||
if (_hasModels)
|
||||
@@ -368,12 +359,6 @@ namespace Umbraco.Cms.DevelopmentMode.Backoffice.InMemoryAuto
|
||||
|
||||
return _infos;
|
||||
}
|
||||
catch (ObjectDisposedException ex)
|
||||
{
|
||||
// Expected when the factory is disposed during shutdown mid-request; log so an unexpected disposal stays traceable.
|
||||
_logger.LogDebug(ex, "EnsureModels interrupted by object disposal (assumed application shutdown); returning current models.");
|
||||
return _infos;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_locker.IsWriteLockHeld)
|
||||
|
||||
@@ -467,20 +467,6 @@ public static class DistributedCacheExtensions
|
||||
|
||||
#endregion
|
||||
|
||||
#region ElementContainerCacheRefresher
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the id/key map for the specified deleted element containers (folders).
|
||||
/// </summary>
|
||||
/// <param name="dc">The distributed cache.</param>
|
||||
/// <param name="deletedContainers">The element containers that were deleted.</param>
|
||||
public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable<EntityContainer> deletedContainers)
|
||||
=> dc.RefreshByPayload(
|
||||
ElementContainerCacheRefresher.UniqueId,
|
||||
deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key)));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Published Snapshot
|
||||
|
||||
/// <summary>
|
||||
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping
|
||||
/// is evicted from <see cref="Services.IIdKeyMap"/> on every server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an
|
||||
/// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node
|
||||
/// itself, so without this handler the container's stale id/key mapping survives until the next app
|
||||
/// restart (see #23072).
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerDeletedDistributedCacheNotificationHandler
|
||||
: DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification>
|
||||
{
|
||||
private readonly DistributedCache _distributedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class.
|
||||
/// </summary>
|
||||
/// <param name="distributedCache">The distributed cache.</param>
|
||||
public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache)
|
||||
=> _distributedCache = distributedCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state)
|
||||
{
|
||||
EntityContainer[] elementContainers = entities
|
||||
.Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer)
|
||||
.ToArray();
|
||||
|
||||
if (elementContainers.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_distributedCache.RemoveElementContainerCache(elementContainers);
|
||||
}
|
||||
}
|
||||
+1
-10
@@ -18,14 +18,5 @@ public sealed class LanguageDeletedDistributedCacheNotificationHandler : Deleted
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Handle(IEnumerable<ILanguage> entities, IDictionary<string, object?> state)
|
||||
{
|
||||
_distributedCache.RemoveLanguageCache(entities);
|
||||
|
||||
// User groups cache their allowed language ids, so a deleted language must be evicted from
|
||||
// them too - otherwise a stale, now-missing id lingers on the cached user group and breaks
|
||||
// reads that resolve those ids. This is a deliberately coarse refresh of the entire user group
|
||||
// and user caches (RefreshAll also clears IUser): we can't know which groups reference the
|
||||
// language without a query, and language deletion is rare enough that a full refresh is fine.
|
||||
_distributedCache.RefreshAllUserGroupCache();
|
||||
}
|
||||
=> _distributedCache.RemoveLanguageCache(entities);
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
|
||||
namespace Umbraco.Cms.Core.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Provides cache refresh functionality for element containers (folders).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A deleted container's node id is never reused, so its key→id mapping in <see cref="IIdKeyMap"/> must be
|
||||
/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and
|
||||
/// the element tree's children query returns nothing until the next app restart. This refresher only evicts
|
||||
/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader
|
||||
/// invalidation performed by <see cref="ElementCacheRefresher"/>.
|
||||
/// </remarks>
|
||||
public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase<ElementContainerCacheRefresherNotification, ElementContainerCacheRefresher.JsonPayload>
|
||||
{
|
||||
private readonly IIdKeyMap _idKeyMap;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresher"/> class.
|
||||
/// </summary>
|
||||
public ElementContainerCacheRefresher(
|
||||
AppCaches appCaches,
|
||||
IJsonSerializer serializer,
|
||||
IIdKeyMap idKeyMap,
|
||||
IEventAggregator eventAggregator,
|
||||
ICacheRefresherNotificationFactory factory)
|
||||
: base(appCaches, serializer, eventAggregator, factory)
|
||||
=> _idKeyMap = idKeyMap;
|
||||
|
||||
#region Json
|
||||
|
||||
/// <summary>
|
||||
/// Represents a JSON-serializable payload identifying an element container that changed.
|
||||
/// </summary>
|
||||
public class JsonPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="JsonPayload"/> class.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique integer identifier for the container.</param>
|
||||
/// <param name="key">The unique GUID key associated with the container.</param>
|
||||
public JsonPayload(int id, Guid key)
|
||||
{
|
||||
Id = id;
|
||||
Key = key;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique integer identifier for the container.
|
||||
/// </summary>
|
||||
public int Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique GUID key associated with the container.
|
||||
/// </summary>
|
||||
public Guid Key { get; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Define
|
||||
|
||||
/// <summary>
|
||||
/// Represents a unique identifier for the cache refresher.
|
||||
/// </summary>
|
||||
public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Guid RefresherUniqueId => UniqueId;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string Name => "Element Container Cache Refresher";
|
||||
|
||||
#endregion
|
||||
|
||||
#region Refresher
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(JsonPayload[] payloads)
|
||||
{
|
||||
foreach (JsonPayload payload in payloads)
|
||||
{
|
||||
// Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync.
|
||||
_idKeyMap.ClearCache(payload.Id);
|
||||
}
|
||||
|
||||
base.Refresh(payloads);
|
||||
}
|
||||
|
||||
// These events should never trigger. Everything should be PAYLOAD/JSON.
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void RefreshAll() => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(int id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Refresh(Guid id) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override void Remove(int id) => throw new NotSupportedException();
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -16,11 +16,6 @@ public class ContentSettings
|
||||
/// </summary>
|
||||
internal const bool StaticResolveUrlsFromTextString = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default value for whether sorting children by a field fires per-item notifications.
|
||||
/// </summary>
|
||||
internal const bool StaticSortChildrenByFieldFiresNotifications = false;
|
||||
|
||||
/// <summary>
|
||||
/// The default preview badge markup template.
|
||||
/// </summary>
|
||||
@@ -114,18 +109,6 @@ public class ContentSettings
|
||||
[DefaultValue(StaticResolveUrlsFromTextString)]
|
||||
public bool ResolveUrlsFromTextString { get; set; } = StaticResolveUrlsFromTextString;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether sorting the children of a node by a field fires
|
||||
/// per-item save/sort notifications (and therefore webhooks).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <c>false</c>: the children are reordered with a single set-based update and a branch
|
||||
/// cache refresh, without per-item notifications. Set to <c>true</c> to restore per-item notifications
|
||||
/// (and webhooks), accepting the additional performance cost on nodes with many children.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSortChildrenByFieldFiresNotifications)]
|
||||
public bool SortChildrenByFieldFiresNotifications { get; set; } = StaticSortChildrenByFieldFiresNotifications;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the collection of error pages.
|
||||
/// </summary>
|
||||
|
||||
@@ -30,17 +30,6 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
internal const string StaticTimeBetweenPruneOperations = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single synchronization operation.
|
||||
/// </summary>
|
||||
internal const string StaticSyncTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single synchronization operation, for use as a fallback when an invalid
|
||||
/// <see cref="SyncTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultSyncTimeout = TimeSpan.Parse(StaticSyncTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum number of instructions that can be processed at startup; otherwise the server
|
||||
/// cold-boots (rebuilds its caches).
|
||||
@@ -66,13 +55,4 @@ public class DatabaseServerMessengerSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTimeBetweenPruneOperations)]
|
||||
public TimeSpan TimeBetweenPruneOperations { get; set; } = TimeSpan.Parse(StaticTimeBetweenPruneOperations);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single synchronization operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single sync,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticSyncTimeout)]
|
||||
public TimeSpan SyncTimeout { get; set; } = DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
@@ -20,17 +20,6 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
internal const string StaticStaleServerTimeout = "00:02:00";
|
||||
|
||||
/// <summary>
|
||||
/// The default timeout for a single server touch operation.
|
||||
/// </summary>
|
||||
internal const string StaticTouchTimeout = "00:01:00"; // TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the default timeout for a single server touch operation, for use as a fallback when an invalid
|
||||
/// <see cref="TouchTimeout" /> is configured.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultTouchTimeout = TimeSpan.Parse(StaticTouchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the amount of time to wait between calls to the database on the background thread.
|
||||
/// </summary>
|
||||
@@ -42,13 +31,4 @@ public class DatabaseServerRegistrarSettings
|
||||
/// </summary>
|
||||
[DefaultValue(StaticStaleServerTimeout)]
|
||||
public TimeSpan StaleServerTimeout { get; set; } = TimeSpan.Parse(StaticStaleServerTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum time to wait for a single server touch operation to complete before it is
|
||||
/// considered stalled (for example, blocked on a hung database connection) and abandoned, so the recurring
|
||||
/// job keeps running rather than stopping permanently. This bounds how long the job waits on a single touch,
|
||||
/// not how long a stalled connection itself takes to recover (which is governed by the database timeouts).
|
||||
/// </summary>
|
||||
[DefaultValue(StaticTouchTimeout)]
|
||||
public TimeSpan TouchTimeout { get; set; } = DefaultTouchTimeout;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,5 @@ public class IndexingSettings
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how many items to index at a time.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the primary lever for the peak memory used while (re)building an index: a full page of
|
||||
/// content and its property data is held in memory at once, so lowering this value reduces rebuild
|
||||
/// memory at the cost of more, smaller batches. Lower it on very large sites that hit memory pressure
|
||||
/// during a rebuild.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticBatchSize)]
|
||||
public int BatchSize { get; set; } = StaticBatchSize;
|
||||
}
|
||||
|
||||
@@ -32,11 +32,6 @@ public class LoggingSettings
|
||||
/// </summary>
|
||||
internal const string StaticFileNameFormatArguments = "MachineName";
|
||||
|
||||
/// <summary>
|
||||
/// The default mode for enriching log events with a session identifier.
|
||||
/// </summary>
|
||||
internal const SessionIdLoggingMode StaticSessionIdLogging = SessionIdLoggingMode.SessionId;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for the maximum age of a log file.
|
||||
/// </summary>
|
||||
@@ -75,16 +70,4 @@ public class LoggingSettings
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticFileNameFormatArguments)]
|
||||
public string FileNameFormatArguments { get; set; } = StaticFileNameFormatArguments;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value determining how log events are enriched with a session identifier.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="SessionIdLoggingMode.SessionId" /> for backward compatibility. Set to
|
||||
/// <see cref="SessionIdLoggingMode.CookieHash" /> or <see cref="SessionIdLoggingMode.None" /> to avoid the
|
||||
/// blocking session-store load that resolving the actual session id incurs per request when the session is
|
||||
/// backed by an <c>IDistributedCache</c>.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticSessionIdLogging)]
|
||||
public SessionIdLoggingMode SessionIdLogging { get; set; } = StaticSessionIdLogging;
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for scheduled publishing.
|
||||
/// </summary>
|
||||
[UmbracoOptions(Constants.Configuration.ConfigScheduledPublishing)]
|
||||
public class ScheduledPublishingSettings
|
||||
{
|
||||
private const string StaticPeriod = "00:01:00";
|
||||
private const bool StaticAlignToClock = false; // TODO (V19): Switch this to true.
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value for how often scheduled publishing runs.
|
||||
/// </summary>
|
||||
[DefaultValue(StaticPeriod)]
|
||||
public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether scheduled publishing runs are aligned to clock boundaries
|
||||
/// derived from <see cref="Period" /> (for example, on the minute, or every N seconds), rather than drifting
|
||||
/// based on when the previous run completed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When enabled, <see cref="Period" /> must be a whole number of seconds that divides evenly into one hour
|
||||
/// (for example 10, 12, 15, 20, 30 or 60 seconds) so that boundaries land on consistent clock times.
|
||||
/// Boundaries are anchored to <strong>UTC</strong>, not the server's local time zone; for sub-minute and
|
||||
/// whole-minute periods this is indistinguishable from local time at the second level.
|
||||
/// </remarks>
|
||||
[DefaultValue(StaticAlignToClock)]
|
||||
public bool AlignToClock { get; set; } = StaticAlignToClock;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Determines how request logging enriches log events with a session identifier.
|
||||
/// </summary>
|
||||
public enum SessionIdLoggingMode
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not enrich log events with a session identifier.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with the actual ASP.NET Core session id. This is the default and matches the
|
||||
/// historical behaviour, but reading the session id forces the session to be loaded from its store, which
|
||||
/// is a blocking round-trip per request when the session is backed by an <c>IDistributedCache</c>.
|
||||
/// </summary>
|
||||
SessionId,
|
||||
|
||||
/// <summary>
|
||||
/// Enrich log events with a one-way hash of the session cookie value. This provides the same per-session
|
||||
/// correlation as <see cref="SessionId" /> without loading the session from its store, so it never incurs
|
||||
/// a distributed-cache round-trip.
|
||||
/// </summary>
|
||||
CookieHash,
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Umbraco.Cms.Core.Configuration.Models.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// Validator for configuration represented as <see cref="ScheduledPublishingSettings" />.
|
||||
/// </summary>
|
||||
public class ScheduledPublishingSettingsValidator : ConfigurationValidatorBase, IValidateOptions<ScheduledPublishingSettings>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ValidateOptionsResult Validate(string? name, ScheduledPublishingSettings options)
|
||||
{
|
||||
if (options.Period <= TimeSpan.Zero)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be greater than zero.");
|
||||
}
|
||||
|
||||
if (options.AlignToClock && IsCleanDivisorOfAnHour(options.Period) == false)
|
||||
{
|
||||
return ValidateOptionsResult.Fail(
|
||||
$"Configuration entry {Constants.Configuration.ConfigScheduledPublishing}:Period must be a whole number of seconds that divides evenly into one hour (3600 seconds) when {Constants.Configuration.ConfigScheduledPublishing}:AlignToClock is enabled, e.g. 10, 12, 15, 20, 30 or 60 seconds.");
|
||||
}
|
||||
|
||||
return ValidateOptionsResult.Success;
|
||||
}
|
||||
|
||||
private static bool IsCleanDivisorOfAnHour(TimeSpan period)
|
||||
{
|
||||
var totalSeconds = period.TotalSeconds;
|
||||
|
||||
// Must be a positive, whole number of seconds (no sub-second component).
|
||||
if (totalSeconds <= 0 || totalSeconds != Math.Floor(totalSeconds))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return 3600 % (long)totalSeconds == 0;
|
||||
}
|
||||
}
|
||||
@@ -291,11 +291,6 @@ public static partial class Constants
|
||||
/// </summary>
|
||||
public const string ConfigDistributedJobs = ConfigPrefix + "DistributedJobs";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for scheduled publishing settings.
|
||||
/// </summary>
|
||||
public const string ConfigScheduledPublishing = ConfigPrefix + "ScheduledPublishing";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key for backoffice token cookie settings.
|
||||
/// </summary>
|
||||
|
||||
@@ -57,7 +57,6 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.Services.AddSingleton<IValidateOptions<RequestHandlerSettings>, RequestHandlerSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<UnattendedSettings>, UnattendedSettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<SecuritySettings>, SecuritySettingsValidator>();
|
||||
builder.Services.AddSingleton<IValidateOptions<ScheduledPublishingSettings>, ScheduledPublishingSettingsValidator>();
|
||||
|
||||
// Register configuration sections.
|
||||
builder
|
||||
@@ -101,7 +100,6 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddUmbracoOptions<CacheSettings>()
|
||||
.AddUmbracoOptions<SystemDateMigrationSettings>()
|
||||
.AddUmbracoOptions<DistributedJobSettings>()
|
||||
.AddUmbracoOptions<ScheduledPublishingSettings>(options => options.ValidateOnStart())
|
||||
.AddUmbracoOptions<BackOfficeTokenCookieSettings>()
|
||||
.AddUmbracoOptions<WebsiteSettings>()
|
||||
.AddUmbracoOptions<SignalRSettings>();
|
||||
|
||||
@@ -402,7 +402,6 @@
|
||||
<key alias="invalidMediaType">The chosen media type is invalid.</key>
|
||||
<key alias="invalidContentType">The chosen content is of invalid type.</key>
|
||||
<key alias="missingContent">The chosen content does not exist.</key>
|
||||
<key alias="missingMedia">The chosen media does not exist.</key>
|
||||
<key alias="multipleMediaNotAllowed">Multiple selected media is not allowed.</key>
|
||||
<key alias="notOneOfOptions">The value '%0%' is not one of the available options.</key>
|
||||
<key alias="multipleNotOneOfOptions">The values '%0%' are not found in the the available options.</key>
|
||||
|
||||
@@ -316,6 +316,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -356,6 +358,8 @@ public static class PublishedContentExtensions
|
||||
{
|
||||
IPublishedProperty? property = content.GetProperty(alias);
|
||||
|
||||
TrackVisualEditorAccess(property, alias, content.Key);
|
||||
|
||||
// if we have a property, and it has a value, return that value
|
||||
if (property != null && property.HasValue(culture, segment))
|
||||
{
|
||||
@@ -373,6 +377,23 @@ public static class PublishedContentExtensions
|
||||
return property == null ? default : property.Value<T>(publishedValueFallback, culture, segment, fallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a visual editor property access for property types
|
||||
/// that have been marked as editable in the visual editor.
|
||||
/// </summary>
|
||||
private static void TrackVisualEditorAccess(IPublishedProperty? property, string alias, Guid contentKey)
|
||||
{
|
||||
if (property is null || !VisualEditorPropertyTracker.IsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (property.PropertyType.EditableInVisualEditor)
|
||||
{
|
||||
VisualEditorPropertyTracker.RecordAccess(alias, contentKey);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region IsSomething: misc.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
namespace Umbraco.Cms.Core.Models.ContentEditing;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a system field that a node's children can be sorted by.
|
||||
/// </summary>
|
||||
public enum ContentSortField
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by the node's name.
|
||||
/// </summary>
|
||||
Name,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was created.
|
||||
/// </summary>
|
||||
CreateDate,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by the date the node was last updated.
|
||||
/// </summary>
|
||||
UpdateDate,
|
||||
}
|
||||
@@ -9,4 +9,9 @@ public class PropertyTypeAppearance
|
||||
/// Gets or sets a value indicating whether the label should be displayed above the property editor.
|
||||
/// </summary>
|
||||
public bool LabelOnTop { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this property type is editable in the visual editor.
|
||||
/// </summary>
|
||||
public bool EditableInVisualEditor { get; set; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -24,9 +24,4 @@ public enum TaggableObjectTypes
|
||||
/// Represents member entities (user accounts).
|
||||
/// </summary>
|
||||
Member,
|
||||
|
||||
/// <summary>
|
||||
/// Represents element entities.
|
||||
/// </summary>
|
||||
Element,
|
||||
}
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Core.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A notification that is used to trigger the Element Container Cache Refresher.
|
||||
/// </summary>
|
||||
public class ElementContainerCacheRefresherNotification : CacheRefresherNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementContainerCacheRefresherNotification"/> class.
|
||||
/// </summary>
|
||||
/// <param name="messageObject">The refresher payload.</param>
|
||||
/// <param name="messageType">Type of the cache refresher message, <see cref="MessageType"/>.</param>
|
||||
public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType)
|
||||
: base(messageObject, messageType)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -16,19 +16,6 @@ public interface IContentRepository<in TId, TEntity> : IReadWriteQueryRepository
|
||||
/// </summary>
|
||||
int RecycleBinId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the sort order of the specified nodes so that each node's sort order matches its
|
||||
/// position in the supplied (already ordered) collection, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="orderedNodeIds">The node identifiers in their desired order.</param>
|
||||
/// <remarks>
|
||||
/// This persists the sort order directly and does not load the entities or fire any notifications;
|
||||
/// callers are responsible for any required cache refresh and auditing.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
void UpdateSortOrder(IReadOnlyList<int> orderedNodeIds)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Gets versions.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the comma-separated content type keys stored in a picker's "allowed content types" configuration value
|
||||
/// (e.g. <see cref="ContentPickerConfiguration.AllowedContentTypeIds"/> or <see cref="ElementPickerConfiguration.AllowedContentTypeIds"/>).
|
||||
/// </summary>
|
||||
internal static class AllowedContentTypeKeysParser
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the configured value into the set of allowed content type keys.
|
||||
/// </summary>
|
||||
/// <param name="configValue">The comma-separated configuration value. Non-GUID entries are ignored.</param>
|
||||
/// <returns>The set of allowed content type keys, or an empty set when nothing is configured.</returns>
|
||||
public static HashSet<Guid> Parse(string? configValue)
|
||||
{
|
||||
if (configValue.IsNullOrWhiteSpace())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new HashSet<Guid>();
|
||||
foreach (var entry in configValue.Split(Constants.CharArrays.Comma, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (Guid.TryParse(entry, out Guid guid))
|
||||
{
|
||||
result.Add(guid);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,4 @@ public class ContentPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
// Copyright (c) Umbraco.
|
||||
// See LICENSE for more details.
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Umbraco.Cms.Core.DependencyInjection;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
@@ -74,21 +70,13 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public ContentPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute,
|
||||
ICoreScopeProvider coreScopeProvider,
|
||||
IContentService contentService,
|
||||
ILocalizedTextService localizedTextService)
|
||||
DataEditorAttribute attribute)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
Validators.Add(new TypedValidatorRunner<string, ContentPickerConfiguration>(
|
||||
new AllowedTypeValidator(localizedTextService, contentService, coreScopeProvider)));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -146,61 +134,4 @@ public class ContentPickerPropertyEditor : DataEditor, IValueSchemaProvider
|
||||
return guidUdi.Guid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the selected content matches the allowed content types configured for the property editor.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="contentService">The content service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
internal sealed class AllowedTypeValidator(ILocalizedTextService localizedTextService, IContentService contentService, ICoreScopeProvider coreScopeProvider)
|
||||
: ITypedValidator<string, ContentPickerConfiguration>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
string? value,
|
||||
ContentPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) ||
|
||||
configuration is null ||
|
||||
Guid.TryParse(value, out Guid id) is false)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all content types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using ICoreScope scope = coreScopeProvider.CreateCoreScope();
|
||||
Guid? key = contentService.GetById(id)?.ContentType?.Key;
|
||||
scope.Complete();
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"missingContent"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
if (allowedContentTypeKeys.Contains(key.Value) is false)
|
||||
{
|
||||
return [new ValidationResult(
|
||||
localizedTextService.Localize(
|
||||
"validation",
|
||||
"invalidObjectType"),
|
||||
["value"])];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,32 +8,4 @@ public class ElementPickerConfiguration : IIgnoreUserStartNodesConfig
|
||||
/// <inheritdoc />
|
||||
[ConfigurationField(Constants.DataTypes.ReservedPreValueKeys.IgnoreUserStartNodes)]
|
||||
public bool IgnoreUserStartNodes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the validation limits for the number of elements allowed.
|
||||
/// </summary>
|
||||
[ConfigurationField("validationLimit")]
|
||||
public NumberRange? ValidationLimit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the content type filter for allowed selections.
|
||||
/// </summary>
|
||||
[ConfigurationField("allowedContentTypes")]
|
||||
public string? AllowedContentTypeIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Represents a numeric range with optional minimum and maximum values.
|
||||
/// </summary>
|
||||
public class NumberRange
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum value of the range.
|
||||
/// </summary>
|
||||
public int? Min { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum value of the range.
|
||||
/// </summary>
|
||||
public int? Max { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.IO;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Models.Editors;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
using Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Serialization;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Strings;
|
||||
using Umbraco.Extensions;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors;
|
||||
|
||||
/// <summary>
|
||||
/// Element picker property editor that stores element keys.
|
||||
/// Element picker property editor that stores element keys
|
||||
/// </summary>
|
||||
[DataEditor(
|
||||
Constants.PropertyEditors.Aliases.ElementPicker,
|
||||
@@ -23,11 +17,6 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
{
|
||||
private readonly IIOHelper _ioHelper;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="dataValueEditorFactory">The data value editor factory.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
public ElementPickerPropertyEditor(IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper)
|
||||
: base(dataValueEditorFactory)
|
||||
{
|
||||
@@ -39,44 +28,21 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
protected override IConfigurationEditor CreateConfigurationEditor() =>
|
||||
new ElementPickerConfigurationEditor(_ioHelper);
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override IDataValueEditor CreateValueEditor() =>
|
||||
DataValueEditorFactory.Create<ElementPickerPropertyValueEditor>(Attribute!);
|
||||
|
||||
/// <summary>
|
||||
/// Provides the value editor for the element picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class ElementPickerPropertyValueEditor : DataValueEditor, IDataValueReference
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ElementPickerPropertyValueEditor" /> class.
|
||||
/// </summary>
|
||||
/// <param name="shortStringHelper">The short string helper.</param>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="ioHelper">The IO helper.</param>
|
||||
/// <param name="attribute">The data editor attribute.</param>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public ElementPickerPropertyValueEditor(
|
||||
IShortStringHelper shortStringHelper,
|
||||
IJsonSerializer jsonSerializer,
|
||||
IIOHelper ioHelper,
|
||||
DataEditorAttribute attribute,
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
DataEditorAttribute attribute)
|
||||
: base(shortStringHelper, jsonSerializer, ioHelper, attribute)
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
Validators.Add(new TypedValidatorRunner<List<string>, ElementPickerConfiguration>(
|
||||
new MinMaxValidator(localizedTextService),
|
||||
new AllowedTypeValidator(localizedTextService, elementService, coreScopeProvider)));
|
||||
}
|
||||
=> _jsonSerializer = jsonSerializer;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<UmbracoEntityReference> GetReferences(object? value)
|
||||
{
|
||||
var asString = value as string ?? value?.ToString();
|
||||
@@ -97,144 +63,4 @@ public class ElementPickerPropertyEditor : DataEditor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that the number of selected elements is within the configured min/max limits, if any.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MinMaxValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
public MinMaxValidator(ILocalizedTextService localizedTextService)
|
||||
=> _localizedTextService = localizedTextService;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
var validationResults = new List<ValidationResult>();
|
||||
|
||||
if (configuration is null || configuration.ValidationLimit is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Min is int min and > 0 && (value is null || value.Count < min))
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesShort",
|
||||
[min.ToString(), (min - (value?.Count ?? 0)).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
if (configuration.ValidationLimit.Max is int max and > 0 && value.Count > max)
|
||||
{
|
||||
validationResults.Add(new ValidationResult(
|
||||
_localizedTextService.Localize(
|
||||
"validation",
|
||||
"entriesExceed",
|
||||
[max.ToString(), (value.Count - max).ToString()]),
|
||||
["value"]));
|
||||
}
|
||||
|
||||
return validationResults;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validator to ensure that all selected elements are of an allowed content type, if any are configured.
|
||||
/// </summary>
|
||||
internal sealed class AllowedTypeValidator : ITypedValidator<List<string>, ElementPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
private readonly IElementService _elementService;
|
||||
private readonly ICoreScopeProvider _coreScopeProvider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AllowedTypeValidator" /> class.
|
||||
/// </summary>
|
||||
/// <param name="localizedTextService">The localized text service.</param>
|
||||
/// <param name="elementService">The element service.</param>
|
||||
/// <param name="coreScopeProvider">The core scope provider.</param>
|
||||
public AllowedTypeValidator(
|
||||
ILocalizedTextService localizedTextService,
|
||||
IElementService elementService,
|
||||
ICoreScopeProvider coreScopeProvider)
|
||||
{
|
||||
_localizedTextService = localizedTextService;
|
||||
_elementService = elementService;
|
||||
_coreScopeProvider = coreScopeProvider;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
List<string>? value,
|
||||
ElementPickerConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (value is null || value.Count == 0 || configuration is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<Guid> allowedContentTypeKeys = AllowedContentTypeKeysParser.Parse(configuration.AllowedContentTypeIds);
|
||||
|
||||
// No filter configured — all element types are allowed.
|
||||
if (allowedContentTypeKeys.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Guid[] elementIds = value
|
||||
.Where(v => Guid.TryParse(v, out _))
|
||||
.Select(Guid.Parse)
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
|
||||
IElement[] elements = _elementService.GetByIds(elementIds).ToArray();
|
||||
scope.Complete();
|
||||
|
||||
// Compare against the distinct requested keys (not the raw value count, which may include
|
||||
// duplicates or non-GUID entries) so existing elements aren't incorrectly reported as missing.
|
||||
if (elements.Length != elementIds.Length)
|
||||
{
|
||||
return [
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "missingContent"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
|
||||
foreach (IElement element in elements)
|
||||
{
|
||||
if (allowedContentTypeKeys.Contains(element.ContentType.Key) is false)
|
||||
{
|
||||
return
|
||||
[
|
||||
new ValidationResult(
|
||||
_localizedTextService.Localize("validation", "invalidObjectType"),
|
||||
["value"])
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ internal sealed class EntityDataPickerPropertyEditor : DataEditor
|
||||
/// <summary>
|
||||
/// Validates the min/max configuration for the entity data picker property editor.
|
||||
/// </summary>
|
||||
internal sealed class MinMaxValidator : ITypedValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
internal sealed class MinMaxValidator : ITypedJsonValidator<EntityDataPickerDto, EntityDataPickerConfiguration>
|
||||
{
|
||||
private readonly ILocalizedTextService _localizedTextService;
|
||||
|
||||
|
||||
@@ -9,13 +9,17 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
[Obsolete("Use ITypedValidator instead; the validator contract is not JSON-specific. Scheduled for removal in Umbraco 20.")]
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration> : ITypedValidator<TValue, TConfiguration>
|
||||
public interface ITypedJsonValidator<TValue, TConfiguration>
|
||||
{
|
||||
// Re-declared (rather than purely inherited from ITypedValidator) so the ITypedJsonValidator.Validate member
|
||||
// remains present for binary compatibility with consumers compiled against this interface in v15-v17.
|
||||
// TODO (V20): remove together with this interface.
|
||||
new IEnumerable<ValidationResult> Validate(
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The deserialized value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
public abstract IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// A validator that operates on an already-typed value and configuration.
|
||||
/// <remarks>
|
||||
/// Used together with an <see cref="IValueValidator"/> runner that materializes the typed value: see
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> for value editors whose value is already typed, and
|
||||
/// <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/> for JSON based value editors, where the value is deserialized once before validation.
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the value consumed by the validator.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the configuration consumed by validator.</typeparam>
|
||||
public interface ITypedValidator<TValue, TConfiguration>
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the specified value against the configuration.
|
||||
/// </summary>
|
||||
/// <param name="value">The typed value to validate.</param>
|
||||
/// <param name="configuration">The data type configuration.</param>
|
||||
/// <param name="valueType">The value type.</param>
|
||||
/// <param name="validationContext">The property validation context.</param>
|
||||
/// <returns>A collection of validation results.</returns>
|
||||
IEnumerable<ValidationResult> Validate(
|
||||
TValue? value,
|
||||
TConfiguration? configuration,
|
||||
string? valueType,
|
||||
PropertyValidationContext validationContext);
|
||||
}
|
||||
@@ -6,47 +6,26 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate <see cref="IValueValidator"/> for JSON based value editors. Deserializes the editor value into
|
||||
/// <typeparamref name="TValue"/> once (avoiding repeated deserialization), casts the configuration once, and passes both
|
||||
/// to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// An aggregate validator for JSON based value editors, to avoid doing multiple deserialization.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this runner when the editor value reaching validation is raw JSON that must be deserialized before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data, which the backoffice JSON object
|
||||
/// converter leaves as un-typed JSON nodes rather than a typed CLR value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is already the typed CLR value (so only a cast is needed, with no deserialization) use
|
||||
/// <see cref="TypedValidatorRunner{TValue,TConfiguration}"/> instead. That is the only difference between the two runners:
|
||||
/// this one deserializes, the other casts.
|
||||
/// Will deserialize once, and cast the configuration once, and pass those values to each <see cref="ITypedJsonValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration</typeparam>
|
||||
/// <seealso cref="TypedValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly IJsonSerializer _jsonSerializer;
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
private readonly ITypedJsonValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
[Obsolete("Use the constructor accepting ITypedValidator instances. Scheduled for removal in Umbraco 20.")]
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedJsonValidator<TValue, TConfiguration>[] validators)
|
||||
: this(jsonSerializer, (ITypedValidator<TValue, TConfiguration>[])validators)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedJsonValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="jsonSerializer">The JSON serializer.</param>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedJsonValidatorRunner(IJsonSerializer jsonSerializer, params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
{
|
||||
_jsonSerializer = jsonSerializer;
|
||||
_validators = validators;
|
||||
@@ -72,7 +51,7 @@ public class TypedJsonValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
return validationResults;
|
||||
}
|
||||
|
||||
foreach (ITypedValidator<TValue, TConfiguration> validator in _validators)
|
||||
foreach (ITypedJsonValidator<TValue, TConfiguration> validator in _validators)
|
||||
{
|
||||
validationResults.AddRange(validator.Validate(deserializedValue, configuration, valueType, validationContext));
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Umbraco.Cms.Core.Models.Validation;
|
||||
|
||||
namespace Umbraco.Cms.Core.PropertyEditors.Validation;
|
||||
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// An aggregate <see cref="IValueValidator"/> that casts the editor value once and passes it, along with the cast
|
||||
/// configuration, to each <see cref="ITypedValidator{TValue,TConfiguration}"/>, aggregating the results.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use this runner when the editor value reaching validation is already the typed CLR value (<typeparamref name="TValue"/>),
|
||||
/// so a cast is all that is needed — for example a content picker (value is a <see cref="string"/>) or an element picker
|
||||
/// (value is a <c>List<string></c>, since the backoffice JSON object converter resolves an array of scalars into a typed list).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When the editor value is instead raw JSON that must be deserialized into <typeparamref name="TValue"/> before validation —
|
||||
/// typically an array of complex objects, such as a media picker storing crop data — use <see cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
/// instead. That is the only difference between the two runners: this one casts, the other deserializes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <typeparam name="TValue">The type of the expected value.</typeparam>
|
||||
/// <typeparam name="TConfiguration">The type of the expected configuration.</typeparam>
|
||||
/// <seealso cref="TypedJsonValidatorRunner{TValue,TConfiguration}"/>
|
||||
public class TypedValidatorRunner<TValue, TConfiguration> : IValueValidator
|
||||
where TValue : class
|
||||
{
|
||||
private readonly ITypedValidator<TValue, TConfiguration>[] _validators;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TypedValidatorRunner{TValue, TConfiguration}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="validators">The collection of validators to run.</param>
|
||||
public TypedValidatorRunner(params ITypedValidator<TValue, TConfiguration>[] validators)
|
||||
=> _validators = validators;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<ValidationResult> Validate(
|
||||
object? value,
|
||||
string? valueType,
|
||||
object? dataTypeConfiguration,
|
||||
PropertyValidationContext validationContext)
|
||||
{
|
||||
if (dataTypeConfiguration is not TConfiguration configuration)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value is not null and not TValue)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var typedValue = value as TValue;
|
||||
|
||||
return _validators
|
||||
.SelectMany(v => v.Validate(typedValue, configuration, valueType, validationContext))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Umbraco.Cms.Core.Models.PublishedContent;
|
||||
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="IPublishedContent"/> for the visual editor preview: the requested document's
|
||||
/// draft content with a set of unsaved property values overlaid on top, converted to their published form.
|
||||
/// </summary>
|
||||
public interface IVisualEditorContentFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the draft content for <paramref name="documentKey"/> and returns a preview
|
||||
/// <see cref="IPublishedContent"/> whose overridden aliases yield the converted unsaved values.
|
||||
/// Returns <c>null</c> if the document does not exist.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The key of the document whose draft content will be used as the base.</param>
|
||||
/// <param name="overrides">The unsaved property values to overlay on top of the draft content.</param>
|
||||
/// <returns>
|
||||
/// A preview <see cref="IPublishedContent"/> with the overrides applied,
|
||||
/// or <c>null</c> if the document cannot be resolved.
|
||||
/// </returns>
|
||||
Task<IPublishedContent?> CreateWithOverridesAsync(
|
||||
Guid documentKey,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
/// <summary>
|
||||
/// A single unsaved property value to overlay onto draft content when rendering the visual editor preview.
|
||||
/// </summary>
|
||||
/// <param name="Alias">The property alias to override.</param>
|
||||
/// <param name="EditorValue">
|
||||
/// The editor-format value as held by the backoffice workspace. Complex editors (rich text, block list)
|
||||
/// expect their serialized JSON; plain editors (e.g. text box) expect the raw value.
|
||||
/// </param>
|
||||
/// <param name="Culture">The culture the override applies to, or <c>null</c> for invariant.</param>
|
||||
/// <param name="Segment">The segment the override applies to, or <c>null</c> for none.</param>
|
||||
public readonly record struct VisualEditorPropertyOverride(string Alias, object? EditorValue, string? Culture, string? Segment);
|
||||
@@ -103,19 +103,11 @@ internal sealed class ContentEditingService
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(
|
||||
ContentCreateModel createModel,
|
||||
Guid userKey)
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidateCulturesAndPropertiesAsync(
|
||||
=> await ValidateCulturesAndPropertiesAsync(
|
||||
createModel,
|
||||
createModel.ContentTypeKey,
|
||||
createModel.Variants.Select(variant => variant.Culture),
|
||||
userKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
|
||||
@@ -219,15 +211,6 @@ internal sealed class ContentEditingService
|
||||
Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture, userKey);
|
||||
|
||||
private async Task<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
|
||||
{
|
||||
if (templateKey == null)
|
||||
@@ -275,8 +258,8 @@ internal sealed class ContentEditingService
|
||||
protected override OperationResult? Delete(IContent content, int userId) => ContentService.Delete(content, userId);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: ordering);
|
||||
protected override IEnumerable<IContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, propertyAliases: null, filter: null, ordering: null);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IContent> items, int userId)
|
||||
@@ -285,13 +268,6 @@ internal sealed class ContentEditingService
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
private async Task<ContentEditingOperationStatus> Save(IContent content, Guid userKey)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -500,10 +500,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
{
|
||||
// these are the only result states currently expected from the invoked IContentService operations
|
||||
OperationResultType.Success => ContentEditingOperationStatus.Success,
|
||||
|
||||
// a no-op (e.g. sorting children when nothing needs reordering) is a successful outcome, not an error
|
||||
OperationResultType.NoOperation => ContentEditingOperationStatus.Success,
|
||||
|
||||
OperationResultType.FailedCancelledByEvent => ContentEditingOperationStatus.CancelledByNotification,
|
||||
OperationResultType.FailedCannot => ContentEditingOperationStatus.CannotDeleteWhenReferenced,
|
||||
|
||||
@@ -665,25 +661,6 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
|
||||
return filteredContentTypes.Any();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that content of the requested type is allowed to be created under the requested parent, applying the
|
||||
/// same "allowed at root", "allowed as child" and content type filter rules that are enforced when the content is
|
||||
/// actually created. This allows the validation endpoints to be consistent with creation.
|
||||
/// </summary>
|
||||
/// <param name="createModel">The content creation model.</param>
|
||||
/// <returns>The operation status; <see cref="ContentEditingOperationStatus.Success"/> when creation is allowed.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> ValidateCreationAllowedAsync(ContentCreationModelBase createModel)
|
||||
{
|
||||
TContentType? contentType = ContentTypeService.Get(createModel.ContentTypeKey);
|
||||
if (contentType is null)
|
||||
{
|
||||
return ContentEditingOperationStatus.ContentTypeNotFound;
|
||||
}
|
||||
|
||||
(int? _, ContentEditingOperationStatus operationStatus) = await TryGetAndValidateParentIdAsync(createModel.ParentKey, contentType);
|
||||
return operationStatus;
|
||||
}
|
||||
|
||||
private void UpdateNames(ContentEditingModelBase contentEditingModelBase, TContent content, TContentType contentType)
|
||||
{
|
||||
if (contentType.VariesByCulture())
|
||||
|
||||
@@ -90,10 +90,9 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
/// <param name="parentId">The parent identifier.</param>
|
||||
/// <param name="pageIndex">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The page size.</param>
|
||||
/// <param name="ordering">The ordering to apply, or <c>null</c> to use the default (sort order).</param>
|
||||
/// <param name="total">The total number of children.</param>
|
||||
/// <returns>The paged children.</returns>
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total);
|
||||
protected abstract IEnumerable<TContent> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total);
|
||||
|
||||
/// <summary>
|
||||
/// Handles the sorting operation asynchronously.
|
||||
@@ -116,7 +115,16 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
List<TContent> children = LoadAllChildren(contentId.Value, ordering: null);
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out var total);
|
||||
var children = new List<TContent>((int)total);
|
||||
children.AddRange(page);
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId.Value, pageNumber++, pageSize, out _);
|
||||
children.AddRange(page);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -134,102 +142,4 @@ internal abstract class ContentEditingServiceWithSortingBase<TContent, TContentT
|
||||
return ContentEditingOperationStatus.SortingInvalid;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles sorting a parent's children by a system field asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The optional parent key.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The user key performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected async Task<ContentEditingOperationStatus> HandleSortByFieldAsync(
|
||||
Guid? parentKey,
|
||||
ContentSortField field,
|
||||
Direction direction,
|
||||
string? culture,
|
||||
Guid userKey)
|
||||
{
|
||||
var contentId = parentKey.HasValue
|
||||
? ContentService.GetById(parentKey.Value)?.Id
|
||||
: Constants.System.Root;
|
||||
|
||||
if (contentId.HasValue is false)
|
||||
{
|
||||
return ContentEditingOperationStatus.NotFound;
|
||||
}
|
||||
|
||||
Ordering ordering = BuildOrdering(field, direction, culture);
|
||||
|
||||
// The database does the ordering (matching the list view and the order shown in the sort UI).
|
||||
if (ContentSettings.SortChildrenByFieldFiresNotifications)
|
||||
{
|
||||
// Opt-in path: load the children and persist via the standard sort, firing per-item
|
||||
// save/sort notifications (and therefore webhooks), at the cost of loading every child.
|
||||
List<TContent> orderedChildren = LoadAllChildren(contentId.Value, ordering);
|
||||
if (orderedChildren.Count == 0)
|
||||
{
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return Sort(orderedChildren, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
// Default path: persist the resulting order with a single set-based update and a branch cache
|
||||
// refresh, without loading every child or firing per-item notifications.
|
||||
List<int> orderedChildIds = LoadOrderedChildIds(contentId.Value, ordering);
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
// Nothing to sort - the order is trivially correct.
|
||||
return ContentEditingOperationStatus.Success;
|
||||
}
|
||||
|
||||
return SortChildrenInBulk(contentId.Value, orderedChildIds, await GetUserIdAsync(userKey));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the supplied (already ordered) child identifiers as the new sort order, without loading
|
||||
/// the children or firing per-item notifications.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The parent identifier, or the root identifier for root-level sorting.</param>
|
||||
/// <param name="orderedChildIds">The child identifiers in their desired order.</param>
|
||||
/// <param name="userId">The user performing the operation.</param>
|
||||
/// <returns>The operation status.</returns>
|
||||
protected abstract ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId);
|
||||
|
||||
private List<int> LoadOrderedChildIds(int contentId, Ordering ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child.Id);
|
||||
|
||||
private List<TContent> LoadAllChildren(int contentId, Ordering? ordering)
|
||||
=> LoadAllChildren(contentId, ordering, child => child);
|
||||
|
||||
// Pages through all children, projecting each page with the selector so callers that only need a
|
||||
// lightweight value (e.g. the id) don't retain every loaded child.
|
||||
private List<TResult> LoadAllChildren<TResult>(int contentId, Ordering? ordering, Func<TContent, TResult> selector)
|
||||
{
|
||||
const int pageSize = 500;
|
||||
var pageNumber = 0;
|
||||
IEnumerable<TContent> page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out var total);
|
||||
var results = new List<TResult>((int)total);
|
||||
results.AddRange(page.Select(selector));
|
||||
while (pageNumber * pageSize < total)
|
||||
{
|
||||
page = GetPagedChildren(contentId, pageNumber++, pageSize, ordering, out _);
|
||||
results.AddRange(page.Select(selector));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static Ordering BuildOrdering(ContentSortField field, Direction direction, string? culture)
|
||||
=> field switch
|
||||
{
|
||||
// Name is variant - the culture selects the variant name to order by (invariant content and media
|
||||
// ignore it). Create and update dates are node-level, so the culture does not apply.
|
||||
ContentSortField.Name => Ordering.By("name", direction, culture),
|
||||
ContentSortField.CreateDate => Ordering.By("createDate", direction),
|
||||
ContentSortField.UpdateDate => Ordering.By("updateDate", direction),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unsupported sort field."),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1652,13 +1652,7 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded content (e.g. loaded with loadTemplates: false or without property data),
|
||||
// and saving those directly would wipe the template and property data (#23120).
|
||||
// GetByIds returns items in the requested order, preserving the caller's ordering that drives the sort.
|
||||
IContent[] reloaded = GetByIds(itemsA.Select(x => x.Id).ToArray()).ToArray();
|
||||
|
||||
OperationResult ret = Sort(scope, reloaded, userId, evtMsgs);
|
||||
OperationResult ret = Sort(scope, itemsA, userId, evtMsgs);
|
||||
scope.Complete();
|
||||
return ret;
|
||||
}
|
||||
@@ -1696,43 +1690,6 @@ public class ContentService : PublishableContentServiceBase<IContent>, IContentS
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.ContentTree);
|
||||
|
||||
_documentRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the content repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IContent[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IContent? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new ContentTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
private OperationResult Sort(ICoreScope scope, IContent[] itemsA, int userId, EventMessages eventMessages)
|
||||
{
|
||||
var sortingNotification = new ContentSortingNotification(itemsA, eventMessages);
|
||||
|
||||
@@ -821,6 +821,7 @@ internal abstract class ContentTypeEditingServiceBase<TContentType, TContentType
|
||||
propertyType.Description = property.Description;
|
||||
propertyType.SortOrder = property.SortOrder;
|
||||
propertyType.LabelOnTop = property.Appearance.LabelOnTop;
|
||||
propertyType.EditableInVisualEditor = property.Appearance.EditableInVisualEditor;
|
||||
|
||||
propertyType.PropertyGroupId = propertyGroup is null
|
||||
? null
|
||||
|
||||
@@ -95,18 +95,6 @@ public interface IContentEditingService
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="culture">The culture whose variant name to sort by, or <c>null</c> to sort by the invariant name. Only applies when sorting by <see cref="ContentSortField.Name"/>. The culture is not validated: a child that does not vary by the given culture - or an unrecognised culture - falls back to the invariant name.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the action.</param>
|
||||
/// <returns>The operation status indicating success or failure.</returns>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, string? culture, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a content item whether it is in the recycle bin or not.
|
||||
/// </summary>
|
||||
|
||||
@@ -418,22 +418,6 @@ public interface IContentService : IPublishableContentService<IContent>
|
||||
/// <returns>The operation result.</returns>
|
||||
OperationResult Sort(IEnumerable<int>? ids, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child document identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{int}?, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Publish Document
|
||||
|
||||
@@ -118,18 +118,6 @@ public interface IMediaEditingService
|
||||
/// </returns>
|
||||
Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by a system field.
|
||||
/// </summary>
|
||||
/// <param name="parentKey">The unique identifier of the parent, or <c>null</c> for root-level sorting.</param>
|
||||
/// <param name="field">The system field to sort the children by.</param>
|
||||
/// <param name="direction">The direction to sort in.</param>
|
||||
/// <param name="userKey">The unique identifier of the user performing the operation.</param>
|
||||
/// <returns>The operation status indicating the operation outcome.</returns>
|
||||
/// <remarks>Media items never vary by culture, so children are always ordered by the invariant name.</remarks>
|
||||
Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
=> throw new NotImplementedException(); // TODO (V19): Remove default implementation.
|
||||
|
||||
/// <summary>
|
||||
/// Permanently deletes a media item from the recycle bin.
|
||||
/// </summary>
|
||||
|
||||
@@ -338,22 +338,6 @@ public interface IMediaService : IContentServiceBase<IMedia>
|
||||
/// <returns>True if sorting succeeded, otherwise False</returns>
|
||||
bool Sort(IEnumerable<IMedia> items, int userId = Constants.Security.SuperUserId);
|
||||
|
||||
/// <summary>
|
||||
/// Sorts the children of a parent by persisting the supplied (already ordered) child identifiers
|
||||
/// as the new sort order, in a single set-based update.
|
||||
/// </summary>
|
||||
/// <param name="parentId">The identifier of the parent, or <see cref="Constants.System.Root"/> for the root.</param>
|
||||
/// <param name="orderedChildIds">The child media identifiers, in the desired order.</param>
|
||||
/// <param name="userId">The identifier of the user performing the action.</param>
|
||||
/// <returns>The operation result.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="Sort(IEnumerable{IMedia}, int)" />, this does not load the children or fire per-item
|
||||
/// save/sort notifications; it persists the order directly and refreshes the affected cache branch.
|
||||
/// </remarks>
|
||||
// TODO (V19): Remove the default implementation.
|
||||
OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
=> throw new NotImplementedException();
|
||||
|
||||
/// <summary>
|
||||
/// Creates an <see cref="IMedia" /> object using the alias of the <see cref="IMediaType" />
|
||||
/// that this Media should based on.
|
||||
|
||||
@@ -54,18 +54,6 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<TaggedEntity> GetTaggedMembersByTag(string tag, string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with any tag in the specified group.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all elements tagged with the specified tag.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags.
|
||||
/// </summary>
|
||||
@@ -112,12 +100,6 @@ public interface ITagService : IService
|
||||
/// </summary>
|
||||
IEnumerable<ITag> GetAllMemberTags(string? group = null, string? culture = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all element tags.
|
||||
/// </summary>
|
||||
// TODO (V19): Remove the default implementation from this interface.
|
||||
IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null) => [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets all tags attached to an entity via a property.
|
||||
/// </summary>
|
||||
|
||||
@@ -87,15 +87,7 @@ internal sealed class MediaEditingService
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
|
||||
{
|
||||
ContentEditingOperationStatus creationAllowedStatus = await ValidateCreationAllowedAsync(createModel);
|
||||
if (creationAllowedStatus != ContentEditingOperationStatus.Success)
|
||||
{
|
||||
return Attempt.FailWithStatus(creationAllowedStatus, new ContentValidationResult());
|
||||
}
|
||||
|
||||
return await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
}
|
||||
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
|
||||
@@ -177,13 +169,6 @@ internal sealed class MediaEditingService
|
||||
public async Task<ContentEditingOperationStatus> SortAsync(Guid? parentKey, IEnumerable<SortingModel> sortingModels, Guid userKey)
|
||||
=> await HandleSortAsync(parentKey, sortingModels, userKey);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ContentEditingOperationStatus> SortByFieldAsync(Guid? parentKey, ContentSortField field, Direction direction, Guid userKey)
|
||||
|
||||
// Media never varies by culture, so children are always ordered by the invariant name.
|
||||
=> await HandleSortByFieldAsync(parentKey, field, direction, culture: null, userKey);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IMedia New(string name, int parentId, IMediaType mediaType)
|
||||
=> new Models.Media(name, parentId, mediaType);
|
||||
@@ -206,8 +191,8 @@ internal sealed class MediaEditingService
|
||||
=> ContentService.Delete(media, userId).Result;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, Ordering? ordering, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total, filter: null, ordering: ordering);
|
||||
protected override IEnumerable<IMedia> GetPagedChildren(int parentId, int pageIndex, int pageSize, out long total)
|
||||
=> ContentService.GetPagedChildren(parentId, pageIndex, pageSize, out total);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus Sort(IEnumerable<IMedia> items, int userId)
|
||||
@@ -218,13 +203,6 @@ internal sealed class MediaEditingService
|
||||
: ContentEditingOperationStatus.CancelledByNotification;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ContentEditingOperationStatus SortChildrenInBulk(int parentId, IReadOnlyList<int> orderedChildIds, int userId)
|
||||
{
|
||||
OperationResult result = ContentService.SortChildren(parentId, orderedChildIds, userId);
|
||||
return OperationResultToOperationStatus(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a media item to the repository.
|
||||
/// </summary>
|
||||
|
||||
@@ -1309,15 +1309,6 @@ namespace Umbraco.Cms.Core.Services
|
||||
{
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
// Reload within the lock so sorting operates on fully-loaded entities. Callers may pass
|
||||
// partially-loaded media (e.g. without property data), and saving those directly would
|
||||
// wipe the property data (#23120). Preserve the caller's ordering, which drives the sort.
|
||||
var reloadedById = GetByIds(itemsA.Select(x => x.Id)).ToDictionary(x => x.Id);
|
||||
itemsA = itemsA
|
||||
.Select(x => reloadedById.TryGetValue(x.Id, out IMedia? media) ? media : null)
|
||||
.WhereNotNull()
|
||||
.ToArray();
|
||||
|
||||
var savingNotification = new MediaSavingNotification(itemsA, messages);
|
||||
if (scope.Notifications.PublishCancelable(savingNotification))
|
||||
{
|
||||
@@ -1356,43 +1347,6 @@ namespace Umbraco.Cms.Core.Services
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OperationResult SortChildren(int parentId, IReadOnlyList<int> orderedChildIds, int userId = Constants.Security.SuperUserId)
|
||||
{
|
||||
EventMessages evtMsgs = EventMessagesFactory.Get();
|
||||
if (orderedChildIds.Count == 0)
|
||||
{
|
||||
return new OperationResult(OperationResultType.NoOperation, evtMsgs);
|
||||
}
|
||||
|
||||
using ICoreScope scope = ScopeProvider.CreateCoreScope();
|
||||
scope.WriteLock(Constants.Locks.MediaTree);
|
||||
|
||||
_mediaRepository.UpdateSortOrder(orderedChildIds);
|
||||
|
||||
// Sort order lives in umbracoNode; neither the published cache nor the media repository cache keeps
|
||||
// a separate serialized copy of it, so refreshing the affected branch (which invalidates both and has
|
||||
// them reload from umbracoNode) is enough to pick up the new order without re-saving each child.
|
||||
if (parentId == Constants.System.Root)
|
||||
{
|
||||
IMedia[] roots = GetByIds(orderedChildIds).ToArray();
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(roots, TreeChangeTypes.RefreshNode, evtMsgs));
|
||||
}
|
||||
else
|
||||
{
|
||||
IMedia? parent = GetById(parentId);
|
||||
if (parent is not null)
|
||||
{
|
||||
scope.Notifications.Publish(new MediaTreeChangeNotification(parent, TreeChangeTypes.RefreshBranch, evtMsgs));
|
||||
}
|
||||
}
|
||||
|
||||
Audit(AuditType.Sort, userId, parentId);
|
||||
|
||||
scope.Complete();
|
||||
return OperationResult.Succeed(evtMsgs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the data integrity of the media tree and optionally fixes detected issues.
|
||||
/// </summary>
|
||||
|
||||
@@ -803,8 +803,6 @@ public class RelationService : RepositoryService, IRelationService
|
||||
UmbracoObjectTypes.MemberType,
|
||||
UmbracoObjectTypes.DataType,
|
||||
UmbracoObjectTypes.MemberGroup,
|
||||
UmbracoObjectTypes.Element,
|
||||
UmbracoObjectTypes.ElementContainer,
|
||||
UmbracoObjectTypes.ROOT,
|
||||
UmbracoObjectTypes.RecycleBin,
|
||||
];
|
||||
|
||||
@@ -101,24 +101,6 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTagGroup(string group, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTagGroup(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<TaggedEntity> GetTaggedElementsByTag(string tag, string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTaggedEntitiesByTag(TaggableObjectTypes.Element, tag, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllTags(string? group = null, string? culture = null)
|
||||
{
|
||||
@@ -180,15 +162,6 @@ public class TagService : RepositoryService, ITagService
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetAllElementTags(string? group = null, string? culture = null)
|
||||
{
|
||||
using (ScopeProvider.CreateCoreScope(autoComplete: true))
|
||||
{
|
||||
return _tagRepository.GetTagsForEntityType(TaggableObjectTypes.Element, group, culture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<ITag> GetTagsForProperty(int contentId, string propertyTypeAlias, string? group = null, string? culture = null)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Umbraco.Cms.Core.PublishedCache;
|
||||
|
||||
namespace Umbraco.Cms.Core.Templates;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a document's assigned template to an HTML string using the visual editor's unsaved values,
|
||||
/// with property-access tracking enabled so the output carries <c>data-umb-*</c> annotations.
|
||||
/// </summary>
|
||||
public interface IVisualEditorRenderService
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders the document identified by <paramref name="documentKey"/> with the supplied unsaved
|
||||
/// <paramref name="overrides"/> overlaid. Returns the rendered HTML, or an empty string if the
|
||||
/// document or its template cannot be resolved.
|
||||
/// </summary>
|
||||
/// <param name="documentKey">The key of the document to render.</param>
|
||||
/// <param name="culture">The culture to render, or <c>null</c> for the default/invariant.</param>
|
||||
/// <param name="segment">The segment to render, or <c>null</c> for none.</param>
|
||||
/// <param name="overrides">The unsaved editor values to overlay onto the draft content.</param>
|
||||
/// <returns>The rendered page HTML, or an empty string if the document or template is unavailable.</returns>
|
||||
Task<string> RenderAsync(
|
||||
Guid documentKey,
|
||||
string? culture,
|
||||
string? segment,
|
||||
IReadOnlyCollection<VisualEditorPropertyOverride> overrides);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs;
|
||||
|
||||
/// <summary>
|
||||
/// A background job that will be executed by an available server. With a single server setup this will always be the same.
|
||||
@@ -16,19 +16,6 @@ public interface IDistributedBackgroundJob
|
||||
/// </summary>
|
||||
TimeSpan Period { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the job's runs should be aligned to clock boundaries derived from <see cref="Period" />.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>true</c>, the job becomes runnable on the next clock boundary that is a multiple of <see cref="Period" />
|
||||
/// (measured from a fixed <strong>UTC</strong> origin, so boundaries fall on round clock times such as on the minute
|
||||
/// or every N seconds) rather than at <c>LastRun + Period</c>.
|
||||
/// For predictable boundaries <see cref="Period" /> should divide evenly into one hour.
|
||||
/// The scheduler may cache this value when it first evaluates registered jobs; changing it at runtime may require an application restart.
|
||||
/// Defaults to <c>false</c>, preserving the original drift-from-completion behaviour.
|
||||
/// </remarks>
|
||||
bool AlignToClock => false;
|
||||
|
||||
/// <summary>
|
||||
/// Run the job.
|
||||
/// </summary>
|
||||
|
||||
+2
-10
@@ -2,9 +2,7 @@
|
||||
// See LICENSE for more details.
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Models;
|
||||
using Umbraco.Cms.Core.Scoping;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
@@ -25,10 +23,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
public string Name => "ScheduledPublishingJob";
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan Period => _scheduledPublishingSettings.CurrentValue.Period;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool AlignToClock => _scheduledPublishingSettings.CurrentValue.AlignToClock;
|
||||
public TimeSpan Period => TimeSpan.FromMinutes(1);
|
||||
|
||||
|
||||
private readonly IContentService _contentService;
|
||||
@@ -38,7 +33,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly IServerMessenger _serverMessenger;
|
||||
private readonly IUmbracoContextFactory _umbracoContextFactory;
|
||||
private readonly IOptionsMonitor<ScheduledPublishingSettings> _scheduledPublishingSettings;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScheduledPublishingJob" /> class.
|
||||
@@ -50,8 +44,7 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
ILogger<ScheduledPublishingJob> logger,
|
||||
IServerMessenger serverMessenger,
|
||||
ICoreScopeProvider scopeProvider,
|
||||
TimeProvider timeProvider,
|
||||
IOptionsMonitor<ScheduledPublishingSettings> scheduledPublishingSettings)
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
_contentService = contentService;
|
||||
_elementService = elementService;
|
||||
@@ -60,7 +53,6 @@ internal class ScheduledPublishingJob : IDistributedBackgroundJob
|
||||
_serverMessenger = serverMessenger;
|
||||
_scopeProvider = scopeProvider;
|
||||
_timeProvider = timeProvider;
|
||||
_scheduledPublishingSettings = scheduledPublishingSettings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+8
-60
@@ -4,6 +4,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Umbraco.Cms.Core.Configuration.Models;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration;
|
||||
@@ -25,8 +26,6 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
|
||||
private readonly ILogger<InstructionProcessJob> _logger;
|
||||
private readonly IServerMessenger _messenger;
|
||||
private readonly TimeSpan _syncTimeout;
|
||||
private Task? _inFlightSync;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="InstructionProcessJob" /> class.
|
||||
@@ -42,78 +41,27 @@ public class InstructionProcessJob : RecurringBackgroundJobBase
|
||||
{
|
||||
_messenger = messenger;
|
||||
_logger = logger;
|
||||
_syncTimeout = ValidateSyncTimeout(globalSettings.Value.DatabaseServerMessenger.SyncTimeout);
|
||||
}
|
||||
|
||||
// A non-positive timeout would make every sync "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateSyncTimeout(TimeSpan configuredSyncTimeout)
|
||||
{
|
||||
if (configuredSyncTimeout > TimeSpan.Zero || configuredSyncTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredSyncTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerMessenger.SyncTimeout of {ConfiguredSyncTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultSyncTimeout}.",
|
||||
configuredSyncTimeout,
|
||||
DatabaseServerMessengerSettings.DefaultSyncTimeout);
|
||||
|
||||
return DatabaseServerMessengerSettings.DefaultSyncTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the instruction processing job asynchronously by synchronizing messages using the messenger service.
|
||||
/// Logs an error if the synchronization fails or stalls, but always completes the task so polling continues.
|
||||
/// Logs an error if the synchronization fails, but always completes the task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token that is signaled when the host is shutting down.</param>
|
||||
/// <returns>
|
||||
/// A task representing the asynchronous operation.
|
||||
/// A completed task representing the asynchronous operation.
|
||||
/// </returns>
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If a previous sync is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads, and logs the stall once rather than on every interval until it recovers.
|
||||
if (_inFlightSync is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// IServerMessenger.Sync() is synchronous and cannot observe the cancellation token, so a hung database
|
||||
// connection would otherwise block this job's recurring loop indefinitely and silently stop cache
|
||||
// polling until the process is recycled. Offload it to the thread pool and bound the wait so the loop
|
||||
// survives and keeps polling; the in-flight call keeps running until its connection faults (bounded by
|
||||
// the database command/connection timeout, not by SyncTimeout), after which syncing resumes without a recycle.
|
||||
//
|
||||
// The loop is already started under ExecutionContext.SuppressFlow() (see RecurringBackgroundJobHostedService.StartAsync),
|
||||
// which is what makes offloading the scope-creating Sync() to Task.Run safe for the static ambient scope stack.
|
||||
var syncTask = Task.Run(_messenger.Sync, cancellationToken);
|
||||
_inFlightSync = syncTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = syncTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
await syncTask.WaitAsync(_syncTimeout, cancellationToken);
|
||||
_logger.LogDebug("Synchronized cache instructions.");
|
||||
_messenger.Sync();
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Cache instruction sync did not complete within {SyncTimeout} and may be stalled on a hung database connection. Cache updates are paused on this server until the stalled connection recovers.",
|
||||
_syncTimeout);
|
||||
}
|
||||
catch (Exception e) when (e is not OperationCanceledException)
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed (will repeat).");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-58
@@ -33,8 +33,6 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
private readonly IServerRoleAccessor _serverRoleAccessor;
|
||||
private readonly IDisposable? _onChangeRegistration;
|
||||
private GlobalSettings _globalSettings;
|
||||
private TimeSpan _touchTimeout;
|
||||
private Task? _inFlightTouch;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TouchServerJob" /> class.
|
||||
@@ -57,13 +55,11 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger = logger;
|
||||
_globalSettings = globalSettings.CurrentValue;
|
||||
_serverRoleAccessor = serverRoleAccessor;
|
||||
_touchTimeout = ValidateTouchTimeout(globalSettings.CurrentValue.DatabaseServerRegistrar.TouchTimeout);
|
||||
|
||||
_onChangeRegistration = globalSettings.OnChange(x =>
|
||||
{
|
||||
_globalSettings = x;
|
||||
Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls;
|
||||
_touchTimeout = ValidateTouchTimeout(x.DatabaseServerRegistrar.TouchTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,23 +71,14 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
/// <returns>
|
||||
/// A completed task when the job has finished running.
|
||||
/// </returns>
|
||||
public override async Task RunJobAsync(CancellationToken cancellationToken)
|
||||
public override Task RunJobAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense,
|
||||
// since all it's used for is to allow the ElectedServerRoleAccessor
|
||||
// to figure out what role a given server has, so we just stop this task.
|
||||
if (_serverRoleAccessor is not ElectedServerRoleAccessor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If a previous touch is still running (e.g. blocked on a hung database connection after a timeout),
|
||||
// skip starting another. This bounds us to a single in-flight call instead of accumulating blocked
|
||||
// thread-pool threads (each contending for the servers lock), and logs the stall once rather than on
|
||||
// every interval until it recovers.
|
||||
if (_inFlightTouch is { IsCompleted: false })
|
||||
{
|
||||
return;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString();
|
||||
@@ -112,56 +99,18 @@ public class TouchServerJob : RecurringBackgroundJobBase
|
||||
_logger.LogDebug("Registering server with application URL {ServerAddress}.", serverAddress);
|
||||
}
|
||||
|
||||
// IServerRegistrationService.TouchServer() runs a synchronous database write and cannot observe the
|
||||
// cancellation token, so a hung connection would otherwise block this job's recurring loop indefinitely
|
||||
// and silently stop server-registration heartbeats until the process is recycled. Offload it to the
|
||||
// thread pool and bound the wait so the loop survives and keeps touching.
|
||||
// (See InstructionProcessJob for the same pattern and the ExecutionContext.SuppressFlow rationale.)
|
||||
TimeSpan staleServerTimeout = _globalSettings.DatabaseServerRegistrar.StaleServerTimeout;
|
||||
var touchTask = Task.Run(() => _serverRegistrationService.TouchServer(serverAddress, staleServerTimeout), cancellationToken);
|
||||
_inFlightTouch = touchTask;
|
||||
|
||||
// Observe the task's eventual fault on every exit path (timeout, shutdown cancellation, or a late
|
||||
// failure once we have stopped awaiting it) so it never surfaces as an UnobservedTaskException.
|
||||
_ = touchTask.ContinueWith(
|
||||
static t => _ = t.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
|
||||
try
|
||||
{
|
||||
await touchTask.WaitAsync(_touchTimeout, cancellationToken);
|
||||
_logger.LogDebug("Touched server registration for {ServerAddress}.", serverAddress);
|
||||
_serverRegistrationService.TouchServer(
|
||||
serverAddress,
|
||||
_globalSettings.DatabaseServerRegistrar.StaleServerTimeout);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Touching the server registration did not complete within {TouchTimeout} and may be stalled on a hung database connection. Server registration is paused on this server until the stalled connection recovers.",
|
||||
_touchTimeout);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update server record in database.");
|
||||
}
|
||||
}
|
||||
|
||||
// A non-positive timeout would make every touch "time out" immediately (or throw from WaitAsync for a
|
||||
// negative value), so guard against misconfiguration and fall back to the default. Timeout.InfiniteTimeSpan
|
||||
// is allowed as an explicit opt-out that restores the unbounded wait.
|
||||
private TimeSpan ValidateTouchTimeout(TimeSpan configuredTouchTimeout)
|
||||
{
|
||||
if (configuredTouchTimeout > TimeSpan.Zero || configuredTouchTimeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return configuredTouchTimeout;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Configured DatabaseServerRegistrar.TouchTimeout of {ConfiguredTouchTimeout} is not valid; it must be positive (or Timeout.InfiniteTimeSpan to disable the timeout). Falling back to {DefaultTouchTimeout}.",
|
||||
configuredTouchTimeout,
|
||||
DatabaseServerRegistrarSettings.DefaultTouchTimeout);
|
||||
|
||||
return DatabaseServerRegistrarSettings.DefaultTouchTimeout;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -466,7 +466,6 @@ public static partial class UmbracoBuilderExtensions
|
||||
.AddNotificationHandler<MemberTypeChangedNotification, MemberTypeChangedDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>()
|
||||
.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>()
|
||||
;
|
||||
|
||||
// add notification handlers for auditing
|
||||
|
||||
@@ -91,7 +91,7 @@ public static partial class UmbracoBuilderExtensions
|
||||
builder.AddNotificationHandler<ExternalMemberCacheRefresherNotification, ExternalMemberIndexingNotificationHandler>();
|
||||
builder.AddNotificationAsyncHandler<LanguageCacheRefresherNotification, LanguageIndexingNotificationHandler>();
|
||||
|
||||
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, RebuildOnStartedHandler>();
|
||||
builder.AddNotificationHandler<UmbracoRequestBeginNotification, RebuildOnStartupHandler>();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
@@ -170,7 +170,13 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
content = _contentService.GetPagedDescendants(contentParentId, pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(content));
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(content).ToArray();
|
||||
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
@@ -210,7 +216,12 @@ public class ContentIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
}
|
||||
}
|
||||
|
||||
ValueSetIndexer.IndexItems(indexes, _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()));
|
||||
var valueSets = _contentValueSetBuilder.GetValueSets(indexableContent.ToArray()).ToArray();
|
||||
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,13 @@ internal sealed class DeliveryApiContentIndexPopulator : IndexPopulator
|
||||
_deliveryApiContentIndexHelper.EnumerateApplicableDescendantsForContentIndex(
|
||||
Constants.System.Root,
|
||||
descendants =>
|
||||
ValueSetIndexer.IndexItems(indexes, _deliveryContentIndexValueSetBuilder.GetValueSets(descendants)));
|
||||
{
|
||||
ValueSet[] valueSets = _deliveryContentIndexValueSetBuilder.GetValueSets(descendants).ToArray();
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(valueSets);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override bool IsRegistered(IIndex index)
|
||||
|
||||
@@ -107,7 +107,11 @@ public class MediaIndexPopulator : IndexPopulator<IUmbracoContentIndex>
|
||||
{
|
||||
media = _mediaService.GetPagedDescendants(mediaParentId, pageIndex, _indexingSettings.BatchSize, out _).ToArray();
|
||||
|
||||
ValueSetIndexer.IndexItems(indexes, _mediaValueSetBuilder.GetValueSets(media));
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_mediaValueSetBuilder.GetValueSets(media));
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,11 @@ public class MemberIndexPopulator : IndexPopulator<IUmbracoMemberIndex>
|
||||
{
|
||||
members = _memberService.GetAll(pageIndex, pageSize, out _).ToArray();
|
||||
|
||||
ValueSetIndexer.IndexItems(indexes, _valueSetBuilder.GetValueSets(members));
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
foreach (IIndex index in indexes)
|
||||
{
|
||||
index.IndexItems(_valueSetBuilder.GetValueSets(members));
|
||||
}
|
||||
|
||||
pageIndex++;
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
using Umbraco.Cms.Core;
|
||||
using Umbraco.Cms.Core.Events;
|
||||
using Umbraco.Cms.Core.Notifications;
|
||||
using Umbraco.Cms.Core.Services;
|
||||
using Umbraco.Cms.Core.Sync;
|
||||
|
||||
namespace Umbraco.Cms.Infrastructure.Examine;
|
||||
|
||||
/// <summary>
|
||||
/// Handles how the indexes are rebuilt after startup.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Once the application has fully started this rebuilds the Examine indexes if they are empty.
|
||||
/// If it is a cold boot, they are all rebuilt.
|
||||
/// </remarks>
|
||||
public sealed class RebuildOnStartedHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
|
||||
{
|
||||
// The notification is published again on restart, but the indexes only need to be
|
||||
// considered for rebuilding once per application lifetime.
|
||||
private static int _hasRun;
|
||||
|
||||
private readonly ISyncBootStateAccessor _syncBootStateAccessor;
|
||||
private readonly IIndexRebuilder _indexRebuilder;
|
||||
private readonly IRuntimeState _runtimeState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Umbraco.Cms.Infrastructure.Examine.RebuildOnStartedHandler"/> class, responsible for handling index rebuilds during application startup.
|
||||
/// </summary>
|
||||
/// <param name="syncBootStateAccessor">Provides access to the application's synchronous boot state, used to determine if the system is ready for index rebuilding.</param>
|
||||
/// <param name="indexRebuilder">The service responsible for rebuilding Examine indexes.</param>
|
||||
/// <param name="runtimeState">Provides information about the current runtime state of the Umbraco application.</param>
|
||||
public RebuildOnStartedHandler(
|
||||
ISyncBootStateAccessor syncBootStateAccessor,
|
||||
IIndexRebuilder indexRebuilder,
|
||||
IRuntimeState runtimeState)
|
||||
{
|
||||
_syncBootStateAccessor = syncBootStateAccessor;
|
||||
_indexRebuilder = indexRebuilder;
|
||||
_runtimeState = runtimeState;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Once the application has fully started, schedule an index rebuild for any empty indexes (or all if it's a cold boot).
|
||||
/// </summary>
|
||||
/// <param name="notification">The notification.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
public async Task HandleAsync(UmbracoApplicationStartedNotification notification, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_runtimeState.Level != RuntimeLevel.Run)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _hasRun, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncBootState bootState = _syncBootStateAccessor.GetSyncBootState();
|
||||
|
||||
// if it's not a cold boot, only rebuild empty ones
|
||||
await _indexRebuilder.RebuildIndexesAsync(
|
||||
bootState != SyncBootState.ColdBoot,
|
||||
TimeSpan.FromMinutes(1));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user