diff --git a/.github/contributing-creating-a-pr.md b/.github/contributing-creating-a-pr.md
index dc1d67ea65..d28c815bdc 100644
--- a/.github/contributing-creating-a-pr.md
+++ b/.github/contributing-creating-a-pr.md
@@ -7,9 +7,26 @@ We recommend you to [sync with our repository][sync fork] before you submit your
GitHub will have picked up on the new branch you've pushed and will offer to create a Pull Request. Click that green button and away you go.

-We like to use [git flow][git flow] as much as possible, but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to `contrib`. This is the branch you should be targeting.
+We like to use [git flow][git flow] as much as possible, but don't worry if you are not familiar with it. The most important thing you need to know is that when you fork the Umbraco repository, the default branch is set to `main`. This is the branch you should be targeting.
-Please note: we are no longer accepting features for v8 and below but will continue to merge security fixes as and when they arise.
+We welcome PRs for features and bugfixes for different versions according to the [published support and EOL schedule][support-and-eol].
+
+We don't have rules for naming PRs - so name them as you prefer. At HQ we do have a best practice on clear and concise PR naming, so if you would like to use the format feel free to do so.
+
+Our convention of doing it is:
+
+_Area: Description (closes #IssueID)_
+
+1. Start by specifying the area. Fx the feature name(UFM, Tiptap etc.) or specific section (migrations, relations, segmentation).
+
+2. In your description, where applicable, mention type of PR (Build, Bump, Fix, Refactor etc.).
+
+4. Good practise is to make sure you describe specifically the change and/or impact of change.
+ Example: Writing "Extension Insights: Fixes CSS alignment" instead of "Fixed issue".
+
+6. Add (closes #IssueID) behind description, if your PR resolves an issue.
+
+That's it!
## The review process
[review process]: #the-review-process
@@ -48,4 +65,5 @@ There will be times that we really like your proposed changes and we’ll finish
[making larger changes]: contributing-before-you-start.md#making-large-changes
[pr or package]: contributing-before-you-start.md#pull-request-or-package
-[Core collabs]: contributing-core-collabs-team.md
\ No newline at end of file
+[Core collabs]: contributing-core-collabs-team.md
+[support-and-eol]: https://umbraco.com/products/knowledge-center/long-term-support-and-end-of-life/
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 6d4441fffc..662f47d2d0 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,8 @@
{
- "cSpell.words": ["unprovide"],
+ "cSpell.words": [
+ "unprovide",
+ "Unproviding"
+ ],
"eslint.useFlatConfig": true,
"eslint.workingDirectories": [
"./src/Umbraco.Web.UI.Client/",
diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml
index c1cec3b15a..b53a2d0ad2 100644
--- a/build/azure-pipelines.yml
+++ b/build/azure-pipelines.yml
@@ -522,160 +522,72 @@ stages:
# Connection string
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.Sqlite
+ DatabaseType: SQLite
+ additionalEnvironmentVariables: false
strategy:
matrix:
LinuxPart1Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=1/3"
LinuxPart2Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=2/3"
LinuxPart3Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=3/3"
WindowsPart1Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=1/3"
WindowsPart2Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=2/3"
WindowsPart3Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTestSqlite -- --shard=3/3"
pool:
vmImage: $(vmImage)
steps:
- # Setup test environment
- - task: DownloadPipelineArtifact@2
- displayName: Download NuGet artifacts
- inputs:
- artifact: nupkg
- path: $(Agent.BuildDirectory)/app/nupkg
-
- - task: NodeTool@0
- displayName: Use Node.js $(nodeVersion)
- retryCountOnTaskFailure: 3
- inputs:
- versionSpec: $(nodeVersion)
-
- - task: UseDotNet@2
- displayName: Use .NET SDK from global.json
- inputs:
- useGlobalJson: true
+ # Setup test environment Template
+ - template: nightly-E2E-setup-template.yml
+ parameters:
+ nodeVersion: ${{ variables.nodeVersion }}
+ PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }}
+ PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }}
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ npm_config_cache: ${{ variables.npm_config_cache }}
- pwsh: |
- "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
- UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
- URL=$(ASPNETCORE_URLS)
- STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
- CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
- displayName: Generate .env
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
-
- # Cache and restore NPM packages
- - task: Cache@2
- displayName: Cache NPM packages
- inputs:
- key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
- restoreKeys: |
- npm_e2e | "$(Agent.OS)"
- npm_e2e
- path: $(npm_config_cache)
-
- - script: npm ci --no-fund --no-audit --prefer-offline
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
- displayName: Restore NPM packages
-
- # Build application
- - pwsh: |
- $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
- dotnet new nugetconfig
- dotnet nuget add source ./nupkg --name Local
- dotnet new install Umbraco.Templates::$cmsVersion
- dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
dotnet restore UmbracoProject
cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
- dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore
+ displayName: Restore project
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore
dotnet dev-certs https
displayName: Build application
workingDirectory: $(Agent.BuildDirectory)/app
+ condition: succeeded()
- # Run application
- - bash: |
- nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
- echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
- displayName: Run application (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- workingDirectory: $(Agent.BuildDirectory)/app
+ # Run application Template
+ - template: nightly-E2E-run-application-template.yml
+ parameters:
+ DatabaseType: ${{ variables.DatabaseType }}
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }}
- - pwsh: |
- $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
- Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
- displayName: Run application (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- # Wait for application to start responding to requests
- - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS)
- displayName: Wait for application
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Install Playwright and dependencies
- - pwsh: npx playwright install chromium
- displayName: Install Playwright only with Chromium browser
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Test
- - pwsh: $(testCommand)
- displayName: Run Playwright tests
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- env:
- CI: true
- CommitId: $(Build.SourceVersion)
- AgentOs: $(Agent.OS)
-
- # Stop application
- - bash: kill -15 $(AcceptanceTestProcessId)
- displayName: Stop application (Linux)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
- displayName: Stop application (Windows)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Copy artifacts
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
- }
- displayName: Copy Playwright results
- condition: succeededOrFailed()
-
- # Copy console error log
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory)
- }
- displayName: Copy console error log
- condition: succeededOrFailed()
-
- # Publish test artifacts
- - task: PublishPipelineArtifact@1
- displayName: Publish test artifacts
- condition: succeededOrFailed()
- inputs:
- targetPath: $(Build.ArtifactStagingDirectory)
- artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
-
- # Publish test results
- - task: PublishTestResults@2
- displayName: "Publish test results"
- condition: succeededOrFailed()
- inputs:
- testResultsFormat: 'JUnit'
- testResultsFiles: '*.xml'
- searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
- testRunTitle: "$(Agent.JobName)"
+ # Run tests Template
+ - template: nightly-E2E-run-tests-template.yml
+ parameters:
+ testCommand: $(testCommand)
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ DatabaseType: ${{ variables.DatabaseType }}
- job:
displayName: E2E Smoke Tests (SQL Server)
@@ -683,354 +595,78 @@ stages:
# Connection string
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
+ SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
+ DatabaseType: SQLServer
+ additionalEnvironmentVariables: false
strategy:
matrix:
${{ if eq(parameters.sqlServerLinuxAcceptanceTests, True) }}:
LinuxPart1Of3:
testCommand: "npm run smokeTest -- --shard=1/3"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
+ testFolder: "DefaultConfig"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
LinuxPart2Of3:
testCommand: "npm run smokeTest -- --shard=2/3"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
+ testFolder: "DefaultConfig"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
LinuxPart3Of3:
testCommand: "npm run smokeTest -- --shard=3/3"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
+ testFolder: "DefaultConfig"
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True"
WindowsPart1Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTest -- --shard=1/3"
WindowsPart2Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTest -- --shard=2/3"
WindowsPart3Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run smokeTest -- --shard=3/3"
pool:
vmImage: $(vmImage)
steps:
- # Setup test environment
- - task: DownloadPipelineArtifact@2
- displayName: Download NuGet artifacts
- inputs:
- artifact: nupkg
- path: $(Agent.BuildDirectory)/app/nupkg
-
- - task: NodeTool@0
- displayName: Use Node.js $(nodeVersion)
- inputs:
- versionSpec: $(nodeVersion)
-
- - task: UseDotNet@2
- displayName: Use .NET SDK from global.json
- inputs:
- useGlobalJson: true
+ # Setup test environment Template
+ - template: nightly-E2E-setup-template.yml
+ parameters:
+ nodeVersion: ${{ variables.nodeVersion }}
+ PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }}
+ PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }}
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ npm_config_cache: ${{ variables.npm_config_cache }}
- pwsh: |
- "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
- UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
- URL=$(ASPNETCORE_URLS)
- STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
- CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
- displayName: Generate .env
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
-
- # Cache and restore NPM packages
- - task: Cache@2
- displayName: Cache NPM packages
- inputs:
- key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
- restoreKeys: |
- npm_e2e | "$(Agent.OS)"
- npm_e2e
- path: $(npm_config_cache)
-
- - script: npm ci --no-fund --no-audit --prefer-offline
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
- displayName: Restore NPM packages
-
- # Build application
- - pwsh: |
- $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
- dotnet new nugetconfig
- dotnet nuget add source ./nupkg --name Local
- dotnet new install Umbraco.Templates::$cmsVersion
- dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
dotnet restore UmbracoProject
cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
- dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore
+ displayName: Restore project
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore
dotnet dev-certs https
displayName: Build application
workingDirectory: $(Agent.BuildDirectory)/app
+ condition: succeeded()
- # Start SQL Server
- - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest
- displayName: Start SQL Server Docker image (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+ # Run application Template
+ - template: nightly-E2E-run-application-template.yml
+ parameters:
+ SA_PASSWORD: ${{ variables.SA_PASSWORD }}
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ DatabaseType: ${{ variables.DatabaseType }}
+ additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }}
- - pwsh: SqlLocalDB start MSSQLLocalDB
- displayName: Start SQL Server LocalDB (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Run application
- - bash: |
- nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
- echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
- displayName: Run application (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- - pwsh: |
- $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
- Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
- displayName: Run application (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- # Wait for application to start responding to requests
- - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS)
- displayName: Wait for application
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Install Playwright and dependencies
- - pwsh: npx playwright install chromium
- displayName: Install Playwright only with Chromium browser
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Test
- - pwsh: $(testCommand)
- displayName: Run Playwright tests
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- env:
- CI: true
- CommitId: $(Build.SourceVersion)
- AgentOs: $(Agent.OS)
-
- # Stop application
- - bash: kill -15 $(AcceptanceTestProcessId)
- displayName: Stop application (Linux)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
- displayName: Stop application (Windows)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Stop SQL Server
- - pwsh: docker stop mssql
- displayName: Stop SQL Server Docker image (Linux)
- condition: eq(variables['Agent.OS'], 'Linux')
-
- - pwsh: SqlLocalDB stop MSSQLLocalDB
- displayName: Stop SQL Server LocalDB (Windows)
- condition: eq(variables['Agent.OS'], 'Windows_NT')
-
- # Copy artifacts
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
- }
- displayName: Copy Playwright results
- condition: succeededOrFailed()
-
- # Copy console error log
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory)
- }
- displayName: Copy console error log
- condition: succeededOrFailed()
-
- # Publish test artifacts
- - task: PublishPipelineArtifact@1
- displayName: Publish test artifacts
- condition: succeededOrFailed()
- inputs:
- targetPath: $(Build.ArtifactStagingDirectory)
- artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
-
- # Publish test results
- - task: PublishTestResults@2
- displayName: "Publish test results"
- condition: succeededOrFailed()
- inputs:
- testResultsFormat: 'JUnit'
- testResultsFiles: '*.xml'
- searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
- testRunTitle: "$(Agent.JobName)"
-
- - job:
- displayName: E2E Release Tests (SQL Server)
- variables:
- # Connection string
- CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
- CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
- condition: eq(dependencies.Build.outputs['A.build.NBGV_PublicRelease'], 'True')
- strategy:
- matrix:
- WindowsPart1Of3:
- vmImage: "windows-latest"
- testCommand: "npm run releaseTest -- --shard=1/3"
- WindowsPart2Of3:
- vmImage: "windows-latest"
- testCommand: "npm run releaseTest -- --shard=2/3"
- WindowsPart3Of3:
- vmImage: "windows-latest"
- testCommand: "npm run releaseTest -- --shard=3/3"
- pool:
- vmImage: $(vmImage)
- steps:
- # Setup test environment
- - task: DownloadPipelineArtifact@2
- displayName: Download NuGet artifacts
- inputs:
- artifact: nupkg
- path: $(Agent.BuildDirectory)/app/nupkg
-
- - task: NodeTool@0
- displayName: Use Node.js $(nodeVersion)
- inputs:
- versionSpec: $(nodeVersion)
-
- - task: UseDotNet@2
- displayName: Use .NET SDK from global.json
- inputs:
- useGlobalJson: true
-
- - pwsh: |
- "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
- UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
- URL=$(ASPNETCORE_URLS)
- STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
- CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
- displayName: Generate .env
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
-
- # Cache and restore NPM packages
- - task: Cache@2
- displayName: Cache NPM packages
- inputs:
- key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
- restoreKeys: |
- npm_e2e | "$(Agent.OS)"
- npm_e2e
- path: $(npm_config_cache)
-
- - script: npm ci --no-fund --no-audit --prefer-offline
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
- displayName: Restore NPM packages
-
- # Build application
- - pwsh: |
- $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
- dotnet new nugetconfig
- dotnet nuget add source ./nupkg --name Local
- dotnet new install Umbraco.Templates::$cmsVersion
- dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
- dotnet restore UmbracoProject
- cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
- dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore
- dotnet dev-certs https
- displayName: Build application
- workingDirectory: $(Agent.BuildDirectory)/app
-
- # Start SQL Server
- - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest
- displayName: Start SQL Server Docker image (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: SqlLocalDB start MSSQLLocalDB
- displayName: Start SQL Server LocalDB (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Run application
- - bash: |
- nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
- echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
- displayName: Run application (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- - pwsh: |
- $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
- Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
- displayName: Run application (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- # Wait for application to start responding to requests
- - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS)
- displayName: Wait for application
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Install Playwright and dependencies
- - pwsh: npx playwright install chromium
- displayName: Install Playwright only with Chromium browser
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Test
- - pwsh: $(testCommand)
- displayName: Run Playwright tests
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- env:
- CI: true
- CommitId: $(Build.SourceVersion)
- AgentOs: $(Agent.OS)
-
- # Stop application
- - bash: kill -15 $(AcceptanceTestProcessId)
- displayName: Stop application (Linux)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
- displayName: Stop application (Windows)
- condition: and(ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Stop SQL Server
- - pwsh: docker stop mssql
- displayName: Stop SQL Server Docker image (Linux)
- condition: eq(variables['Agent.OS'], 'Linux')
-
- - pwsh: SqlLocalDB stop MSSQLLocalDB
- displayName: Stop SQL Server LocalDB (Windows)
- condition: eq(variables['Agent.OS'], 'Windows_NT')
-
- # Copy artifacts
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
- }
- displayName: Copy Playwright results
- condition: succeededOrFailed()
-
- # Copy console error log
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory)
- }
- displayName: Copy console error log
- condition: succeededOrFailed()
-
- # Publish test artifacts
- - task: PublishPipelineArtifact@1
- displayName: Publish test artifacts
- condition: succeededOrFailed()
- inputs:
- targetPath: $(Build.ArtifactStagingDirectory)
- artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
-
- # Publish test results
- - task: PublishTestResults@2
- displayName: "Publish test results"
- condition: succeededOrFailed()
- inputs:
- testResultsFormat: 'JUnit'
- testResultsFiles: '*.xml'
- searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
- testRunTitle: "$(Agent.JobName)"
+ # Run tests Template
+ - template: nightly-E2E-run-tests-template.yml
+ parameters:
+ testCommand: $(testCommand)
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ DatabaseType: ${{ variables.DatabaseType }}
###############################################
## Release
@@ -1237,4 +873,4 @@ stages:
storage: umbracoapidocs
ContainerName: "$web"
BlobPrefix: v$(umbracoMajorVersion)/ui-api
- CleanTargetBeforeCopy: true
\ No newline at end of file
+ CleanTargetBeforeCopy: true
diff --git a/build/nightly-E2E-build-template.yml b/build/nightly-E2E-build-template.yml
new file mode 100644
index 0000000000..4ec5f299be
--- /dev/null
+++ b/build/nightly-E2E-build-template.yml
@@ -0,0 +1,74 @@
+parameters:
+ - name: testFolder
+ type: string
+ default: ''
+
+ - name: buildConfiguration
+ type: string
+ default: ''
+
+ - name: additionalEnvironmentVariables
+ type: string
+ default: 'false'
+
+steps:
+ - pwsh: |
+ dotnet restore UmbracoProject
+ cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
+ displayName: Restore project
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ # Update application to use necessary app settings
+ - pwsh: |
+ $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup"
+ $destinationPath = "UmbracoProject"
+ $jsonFiles = Get-ChildItem -Path $sourcePath -Filter "*.json"
+ if ($jsonFiles) {
+ $jsonFiles | ForEach-Object {
+ Write-Host "Copying: $($_.FullName)"
+ Copy-Item -Path $_.FullName -Destination $destinationPath -Force
+ }
+ } else {
+ Write-Host "No JSON files found."
+ }
+ displayName: Update application to use necessary app settings
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ # Update application to use necessary App_Plugins
+ - pwsh: |
+ $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup"
+ $destinationPath = "UmbracoProject"
+ $appPluginsFolders = Get-ChildItem -Path $sourcePath -Directory -Filter "App_Plugins"
+ if ($appPluginsFolders) {
+ foreach ($folder in $appPluginsFolders) {
+ Write-Host "Copying folder: $($folder.FullName)"
+ Copy-Item -Path $folder.FullName -Destination $destinationPath -Recurse -Force
+ }
+ } else {
+ Write-Host "No App_Plugins found."
+ }
+ displayName: Update application to use necessary app plugins
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ # Update application to use necessary classes
+ - pwsh: |
+ $sourcePath = "$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/tests/${{ parameters.testFolder }}/AdditionalSetup"
+ $destinationPath = "UmbracoProject"
+ $csharpFiles = Get-ChildItem -Path $sourcePath -Filter "*.cs"
+ if ($csharpFiles) {
+ $csharpFiles | ForEach-Object {
+ Write-Host "Copying: $($_.FullName)"
+ Copy-Item -Path $_.FullName -Destination $destinationPath -Force
+ }
+ } else {
+ Write-Host "No C# files found."
+ }
+ displayName: Update application to use necessary classes
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-restore
+ dotnet dev-certs https
+ displayName: Build application
+ workingDirectory: $(Agent.BuildDirectory)/app
+ condition: and(succeeded(), eq(variables['additionalEnvironmentVariables'], 'false'))
diff --git a/build/nightly-E2E-run-application-template.yml b/build/nightly-E2E-run-application-template.yml
new file mode 100644
index 0000000000..5301ddeaf1
--- /dev/null
+++ b/build/nightly-E2E-run-application-template.yml
@@ -0,0 +1,45 @@
+parameters:
+ - name: SA_PASSWORD
+ type: string
+ default: ''
+
+ - name: buildConfiguration
+ type: string
+ default: ''
+
+ - name: additionalEnvironmentVariables
+ type: string
+ default: 'false'
+
+ - name: DatabaseType
+ type: string
+ default: ''
+
+steps:
+ # Skips the SQLServer setup if the databaseType does not match
+ - ${{ if eq(parameters.DatabaseType, 'SQLServer') }}:
+ # Start SQL Server Linux
+ - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=${{ parameters.SA_PASSWORD }}" mcr.microsoft.com/mssql/server:2022-latest
+ displayName: Start SQL Server Docker image (Linux)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+
+ # Start SQL Server LocalDB Windows
+ - pwsh: SqlLocalDB start MSSQLLocalDB
+ displayName: Start SQL Server LocalDB (Windows)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
+
+ # Run application for Linux
+ - bash: |
+ nohup dotnet run --project UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
+ echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
+ displayName: Run application (Linux)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), eq(variables['additionalEnvironmentVariables'], 'false'))
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ # Run application for Windows
+ - pwsh: |
+ $process = Start-Process dotnet "run --project UmbracoProject --configuration ${{ parameters.buildConfiguration }} --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
+ Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
+ displayName: Run application (Windows)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['additionalEnvironmentVariables'], 'false'))
+ workingDirectory: $(Agent.BuildDirectory)/app
diff --git a/build/nightly-E2E-run-tests-template.yml b/build/nightly-E2E-run-tests-template.yml
new file mode 100644
index 0000000000..c424931d8a
--- /dev/null
+++ b/build/nightly-E2E-run-tests-template.yml
@@ -0,0 +1,106 @@
+parameters:
+ - name: ASPNETCORE_URLS
+ type: string
+ default: ''
+
+ - name: testCommand
+ type: string
+ default: ''
+
+ - name: port
+ type: string
+ default: ''
+
+ - name: AZUREB2CTESTUSEREMAIL
+ type: string
+ default: ''
+
+ - name: AZUREB2CTESTUSERPASSWORD
+ type: string
+ default: ''
+
+ - name: DatabaseType
+ type: string
+ default: ''
+
+steps:
+ # Ensures we have the package wait-on installed
+ - pwsh: npm install wait-on
+ displayName: Install wait-on package
+
+ # Wait for either the port of the aspnetcore url
+ - pwsh: |
+ $Port = "${{ parameters.port }}"
+ $Url = "${{ parameters.ASPNETCORE_URLS }}"
+
+ if ($Port -ne "") {
+ Write-Host "Waiting on TCP port $Port"
+ npx wait-on -v --interval 1000 --timeout 120000 "tcp:$Port"
+ } else {
+ Write-Host "Waiting on URL $Url"
+ npx wait-on -v --interval 1000 --timeout 120000 "$Url"
+ }
+ displayName: Wait for application
+ workingDirectory: tests/Umbraco.Tests.AcceptanceTest
+
+ # Install Playwright and dependencies
+ - pwsh: npx playwright install chromium
+ displayName: Install Playwright only with Chromium browser
+ workingDirectory: tests/Umbraco.Tests.AcceptanceTest
+
+ # Test
+ - pwsh: ${{ parameters.testCommand }}
+ displayName: Run Playwright tests
+ continueOnError: true
+ workingDirectory: tests/Umbraco.Tests.AcceptanceTest
+ env:
+ CI: true
+ CommitId: $(Build.SourceVersion)
+ AgentOs: $(Agent.OS)
+ AZUREADB2CTESTUSEREMAIL: ${{ parameters.AZUREB2CTESTUSEREMAIL }}
+ AZUREADB2CTESTUSERPASSWORD: ${{ parameters.AZUREB2CTESTUSERPASSWORD }}
+
+ # Stop application
+ - bash: kill -15 $(AcceptanceTestProcessId)
+ displayName: Stop application (Linux)
+ condition: and(succeededOrFailed(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
+
+ - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
+ displayName: Stop application (Windows)
+ condition: and(succeededOrFailed(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
+
+ - ${{ if eq(parameters.DatabaseType, 'SQLServer') }}:
+ # Stop SQL Server
+ - pwsh: docker stop mssql
+ displayName: Stop SQL Server Docker image (Linux)
+ condition: and(succeededOrFailed(), eq(variables['Agent.OS'], 'Linux'))
+
+ - pwsh: SqlLocalDB stop MSSQLLocalDB
+ displayName: Stop SQL Server LocalDB (Windows)
+ condition: and(succeededOrFailed(), eq(variables['Agent.OS'], 'Windows_NT'))
+
+ # Copy artifacts
+ - pwsh: |
+ if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
+ Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
+ }
+ displayName: Copy Playwright results
+ condition: succeededOrFailed()
+
+ # Publish
+ - task: PublishPipelineArtifact@1
+ displayName: Publish test artifacts
+ condition: succeededOrFailed()
+ inputs:
+ targetPath: $(Build.ArtifactStagingDirectory)
+ artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
+
+ # Publish test results
+ - task: PublishTestResults@2
+ displayName: "Publish test results"
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFormat: 'JUnit'
+ testResultsFiles: '*.xml'
+ searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
+ testRunTitle: "$(Agent.JobName)"
diff --git a/build/nightly-E2E-setup-template.yml b/build/nightly-E2E-setup-template.yml
new file mode 100644
index 0000000000..8085561900
--- /dev/null
+++ b/build/nightly-E2E-setup-template.yml
@@ -0,0 +1,70 @@
+parameters:
+ - name: nodeVersion
+ type: string
+ default: ''
+
+ - name: PlaywrightUserEmail
+ type: string
+ default: ''
+
+ - name: PlaywrightPassword
+ type: string
+ default: ''
+
+ - name: ASPNETCORE_URLS
+ type: string
+ default: ''
+
+ - name: npm_config_cache
+ type: string
+ default: ''
+
+steps:
+ - task: DownloadPipelineArtifact@2
+ displayName: Download NuGet artifacts
+ inputs:
+ artifact: nupkg
+ path: $(Agent.BuildDirectory)/app/nupkg
+
+ - task: NodeTool@0
+ displayName: Use Node.js $(nodeVersion)
+ inputs:
+ versionSpec: $(nodeVersion)
+
+ - task: UseDotNet@2
+ displayName: Use .NET SDK from global.json
+ inputs:
+ useGlobalJson: true
+
+ - pwsh: |
+ "UMBRACO_USER_LOGIN=${{ parameters.PlaywrightUserEmail }}
+ UMBRACO_USER_PASSWORD=${{ parameters.PlaywrightPassword }}
+ URL=${{ parameters.ASPNETCORE_URLS }}
+ STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
+ CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
+ displayName: Generate .env
+ workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
+
+ # Cache and restore NPM packages
+ - task: Cache@2
+ displayName: Cache NPM packages
+ inputs:
+ key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
+ restoreKeys: |
+ npm_e2e | "$(Agent.OS)"
+ npm_e2e
+ path: ${{ parameters.npm_config_cache }}
+
+ - script: npm ci --no-fund --no-audit --prefer-offline
+ workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
+ displayName: Restore NPM packages
+
+ # Install Template
+ - pwsh: |
+ $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
+ dotnet new nugetconfig
+ dotnet nuget add source ./nupkg --name Local
+ dotnet new install Umbraco.Templates::$cmsVersion
+ dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
+ displayName: Install Template
+ workingDirectory: $(Agent.BuildDirectory)/app
diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml
index 73034b6d7c..7bbdec8e14 100644
--- a/build/nightly-E2E-test-pipelines.yml
+++ b/build/nightly-E2E-test-pipelines.yml
@@ -12,9 +12,18 @@ schedules:
- main
parameters:
- # Skipped due to DB locks
- - name: sqliteAcceptanceTests
- displayName: Run SQLite Acceptance Tests
+ - name: skipIntegrationTests
+ displayName: Skip integration tests
+ type: boolean
+ default: false
+
+ - name: differentAppSettingsAcceptanceTests
+ displayName: Run acceptance tests with different app settings
+ type: boolean
+ default: false
+
+ - name: skipDefaultConfigAcceptanceTests
+ displayName: Skip tests with DefaultConfig
type: boolean
default: false
@@ -100,8 +109,191 @@ stages:
targetPath: $(Build.ArtifactStagingDirectory)/npm
artifactName: npm
- - stage: E2E
- displayName: E2E Tests
+ - stage: Integration
+ displayName: Integration Tests
+ dependsOn: Build
+ condition: ${{ eq(parameters.skipIntegrationTests, false) }}
+ jobs:
+ # Integration Tests (SQLite)
+ - job:
+ timeoutInMinutes: 180
+ displayName: Integration Tests (SQLite)
+ strategy:
+ matrix:
+ # Windows:
+ # vmImage: 'windows-latest'
+ # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline
+ LinuxPart1Of3:
+ vmImage: "ubuntu-latest"
+ # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
+ LinuxPart2Of3:
+ vmImage: "ubuntu-latest"
+ # Filter tests that are part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
+ LinuxPart3Of3:
+ vmImage: "ubuntu-latest"
+ # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
+ testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
+ macOSPart1Of3:
+ vmImage: "macOS-latest"
+ # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
+ macOSPart2Of3:
+ vmImage: "macOS-latest"
+ # Filter tests that are part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
+ macOSPart3Of3:
+ vmImage: "macOS-latest"
+ # Filter tests that are not part of the Umbraco.Infrastructure namespace.
+ testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
+ pool:
+ vmImage: $(vmImage)
+ variables:
+ Tests__Database__DatabaseType: "Sqlite"
+ steps:
+ - checkout: self
+ submodules: false
+ lfs: false,
+ fetchDepth: 1
+ fetchFilter: tree:0
+ # Setup test environment
+ - task: DownloadPipelineArtifact@2
+ displayName: Download build artifacts
+ inputs:
+ artifact: build_output
+ path: $(Build.SourcesDirectory)
+
+ - task: UseDotNet@2
+ displayName: Use .NET SDK from global.json
+ inputs:
+ useGlobalJson: true
+
+ # Test
+ - task: DotNetCoreCLI@2
+ displayName: Run dotnet test
+ inputs:
+ command: test
+ projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
+ testRunTitle: Integration Tests SQLite - $(Agent.OS)
+ arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
+
+ # Integration Tests (SQL Server)
+ - job:
+ timeoutInMinutes: 180
+ displayName: Integration Tests (SQL Server)
+ variables:
+ SA_PASSWORD: UmbracoAcceptance123!
+ strategy:
+ matrix:
+ # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline
+ WindowsPart1Of3:
+ vmImage: "windows-latest"
+ Tests__Database__DatabaseType: LocalDb
+ Tests__Database__SQLServerMasterConnectionString: N/A
+ # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
+ WindowsPart2Of3:
+ vmImage: "windows-latest"
+ Tests__Database__DatabaseType: LocalDb
+ Tests__Database__SQLServerMasterConnectionString: N/A
+ # Filter tests that are part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
+ WindowsPart3Of3:
+ vmImage: "windows-latest"
+ Tests__Database__DatabaseType: LocalDb
+ Tests__Database__SQLServerMasterConnectionString: N/A
+ # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
+ testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
+ LinuxPart1Of3:
+ vmImage: "ubuntu-latest"
+ Tests__Database__DatabaseType: SqlServer
+ Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
+ # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)"
+ LinuxPart2Of3:
+ vmImage: "ubuntu-latest"
+ Tests__Database__DatabaseType: SqlServer
+ Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
+ # Filter tests that are part of the Umbraco.Infrastructure.Service namespace
+ testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)"
+ LinuxPart3Of3:
+ vmImage: "ubuntu-latest"
+ Tests__Database__DatabaseType: SqlServer
+ Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
+ # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace
+ testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)"
+ pool:
+ vmImage: $(vmImage)
+ steps:
+ # Setup test environment
+ - task: DownloadPipelineArtifact@2
+ displayName: Download build artifacts
+ inputs:
+ artifact: build_output
+ path: $(Build.SourcesDirectory)
+
+ - task: UseDotNet@2
+ displayName: Use .NET SDK from global.json
+ inputs:
+ useGlobalJson: true
+
+ # Start SQL Server
+ - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest
+ displayName: Start SQL Server Docker image (Linux)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+
+ - powershell: |
+ $maxAttempts = 12
+ $attempt = 0
+ $status = ""
+
+ while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) {
+ Start-Sleep -Seconds 5
+ # We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build.
+ $status = docker inspect -f '{{.State.Status}}' mssql
+
+ if ($status -ne 'running') {
+ Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)"
+ $attempt++
+ }
+ }
+
+ if ($status -eq 'running') {
+ Write-Host "SQL Server container is running"
+ docker ps -a
+ } else {
+ Write-Host "SQL Server did not become ready in time. Last known status: $status"
+ docker logs mssql
+ exit 1
+ }
+ displayName: Wait for SQL Server to be ready (Linux)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+
+ - pwsh: SqlLocalDB start MSSQLLocalDB
+ displayName: Start SQL Server LocalDB (Windows)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
+
+ # Test
+ - task: DotNetCoreCLI@2
+ displayName: Run dotnet test
+ inputs:
+ command: test
+ projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj"
+ testRunTitle: Integration Tests SQL Server - $(Agent.OS)
+ arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build'
+
+ # Stop SQL Server
+ - pwsh: docker stop mssql
+ displayName: Stop SQL Server Docker image (Linux)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+
+ - pwsh: SqlLocalDB stop MSSQLLocalDB
+ displayName: Stop SQL Server LocalDB (Windows)
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
+
+ - stage: DefaultConfigE2E
+ displayName: Default Config E2E Tests
dependsOn: Build
variables:
npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
@@ -128,369 +320,304 @@ stages:
# E2E Tests
- job:
displayName: E2E Tests (SQLite)
- condition: eq(${{parameters.sqliteAcceptanceTests}}, True)
timeoutInMinutes: 180
+ condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }}
variables:
# Connection string
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=Umbraco;Mode=Memory;Cache=Shared;Foreign Keys=True;Pooling=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.Sqlite
+ DatabaseType: SQLite
+ additionalEnvironmentVariables: false
strategy:
matrix:
LinuxPart1Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=1/3"
LinuxPart2Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=2/3"
LinuxPart3Of3:
vmImage: "ubuntu-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=3/3"
WindowsPart1Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=1/3"
WindowsPart2Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=2/3"
WindowsPart3Of3:
vmImage: "windows-latest"
+ testFolder: "DefaultConfig"
testCommand: "npm run testSqlite -- --shard=3/3"
pool:
vmImage: $(vmImage)
steps:
- # Setup test environment
- - task: DownloadPipelineArtifact@2
- displayName: Download NuGet artifacts
- inputs:
- artifact: nupkg
- path: $(Agent.BuildDirectory)/app/nupkg
-
- - task: NodeTool@0
- displayName: Use Node.js $(nodeVersion)
- retryCountOnTaskFailure: 3
- inputs:
- versionSpec: $(nodeVersion)
-
- - task: UseDotNet@2
- displayName: Use .NET SDK from global.json
- inputs:
- useGlobalJson: true
+ # Setup test environment Template
+ - template: nightly-E2E-setup-template.yml
+ parameters:
+ nodeVersion: ${{ variables.nodeVersion }}
+ PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }}
+ PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }}
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ npm_config_cache: ${{ variables.npm_config_cache }}
- pwsh: |
- "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
- UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
- URL=$(ASPNETCORE_URLS)
- STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
- CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
- displayName: Generate .env
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
-
- # Cache and restore NPM packages
- - task: Cache@2
- displayName: Cache NPM packages
- inputs:
- key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
- restoreKeys: |
- npm_e2e | "$(Agent.OS)"
- npm_e2e
- path: $(npm_config_cache)
-
- - script: npm ci --no-fund --no-audit --prefer-offline
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
- displayName: Restore NPM packages
-
- # Build application
- - pwsh: |
- $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
- dotnet new nugetconfig
- dotnet nuget add source ./nupkg --name Local
- dotnet new install Umbraco.Templates::$cmsVersion
- dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
dotnet restore UmbracoProject
cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
- dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore
+ displayName: Restore project
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore
dotnet dev-certs https
displayName: Build application
workingDirectory: $(Agent.BuildDirectory)/app
+ condition: succeeded()
- # Run application
- - bash: |
- nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
- echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
- displayName: Run application (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
- workingDirectory: $(Agent.BuildDirectory)/app
+ # Run application Template
+ - template: nightly-E2E-run-application-template.yml
+ parameters:
+ DatabaseType: ${{ variables.DatabaseType }}
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }}
- - pwsh: |
- $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
- Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
- displayName: Run application (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
- workingDirectory: $(Agent.BuildDirectory)/app
-
- # Ensures we have the package wait-on installed
- - pwsh: npm install wait-on
- displayName: Install wait-on package
-
- # Wait for application to start responding to requests
- - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS)
- displayName: Wait for application
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Install Playwright and dependencies
- - pwsh: npx playwright install chromium
- displayName: Install Playwright only with Chromium browser
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Test
- - pwsh: $(testCommand)
- displayName: Run Playwright tests
- continueOnError: true
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
- env:
- CI: true
- CommitId: $(Build.SourceVersion)
- AgentOs: $(Agent.OS)
-
- # Stop application
- - bash: kill -15 $(AcceptanceTestProcessId)
- displayName: Stop application (Linux)
- condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
- displayName: Stop application (Windows)
- condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Copy artifacts
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
- }
- displayName: Copy Playwright results
- condition: succeededOrFailed()
-
- # Copy console error log
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory)
- }
- displayName: Copy console error log
- condition: succeededOrFailed()
-
- # Publish
- - task: PublishPipelineArtifact@1
- displayName: Publish test artifacts
- condition: succeededOrFailed()
- inputs:
- targetPath: $(Build.ArtifactStagingDirectory)
- artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
-
- # Publish test results
- - task: PublishTestResults@2
- displayName: "Publish test results"
- condition: succeededOrFailed()
- inputs:
- testResultsFormat: 'JUnit'
- testResultsFiles: '*.xml'
- searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
- testRunTitle: "$(Agent.JobName)"
+ # Run tests Template
+ - template: nightly-E2E-run-tests-template.yml
+ parameters:
+ testCommand: $(testCommand)
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ DatabaseType: ${{ variables.DatabaseType }}
- job:
displayName: E2E Tests (SQL Server)
timeoutInMinutes: 180
+ condition: ${{ eq(parameters.skipDefaultConfigAcceptanceTests, false) }}
variables:
# Connection string
CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
+ DatabaseType: SQLServer
+ SA_PASSWORD: UmbracoAcceptance123!
+ additionalEnvironmentVariables: false
strategy:
matrix:
LinuxPart1Of3:
testCommand: "npm run test -- --shard=1/3"
+ testFolder: "DefaultConfig"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
LinuxPart2Of3:
testCommand: "npm run test -- --shard=2/3"
+ testFolder: "DefaultConfig"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
LinuxPart3Of3:
testCommand: "npm run test -- --shard=3/3"
+ testFolder: "DefaultConfig"
vmImage: "ubuntu-latest"
- SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True"
WindowsPart1Of3:
- vmImage: "windows-latest"
testCommand: "npm run test -- --shard=1/3"
+ testFolder: "DefaultConfig"
+ vmImage: "windows-latest"
WindowsPart2Of3:
- vmImage: "windows-latest"
testCommand: "npm run test -- --shard=2/3"
- WindowsPart3Of3:
+ testFolder: "DefaultConfig"
vmImage: "windows-latest"
+ WindowsPart3Of3:
testCommand: "npm run test -- --shard=3/3"
+ testFolder: "DefaultConfig"
+ vmImage: "windows-latest"
pool:
vmImage: $(vmImage)
steps:
- # Setup test environment
- - task: DownloadPipelineArtifact@2
- displayName: Download NuGet artifacts
- inputs:
- artifact: nupkg
- path: $(Agent.BuildDirectory)/app/nupkg
-
- - task: NodeTool@0
- displayName: Use Node.js $(nodeVersion)
- inputs:
- versionSpec: $(nodeVersion)
-
- - task: UseDotNet@2
- displayName: Use .NET SDK from global.json
- inputs:
- useGlobalJson: true
+ # Setup test environment Template
+ - template: nightly-E2E-setup-template.yml
+ parameters:
+ nodeVersion: ${{ variables.nodeVersion }}
+ PlaywrightUserEmail: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL }}
+ PlaywrightPassword: ${{ variables.UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD }}
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ npm_config_cache: ${{ variables.npm_config_cache }}
- pwsh: |
- "UMBRACO_USER_LOGIN=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSEREMAIL)
- UMBRACO_USER_PASSWORD=$(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD)
- URL=$(ASPNETCORE_URLS)
- STORAGE_STAGE_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/playwright/.auth/user.json
- CONSOLE_ERRORS_PATH=$(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/console-errors.json" | Out-File .env
- displayName: Generate .env
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
-
- # Cache and restore NPM packages
- - task: Cache@2
- displayName: Cache NPM packages
- inputs:
- key: 'npm_e2e | "$(Agent.OS)" | $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/package-lock.json'
- restoreKeys: |
- npm_e2e | "$(Agent.OS)"
- npm_e2e
- path: $(npm_config_cache)
-
- - script: npm ci --no-fund --no-audit --prefer-offline
- workingDirectory: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest
- displayName: Restore NPM packages
-
- # Build application
- - pwsh: |
- $cmsVersion = "$(Build.BuildNumber)" -replace "\+",".g"
- dotnet new nugetconfig
- dotnet nuget add source ./nupkg --name Local
- dotnet new install Umbraco.Templates::$cmsVersion
- dotnet new umbraco --name UmbracoProject --version $cmsVersion --exclude-gitignore --no-restore --no-update-check
dotnet restore UmbracoProject
cp $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest.UmbracoProject/*.cs UmbracoProject
- dotnet build UmbracoProject --configuration $(buildConfiguration) --no-restore
+ displayName: Restore project
+ workingDirectory: $(Agent.BuildDirectory)/app
+
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore
dotnet dev-certs https
displayName: Build application
workingDirectory: $(Agent.BuildDirectory)/app
+ condition: succeeded()
- # Start SQL Server
- - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest
- displayName: Start SQL Server Docker image (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+ # Run application Template
+ - template: nightly-E2E-run-application-template.yml
+ parameters:
+ SA_PASSWORD: ${{ variables.SA_PASSWORD }}
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ DatabaseType: ${{ variables.DatabaseType }}
+ additionalEnvironmentVariables: ${{ variables.additionalEnvironmentVariables }}
- - pwsh: SqlLocalDB start MSSQLLocalDB
- displayName: Start SQL Server LocalDB (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
+ # Run tests Template
+ - template: nightly-E2E-run-tests-template.yml
+ parameters:
+ testCommand: $(testCommand)
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ DatabaseType: ${{ variables.DatabaseType }}
- # Run application
+ - stage: AdditionalConfigE2E
+ displayName: Additional Config E2E Tests
+ dependsOn: Build
+ variables:
+ npm_config_cache: $(Pipeline.Workspace)/.npm_e2e
+ ASPNETCORE_URLS: https://localhost:44331
+ PlaywrightPassword: UmbracoAcceptance123!
+ PlaywrightUserEmail: playwright@umbraco.com
+ jobs:
+ - job:
+ displayName: E2E Tests with Different App settings (SQL Server)
+ condition: ${{ or(eq(parameters.differentAppSettingsAcceptanceTests, true), eq(parameters.skipDefaultConfigAcceptanceTests, true)) }}
+ timeoutInMinutes: 180
+ variables:
+ SA_PASSWORD: UmbracoAcceptance123!
+ DatabaseType: SQLServer
+ strategy:
+ matrix:
+ # UnattendedInstallConfig
+ WindowsUnattendedInstallConfig:
+ vmImage: "windows-latest"
+ testFolder: "UnattendedInstallConfig"
+ testCommand: "npx playwright test --project=unattendedInstallConfig --grep=InstallSQLServer"
+ port: 44331
+ additionalEnvironmentVariables: false
+ # DeliveryApiConfig
+ WindowsDeliveryApiConfig:
+ vmImage: "windows-latest"
+ testFolder: "DeliveryApi"
+ port: ''
+ testCommand: "npx playwright test --project=deliveryApi"
+ CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
+ CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
+ additionalEnvironmentVariables: false
+ LinuxDeliveryApiConfig:
+ vmImage: "ubuntu-latest"
+ testFolder: "DeliveryApi"
+ port: ''
+ testCommand: "npx playwright test --project=deliveryApi"
+ CONNECTIONSTRINGS__UMBRACODBDSN: Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True
+ CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
+ additionalEnvironmentVariables: false
+ # ExternalLogin AzureADB2C
+ WindowsExternalLoginAzureADB2C:
+ vmImage: "windows-latest"
+ testFolder: "ExternalLogin\\AzureADB2C"
+ testCommand: "npx playwright test --project=externalLoginAzureADB2C"
+ port: 44331
+ packageName: "Microsoft.AspNetCore.Authentication.OpenIdConnect"
+ packageVersion: "9.0.8"
+ CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True
+ CONNECTIONSTRINGS__UMBRACODBDSN_PROVIDERNAME: Microsoft.Data.SqlClient
+ additionalEnvironmentVariables: true
+ pool:
+ vmImage: $(vmImage)
+ steps:
+ # Setup test environment Template
+ - template: nightly-E2E-setup-template.yml
+ parameters:
+ nodeVersion: ${{ variables.nodeVersion }}
+ PlaywrightUserEmail: ${{ variables.PlaywrightUserEmail }}
+ PlaywrightPassword: ${{ variables.PlaywrightPassword }}
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ npm_config_cache: ${{ variables.npm_config_cache }}
+
+ # Install NuGet package if specified in the matrix
+ - pwsh: |
+ Write-Host "Installing package $(packageName) version $(packageVersion)"
+ dotnet add package $(packageName) --version $(packageVersion)
+ displayName: "Install NuGet package: $(packageName)"
+ workingDirectory: $(Agent.BuildDirectory)/app/UmbracoProject
+ condition: and(succeeded(), ne(variables['packageName'], ''), ne(variables['packageVersion'], ''))
+
+ # Build application Template
+ - template: nightly-E2E-build-template.yml
+ parameters:
+ testFolder: $(testFolder)
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ additionalEnvironmentVariables: $(additionalEnvironmentVariables)
+
+ # Build application for AzureADB2C
+ - pwsh: |
+ dotnet build UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-restore
+ dotnet dev-certs https
+ displayName: Build application for AzureADB2C
+ workingDirectory: $(Agent.BuildDirectory)/app
+ env:
+ AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN)
+ AZUREADB2CTENANT: $(AZUREB2CTENANT)
+ AZUREADB2CPOLICY: $(AZUREB2CPOLICY)
+ AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID)
+ AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET)
+ condition: and(succeeded(), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C'))
+
+ # Run application Template
+ - template: nightly-E2E-run-application-template.yml
+ parameters:
+ SA_PASSWORD: ${{ variables.SA_PASSWORD }}
+ additionalEnvironmentVariables: $(additionalEnvironmentVariables)
+ buildConfiguration: ${{ variables.buildConfiguration }}
+ DatabaseType: ${{ variables.DatabaseType }}
+
+ # Run application for Linux with additional Environment Variables for Azure AD
- bash: |
- nohup dotnet run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
+ nohup dotnet run --project UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-build --no-launch-profile > $(Build.ArtifactStagingDirectory)/playwright.log 2>&1 &
echo "##vso[task.setvariable variable=AcceptanceTestProcessId]$!"
displayName: Run application (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C'))
workingDirectory: $(Agent.BuildDirectory)/app
+ env:
+ AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN)
+ AZUREADB2CTENANT: $(AZUREB2CTENANT)
+ AZUREADB2CPOLICY: $(AZUREB2CPOLICY)
+ AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID)
+ AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET)
+ # Run application for Windows with additional Environment Variables for Azure AD
- pwsh: |
- $process = Start-Process dotnet "run --project UmbracoProject --configuration $(buildConfiguration) --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
+ $process = Start-Process dotnet "run --project UmbracoProject --configuration ${{ variables.buildConfiguration }} --no-build --no-launch-profile 2>&1" -PassThru -NoNewWindow -RedirectStandardOutput $(Build.ArtifactStagingDirectory)/playwright.log
Write-Host "##vso[task.setvariable variable=AcceptanceTestProcessId]$($process.Id)"
displayName: Run application (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
+ condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'), eq(variables['testFolder'], 'ExternalLogin\AzureADB2C'))
workingDirectory: $(Agent.BuildDirectory)/app
-
- # Ensures we have the package wait-on installed
- - pwsh: npm install wait-on
- displayName: Install wait-on package
-
- # Wait for application to start responding to requests
- - pwsh: npx wait-on -v --interval 1000 --timeout 120000 $(ASPNETCORE_URLS)
- displayName: Wait for application
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Install Playwright and dependencies
- - pwsh: npx playwright install chromium
- displayName: Install Playwright only with Chromium browser
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
-
- # Test
- - pwsh: $(testCommand)
- displayName: Run Playwright tests
- continueOnError: true
- workingDirectory: tests/Umbraco.Tests.AcceptanceTest
env:
- CI: true
- CommitId: $(Build.SourceVersion)
- AgentOs: $(Agent.OS)
+ AZUREADB2CDOMAIN: $(AZUREB2CDOMAIN)
+ AZUREADB2CTENANT: $(AZUREB2CTENANT)
+ AZUREADB2CPOLICY: $(AZUREB2CPOLICY)
+ AZUREADB2CCLIENTID: $(AZUREB2CCLIENTID)
+ AZUREADB2CCLIENTSECRET: $(AZUREB2CCLIENTSECRET)
- # Stop application
- - bash: kill -15 $(AcceptanceTestProcessId)
- displayName: Stop application (Linux)
- condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: Stop-Process -Id $(AcceptanceTestProcessId)
- displayName: Stop application (Windows)
- condition: and(succeeded(), ne(variables.AcceptanceTestProcessId, ''), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Stop SQL Server
- - pwsh: docker stop mssql
- displayName: Stop SQL Server Docker image (Linux)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux'))
-
- - pwsh: SqlLocalDB stop MSSQLLocalDB
- displayName: Stop SQL Server LocalDB (Windows)
- condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT'))
-
- # Copy artifacts
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/results/*) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/results/* $(Build.ArtifactStagingDirectory) -Recurse
- }
- displayName: Copy Playwright results
- condition: succeededOrFailed()
-
- # Copy console error log
- - pwsh: |
- if (Test-Path tests/Umbraco.Tests.AcceptanceTest/console-errors.json) {
- Copy-Item tests/Umbraco.Tests.AcceptanceTest/console-errors.json $(Build.ArtifactStagingDirectory)
- }
- displayName: Copy console error log
- condition: succeededOrFailed()
-
- # Publish
- - task: PublishPipelineArtifact@1
- displayName: Publish test artifacts
- condition: succeededOrFailed()
- inputs:
- targetPath: $(Build.ArtifactStagingDirectory)
- artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)"
-
- # Publish test results
- - task: PublishTestResults@2
- displayName: "Publish test results"
- condition: succeededOrFailed()
- inputs:
- testResultsFormat: 'JUnit'
- testResultsFiles: '*.xml'
- searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results"
- testRunTitle: "$(Agent.JobName)"
+ # Run tests Template
+ - template: nightly-E2E-run-tests-template.yml
+ parameters:
+ testCommand: $(testCommand)
+ ASPNETCORE_URLS: ${{ variables.ASPNETCORE_URLS }}
+ port: $(port)
+ AZUREB2CTESTUSEREMAIL: $(AZUREB2CTESTUSEREMAIL)
+ AZUREB2CTESTUSERPASSWORD: $(AZUREB2CTESTUSERPASSWORD)
+ DatabaseType: ${{ variables.DatabaseType }}
- stage: NotifySlackBot
displayName: Notify Slack on Failure
- dependsOn: E2E
+ dependsOn: DefaultConfigE2E
# This stage will only run if the E2E tests fail or succeed with issues
- condition: or(
- eq(dependencies.E2E.result, 'failed'),
- eq(dependencies.E2E.result, 'succeededWithIssues'))
+ condition: or(eq(dependencies.DefaultConfigE2E.result, 'failed'), eq(dependencies.DefaultConfigE2E.result, 'succeededWithIssues'))
jobs:
- job: PostToSlack
displayName: Send Slack Notification
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs
new file mode 100644
index 0000000000..06f2f9284f
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/RecycleBin/SiblingsDocumentRecycleBinController.cs
@@ -0,0 +1,24 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Factories;
+using Umbraco.Cms.Api.Management.ViewModels.Document.RecycleBin;
+using Umbraco.Cms.Core.Services;
+
+namespace Umbraco.Cms.Api.Management.Controllers.Document.RecycleBin;
+
+[ApiVersion("1.0")]
+public class SiblingsDocumentRecycleBinController : DocumentRecycleBinControllerBase
+{
+ public SiblingsDocumentRecycleBinController(IEntityService entityService, IDocumentPresentationFactory documentPresentationFactory)
+ : base(entityService, documentPresentationFactory)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [MapToApiVersion("1.0")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
+ => await GetSiblings(target, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs
new file mode 100644
index 0000000000..5628aa551e
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/RecycleBin/SiblingsMediaRecycleBinController.cs
@@ -0,0 +1,24 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Factories;
+using Umbraco.Cms.Api.Management.ViewModels.Media.RecycleBin;
+using Umbraco.Cms.Core.Services;
+
+namespace Umbraco.Cms.Api.Management.Controllers.Media.RecycleBin;
+
+[ApiVersion("1.0")]
+public class SiblingsMediaRecycleBinController : MediaRecycleBinControllerBase
+{
+ public SiblingsMediaRecycleBinController(IEntityService entityService, IMediaPresentationFactory mediaPresentationFactory)
+ : base(entityService, mediaPresentationFactory)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [MapToApiVersion("1.0")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null)
+ => await GetSiblings(target, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs
new file mode 100644
index 0000000000..4ed8e9f949
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/MemberType/Tree/SiblingMemberTypeTreeController.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.Flags;
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.Services;
+
+namespace Umbraco.Cms.Api.Management.Controllers.MemberType.Tree;
+
+public class SiblingMemberTypeTreeController : MemberTypeTreeControllerBase
+{
+ public SiblingMemberTypeTreeController(
+ IEntityService entityService,
+ FlagProviderCollection flagProviders,
+ IMemberTypeService memberTypeService)
+ : base(entityService, flagProviders, memberTypeService)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(
+ CancellationToken cancellationToken,
+ Guid target,
+ int before,
+ int after)
+ => await GetSiblings(target, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs
index 4048e1b9e7..3f79545e39 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/AncestorsPartialViewTreeController.cs
@@ -1,16 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
+using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree;
[ApiVersion("1.0")]
public class AncestorsPartialViewTreeController : PartialViewTreeControllerBase
{
+ private readonly IPartialViewTreeService _partialViewTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public AncestorsPartialViewTreeController(IPartialViewTreeService partialViewTreeService)
+ : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _partialViewTreeService = partialViewTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public AncestorsPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems)
+ : base(partialViewTreeService, fileSystems) =>
+ _partialViewTreeService = partialViewTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public AncestorsPartialViewTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs
index 2877248e41..099f01f342 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/ChildrenPartialViewTreeController.cs
@@ -1,17 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree;
[ApiVersion("1.0")]
public class ChildrenPartialViewTreeController : PartialViewTreeControllerBase
{
+ private readonly IPartialViewTreeService _partialViewTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public ChildrenPartialViewTreeController(IPartialViewTreeService partialViewTreeService)
+ : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _partialViewTreeService = partialViewTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public ChildrenPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems)
+ : base(partialViewTreeService, fileSystems) =>
+ _partialViewTreeService = partialViewTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public ChildrenPartialViewTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs
index 11e6a946ea..b45aff4616 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/PartialViewTreeControllerBase.cs
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Routing;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Authorization;
@@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree;
[Authorize(Policy = AuthorizationPolicies.TreeAccessPartialViews)]
public class PartialViewTreeControllerBase : FileSystemTreeControllerBase
{
- public PartialViewTreeControllerBase(FileSystems fileSystems)
- => FileSystem = fileSystems.PartialViewsFileSystem ??
- throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
+ private readonly IPartialViewTreeService _partialViewTreeService;
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public PartialViewTreeControllerBase(IPartialViewTreeService partialViewTreeService)
+ : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService()) =>
+ _partialViewTreeService = partialViewTreeService;
+
+ // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this.
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Scheduled for removal in Umbraco 19")]
+ public PartialViewTreeControllerBase(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems)
+ : base(partialViewTreeService)
+ {
+ _partialViewTreeService = partialViewTreeService;
+ FileSystem = fileSystems.PartialViewsFileSystem ??
+ throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+ }
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public PartialViewTreeControllerBase(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService())
+ => FileSystem = fileSystems.PartialViewsFileSystem ??
+ throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+
+ [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")]
protected override IFileSystem FileSystem { get; }
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs
index 4247ded602..4e42266389 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/RootPartialViewTreeController.cs
@@ -1,17 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree;
[ApiVersion("1.0")]
public class RootPartialViewTreeController : PartialViewTreeControllerBase
{
+ private readonly IPartialViewTreeService _partialViewTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public RootPartialViewTreeController(IPartialViewTreeService partialViewTreeService)
+ : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _partialViewTreeService = partialViewTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public RootPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems)
+ : base(partialViewTreeService, fileSystems) =>
+ _partialViewTreeService = partialViewTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public RootPartialViewTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs
new file mode 100644
index 0000000000..af1e3171a6
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/PartialView/Tree/SiblingsPartialViewTreeController.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Controllers.PartialView.Tree;
+
+public class SiblingsPartialViewTreeController : PartialViewTreeControllerBase
+{
+ private readonly IPartialViewTreeService _partialViewTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public SiblingsPartialViewTreeController(IPartialViewTreeService partialViewTreeService)
+ : this(partialViewTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _partialViewTreeService = partialViewTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsPartialViewTreeController(IPartialViewTreeService partialViewTreeService, FileSystems fileSystems)
+ : base(partialViewTreeService, fileSystems) =>
+ _partialViewTreeService = partialViewTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsPartialViewTreeController(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(
+ CancellationToken cancellationToken,
+ string path,
+ int before,
+ int after)
+ => await GetSiblings(path, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs
index 850d7240d0..59e10df454 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/RecycleBin/RecycleBinControllerBase.cs
@@ -4,6 +4,7 @@ using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Controllers.Content;
using Umbraco.Cms.Api.Management.ViewModels.Item;
using Umbraco.Cms.Api.Management.ViewModels.RecycleBin;
+using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Services;
@@ -45,6 +46,24 @@ public abstract class RecycleBinControllerBase : ContentControllerBase
return Task.FromResult>>(Ok(result));
}
+ protected async Task>> GetSiblings(Guid target, int before, int after)
+ {
+ IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter);
+ if (siblings.Length == 0)
+ {
+ return NotFound();
+ }
+
+ IEntitySlim entity = siblings.First();
+ Guid? parentKey = GetParentKey(entity);
+
+ TItem[] treeItemViewModels = MapRecycleBinViewModels(parentKey, siblings);
+
+ SubsetViewModel result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter);
+
+ return Ok(result);
+ }
+
protected virtual TItem MapRecycleBinViewModel(Guid? parentKey, IEntitySlim entity)
{
if (entity == null)
@@ -136,4 +155,27 @@ public abstract class RecycleBinControllerBase : ContentControllerBase
private PagedViewModel PagedViewModel(IEnumerable treeItemViewModels, long totalItems)
=> new() { Total = totalItems, Items = treeItemViewModels };
+
+ protected SubsetViewModel SubsetViewModel(IEnumerable treeItemViewModels, long totalBefore, long totalAfter)
+ => new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = treeItemViewModels };
+
+ protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) =>
+ _entityService
+ .GetTrashedSiblings(
+ target,
+ [ItemObjectType],
+ before,
+ after,
+ out totalBefore,
+ out totalAfter,
+ ordering: Ordering.By(nameof(Infrastructure.Persistence.Dtos.NodeDto.Text)))
+ .ToArray();
+
+ ///
+ /// Gets the parent key for an entity, or root if null or no parent.
+ ///
+ protected virtual Guid? GetParentKey(IEntitySlim entity) =>
+ entity.ParentId > 0
+ ? _entityService.GetKey(entity.ParentId, ItemObjectType).Result
+ : Constants.System.RootKey;
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs
index ce3ae4189c..ed5acbc0c3 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/AncestorsScriptTreeController.cs
@@ -1,7 +1,10 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
@@ -9,8 +12,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
[ApiVersion("1.0")]
public class AncestorsScriptTreeController : ScriptTreeControllerBase
{
+ private readonly IScriptTreeService _scriptTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public AncestorsScriptTreeController(IScriptTreeService scriptTreeService)
+ : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _scriptTreeService = scriptTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public AncestorsScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems)
+ : base(scriptTreeService, fileSystems) =>
+ _scriptTreeService = scriptTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public AncestorsScriptTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs
index 73f028eab2..ba40037841 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ChildrenScriptTreeController.cs
@@ -1,17 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
[ApiVersion("1.0")]
public class ChildrenScriptTreeController : ScriptTreeControllerBase
{
+ private readonly IScriptTreeService _scriptTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public ChildrenScriptTreeController(IScriptTreeService scriptTreeService)
+ : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _scriptTreeService = scriptTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public ChildrenScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems)
+ : base(scriptTreeService, fileSystems) =>
+ _scriptTreeService = scriptTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public ChildrenScriptTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs
index 3eff3b5f50..f29d3bdb44 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/RootScriptTreeController.cs
@@ -1,17 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
[ApiVersion("1.0")]
public class RootScriptTreeController : ScriptTreeControllerBase
{
+ private readonly IScriptTreeService _scriptTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public RootScriptTreeController(IScriptTreeService scriptTreeService)
+ : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _scriptTreeService = scriptTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public RootScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems)
+ : base(scriptTreeService, fileSystems) =>
+ _scriptTreeService = scriptTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public RootScriptTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs
index e8bda7446d..ba302fc920 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/ScriptTreeControllerBase.cs
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Routing;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Authorization;
@@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
[Authorize(Policy = AuthorizationPolicies.TreeAccessScripts)]
public class ScriptTreeControllerBase : FileSystemTreeControllerBase
{
+ private readonly IScriptTreeService _scriptTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public ScriptTreeControllerBase(IScriptTreeService scriptTreeService)
+ : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService()) =>
+ _scriptTreeService = scriptTreeService;
+
+ // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this.
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Scheduled for removal in Umbraco 19")]
+ public ScriptTreeControllerBase(IScriptTreeService scriptTreeService, FileSystems fileSystems)
+ : base(scriptTreeService)
+ {
+ _scriptTreeService = scriptTreeService;
+ FileSystem = fileSystems.ScriptsFileSystem ??
+ throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+ }
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public ScriptTreeControllerBase(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService())
=> FileSystem = fileSystems.ScriptsFileSystem ??
throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+ [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")]
protected override IFileSystem FileSystem { get; }
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs
new file mode 100644
index 0000000000..deec60cacb
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Script/Tree/SiblingsScriptTreeController.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Controllers.Script.Tree;
+
+public class SiblingsScriptTreeController : ScriptTreeControllerBase
+{
+ private readonly IScriptTreeService _scriptTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public SiblingsScriptTreeController(IScriptTreeService scriptTreeService)
+ : this(scriptTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _scriptTreeService = scriptTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsScriptTreeController(IScriptTreeService scriptTreeService, FileSystems fileSystems)
+ : base(scriptTreeService, fileSystems) =>
+ _scriptTreeService = scriptTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsScriptTreeController(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(
+ CancellationToken cancellationToken,
+ string path,
+ int before,
+ int after)
+ => await GetSiblings(path, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs
index e4ee568150..0695f0b0ed 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/StaticFile/Tree/StaticFileTreeControllerBase.cs
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Routing;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Api.Management.Controllers.StaticFile.Tree;
@@ -11,28 +14,39 @@ namespace Umbraco.Cms.Api.Management.Controllers.StaticFile.Tree;
[ApiExplorerSettings(GroupName = "Static File")]
public class StaticFileTreeControllerBase : FileSystemTreeControllerBase
{
+ private readonly IFileSystemTreeService _fileSystemTreeService;
private static readonly string[] _allowedRootFolders = { $"{Path.DirectorySeparatorChar}App_Plugins", $"{Path.DirectorySeparatorChar}wwwroot" };
+ public StaticFileTreeControllerBase(IPhysicalFileSystem physicalFileSystem, IFileSystemTreeService fileSystemTreeService)
+ : base (fileSystemTreeService)
+ {
+ FileSystem = physicalFileSystem;
+ _fileSystemTreeService = fileSystemTreeService;
+ }
+
+ [Obsolete("Please use the other constructor. Scheduled for removal in Umbraco 19")]
public StaticFileTreeControllerBase(IPhysicalFileSystem physicalFileSystem)
- => FileSystem = physicalFileSystem;
+ : this(physicalFileSystem, StaticServiceProvider.Instance.GetRequiredService())
+ {
+ }
protected override IFileSystem FileSystem { get; }
- protected override string[] GetDirectories(string path) =>
+ protected string[] GetDirectories(string path) =>
IsTreeRootPath(path)
? _allowedRootFolders
: IsAllowedPath(path)
- ? base.GetDirectories(path)
+ ? _fileSystemTreeService.GetDirectories(path)
: Array.Empty();
- protected override string[] GetFiles(string path)
+ protected string[] GetFiles(string path)
=> IsTreeRootPath(path) || IsAllowedPath(path) == false
? Array.Empty()
- : base.GetFiles(path);
+ : _fileSystemTreeService.GetFiles(path);
- protected override FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf)
+ protected FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf)
=> IsAllowedPath(path)
- ? base.GetAncestorModels(path, includeSelf)
+ ? _fileSystemTreeService.GetAncestorModels(path, includeSelf)
: Array.Empty();
private bool IsTreeRootPath(string path) => path == Path.DirectorySeparatorChar.ToString();
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs
index 3863389125..3760808263 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/AncestorsStylesheetTreeController.cs
@@ -1,7 +1,10 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
@@ -9,8 +12,22 @@ namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
[ApiVersion("1.0")]
public class AncestorsStylesheetTreeController : StylesheetTreeControllerBase
{
+ private readonly IStyleSheetTreeService _styleSheetTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public AncestorsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService)
+ : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _styleSheetTreeService = styleSheetTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public AncestorsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems)
+ : base(styleSheetTreeService, fileSystems) =>
+ _styleSheetTreeService = styleSheetTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public AncestorsStylesheetTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs
index 3435f67225..41484bce50 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/ChildrenStylesheetTreeController.cs
@@ -1,20 +1,36 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
[ApiVersion("1.0")]
public class ChildrenStylesheetTreeController : StylesheetTreeControllerBase
{
+ private readonly IStyleSheetTreeService _styleSheetTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public ChildrenStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService)
+ : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _styleSheetTreeService = styleSheetTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public ChildrenStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems)
+ : base(styleSheetTreeService, fileSystems) =>
+ _styleSheetTreeService = styleSheetTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public ChildrenStylesheetTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
-
[HttpGet("children")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)]
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs
index c01f15ea59..417c636a37 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/RootStylesheetTreeController.cs
@@ -1,17 +1,34 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
[ApiVersion("1.0")]
public class RootStylesheetTreeController : StylesheetTreeControllerBase
{
+ private readonly IStyleSheetTreeService _styleSheetTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public RootStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService)
+ : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _styleSheetTreeService = styleSheetTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public RootStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems)
+ : base(styleSheetTreeService, fileSystems) =>
+ _styleSheetTreeService = styleSheetTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
public RootStylesheetTreeController(FileSystems fileSystems)
- : base(fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
{
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs
new file mode 100644
index 0000000000..0f2b03b704
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/SiblingsStylesheetTreeController.cs
@@ -0,0 +1,41 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Umbraco.Cms.Api.Common.ViewModels.Pagination;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
+
+public class SiblingsStylesheetTreeController : StylesheetTreeControllerBase
+{
+ private readonly IStyleSheetTreeService _styleSheetTreeService;
+
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public SiblingsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService)
+ : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService())
+ => _styleSheetTreeService = styleSheetTreeService;
+
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsStylesheetTreeController(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems)
+ : base(styleSheetTreeService, fileSystems) =>
+ _styleSheetTreeService = styleSheetTreeService;
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public SiblingsStylesheetTreeController(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService(), fileSystems)
+ {
+ }
+
+ [HttpGet("siblings")]
+ [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)]
+ public async Task>> Siblings(
+ CancellationToken cancellationToken,
+ string path,
+ int before,
+ int after)
+ => await GetSiblings(path, before, after);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs
index 428f892f6b..dd15a02d73 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Stylesheet/Tree/StylesheetTreeControllerBase.cs
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Controllers.Tree;
using Umbraco.Cms.Api.Management.Routing;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Cms.Web.Common.Authorization;
@@ -13,9 +16,30 @@ namespace Umbraco.Cms.Api.Management.Controllers.Stylesheet.Tree;
[Authorize(Policy = AuthorizationPolicies.TreeAccessStylesheets)]
public class StylesheetTreeControllerBase : FileSystemTreeControllerBase
{
- public StylesheetTreeControllerBase(FileSystems fileSystems)
- => FileSystem = fileSystems.StylesheetsFileSystem ??
- throw new ArgumentException("Missing stylesheets file system", nameof(fileSystems));
+ private readonly IStyleSheetTreeService _styleSheetTreeService;
+ // TODO Remove the static service provider, and replace with base when the other constructors are obsoleted.
+ public StylesheetTreeControllerBase(IStyleSheetTreeService styleSheetTreeService)
+ : this(styleSheetTreeService, StaticServiceProvider.Instance.GetRequiredService()) =>
+ _styleSheetTreeService = styleSheetTreeService;
+
+ // FileSystem is required therefore, we can't remove it without some wizadry. When obsoletion is due, remove this.
+ [ActivatorUtilitiesConstructor]
+ [Obsolete("Scheduled for removal in Umbraco 19")]
+ public StylesheetTreeControllerBase(IStyleSheetTreeService styleSheetTreeService, FileSystems fileSystems)
+ : base(styleSheetTreeService)
+ {
+ _styleSheetTreeService = styleSheetTreeService;
+ FileSystem = fileSystems.ScriptsFileSystem ??
+ throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+ }
+
+ [Obsolete("Please use the other constructor. Scheduled to be removed in Umbraco 19")]
+ public StylesheetTreeControllerBase(FileSystems fileSystems)
+ : this(StaticServiceProvider.Instance.GetRequiredService())
+ => FileSystem = fileSystems.ScriptsFileSystem ??
+ throw new ArgumentException("Missing scripts file system", nameof(fileSystems));
+
+ [Obsolete("Included in the service class. Scheduled to be removed in Umbraco 19")]
protected override IFileSystem FileSystem { get; }
}
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs
index 1388e4b798..933986e2f3 100644
--- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs
+++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FileSystemTreeControllerBase.cs
@@ -1,8 +1,11 @@
using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Extensions;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.FileSystem;
using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.IO;
using Umbraco.Extensions;
@@ -10,11 +13,24 @@ namespace Umbraco.Cms.Api.Management.Controllers.Tree;
public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase
{
+ private readonly IFileSystemTreeService _fileSystemTreeService;
+
+ [Obsolete("Has been moved to the individual services. Scheduled to be removed in Umbraco 19")]
protected abstract IFileSystem FileSystem { get; }
+ [ActivatorUtilitiesConstructor]
+ protected FileSystemTreeControllerBase(IFileSystemTreeService fileSystemTreeService) => _fileSystemTreeService = fileSystemTreeService;
+
+ [Obsolete("Use the other constructor. Scheduled for removal in Umbraco 19")]
+ protected FileSystemTreeControllerBase()
+ : this(StaticServiceProvider.Instance.GetRequiredService())
+ {
+ }
+
+
protected Task>> GetRoot(int skip, int take)
{
- FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(string.Empty, skip, take, out var totalItems);
+ FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetPathViewModels(string.Empty, skip, take, out var totalItems);
PagedViewModel result = PagedViewModel(viewModels, totalItems);
return Task.FromResult>>(Ok(result));
@@ -22,20 +38,39 @@ public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase
protected Task>> GetChildren(string path, int skip, int take)
{
- FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(path, skip, take, out var totalItems);
+ FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetPathViewModels(path, skip, take, out var totalItems);
PagedViewModel result = PagedViewModel(viewModels, totalItems);
return Task.FromResult>>(Ok(result));
}
+ ///
+ /// Gets the sibling of the targeted item based on its path.
+ ///
+ /// The path to the item.
+ /// The amount of siblings you want to fetch from before the items position in the array.
+ /// The amount of siblings you want to fetch after the items position in the array.
+ /// A SubsetViewModel of the siblings of the item and the item itself.
+ protected Task>> GetSiblings(string path, int before, int after)
+ {
+ FileSystemTreeItemPresentationModel[] viewModels = _fileSystemTreeService.GetSiblingsViewModels(path, before, after, out var totalBefore, out var totalAfter);
+
+ SubsetViewModel result = new() { TotalBefore = totalBefore, TotalAfter = totalAfter, Items = viewModels };
+ return Task.FromResult>>(Ok(result));
+ }
+
protected virtual Task>> GetAncestors(string path, bool includeSelf = true)
{
path = path.VirtualPathToSystemPath();
- FileSystemTreeItemPresentationModel[] models = GetAncestorModels(path, includeSelf);
+ FileSystemTreeItemPresentationModel[] models = _fileSystemTreeService.GetAncestorModels(path, includeSelf);
return Task.FromResult>>(Ok(models));
}
+ private PagedViewModel PagedViewModel(IEnumerable viewModels, long totalItems)
+ => new() { Total = totalItems, Items = viewModels };
+
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
protected virtual FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf)
{
var directories = path.Split(Path.DirectorySeparatorChar).Take(Range.EndAt(Index.FromEnd(1))).ToArray();
@@ -52,49 +87,28 @@ public abstract class FileSystemTreeControllerBase : ManagementApiControllerBase
return result.ToArray();
}
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
protected virtual string[] GetDirectories(string path) => FileSystem
.GetDirectories(path)
.OrderBy(directory => directory)
.ToArray();
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
protected virtual string[] GetFiles(string path) => FileSystem
.GetFiles(path)
.OrderBy(file => file)
.ToArray();
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
protected virtual bool DirectoryHasChildren(string path)
=> FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any();
- private FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems)
- {
- path = path.VirtualPathToSystemPath();
- var allItems = GetDirectories(path)
- .Select(directory => new { Path = directory, IsFolder = true })
- .Union(GetFiles(path).Select(file => new { Path = file, IsFolder = false }))
- .ToArray();
-
- totalItems = allItems.Length;
-
- FileSystemTreeItemPresentationModel ViewModel(string itemPath, bool isFolder)
- => MapViewModel(
- itemPath,
- GetFileSystemItemName(isFolder, itemPath),
- isFolder);
-
- return allItems
- .Skip(skip)
- .Take(take)
- .Select(item => ViewModel(item.Path, item.IsFolder))
- .ToArray();
- }
-
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
private string GetFileSystemItemName(bool isFolder, string itemPath) => isFolder
? Path.GetFileName(itemPath)
: FileSystem.GetFileName(itemPath);
- private PagedViewModel PagedViewModel(IEnumerable viewModels, long totalItems)
- => new() { Total = totalItems, Items = viewModels };
-
+ [Obsolete("Has been moved to FileSystemTreeServiceBase. Scheduled for removal in Umbraco 19")]
private FileSystemTreeItemPresentationModel MapViewModel(string path, string name, bool isFolder)
{
var parentPath = Path.GetDirectoryName(path);
diff --git a/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs b/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs
new file mode 100644
index 0000000000..e2bb3e8849
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Controllers/UserData/DeleteUserDataController.cs
@@ -0,0 +1,52 @@
+using Asp.Versioning;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Umbraco.Cms.Core;
+using Umbraco.Cms.Core.Models.Membership;
+using Umbraco.Cms.Core.Security;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Core.Services.OperationStatus;
+
+namespace Umbraco.Cms.Api.Management.Controllers.UserData;
+
+[ApiVersion("1.0")]
+public class DeleteUserDataController : UserDataControllerBase
+{
+ private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
+ private readonly IUserDataService _userDataService;
+
+ public DeleteUserDataController(
+ IBackOfficeSecurityAccessor backOfficeSecurityAccessor,
+ IUserDataService userDataService)
+ {
+ _backOfficeSecurityAccessor = backOfficeSecurityAccessor;
+ _userDataService = userDataService;
+ }
+
+ [HttpDelete("{id:guid}")]
+ [MapToApiVersion("1.0")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(UserDataOperationStatus), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(UserDataOperationStatus), StatusCodes.Status404NotFound)]
+ public async Task Delete(CancellationToken cancellationToken, Guid id)
+ {
+ IUserData? data = await _userDataService.GetAsync(id);
+ if (data is null)
+ {
+ return NotFound();
+ }
+
+ Guid currentUserKey = CurrentUserKey(_backOfficeSecurityAccessor);
+
+ if (data.UserKey != currentUserKey)
+ {
+ return Unauthorized();
+ }
+
+ Attempt attempt = await _userDataService.DeleteAsync(id);
+
+ return attempt.Success
+ ? Ok()
+ : UserDataOperationStatusResult(attempt.Result);
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs
index ee95a14900..733223efa0 100644
--- a/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs
+++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/TreeBuilderExtensions.cs
@@ -1,6 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using Umbraco.Cms.Api.Management.Services.Entities;
+using Umbraco.Cms.Api.Management.Services.FileSystem;
using Umbraco.Cms.Core.DependencyInjection;
+using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.DependencyInjection;
@@ -9,6 +11,9 @@ internal static class TreeBuilderExtensions
internal static IUmbracoBuilder AddTrees(this IUmbracoBuilder builder)
{
builder.Services.AddTransient();
+ builder.Services.AddUnique();
+ builder.Services.AddUnique();
+ builder.Services.AddUnique();
return builder;
}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs
new file mode 100644
index 0000000000..43816e77b2
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/FileSystemTreeServiceBase.cs
@@ -0,0 +1,102 @@
+using Umbraco.Cms.Api.Management.Extensions;
+using Umbraco.Cms.Api.Management.ViewModels.FileSystem;
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.IO;
+using Umbraco.Extensions;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public abstract class FileSystemTreeServiceBase : IFileSystemTreeService
+{
+ protected abstract IFileSystem FileSystem { get; }
+
+ public FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf)
+ {
+ var directories = path.Split(Path.DirectorySeparatorChar).Take(Range.EndAt(Index.FromEnd(1))).ToArray();
+ var result = directories
+ .Select((directory, index) => MapViewModel(string.Join(Path.DirectorySeparatorChar, directories.Take(index + 1)), directory, true))
+ .ToList();
+
+ if (includeSelf)
+ {
+ var selfIsFolder = FileSystem.FileExists(path) is false;
+ result.Add(MapViewModel(path, GetFileSystemItemName(selfIsFolder, path), selfIsFolder));
+ }
+
+ return result.ToArray();
+ }
+
+ public FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems)
+ {
+ path = path.VirtualPathToSystemPath();
+ var allItems = GetDirectories(path)
+ .Select(directory => new { Path = directory, IsFolder = true })
+ .Union(GetFiles(path).Select(file => new { Path = file, IsFolder = false }))
+ .ToArray();
+
+ totalItems = allItems.Length;
+
+ FileSystemTreeItemPresentationModel ViewModel(string itemPath, bool isFolder)
+ => MapViewModel(
+ itemPath,
+ GetFileSystemItemName(isFolder, itemPath),
+ isFolder);
+
+ return allItems
+ .Skip(skip)
+ .Take(take)
+ .Select(item => ViewModel(item.Path, item.IsFolder))
+ .ToArray();
+ }
+
+ public FileSystemTreeItemPresentationModel[] GetSiblingsViewModels(string path, int before, int after, out long totalBefore, out long totalAfter)
+ {
+ var filePath = Path.GetDirectoryName(path);
+ var fileName = Path.GetFileName(path);
+
+ FileSystemTreeItemPresentationModel[] viewModels = GetPathViewModels(filePath!, 0, int.MaxValue, out totalBefore);
+ FileSystemTreeItemPresentationModel? target = viewModels.FirstOrDefault(item => item.Name == fileName);
+ var position = Array.IndexOf(viewModels, target);
+
+ totalBefore = position - before < 0 ? 0 : position - before;
+ totalAfter = (viewModels.Length - 1) - (position + after) < 0 ? 0 : (viewModels.Length - 1) - (position + after);
+
+ return viewModels
+ .Select((item, index) => new { item, index })
+ .Where(item => item.index >= position - before && item.index <= position + after)
+ .Select(item => item.item)
+ .ToArray();
+ }
+
+ public string[] GetDirectories(string path) => FileSystem
+ .GetDirectories(path)
+ .OrderBy(directory => directory)
+ .ToArray();
+
+ public string[] GetFiles(string path) => FileSystem
+ .GetFiles(path)
+ .OrderBy(file => file)
+ .ToArray();
+
+ public bool DirectoryHasChildren(string path)
+ => FileSystem.GetFiles(path).Any() || FileSystem.GetDirectories(path).Any();
+
+ public string GetFileSystemItemName(bool isFolder, string itemPath) => isFolder
+ ? Path.GetFileName(itemPath)
+ : FileSystem.GetFileName(itemPath);
+
+ private FileSystemTreeItemPresentationModel MapViewModel(string path, string name, bool isFolder)
+ {
+ var parentPath = Path.GetDirectoryName(path);
+ return new FileSystemTreeItemPresentationModel
+ {
+ Path = path.SystemPathToVirtualPath(),
+ Name = name,
+ HasChildren = isFolder && DirectoryHasChildren(path),
+ IsFolder = isFolder,
+ Parent = parentPath.IsNullOrWhiteSpace()
+ ? null
+ : new FileSystemFolderModel { Path = parentPath.SystemPathToVirtualPath() }
+ };
+ }
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs
new file mode 100644
index 0000000000..cdf96a4910
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IFileSystemTreeService.cs
@@ -0,0 +1,18 @@
+using Umbraco.Cms.Api.Management.ViewModels.Tree;
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public interface IFileSystemTreeService
+{
+ FileSystemTreeItemPresentationModel[] GetAncestorModels(string path, bool includeSelf);
+
+ FileSystemTreeItemPresentationModel[] GetPathViewModels(string path, int skip, int take, out long totalItems);
+
+ FileSystemTreeItemPresentationModel[] GetSiblingsViewModels(string path, int before, int after, out long totalBefore,
+ out long totalAfter);
+
+ string[] GetDirectories(string path);
+
+ string[] GetFiles(string path);
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs
new file mode 100644
index 0000000000..6f2e48e80d
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IPartialViewTreeService.cs
@@ -0,0 +1,5 @@
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public interface IPartialViewTreeService : IFileSystemTreeService
+{
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs
new file mode 100644
index 0000000000..1681b52cbb
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IScriptTreeService.cs
@@ -0,0 +1,5 @@
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public interface IScriptTreeService : IFileSystemTreeService
+{
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs
new file mode 100644
index 0000000000..4426be4a68
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/IStyleSheetTreeService.cs
@@ -0,0 +1,7 @@
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public interface IStyleSheetTreeService : IFileSystemTreeService
+{
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs
new file mode 100644
index 0000000000..3da299008c
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/PartialViewTreeService.cs
@@ -0,0 +1,14 @@
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public class PartialViewTreeService : FileSystemTreeServiceBase, IPartialViewTreeService
+{
+ private readonly IFileSystem _partialViewFileSystem;
+
+ protected override IFileSystem FileSystem => _partialViewFileSystem;
+
+ public PartialViewTreeService(FileSystems fileSystems) =>
+ _partialViewFileSystem = fileSystems.PartialViewsFileSystem ??
+ throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs
new file mode 100644
index 0000000000..ef870406f8
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/ScriptTreeService.cs
@@ -0,0 +1,14 @@
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public class ScriptTreeService : FileSystemTreeServiceBase, IScriptTreeService
+{
+ private readonly IFileSystem _scriptFileSystem;
+
+ protected override IFileSystem FileSystem => _scriptFileSystem;
+
+ public ScriptTreeService(FileSystems fileSystems) =>
+ _scriptFileSystem = fileSystems.ScriptsFileSystem ??
+ throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
+}
diff --git a/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs b/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs
new file mode 100644
index 0000000000..ed14819231
--- /dev/null
+++ b/src/Umbraco.Cms.Api.Management/Services/FileSystem/StyleSheetTreeService.cs
@@ -0,0 +1,14 @@
+using Umbraco.Cms.Core.IO;
+
+namespace Umbraco.Cms.Api.Management.Services.FileSystem;
+
+public class StyleSheetTreeService : FileSystemTreeServiceBase, IStyleSheetTreeService
+{
+ private readonly IFileSystem _scriptFileSystem;
+
+ protected override IFileSystem FileSystem => _scriptFileSystem;
+
+ public StyleSheetTreeService(FileSystems fileSystems) =>
+ _scriptFileSystem = fileSystems.StylesheetsFileSystem ??
+ throw new ArgumentException("Missing partial views file system", nameof(fileSystems));
+}
diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs
index 6fefac9040..fa3586a065 100644
--- a/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs
+++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedContent.cs
@@ -100,7 +100,7 @@ public interface IPublishedContent : IPublishedElement
/// Gets the parent of the content item.
///
/// The parent of root content is null.
- [Obsolete("Please use either the IPublishedContent.Parent<>() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")]
+ [Obsolete("Please use either the IPublishedContent.Parent<>() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")]
IPublishedContent? Parent { get; }
///
@@ -142,6 +142,6 @@ public interface IPublishedContent : IPublishedElement
///
/// Gets the children of the content item that are available for the current culture.
///
- [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in V16.")]
+ [Obsolete("Please use either the IPublishedContent.Children() extension method in the Umbraco.Extensions namespace, or IDocumentNavigationQueryService if you only need keys. Scheduled for removal in Umbraco 18.")]
IEnumerable Children { get; }
}
diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs
index 47cf15c3fc..9f8640c3c2 100644
--- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs
+++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs
@@ -46,6 +46,33 @@ public interface IEntityRepository : IRepository
return [];
}
+ ///
+ /// Gets trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
+ ///
+ /// The object type keys of the entities.
+ /// The key of the target entity whose siblings are to be retrieved.
+ /// The number of siblings to retrieve before the target entity.
+ /// The number of siblings to retrieve after the target entity.
+ /// An optional filter to apply to the result set.
+ /// The ordering to apply to the siblings.
+ /// Outputs the total number of siblings before the target entity.
+ /// Outputs the total number of siblings after the target entity.
+ /// Enumerable of trashed sibling entities.
+ IEnumerable GetTrashedSiblings(
+ ISet objectTypes,
+ Guid targetKey,
+ int before,
+ int after,
+ IQuery? filter,
+ Ordering ordering,
+ out long totalBefore,
+ out long totalAfter)
+ {
+ totalBefore = 0;
+ totalAfter = 0;
+ return [];
+ }
+
///
/// Gets entities for a query
///
diff --git a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs
index 5d10cf7e76..58c86b1426 100644
--- a/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs
+++ b/src/Umbraco.Core/PropertyEditors/DecimalPropertyEditor.cs
@@ -64,11 +64,18 @@ public class DecimalPropertyEditor : DataEditor
=> TryParsePropertyValue(editorValue.Value);
private static decimal? TryParsePropertyValue(object? value)
- => value is decimal decimalValue
- ? decimalValue
- : decimal.TryParse(value?.ToString(), CultureInfo.InvariantCulture, out var parsedDecimalValue)
- ? parsedDecimalValue
- : null;
+ => value switch
+ {
+ decimal d => d,
+ double db => (decimal)db,
+ float f => (decimal)f,
+ IFormattable f => decimal.TryParse(f.ToString(null, CultureInfo.InvariantCulture), NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedDecimalValue)
+ ? parsedDecimalValue
+ : null,
+ _ => decimal.TryParse(value?.ToString(), CultureInfo.CurrentCulture, out var parsedDecimalValue)
+ ? parsedDecimalValue
+ : null,
+ };
///
/// Base validator for the decimal property editor validation against data type configured values.
diff --git a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs
index 0fb6b3a13f..1a8a68cde0 100644
--- a/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs
+++ b/src/Umbraco.Core/PropertyEditors/TextStringValueConverter.cs
@@ -40,7 +40,7 @@ public class TextStringValueConverter : PropertyValueConverterBase, IDeliveryApi
var sourceString = source.ToString();
// ensures string is parsed for {localLink} and URLs are resolved correctly
- sourceString = _linkParser.EnsureInternalLinks(sourceString!, preview);
+ sourceString = _linkParser.EnsureInternalLinks(sourceString!);
sourceString = _urlParser.EnsureUrls(sourceString);
return sourceString;
diff --git a/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs
index 1b77f1c655..10409cd7bd 100644
--- a/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs
+++ b/src/Umbraco.Core/PropertyEditors/Validators/MultipleValueValidator.cs
@@ -39,7 +39,7 @@ public class MultipleValueValidator : IValueValidator
}
var invalidValues = values
- .Where(x => valueListConfiguration.Items.Contains(x) is false)
+ .Where(x => x.IsNullOrWhiteSpace() is false && valueListConfiguration.Items.Contains(x) is false)
.ToList();
if (invalidValues.Count == 1)
diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs
index edfde776e2..15a4b9670e 100644
--- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs
+++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs
@@ -526,8 +526,12 @@ internal abstract class ContentEditingServiceBase
+ public IEnumerable GetTrashedSiblings(
+ Guid key,
+ IEnumerable objectTypes,
+ int before,
+ int after,
+ out long totalBefore,
+ out long totalAfter,
+ IQuery? filter = null,
+ Ordering? ordering = null)
+ {
+ if (before < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(before), "The 'before' parameter must be greater than or equal to 0.");
+ }
+
+ if (after < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(after), "The 'after' parameter must be greater than or equal to 0.");
+ }
+
+ ordering ??= new Ordering("sortOrder");
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope();
+
+ var objectTypeGuids = objectTypes.Select(x => x.GetGuid()).ToHashSet();
+
+ IEnumerable siblings = _entityRepository.GetTrashedSiblings(
+ objectTypeGuids,
+ key,
+ before,
+ after,
+ filter,
+ ordering,
+ out totalBefore,
+ out totalAfter);
+
+ scope.Complete();
+ return siblings;
+ }
+
///
public virtual IEnumerable GetDescendants(int id)
{
diff --git a/src/Umbraco.Core/Services/IEntityService.cs b/src/Umbraco.Core/Services/IEntityService.cs
index 1099c4af74..cab5615103 100644
--- a/src/Umbraco.Core/Services/IEntityService.cs
+++ b/src/Umbraco.Core/Services/IEntityService.cs
@@ -171,7 +171,7 @@ public interface IEntityService
IEntitySlim? GetParent(int id, UmbracoObjectTypes objectType);
///
- /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
+ /// Gets non-trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
///
/// The key of the target entity whose siblings are to be retrieved.
/// The object types of the entities.
@@ -181,7 +181,7 @@ public interface IEntityService
/// The ordering to apply to the siblings.
/// Outputs the total number of siblings before the target entity.
/// Outputs the total number of siblings after the target entity.
- /// Enumerable of sibling entities.
+ /// Enumerable of non-trashed sibling entities.
IEnumerable GetSiblings(
Guid key,
IEnumerable objectTypes,
@@ -197,6 +197,33 @@ public interface IEntityService
return [];
}
+ ///
+ /// Gets trashed sibling entities of a specified target entity, within a given range before and after the target, ordered as specified.
+ ///
+ /// The key of the target entity whose siblings are to be retrieved.
+ /// The object types of the entities.
+ /// The number of siblings to retrieve before the target entity. Needs to be greater or equal to 0.
+ /// The number of siblings to retrieve after the target entity. Needs to be greater or equal to 0.
+ /// An optional filter to apply to the result set.
+ /// The ordering to apply to the siblings.
+ /// Outputs the total number of siblings before the target entity.
+ /// Outputs the total number of siblings after the target entity.
+ /// Enumerable of trashed sibling entities.
+ IEnumerable GetTrashedSiblings(
+ Guid key,
+ IEnumerable objectTypes,
+ int before,
+ int after,
+ out long totalBefore,
+ out long totalAfter,
+ IQuery? filter = null,
+ Ordering? ordering = null)
+ {
+ totalBefore = 0;
+ totalAfter = 0;
+ return [];
+ }
+
///
/// Gets the children of an entity.
///
diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs
index fe2d3c998c..fe95f18911 100644
--- a/src/Umbraco.Core/Services/PropertyValidationService.cs
+++ b/src/Umbraco.Core/Services/PropertyValidationService.cs
@@ -58,10 +58,9 @@ public class PropertyValidationService : IPropertyValidationService
}
IDataEditor? dataEditor = GetDataEditor(propertyType);
- if (dataEditor == null)
+ if (dataEditor is null)
{
- throw new InvalidOperationException("No property editor found by alias " +
- propertyType.PropertyEditorAlias);
+ return [];
}
// only validate culture invariant properties if
diff --git a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
index ba6018d1b3..a05eca2354 100644
--- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
+++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using System.Text.RegularExpressions;
+using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
namespace Umbraco.Cms.Core.Templates;
@@ -45,17 +46,18 @@ public sealed class HtmlLocalLinkParser
///
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
///
- ///
- ///
- ///
+ [Obsolete("This method overload is no longer used in Umbraco and delegates to the overload without the preview parameter. Scheduled for removal in Umbraco 18.")]
public string EnsureInternalLinks(string text, bool preview) => EnsureInternalLinks(text);
///
/// Parses the string looking for the {localLink} syntax and updates them to their correct links.
///
- ///
- ///
- public string EnsureInternalLinks(string text)
+ public string EnsureInternalLinks(string text) => EnsureInternalLinks(text, UrlMode.Default);
+
+ ///
+ /// Parses the string looking for the {localLink} syntax and updates them to their correct links.
+ ///
+ public string EnsureInternalLinks(string text, UrlMode urlMode)
{
foreach (LocalLinkTag tagData in FindLocalLinkIds(text))
{
@@ -63,8 +65,8 @@ public sealed class HtmlLocalLinkParser
{
var newLink = tagData.Udi?.EntityType switch
{
- Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid),
- Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid),
+ Constants.UdiEntityType.Document => _publishedUrlProvider.GetUrl(tagData.Udi.Guid, urlMode),
+ Constants.UdiEntityType.Media => _publishedUrlProvider.GetMediaUrl(tagData.Udi.Guid, urlMode),
_ => string.Empty,
};
@@ -73,7 +75,7 @@ public sealed class HtmlLocalLinkParser
}
else if (tagData.IntId.HasValue)
{
- var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value);
+ var newLink = _publishedUrlProvider.GetUrl(tagData.IntId.Value, urlMode);
text = text.Replace(tagData.TagHref, newLink);
}
}
diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs
index 6bad48923a..527253e91a 100644
--- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs
@@ -193,6 +193,72 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
Ordering ordering,
out long totalBefore,
out long totalAfter)
+ {
+ Sql mainSql = SiblingsSql(
+ false,
+ objectTypes,
+ targetKey,
+ before,
+ after,
+ filter,
+ ordering,
+ out totalBefore,
+ out totalAfter);
+
+ List? keys = Database.Fetch(mainSql);
+
+ if (keys is null || keys.Count == 0)
+ {
+ return [];
+ }
+
+ // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last.
+ return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys));
+ }
+
+ ///
+ public IEnumerable GetTrashedSiblings(
+ ISet objectTypes,
+ Guid targetKey,
+ int before,
+ int after,
+ IQuery? filter,
+ Ordering ordering,
+ out long totalBefore,
+ out long totalAfter)
+ {
+ Sql? mainSql = SiblingsSql(
+ true,
+ objectTypes,
+ targetKey,
+ before,
+ after,
+ filter,
+ ordering,
+ out totalBefore,
+ out totalAfter);
+
+ List? keys = Database.Fetch(mainSql);
+
+ if (keys is null || keys.Count == 0)
+ {
+ return [];
+ }
+
+ // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last.
+ return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys));
+ }
+
+ private Sql SiblingsSql(
+ bool isTrashed,
+ ISet objectTypes,
+ Guid targetKey,
+ int before,
+ int after,
+ IQuery? filter,
+ Ordering ordering,
+ out long totalBefore,
+ out long totalAfter)
{
// Ideally we don't want to have to do a second query for the parent ID, but the siblings query is already messy enough
// without us also having to do a nested query for the parent ID too.
@@ -212,7 +278,7 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
.Select($"ROW_NUMBER() OVER ({orderingSql.SQL}) AS rn")
.AndSelect(n => n.UniqueId)
.From()
- .Where(x => x.ParentId == parentId && x.Trashed == false)
+ .Where(x => x.ParentId == parentId && x.Trashed == isTrashed)
.WhereIn(x => x.NodeObjectType, objectTypes);
// Apply the filter if provided.
@@ -245,25 +311,16 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend
var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset;
var beforeArgumentsArray = beforeArguments.ToArray();
var afterArgumentsArray = afterArguments.ToArray();
- Sql? mainSql = Sql()
+
+ totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true);
+ totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false);
+
+ return Sql()
.Select("UniqueId")
.From().AppendSubQuery(rowNumberSql, "NumberedNodes")
.Where($"rn >= ({targetRowSql.SQL}) - @{beforeAfterParameterIndex}", beforeArgumentsArray)
.Where($"rn <= ({targetRowSql.SQL}) + @{beforeAfterParameterIndex}", afterArgumentsArray)
.OrderBy("rn");
-
- List? keys = Database.Fetch(mainSql);
-
- totalBefore = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, beforeArgumentsArray, true);
- totalAfter = GetNumberOfSiblingsOutsideSiblingRange(rowNumberSql, targetRowSql, beforeAfterParameterIndex, afterArgumentsArray, false);
-
- if (keys is null || keys.Count == 0)
- {
- return [];
- }
-
- // To re-use this method we need to provide a single object type. By convention for folder based trees, we provide the primary object type last.
- return PerformGetAll(objectTypes.ToArray(), ordering, sql => sql.WhereIn(x => x.UniqueId, keys));
}
private static int GetBeforeAfterParameterOffset(ISet objectTypes, IQuery? filter)
diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs
index b005c9b881..c8e2a3f6e7 100644
--- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs
+++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/UserRepository.cs
@@ -681,6 +681,15 @@ SELECT 4 AS {keyAlias}, COUNT(id) AS {valueAlias} FROM {userTableName}
protected override void PersistDeletedItem(IUser entity)
{
+ // Clear user group caches for any user groups associated with the deleted user.
+ // We need to do this because the count of the number of users in the user group is cached
+ // along with the user group, and if we've made changes to the user groups assigned to the user,
+ // the count for the groups need to be refreshed.
+ foreach (IReadOnlyUserGroup group in entity.Groups)
+ {
+ ClearRepositoryCacheForUserGroup(group.Id);
+ }
+
IEnumerable deletes = GetDeleteClauses();
foreach (var delete in deletes)
{
@@ -722,20 +731,31 @@ SELECT 4 AS {keyAlias}, COUNT(id) AS {valueAlias} FROM {userTableName}
if (entity.IsPropertyDirty("Groups"))
{
- // lookup all assigned
- Sql sql = SqlContext.Sql()
- .SelectAll()
- .From()
- .WhereIn(x => x.Alias, entity.Groups.Select(x => x.Alias).ToArray());
- List? assigned = entity.Groups == null || entity.Groups.Any() == false
- ? new List()
- : Database.Fetch(sql);
+ // Lookup all assigned groups.
+ List assigned = [];
+ if (entity.Groups.Any())
+ {
+ Sql sql = SqlContext.Sql()
+ .SelectAll()
+ .From()
+ .WhereIn(x => x.Alias, entity.Groups.Select(x => x.Alias).ToArray());
+ assigned = Database.Fetch(sql);
+ }
foreach (UserGroupDto? groupDto in assigned)
{
var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id };
Database.Insert(dto);
}
+
+ // Clear user group caches for the user groups associated with the new user.
+ // We need to do this because the count of the number of users in the user group is cached
+ // along with the user group, and if we've made changes to the user groups assigned to the user,
+ // the count for the groups need to be refreshed.
+ foreach (IReadOnlyUserGroup group in entity.Groups)
+ {
+ ClearRepositoryCacheForUserGroup(group.Id);
+ }
}
entity.ResetDirtyProperties();
@@ -849,34 +869,68 @@ SELECT 4 AS {keyAlias}, COUNT(id) AS {valueAlias} FROM {userTableName}
if (entity.IsPropertyDirty("Groups"))
{
- // lookup all assigned
+ // Get all user groups Ids currently assigned to the user.
Sql sql = SqlContext.Sql()
- .SelectAll()
- .From()
- .WhereIn(
- x => x.Alias,
- entity.Groups.Select(x => x.Alias).ToArray());
-
- List? assigned = entity.Groups == null || entity.Groups.Any() == false
- ? []
- : Database.Fetch(sql);
-
- // first delete all
- sql = SqlContext.Sql()
- .Delete()
+ .Select(x => x.UserGroupId)
+ .From()
.Where(c => c.UserId == entity.Id);
- Database.Execute(sql);
- foreach (UserGroupDto? groupDto in assigned)
+ List existingUserGroupIds = Database.Fetch(sql);
+
+ // Get the user groups Ids that need to be removed and added.
+ var userGroupsIdsToRemove = existingUserGroupIds
+ .Except(entity.Groups.Select(x => x.Id))
+ .ToList();
+ var userGroupIdsToAdd = entity.Groups
+ .Select(x => x.Id)
+ .Except(existingUserGroupIds)
+ .ToList();
+
+ // Remove user groups that are no longer assigned to the user.
+ if (userGroupsIdsToRemove.Count > 0)
{
- var dto = new User2UserGroupDto { UserGroupId = groupDto.Id, UserId = entity.Id };
- Database.Insert(dto);
+ Database.Delete(
+ Sql()
+ .Where(x => x.UserId == entity.Id)
+ .WhereIn(x => x.UserGroupId, userGroupsIdsToRemove));
+ }
+
+ // Add user groups that are newly assigned to the user.
+ if (userGroupIdsToAdd.Count > 0)
+ {
+ IEnumerable user2UserGroupDtos = userGroupIdsToAdd
+ .Select(userGroupId => new User2UserGroupDto
+ {
+ UserGroupId = userGroupId,
+ UserId = entity.Id,
+ });
+ Database.InsertBulk(user2UserGroupDtos);
+ }
+
+ // Clear user group caches for any user group that have been removed or added.
+ // We need to do this because the count of the number of users in the user group is cached
+ // along with the user group, and if we've made changes to the user groups assigned to the user,
+ // the count for the groups need to be refreshed.
+ var userGroupIdsToRefresh = userGroupsIdsToRemove
+ .Union(userGroupIdsToAdd)
+ .ToList();
+ foreach (int userGroupIdToRefresh in userGroupIdsToRefresh)
+ {
+ ClearRepositoryCacheForUserGroup(userGroupIdToRefresh);
}
}
entity.ResetDirtyProperties();
}
+ private void ClearRepositoryCacheForUserGroup(int id)
+ {
+ IAppPolicyCache userGroupCache = AppCaches.IsolatedCaches.GetOrCreate();
+
+ string cacheKey = RepositoryCacheKeys.GetKey(id);
+ userGroupCache.Clear(cacheKey);
+ }
+
private void AddingOrUpdateStartNodes(IEntity entity, IEnumerable current,
UserStartNodeDto.StartNodeTypeValue startNodeType, int[]? entityStartIds)
{
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs
index d91f902228..443e497b09 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MarkdownEditorValueConverter.cs
@@ -41,7 +41,7 @@ public class MarkdownEditorValueConverter : PropertyValueConverterBase, IDeliver
var sourceString = source.ToString()!;
// ensures string is parsed for {localLink} and URLs are resolved correctly
- sourceString = _localLinkParser.EnsureInternalLinks(sourceString, preview);
+ sourceString = _localLinkParser.EnsureInternalLinks(sourceString);
sourceString = _urlParser.EnsureUrls(sourceString);
return sourceString;
diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs
index b2c47fc3cb..d39d13e243 100644
--- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs
+++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteBlockRenderingValueConverter.cs
@@ -135,7 +135,7 @@ public class RteBlockRenderingValueConverter : SimpleRichTextValueConverter, IDe
var sourceString = intermediateValue.Markup;
// ensures string is parsed for {localLink} and URLs and media are resolved correctly
- sourceString = _linkParser.EnsureInternalLinks(sourceString, preview);
+ sourceString = _linkParser.EnsureInternalLinks(sourceString);
sourceString = _urlParser.EnsureUrls(sourceString);
sourceString = _imageSourceParser.EnsureImageSources(sourceString);
diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
index 6457773e31..2d41bc0a12 100644
--- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
+++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs
@@ -132,10 +132,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService
GetEntryOptions(key, preview),
GenerateTags(key));
- // We don't want to cache removed items, this may cause issues if the L2 serializer changes.
if (contentCacheNode is null)
{
- await _hybridCache.RemoveAsync(cacheKey);
return null;
}
diff --git a/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs b/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs
index c744ff1608..d6375a5fc8 100644
--- a/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs
+++ b/src/Umbraco.Web.Common/Extensions/FriendlyPublishedContentExtensions.cs
@@ -347,7 +347,7 @@ public static class FriendlyPublishedContentExtensions
///
///
///
- /// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot
+ /// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot.
///
public static IEnumerable DescendantsOrSelfOfType(
this IEnumerable parentNodes, string docTypeAlias, string? culture = null)
@@ -375,7 +375,7 @@ public static class FriendlyPublishedContentExtensions
///
///
///
- /// This can be useful in order to return all nodes in an entire site by a type when combined with TypedContentAtRoot
+ /// This can be useful in order to return all nodes in an entire site by a type when combined with ContentAtRoot.
///
public static IEnumerable DescendantsOrSelf(
this IEnumerable parentNodes,
diff --git a/src/Umbraco.Web.Common/UmbracoHelper.cs b/src/Umbraco.Web.Common/UmbracoHelper.cs
index 9b1a9c6275..2bf332749f 100644
--- a/src/Umbraco.Web.Common/UmbracoHelper.cs
+++ b/src/Umbraco.Web.Common/UmbracoHelper.cs
@@ -309,6 +309,10 @@ public class UmbracoHelper
/// If an identifier does not match an existing content, it will be missing in the returned value.
public IEnumerable Content(IEnumerable ids) => _publishedContentQuery.Content(ids);
+ ///
+ /// Gets the documents at root.
+ ///
+ /// A collection of found at the root.
public IEnumerable ContentAtRoot() => _publishedContentQuery.ContentAtRoot();
#endregion
diff --git a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts
index 8678114981..6f5b7775c2 100644
--- a/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts
+++ b/src/Umbraco.Web.UI.Client/examples/workspace-view-hint/hint-workspace-view.ts
@@ -41,10 +41,10 @@ export class ExampleHintWorkspaceView extends UmbElementMixin(LitElement) {
throw new Error('Could not find the workspace');
}
- if (workspace.hints.has('exampleHintFromToggleAction')) {
- workspace.hints.removeOne('exampleHintFromToggleAction');
+ if (workspace.view.hints.has('exampleHintFromToggleAction')) {
+ workspace.view.hints.removeOne('exampleHintFromToggleAction');
} else {
- workspace.hints.addOne({
+ workspace.view.hints.addOne({
unique: 'exampleHintFromToggleAction',
path: ['Umb.WorkspaceView.Document.Edit'],
text: 'Hi',
diff --git a/src/Umbraco.Web.UI.Client/package-lock.json b/src/Umbraco.Web.UI.Client/package-lock.json
index f9445c5a30..e7608ac345 100644
--- a/src/Umbraco.Web.UI.Client/package-lock.json
+++ b/src/Umbraco.Web.UI.Client/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "@umbraco-cms/backoffice",
- "version": "16.2.0-rc",
+ "version": "17.0.0-rc",
"lockfileVersion": 3,
"requires": true,
"packages": {
diff --git a/src/Umbraco.Web.UI.Client/package.json b/src/Umbraco.Web.UI.Client/package.json
index 9f709c5d12..fbc77a4a5b 100644
--- a/src/Umbraco.Web.UI.Client/package.json
+++ b/src/Umbraco.Web.UI.Client/package.json
@@ -31,6 +31,7 @@
"./collection": "./dist-cms/packages/core/collection/index.js",
"./components": "./dist-cms/packages/core/components/index.js",
"./const": "./dist-cms/packages/core/const/index.js",
+ "./content-picker": "./dist-cms/packages/property-editors/content-picker/index.js",
"./content-type": "./dist-cms/packages/content/content-type/index.js",
"./content": "./dist-cms/packages/content/content/index.js",
"./culture": "./dist-cms/packages/core/culture/index.js",
@@ -46,8 +47,8 @@
"./entity-action": "./dist-cms/packages/core/entity-action/index.js",
"./entity-bulk-action": "./dist-cms/packages/core/entity-bulk-action/index.js",
"./entity-create-option-action": "./dist-cms/packages/core/entity-create-option-action/index.js",
- "./entity": "./dist-cms/packages/core/entity/index.js",
"./entity-item": "./dist-cms/packages/core/entity-item/index.js",
+ "./entity": "./dist-cms/packages/core/entity/index.js",
"./event": "./dist-cms/packages/core/event/index.js",
"./extension-registry": "./dist-cms/packages/core/extension-registry/index.js",
"./health-check": "./dist-cms/packages/health-check/index.js",
@@ -57,6 +58,7 @@
"./icon": "./dist-cms/packages/core/icon-registry/index.js",
"./id": "./dist-cms/packages/core/id/index.js",
"./imaging": "./dist-cms/packages/media/imaging/index.js",
+ "./interaction-memory": "./dist-cms/packages/core/interaction-memory/index.js",
"./language": "./dist-cms/packages/language/index.js",
"./lit-element": "./dist-cms/packages/core/lit-element/index.js",
"./localization": "./dist-cms/packages/core/localization/index.js",
@@ -66,9 +68,9 @@
"./media-type": "./dist-cms/packages/media/media-types/index.js",
"./media": "./dist-cms/packages/media/media/index.js",
"./member-group": "./dist-cms/packages/members/member-group/index.js",
+ "./member-public-access": "./dist-cms/packages/members/member-public-access/index.js",
"./member-type": "./dist-cms/packages/members/member-type/index.js",
"./member": "./dist-cms/packages/members/member/index.js",
- "./member-public-access": "./dist-cms/packages/members/member-public-access/index.js",
"./menu": "./dist-cms/packages/core/menu/index.js",
"./modal": "./dist-cms/packages/core/modal/index.js",
"./models": "./dist-cms/packages/core/models/index.js",
@@ -94,9 +96,10 @@
"./search": "./dist-cms/packages/search/index.js",
"./section": "./dist-cms/packages/core/section/index.js",
"./segment": "./dist-cms/packages/segment/index.js",
- "./server": "./dist-cms/packages/core/server/index.js",
"./server-file-system": "./dist-cms/packages/core/server-file-system/index.js",
+ "./server": "./dist-cms/packages/core/server/index.js",
"./settings": "./dist-cms/packages/settings/index.js",
+ "./shortcut": "./dist-cms/packages/core/shortcut/index.js",
"./sorter": "./dist-cms/packages/core/sorter/index.js",
"./static-file": "./dist-cms/packages/static-file/index.js",
"./store": "./dist-cms/packages/core/store/index.js",
diff --git a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts
index 7a17520579..bbe8d7b98e 100644
--- a/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/apps/app/app.element.ts
@@ -22,6 +22,7 @@ import { filter, first, firstValueFrom } from '@umbraco-cms/backoffice/external/
import { hasOwnOpener, redirectToStoredPath } from '@umbraco-cms/backoffice/utils';
import { UmbApiInterceptorController } from '@umbraco-cms/backoffice/resources';
import { umbHttpClient } from '@umbraco-cms/backoffice/http-client';
+import { UmbViewContext } from '@umbraco-cms/backoffice/view';
import './app-logo.element.js';
import './app-oauth.element.js';
@@ -159,6 +160,8 @@ export class UmbAppElement extends UmbLitElement {
new UmbContextDebugController(this);
new UmbNetworkConnectionStatusManager(this);
+
+ new UmbViewContext(this, null);
}
override connectedCallback(): void {
diff --git a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts
index 89c0ffbb51..117867336b 100644
--- a/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/apps/backoffice/components/backoffice-header-sections.element.ts
@@ -54,6 +54,10 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
);
}
+ #getSectionName(section: UmbExtensionManifestInitializer) {
+ return section.manifest?.meta.label ? this.localize.string(section.manifest?.meta.label) : section.manifest?.name;
+ }
+
#getSectionPath(manifest: ManifestSection | undefined) {
return `section/${manifest?.meta.pathname}`;
}
@@ -108,12 +112,10 @@ export class UmbBackofficeHeaderSectionsElement extends UmbLitElement {
?active="${this._currentSectionAlias === section.alias}"
@click=${(event: PointerEvent) => this.#onSectionClick(event, section.manifest)}
href="${this.#getSectionPath(section.manifest)}"
- label="${ifDefined(
- section.manifest?.meta.label
- ? this.localize.string(section.manifest?.meta.label)
- : section.manifest?.name,
- )}"
- data-mark="section-link:${section.alias}">
+ label="${ifDefined(this.#getSectionName(section))}"
+ data-mark="section-link:${section.alias}"
+ >${this.#getSectionName(section)}
`,
)}
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts
index ac62461746..c76993903f 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/cy.ts
@@ -2211,6 +2211,7 @@ export default {
searchContentTree: "Chwilio'r coeden cynnwys",
maxAmount: 'Uchafswm',
expandChildItems: 'Ehangu eitemau plentyn ar gyfer',
+ collapseChildItems: 'Cuddio eitemau plant ar gyfer',
openContextNode: 'Agor nod cyd-destun ar gyfer',
},
references: {
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts
index f9485c9871..5c8ae8a9bd 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/da.ts
@@ -2350,6 +2350,7 @@ export default {
maxAmount: 'Maximum antal',
contextDialogDescription: 'Perform action %0% on the %1% node',
expandChildItems: 'Udvid underordnede elementer for',
+ collapseChildItems: 'Skjul underordnede elementer for',
openContextNode: 'Åbn kontekstnode for',
},
references: {
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
index 7b848d6e90..fe14394776 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/en.ts
@@ -357,6 +357,9 @@ export default {
saveAndPublishModalTitle: 'Save and publish',
publishModalTitle: 'Publish',
openSplitViewForVariant: (variant: string) => `Open ${variant} in split view`,
+ sharedAcrossCultures: 'Shared across cultures',
+ sharedAcrossSegments: 'Shared across segments',
+ shared: 'Shared',
},
blueprints: {
createBlueprintFrom: "Create a new Document Blueprint from '%0%'",
@@ -2408,6 +2411,7 @@ export default {
searchContentTree: 'Search content tree',
maxAmount: 'Maximum amount',
expandChildItems: 'Expand child items for',
+ collapseChildItems: 'Collapse child items for',
openContextNode: 'Open context node for',
},
references: {
@@ -2834,10 +2838,17 @@ export default {
resetUrlLabel: 'Reset',
},
missingEditor: {
+ title: 'This property type is no longer available.',
description:
- '
Error! This property type is no longer available. Please reach out to your administrator.
',
+ "Don't worry, your content is safe and publishing this document won't overwrite it or remove it. Please contact your site administrator to resolve this issue.",
+ detailsTitle: 'Additional details',
detailsDescription:
- '
This property type is no longer available. Please contact your administrator so they can either delete this property or restore the property type.
Data:
',
+ "To resolve this you should either restore the property editor, change the property to use a supported data type or remove the property if it's no longer needed.",
+ detailsDataType: 'Data type',
+ detailsPropertyEditor: 'Property editor',
+ detailsData: 'Data',
+ detailsHide: 'Hide details',
+ detailsShow: 'Show details',
},
uiCulture: {
ar: 'العربية',
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts
index dcbc5655b7..7c7bcf58f5 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/fr.ts
@@ -1873,6 +1873,7 @@ export default {
searchContentTree: "Chercher dans l'arborescence de contenu",
maxAmount: 'Quantité maximum',
expandChildItems: 'Afficher les éléments enfant pour',
+ collapseChildItems: 'Cacher les éléments enfant pour',
openContextNode: 'Ouvrir le noeud de contexte pour',
},
references: {
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts
index cd63947ae4..68201301c8 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/pt.ts
@@ -2407,6 +2407,7 @@ export default {
searchContentTree: 'Pesquisar Árvore de Conteúdo',
maxAmount: 'Quantidade máxima',
expandChildItems: 'Expandir itens filhos para',
+ collapseChildItems: 'Fechar itens filhos para',
openContextNode: 'Abrir nó de contexto para',
},
references: {
@@ -2832,9 +2833,16 @@ export default {
resetUrlLabel: 'Redefinir',
},
missingEditor: {
+ title: 'Este tipo de propriedade já não se encontra disponível.',
description:
- '
Erro! Este tipo de propriedade já não se encontra disponível. Por favor, contacte o administrador.
',
+ 'Não se preocupe, o seu conteúdo está seguro e a publicação deste documento não o substituirá nem removerá. Entre em contacto com o administrador do site para resolver o problema.',
+ detailsTitle: 'Detalhes adicionais',
detailsDescription:
- '
Este tipo de propriedade já não se encontra disponível. Por favor, contacte o administrador para que ele possa apagar a propriedade ou restaurar o tipo de propriedade.
Dados:
',
+ 'Para resolver o problema, deverá ou restaurar o editor de propriedades, ou alterar a propriedade para usar um tipo de dados compatível ou remover a propriedade se ela não for mais necessária.',
+ detailsDataType: 'Tipo de dados',
+ detailsPropertyEditor: 'Editor de propriedades',
+ detailsData: 'Dados',
+ detailsHide: 'Esconder detalhes',
+ detailsShow: 'Mostrar detalhes',
},
} as UmbLocalizationDictionary;
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts
index 86a9f82544..57f308e160 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/sv.ts
@@ -318,6 +318,7 @@ export default {
searchContentTree: 'Sök i innehållsträdet',
maxAmount: 'Maximalt värde',
expandChildItems: 'Visa underliggande noder för',
+ collapseChildItems: 'Dölj underliggande noder för',
openContextNode: 'Öppna kontext för',
},
prompt: {
diff --git a/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts b/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts
index 1058aa0909..65be353739 100644
--- a/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts
+++ b/src/Umbraco.Web.UI.Client/src/assets/lang/vi.ts
@@ -2410,6 +2410,7 @@ export default {
searchContentTree: 'Tìm kiếm cây nội dung',
maxAmount: 'Số lượng tối đa',
expandChildItems: 'Mở rộng các mục con cho',
+ collapseChildItems: 'Thu gọn các mục con cho',
openContextNode: 'Mở nút ngữ cảnh cho %0%',
},
references: {
diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts
index 6ac9729c10..935b341087 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-element-manager.ts
@@ -84,7 +84,12 @@ export class UmbBlockElementManager {
+ const contentTypeLabel = this.structure.getOwnerContentType()?.name;
+ const blockLabel = host.getName();
+ return contentTypeLabel ? `${contentTypeLabel} ${blockLabel}` : blockLabel;
+ };
this.propertyViewGuard.fallbackToPermitted();
this.propertyWriteGuard.fallbackToPermitted();
@@ -94,6 +99,7 @@ export class UmbBlockElementManager {
if (key) {
this.validation.setDataPath('$.' + dataPathPropertyName + `[?(@.key == '${key}')]`);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts
index 7be599c5a2..770ef5fd19 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/block/block/workspace/block-workspace.context.ts
@@ -26,6 +26,7 @@ import { UmbVariantId } from '@umbraco-cms/backoffice/variant';
import type { UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui';
export type UmbBlockWorkspaceElementManagerNames = 'content' | 'settings';
+
export class UmbBlockWorkspaceContext
extends UmbSubmittableWorkspaceContextBase
implements UmbRoutableWorkspaceContext
@@ -470,7 +471,7 @@ export class UmbBlockWorkspaceContext data?.collection);
// Keep current data in sync with the owner content type - This is used for the discard changes feature
- this.observe(this.structure.ownerContentType, (data) => this._data.setCurrent(data));
+ this.observe(this.structure.ownerContentType, (data) => this._data.setCurrent(data), null);
+ this.observe(this.name, (name) => this.view.setTitle(name), null);
+ // TODO: sometimes the browserTitle for a parent view is set later than the child is updating. We need to fix this as well enable a parent browser title to be updating on the go. [NL]
}
/**
diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts
index dc529f7980..c45e72c363 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context.interface.ts
@@ -1,13 +1,13 @@
import type { UmbContentTypeCompositionModel, UmbContentTypeModel, UmbContentTypeSortModel } from '../types.js';
import type { UmbContentTypeStructureManager } from '../structure/index.js';
import type { Observable } from '@umbraco-cms/backoffice/external/rxjs';
-import type { UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace';
+import type { UmbNamableWorkspaceContext, UmbSubmittableWorkspaceContext } from '@umbraco-cms/backoffice/workspace';
export interface UmbContentTypeWorkspaceContext
- extends UmbSubmittableWorkspaceContext {
+ extends UmbSubmittableWorkspaceContext,
+ UmbNamableWorkspaceContext {
readonly IS_CONTENT_TYPE_WORKSPACE_CONTEXT: true;
- readonly name: Observable;
readonly alias: Observable;
readonly description: Observable;
readonly icon: Observable;
@@ -32,7 +32,4 @@ export interface UmbContentTypeWorkspaceContext
+ ${this.localize.term('contentTypeEditor_displaySettingsLabelOnTop')}
`
: nothing}
@@ -348,7 +349,7 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement {
) {
return html`
- ${this.localize.term(
+ ${this.localize.term(
'contentTypeEditor_cultureAndVariantInvariantLabel',
)}
@@ -357,13 +358,17 @@ export class UmbContentTypeDesignEditorPropertyElement extends UmbLitElement {
if (this.ownerVariesByCulture && !this.property.variesByCulture) {
return html`
- ${this.localize.term('contentTypeEditor_cultureInvariantLabel')}
+ ${this.localize.term(
+ 'contentTypeEditor_cultureInvariantLabel',
+ )}
`;
}
if (this.ownerVariesBySegment && !this.property.variesBySegment) {
return html`
- ${this.localize.term('contentTypeEditor_segmentInvariantLabel')}
+ ${this.localize.term(
+ 'contentTypeEditor_segmentInvariantLabel',
+ )}
`;
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts
index c224375f7d..95fd159185 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts
@@ -9,7 +9,12 @@ import type { UmbContentCollectionWorkspaceContext } from '../collection/content
import type { UmbContentWorkspaceContext } from './content-workspace-context.interface.js';
import { UmbContentDetailValidationPathTranslator } from './content-detail-validation-path-translator.js';
import { UmbContentValidationToHintsManager } from './content-validation-to-hints.manager.js';
-import { appendToFrozenArray, mergeObservables, UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
+import {
+ appendToFrozenArray,
+ mergeObservables,
+ observeMultiple,
+ UmbArrayState,
+} from '@umbraco-cms/backoffice/observable-api';
import { firstValueFrom, map } from '@umbraco-cms/backoffice/external/rxjs';
import { umbOpenModal } from '@umbraco-cms/backoffice/modal';
import { UmbContentTypeStructureManager } from '@umbraco-cms/backoffice/content-type';
@@ -21,7 +26,6 @@ import {
UmbRequestReloadChildrenOfEntityEvent,
UmbRequestReloadStructureForEntityEvent,
} from '@umbraco-cms/backoffice/entity-action';
-import { UmbHintContext } from '@umbraco-cms/backoffice/hint';
import { UmbLanguageCollectionRepository } from '@umbraco-cms/backoffice/language';
import {
UmbPropertyValuePresetVariantBuilderController,
@@ -52,7 +56,6 @@ import type { UmbLanguageDetailModel } from '@umbraco-cms/backoffice/language';
import type { UmbPropertyTypePresetModel, UmbPropertyTypePresetModelTypeModel } from '@umbraco-cms/backoffice/property';
import type { UmbModalToken } from '@umbraco-cms/backoffice/modal';
import type { UmbSegmentCollectionItemModel } from '@umbraco-cms/backoffice/segment';
-import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint';
export interface UmbContentDetailWorkspaceContextArgs<
DetailModelType extends UmbContentDetailModel,
@@ -141,9 +144,6 @@ export abstract class UmbContentDetailWorkspaceContextBase<
readonly collection: UmbContentCollectionManager;
- /* Hints */
- readonly hints = new UmbHintContext(this);
-
/* Variant Options */
// TODO: Optimize this so it uses either a App Language Context? [NL]
#languageRepository = new UmbLanguageCollectionRepository(this);
@@ -221,7 +221,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
this,
this.structure,
this.validationContext,
- this.hints,
+ this.view.hints,
);
this.variantOptions = mergeObservables(
@@ -334,6 +334,17 @@ export abstract class UmbContentDetailWorkspaceContextBase<
null,
);
+ this.observe(
+ observeMultiple([this.splitView.activeVariantByIndex(0), this.variants]),
+ ([activeVariant, variants]) => {
+ const variantName = variants.find(
+ (v) => v.culture === activeVariant?.culture && v.segment === activeVariant?.segment,
+ )?.name;
+ this.view.setTitle(variantName);
+ },
+ null,
+ );
+
this.observe(
this.varies,
(varies) => {
@@ -810,7 +821,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
* Request a submit of the workspace, in the case of Document Workspaces the validation does not need to be valid for this to be submitted.
* @returns {Promise} a promise which resolves once it has been completed.
*/
- public override requestSubmit() {
+ public override requestSubmit(): Promise {
return this._handleSubmit();
}
@@ -820,7 +831,7 @@ export abstract class UmbContentDetailWorkspaceContextBase<
/**
* Request a save of the workspace, in the case of Document Workspaces the validation does not need to be valid for this to be saved.
- * @returns {Promise} a promise which resolves once it has been completed.
+ * @returns {Promise} A promise which resolves once it has been completed.
*/
public requestSave() {
return this._handleSave();
@@ -836,11 +847,11 @@ export abstract class UmbContentDetailWorkspaceContextBase<
return this._data.constructData(variantIds);
}
- protected async _handleSubmit() {
+ protected async _handleSubmit(): Promise {
await this._handleSave();
this._closeModal();
}
- protected async _handleSave() {
+ protected async _handleSave(): Promise {
const data = this.getData();
if (!data) {
throw new Error('Data is missing');
@@ -866,7 +877,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
value: { selection: selected },
}).catch(() => undefined);
- if (!result?.selection.length) return;
+ if (!result?.selection.length) {
+ return Promise.reject('Cannot save without selecting at least one variant.');
+ }
variantIds = result?.selection.map((x) => UmbVariantId.FromString(x)) ?? [];
} else {
@@ -886,7 +899,9 @@ export abstract class UmbContentDetailWorkspaceContextBase<
() => false,
);
if (valid || this.#ignoreValidationResultOnSubmit) {
- return this.performCreateOrUpdate(variantIds, saveData);
+ await this.performCreateOrUpdate(variantIds, saveData);
+ } else {
+ return Promise.reject('Validation issues prevent saving');
}
} else {
await this.performCreateOrUpdate(variantIds, saveData);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts
index 45c8d3b817..a5da1c1333 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/views/edit/content-editor.element.ts
@@ -1,22 +1,28 @@
import type { UmbContentWorkspaceViewEditTabElement } from './content-editor-tab.element.js';
import { css, html, customElement, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit';
+import { encodeFolderName } from '@umbraco-cms/backoffice/router';
+import {
+ UmbContentTypeContainerStructureHelper,
+ UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT,
+} from '@umbraco-cms/backoffice/content-type';
+import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
+import { UMB_VIEW_CONTEXT, UmbViewController } from '@umbraco-cms/backoffice/view';
+import type {
+ PageComponent,
+ UmbRoute,
+ UmbRouterSlotChangeEvent,
+ UmbRouterSlotInitEvent,
+} from '@umbraco-cms/backoffice/router';
import type {
UmbContentTypeModel,
UmbContentTypeStructureManager,
UmbPropertyTypeContainerMergedModel,
} from '@umbraco-cms/backoffice/content-type';
-import {
- UmbContentTypeContainerStructureHelper,
- UMB_PROPERTY_STRUCTURE_WORKSPACE_CONTEXT,
-} from '@umbraco-cms/backoffice/content-type';
-import type { UmbRoute, UmbRouterSlotChangeEvent, UmbRouterSlotInitEvent } from '@umbraco-cms/backoffice/router';
-import { encodeFolderName } from '@umbraco-cms/backoffice/router';
-import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
-import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace';
-import './content-editor-tab.element.js';
import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint';
-import { UMB_VIEW_CONTEXT, UmbViewContext } from '@umbraco-cms/backoffice/view';
+import type { UmbWorkspaceViewElement } from '@umbraco-cms/backoffice/workspace';
+
+import './content-editor-tab.element.js';
@customElement('umb-content-workspace-view-edit')
export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements UmbWorkspaceViewElement {
@@ -25,7 +31,7 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
@state()
private _hasRootProperties = false;
*/
- #viewContext?: UmbViewContext;
+ #viewContext?: typeof UMB_VIEW_CONTEXT.TYPE;
@state()
private _hasRootGroups = false;
@@ -43,9 +49,9 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
private _activePath = '';
@state()
- private _hintMap: Map = new Map();
+ private _hintMap: Map = new Map();
- #tabViewContexts: Array = [];
+ #tabViewContexts: Array = [];
#structureManager?: UmbContentTypeStructureManager;
@@ -104,9 +110,10 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
component: () => import('./content-editor-tab.element.js'),
setup: (component) => {
(component as UmbContentWorkspaceViewEditTabElement).containerId = null;
+ this.#provideViewContext(null, component);
},
});
- this.#createViewContext('root');
+ this.#createViewContext(null, '#general_generic');
}
if (this._tabs.length > 0) {
@@ -118,9 +125,10 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
component: () => import('./content-editor-tab.element.js'),
setup: (component) => {
(component as UmbContentWorkspaceViewEditTabElement).containerId = tab.ownerId ?? tab.ids[0];
+ this.#provideViewContext(path, component);
},
});
- this.#createViewContext(path);
+ this.#createViewContext(path, tabName);
});
}
@@ -140,11 +148,17 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
this._routes = routes;
}
- #createViewContext(viewAlias: string) {
+ #createViewContext(viewAlias: string | null, tabName: string) {
if (!this.#tabViewContexts.find((context) => context.viewAlias === viewAlias)) {
- const view = new UmbViewContext(this, viewAlias);
+ const view = new UmbViewController(this, viewAlias);
this.#tabViewContexts.push(view);
+ if (viewAlias === null) {
+ // for the root tab, we need to filter hints, so in this case we do accept everything that is not in a tab: [NL]
+ view.hints.setPathFilter((paths) => paths[0].includes('tab/') === false);
+ }
+
+ view.setTitle(tabName);
view.inheritFrom(this.#viewContext);
this.observe(
@@ -162,13 +176,33 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
}
}
+ #currentProvidedView?: UmbViewController;
+
+ #provideViewContext(viewAlias: string | null, component: PageComponent) {
+ const view = this.#tabViewContexts.find((context) => context.viewAlias === viewAlias);
+ if (this.#currentProvidedView === view) {
+ return;
+ }
+ this.#currentProvidedView?.unprovide();
+ if (!view) {
+ throw new Error(`View context with alias ${viewAlias} not found`);
+ }
+ this.#currentProvidedView = view;
+ // ViewAlias null is only for the root tab, therefor we can implement this hack.
+ if (viewAlias === null) {
+ // Specific hack for the Generic tab to only show its name if there are other tabs.
+ view.setTitle(this._tabs && this._tabs?.length > 0 ? '#general_generic' : undefined);
+ }
+ view.provideAt(component as any);
+ }
+
override render() {
if (!this._routes || !this._tabs) return;
return html`
${this._routerPath && (this._tabs.length > 1 || (this._tabs.length === 1 && this._hasRootGroups))
? html`
- ${this._hasRootGroups && this._tabs.length > 0 ? this.#renderTab('root', '#general_generic') : nothing}
+ ${this._hasRootGroups && this._tabs.length > 0 ? this.#renderTab(null, '#general_generic') : nothing}
${repeat(
this._tabs,
(tab) => tab.name,
@@ -194,20 +228,21 @@ export class UmbContentWorkspaceViewEditElement extends UmbLitElement implements
`;
}
- #renderTab(path: string, name: string, index = 0) {
+ #renderTab(path: string | null, name: string, index = 0) {
const hint = this._hintMap.get(path);
- const fullPath = this._routerPath + '/' + path;
+ const fullPath = this._routerPath + '/' + (path ? path : 'root');
const active =
fullPath === this._activePath ||
- (!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath);
+ (!this._hasRootGroups && index === 0 && this._routerPath + '/' === this._activePath) ||
+ (this._hasRootGroups && index === 0 && path === null && this._routerPath + '/' === this._activePath);
return html`${hint && !active
- ? html`${hint.text}${hint.text}`
: nothing}`;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts
index bd4e7ed78b..faa4e5ae2a 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/content/property-type/workspace/property-type-workspace.context.ts
@@ -6,6 +6,7 @@ import type {
UmbInvariantDatasetWorkspaceContext,
UmbRoutableWorkspaceContext,
ManifestWorkspace,
+ UmbNamableWorkspaceContext,
} from '@umbraco-cms/backoffice/workspace';
import {
UmbSubmittableWorkspaceContextBase,
@@ -26,7 +27,7 @@ type PropertyTypeDataModel = UmbPropertyTypeScaffoldModel;
export class UmbPropertyTypeWorkspaceContext
extends UmbSubmittableWorkspaceContextBase
- implements UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext
+ implements UmbInvariantDatasetWorkspaceContext, UmbRoutableWorkspaceContext, UmbNamableWorkspaceContext
{
// Just for context token safety:
public readonly IS_PROPERTY_TYPE_WORKSPACE_CONTEXT = true;
@@ -62,11 +63,22 @@ export class UmbPropertyTypeWorkspaceContext
this.validationContext = new UmbValidationContext(this);
this.addValidationContext(this.validationContext);
- this.observe(this.unique, (unique) => {
- if (unique) {
- this.validationContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique }));
- }
- });
+ this.observe(
+ this.unique,
+ (unique) => {
+ if (unique) {
+ this.validationContext.setDataPath(UmbDataPathPropertyTypeQuery({ id: unique }));
+ }
+ },
+ null,
+ );
+ this.observe(
+ this.name,
+ (name) => {
+ this.view.setTitle(name);
+ },
+ null,
+ );
this.#init = this.consumeContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT, (context) => {
this.#contentTypeContext = context;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts
index 923f5cc01a..7ddb228c63 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/components/collection-filter-field.element.ts
@@ -33,6 +33,10 @@ export class UmbCollectionFilterFieldElement extends UmbLitElement {
static override readonly styles = [
css`
+ :host {
+ display: flex;
+ }
+
uui-input {
width: 100%;
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts
new file mode 100644
index 0000000000..13bb1e67ad
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/badge.element.ts
@@ -0,0 +1,72 @@
+import { UmbTextStyles } from '@umbraco-cms/backoffice/style';
+import { html, customElement, property, css, LitElement, ifDefined } from '@umbraco-cms/backoffice/external/lit';
+import type { UUIInterfaceColor, UUIInterfaceLook } from '@umbraco-cms/backoffice/external/uui';
+
+/**
+ * @element umb-badge
+ * @description A wrapper for the uui-badge component with position fixed support to go on top of other elements.
+ * @augments {LitElement}
+ */
+@customElement('umb-badge')
+export class UmbBadgeElement extends LitElement {
+ /**
+ * Changes the look of the button to one of the predefined, symbolic looks.
+ * @type {"default" | "positive" | "warning" | "danger"}
+ * @attr
+ * @default "default"
+ */
+ @property({ type: String })
+ color?: UUIInterfaceColor;
+
+ /**
+ * Changes the look of the button to one of the predefined, symbolic looks.
+ * @type {"default" | "primary" | "secondary" | "outline" | "placeholder"}
+ * @attr
+ * @default "default"
+ */
+ @property({ type: String })
+ look?: UUIInterfaceLook;
+
+ /**
+ * Bring attention to this badge by applying a bounce animation.
+ * @type boolean
+ * @attr
+ * @default false
+ */
+ @property({ type: Boolean })
+ attention?: boolean;
+
+ override render() {
+ return html``;
+ }
+
+ static override styles = [
+ UmbTextStyles,
+ css`
+ :host {
+ position: absolute;
+ anchor-name: --umb-badge-anchor;
+ /** because inset has no effect on uui-badge in this case, we then apply it here: */
+ inset: var(--uui-badge-inset, -8px -8px auto auto);
+ }
+
+ @supports (position-anchor: --my-name) {
+ uui-badge {
+ position: fixed;
+ position-anchor: --umb-badge-anchor;
+ z-index: 1;
+ top: anchor(top);
+ right: anchor(right);
+ }
+ }
+ `,
+ ];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'umb-badge': UmbBadgeElement;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts
new file mode 100644
index 0000000000..4094fdef5a
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/badge/index.ts
@@ -0,0 +1 @@
+export * from './badge.element.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts
index a6494c5c3b..f1d63a722e 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/index.ts
@@ -2,6 +2,7 @@
// TODO: we need to move these files into their respective folders/silos. We then need a way for a silo to globally register a component
export * from './backoffice-modal-container/backoffice-modal-container.element.js';
export * from './backoffice-notification-container/backoffice-notification-container.element.js';
+export * from './badge/index.js';
export * from './body-layout/body-layout.element.js';
export * from './code-block/index.js';
export * from './dropdown/index.js';
@@ -25,6 +26,6 @@ export * from './multiple-color-picker-input/index.js';
export * from './multiple-text-string-input/index.js';
export * from './popover-layout/index.js';
export * from './ref-item/index.js';
-export * from './stack/index.js';
export * from './split-panel/index.js';
+export * from './stack/index.js';
export * from './table/index.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts
index 47d05e9cbe..6fed4cef67 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/components/multiple-color-picker-input/multiple-color-picker-item-input.element.ts
@@ -189,7 +189,7 @@ export class UmbMultipleColorPickerItemInputElement extends UUIFormControlMixin(
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts
index 5ffb94a697..97b469d8a5 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/entity-item-ref/entity-item-ref.element.ts
@@ -120,6 +120,12 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
}
+ @property({ type: Boolean })
+ error?: boolean;
+
+ @property({ type: String, attribute: 'error-message', reflect: false })
+ errorMessage?: string;
+
#pathAddendum = new UmbRoutePathAddendumContext(this);
#onSelected(event: UmbSelectedEvent) {
@@ -155,6 +161,7 @@ export class UmbEntityItemRefElement extends UmbLitElement {
this._component?.remove();
const component = extensionControllers[0]?.component || document.createElement('umb-default-item-ref');
+ // TODO: I would say this code can use feature of the UmbExtensionsElementInitializer, to set properties and get a fallback element. [NL]
// assign the properties to the component
component.item = this.#item;
component.readonly = this.readonly;
@@ -182,7 +189,25 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
override render() {
- return html`${this._component}`;
+ if (this._component) {
+ return html`${this._component}`;
+ }
+ // Error:
+ if (this.error) {
+ return html`
+
+
+ `;
+ }
+ // Loading:
+ return html``;
}
override destroy(): void {
@@ -211,7 +236,7 @@ export class UmbEntityItemRefElement extends UmbLitElement {
}
:host([drag-placeholder]) {
- --uui-color-focus:transparent;
+ --uui-color-focus: transparent;
}
:host([drag-placeholder])::after {
@@ -233,7 +258,6 @@ export class UmbEntityItemRefElement extends UmbLitElement {
transition: opacity 50ms 16ms;
opacity: 0;
}
-
`,
];
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts
index d1236bc97b..89763284e8 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/entry-point.ts
@@ -1,10 +1,12 @@
-import { UMB_AUTH_CONTEXT } from './auth/auth.context.token.js';
-import { UmbBackofficeNotificationContainerElement, UmbBackofficeModalContainerElement } from './components/index.js';
-import { UmbActionEventContext } from './action/action-event.context.js';
import { manifests as coreManifests } from './manifests.js';
-import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification';
+import { UMB_AUTH_CONTEXT } from './auth/auth.context.token.js';
+import { UmbActionEventContext } from './action/action-event.context.js';
+import { UmbBackofficeNotificationContainerElement, UmbBackofficeModalContainerElement } from './components/index.js';
+import { UmbInteractionMemoryContext } from './interaction-memory/index.js';
+import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api';
import { UmbModalManagerContext } from '@umbraco-cms/backoffice/modal';
-import { UmbExtensionsApiInitializer, type UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api';
+import { UmbNotificationContext } from '@umbraco-cms/backoffice/notification';
+import type { UmbEntryPointOnInit } from '@umbraco-cms/backoffice/extension-api';
import './property-action/components/index.js';
import './menu/components/index.js';
@@ -31,6 +33,7 @@ export const onInit: UmbEntryPointOnInit = (host, extensionRegistry) => {
new UmbNotificationContext(host);
new UmbModalManagerContext(host);
new UmbActionEventContext(host);
+ new UmbInteractionMemoryContext(host);
host.consumeContext(UMB_AUTH_CONTEXT, (authContext) => {
// Initialize the auth context to let the app context know that the core module is ready
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts
index 5649e78018..f85498b378 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context-token.ts
@@ -1,4 +1,4 @@
-import type { UmbHintController } from './hints.controller.js';
+import type { UmbHintController } from './hint.controller.js';
import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
export const UMB_HINT_CONTEXT = new UmbContextToken('UmbHintContext');
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts
similarity index 97%
rename from src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts
index 3a9875d47c..7d381dcecd 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.context.ts
@@ -1,6 +1,6 @@
import type { UmbHint, UmbIncomingHintBase } from '../types.js';
import { UMB_HINT_CONTEXT } from './hint.context-token.js';
-import { UmbHintController, type UmbHintControllerArgs } from './hints.controller.js';
+import { UmbHintController, type UmbHintControllerArgs } from './hint.controller.js';
import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.controller.ts
similarity index 82%
rename from src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts
rename to src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.controller.ts
index 6b9f3ba1e6..ca7c4302a1 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hints.controller.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/hint.controller.ts
@@ -7,7 +7,7 @@ import { UmbArrayState, UmbObjectState, type Observable } from '@umbraco-cms/bac
import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api';
export interface UmbHintControllerArgs {
- viewAlias?: string;
+ viewAlias?: string | null;
scaffold?: Partial;
}
@@ -16,10 +16,15 @@ export class UmbHintController<
IncomingHintType extends UmbIncomingHintBase = UmbPartialSome,
> extends UmbControllerBase {
//
- #viewAlias?: string;
- getViewAlias(): string | undefined {
+ #viewAlias: string | null;
+ getViewAlias(): string | null {
return this.#viewAlias;
}
+ #pathFilter?: (path: Array) => boolean;
+ setPathFilter(filter: (path: Array) => boolean) {
+ this.#pathFilter = filter;
+ }
+
#scaffold = new UmbObjectState>({});
readonly scaffold = this.#scaffold.asObservable();
#inUnprovidingState?: boolean;
@@ -43,7 +48,7 @@ export class UmbHintController<
constructor(host: UmbControllerHost, args?: UmbHintControllerArgs) {
super(host);
- this.#viewAlias = args?.viewAlias;
+ this.#viewAlias = args?.viewAlias ?? null;
if (args?.scaffold) {
this.#scaffold.setValue(args?.scaffold);
}
@@ -82,7 +87,7 @@ export class UmbHintController<
return this.#hints.asObservablePart(fn);
}
- descendingHints(viewAlias?: string): Observable | undefined> {
+ descendingHints(viewAlias?: string | null): Observable | undefined> {
if (viewAlias) {
return this.#hints.asObservablePart((hints) => {
return hints.filter((hint) => hint.path[0] === viewAlias);
@@ -92,7 +97,22 @@ export class UmbHintController<
}
}
+ /**
+ * @internal
+ * @param {(path: Array) => boolean} filter - A filter function to filter the hints by their path.
+ * @returns {Observable | undefined>} An observable of an array of hints that match the filter.
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ _internal_descendingHintsByFilter(filter: (path: Array) => boolean): Observable | undefined> {
+ return this.#hints.asObservablePart((hints) => {
+ return hints.filter((hint) => filter(hint.path));
+ });
+ }
+
inherit(): void {
+ if (this.#viewAlias === null && this.#pathFilter === undefined) {
+ throw new Error('A Hint Controller needs a view alias or pathFilter to be able to inherit from a parent.');
+ }
this.consumeContext(UMB_HINT_CONTEXT, (parent) => {
this.inheritFrom(parent);
}).skipHost();
@@ -101,13 +121,24 @@ export class UmbHintController<
inheritFrom(parent: UmbHintController | undefined): void {
if (this.#parent === parent) return;
+ if (this.#viewAlias === null && this.#pathFilter === undefined) {
+ throw new Error('A Hint Controller needs a view alias or pathFilter to be able to inherit from a parent.');
+ }
this.#parent = parent;
this.observe(this.#parent?.scaffold, (scaffold) => {
if (scaffold) {
this.#scaffold.update(scaffold as any);
}
});
- this.observe(parent?.descendingHints(this.#viewAlias), this.#receiveHints, 'observeParentHints');
+ if (this.#viewAlias) {
+ this.observe(parent?.descendingHints(this.#viewAlias), this.#receiveHints, 'observeParentHints');
+ } else if (this.#pathFilter) {
+ this.observe(
+ parent?._internal_descendingHintsByFilter(this.#pathFilter),
+ this.#receiveHints,
+ 'observeParentHints',
+ );
+ }
this.observe(this.hints, this.#propagateHints, 'observeLocalMessages');
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts
index 9523595c70..df3deea0fd 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/hint/context/index.ts
@@ -1,3 +1,3 @@
export * from './hint.context-token.js';
-export * from './hints.context.js';
-export * from './hints.controller.js';
+export * from './hint.context.js';
+export * from './hint.controller.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json
index d095317698..a21e080e58 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icon-dictionary.json
@@ -100,6 +100,10 @@
"name": "icon-backspace",
"file": "delete.svg"
},
+ {
+ "name": "icon-badge",
+ "file": "badge.svg"
+ },
{
"name": "icon-badge-add",
"file": "circle-plus.svg"
@@ -974,6 +978,10 @@
"file": "hard-drive.svg",
"legacy": true
},
+ {
+ "name": "icon-heading",
+ "file": "heading.svg"
+ },
{
"name": "icon-heading-1",
"file": "heading-1.svg"
@@ -990,6 +998,14 @@
"name": "icon-heading-4",
"file": "heading-4.svg"
},
+ {
+ "name": "icon-heading-5",
+ "file": "heading-5.svg"
+ },
+ {
+ "name": "icon-heading-6",
+ "file": "heading-6.svg"
+ },
{
"name": "icon-headphones",
"file": "headphones.svg"
@@ -2210,6 +2226,10 @@
"name": "icon-tree",
"file": "tree-deciduous.svg"
},
+ {
+ "name": "icon-trending-up-down",
+ "file": "trending-up-down.svg"
+ },
{
"name": "icon-trophy",
"file": "trophy.svg"
@@ -2411,6 +2431,10 @@
"name": "icon-star",
"file": "star.svg"
},
+ {
+ "name": "icon-stretch-horizontal",
+ "file": "stretch-horizontal.svg"
+ },
{
"name": "icon-database",
"file": "database.svg"
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts
index 653bf6a54e..5e85f87f8f 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons.ts
@@ -74,6 +74,9 @@ path: () => import("./icons/icon-axis-rotation.js"),
name: "icon-backspace",
path: () => import("./icons/icon-backspace.js"),
},{
+name: "icon-badge",
+path: () => import("./icons/icon-badge.js"),
+},{
name: "icon-badge-add",
path: () => import("./icons/icon-badge-add.js"),
},{
@@ -766,6 +769,9 @@ legacy: true,
hidden: true,
path: () => import("./icons/icon-hard-drive.js"),
},{
+name: "icon-heading",
+path: () => import("./icons/icon-heading.js"),
+},{
name: "icon-heading-1",
path: () => import("./icons/icon-heading-1.js"),
},{
@@ -778,6 +784,12 @@ path: () => import("./icons/icon-heading-3.js"),
name: "icon-heading-4",
path: () => import("./icons/icon-heading-4.js"),
},{
+name: "icon-heading-5",
+path: () => import("./icons/icon-heading-5.js"),
+},{
+name: "icon-heading-6",
+path: () => import("./icons/icon-heading-6.js"),
+},{
name: "icon-headphones",
path: () => import("./icons/icon-headphones.js"),
},{
@@ -1798,6 +1810,9 @@ path: () => import("./icons/icon-trash.js"),
name: "icon-tree",
path: () => import("./icons/icon-tree.js"),
},{
+name: "icon-trending-up-down",
+path: () => import("./icons/icon-trending-up-down.js"),
+},{
name: "icon-trophy",
path: () => import("./icons/icon-trophy.js"),
},{
@@ -1965,6 +1980,9 @@ path: () => import("./icons/icon-zoom-out.js"),
name: "icon-star",
path: () => import("./icons/icon-star.js"),
},{
+name: "icon-stretch-horizontal",
+path: () => import("./icons/icon-stretch-horizontal.js"),
+},{
name: "icon-database",
path: () => import("./icons/icon-database.js"),
},{
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts
new file mode 100644
index 0000000000..7238c1faba
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-badge.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts
new file mode 100644
index 0000000000..af737d0595
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-5.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts
new file mode 100644
index 0000000000..f23122ecdc
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading-6.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts
new file mode 100644
index 0000000000..ab812d887c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-heading.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts
new file mode 100644
index 0000000000..a4409ea351
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-stretch-horizontal.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts
new file mode 100644
index 0000000000..9d08233c6e
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/icon-registry/icons/icon-trending-up-down.ts
@@ -0,0 +1 @@
+export default ``;
\ No newline at end of file
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts
new file mode 100644
index 0000000000..9641d5013b
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/constants.ts
@@ -0,0 +1 @@
+export * from './interaction-memory.context.token.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts
new file mode 100644
index 0000000000..e9d65ff065
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/event/interaction-memories-change.event.ts
@@ -0,0 +1,8 @@
+export class UmbInteractionMemoriesChangeEvent extends Event {
+ public static readonly TYPE = 'interaction-memories-change';
+
+ public constructor() {
+ // mimics the native change event
+ super(UmbInteractionMemoriesChangeEvent.TYPE, { bubbles: true, composed: false, cancelable: false });
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts
new file mode 100644
index 0000000000..175b537a06
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/index.ts
@@ -0,0 +1,6 @@
+export * from './constants.js';
+export * from './event/interaction-memories-change.event.js';
+export * from './interaction-memory.context.js';
+export * from './interaction-memory.manager.js';
+
+export type * from './types.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts
new file mode 100644
index 0000000000..d37a64fe99
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.token.ts
@@ -0,0 +1,6 @@
+import type { UmbInteractionMemoryContext } from './interaction-memory.context.js';
+import { UmbContextToken } from '@umbraco-cms/backoffice/context-api';
+
+export const UMB_INTERACTION_MEMORY_CONTEXT = new UmbContextToken(
+ 'UmbInteractionMemoryContext',
+);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts
new file mode 100644
index 0000000000..32e267129a
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.context.ts
@@ -0,0 +1,12 @@
+import { UMB_INTERACTION_MEMORY_CONTEXT } from './interaction-memory.context.token.js';
+import { UmbInteractionMemoryManager } from './interaction-memory.manager.js';
+import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
+import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
+
+export class UmbInteractionMemoryContext extends UmbContextBase {
+ public readonly memory = new UmbInteractionMemoryManager(this);
+
+ constructor(host: UmbControllerHost) {
+ super(host, UMB_INTERACTION_MEMORY_CONTEXT);
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts
new file mode 100644
index 0000000000..6c506732d6
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.test.ts
@@ -0,0 +1,103 @@
+import { UmbInteractionMemoryManager } from './interaction-memory.manager.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+import { expect } from '@open-wc/testing';
+import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
+import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
+
+@customElement('test-my-controller-host')
+class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {}
+
+describe('UmbInteractionMemoryManager', () => {
+ let manager: UmbInteractionMemoryManager;
+ const nestedMemory1 = { unique: 'nestedMemory1', value: 'Nested Memory 1' };
+ const nestedMemory2 = { unique: 'nestedMemory2', value: 'Nested Memory 2' };
+ const memory1 = { unique: '1', value: 'Memory 1' };
+ const memory2 = { unique: '2', value: 'Memory 2', memories: [nestedMemory1, nestedMemory2] };
+
+ beforeEach(() => {
+ const hostElement = new UmbTestControllerHostElement();
+ manager = new UmbInteractionMemoryManager(hostElement);
+ manager.setMemory(memory1);
+ manager.setMemory(memory2);
+ });
+
+ describe('Public API', () => {
+ describe('properties', () => {
+ it('has a memories property', () => {
+ expect(manager).to.have.property('memories').to.be.an.instanceOf(Observable);
+ });
+ });
+
+ describe('methods', () => {
+ it('has a memory method', () => {
+ expect(manager).to.have.property('memory').that.is.a('function');
+ });
+
+ it('has a getMemory method', () => {
+ expect(manager).to.have.property('getMemory').that.is.a('function');
+ });
+
+ it('has a setMemory method', () => {
+ expect(manager).to.have.property('setMemory').that.is.a('function');
+ });
+
+ it('has a deleteMemory method', () => {
+ expect(manager).to.have.property('deleteMemory').that.is.a('function');
+ });
+
+ it('has a getAllMemories method', () => {
+ expect(manager).to.have.property('getAllMemories').that.is.a('function');
+ });
+
+ it('has a clear method', () => {
+ expect(manager).to.have.property('clear').that.is.a('function');
+ });
+ });
+ });
+
+ describe('getMemory()', () => {
+ it('returns the correct memory item by unique identifier', () => {
+ const result = manager.getMemory('1');
+ expect(result).to.deep.equal(memory1);
+ });
+ });
+
+ describe('setMemory()', () => {
+ it('create a new memory unique identifier', () => {
+ const newMemory = { unique: 'newMemory', value: 'New Memory' };
+ manager.setMemory(newMemory);
+ const result = manager.getMemory('newMemory');
+ expect(result).to.deep.equal(newMemory);
+ });
+
+ it('update an existing memory item by unique identifier', () => {
+ const updatedMemory = { unique: '1', value: 'Updated Memory 1' };
+ manager.setMemory(updatedMemory);
+ const result = manager.getMemory('1');
+ expect(result).to.deep.equal(updatedMemory);
+ });
+ });
+
+ describe('deleteMemory()', () => {
+ it('deletes an existing memory item by unique identifier', () => {
+ manager.deleteMemory('1');
+ const result = manager.getMemory('1');
+ expect(result).to.be.undefined;
+ });
+ });
+
+ describe('getAllMemories()', () => {
+ it('returns all memory items', () => {
+ const result = manager.getAllMemories();
+ expect(result).to.deep.equal([memory1, memory2]);
+ });
+ });
+
+ describe('clear()', () => {
+ it('clears all memory items', () => {
+ manager.clear();
+ const result = manager.getAllMemories();
+ expect(result.length).to.equal(0);
+ });
+ });
+});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts
new file mode 100644
index 0000000000..a385e4a59c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/interaction-memory.manager.ts
@@ -0,0 +1,71 @@
+import type { UmbInteractionMemoryModel } from './types.js';
+import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import type { Observable } from '@umbraco-cms/backoffice/observable-api';
+
+/**
+ * A manager for handling interaction memory items.
+ * @exports
+ * @class UmbInteractionMemoryManager
+ * @augments {UmbControllerBase}
+ */
+export class UmbInteractionMemoryManager extends UmbControllerBase {
+ #memories = new UmbArrayState([], (x) => x.unique);
+ /** Observable for all memory items. */
+ memories = this.#memories.asObservable();
+
+ /**
+ * Observable for a specific memory item by its unique identifier.
+ * @param {string} unique - The unique identifier of the memory item.
+ * @returns {(Observable)} An observable that emits the memory item or undefined if not found.
+ * @memberof UmbInteractionMemoryManager
+ */
+ memory(unique: string): Observable {
+ return this.#memories.asObservablePart((items) => items.find((item) => item.unique === unique));
+ }
+
+ /**
+ * Get a specific memory item by its unique identifier.
+ * @param {string} unique - The unique identifier of the memory item.
+ * @returns {(UmbInteractionMemoryModel | undefined)} The memory item or undefined if not found.
+ * @memberof UmbInteractionMemoryManager
+ */
+ getMemory(unique: string): UmbInteractionMemoryModel | undefined {
+ return this.#memories.getValue().find((item) => item.unique === unique);
+ }
+
+ /**
+ * Add or update a memory item.
+ * @param {UmbInteractionMemoryModel} memory - The memory item to add or update.
+ * @memberof UmbInteractionMemoryManager
+ */
+ setMemory(memory: UmbInteractionMemoryModel) {
+ this.#memories.appendOne(memory);
+ }
+
+ /**
+ * Delete a memory item by its unique identifier.
+ * @param {string} unique - The unique identifier of the memory item.
+ * @memberof UmbInteractionMemoryManager
+ */
+ deleteMemory(unique: string) {
+ this.#memories.removeOne(unique);
+ }
+
+ /**
+ * Get all memory items from the manager.
+ * @returns {Array} An array of all memory items.
+ * @memberof UmbInteractionMemoryManager
+ */
+ getAllMemories(): Array {
+ return this.#memories.getValue();
+ }
+
+ /**
+ * Clear all memory items from the manager.
+ * @memberof UmbInteractionMemoryManager
+ */
+ clear() {
+ this.#memories.clear();
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts
new file mode 100644
index 0000000000..7f3d18c531
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/interaction-memory/types.ts
@@ -0,0 +1,5 @@
+export interface UmbInteractionMemoryModel {
+ unique: string;
+ value?: any;
+ memories?: Array;
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts
index a51d3651b8..15d6b5349c 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/localization/registry/localization.registry.ts
@@ -118,6 +118,9 @@ export class UmbLocalizationRegistry {
)
// Subscribe to the observable to trigger the loading of translations
.subscribe();
+
+ // Always register the fallback language (en) to ensure there is always at least one language available
+ this.loadLanguage(UMB_DEFAULT_LOCALIZATION_CULTURE);
}
#loadExtension = async (extension: ManifestLocalization) => {
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts
index 67f35e0f7f..e9067ba9cb 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/component/modal.element.ts
@@ -55,6 +55,7 @@ export class UmbModalElement extends UmbLitElement {
}
this.#modalContext.addEventListener('umb:destroy', this.#onContextDestroy);
+ this.#modalContext.view.provideAt(this);
this.element = await this.#createContainerElement();
// Makes sure that the modal triggers the reject of the context promise when it is closed by pressing escape.
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts
index 3271d035d4..fa2a78d9c3 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/context/modal.context.ts
@@ -2,12 +2,15 @@ import { UmbModalToken } from '../token/modal-token.js';
import type { UmbModalConfig, UmbModalType } from '../types.js';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import { umbDeepMerge } from '@umbraco-cms/backoffice/utils';
import { UmbId } from '@umbraco-cms/backoffice/id';
import { UmbObjectState, UmbStringState } from '@umbraco-cms/backoffice/observable-api';
-import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
-import { type UmbDeepPartialObject, umbDeepMerge } from '@umbraco-cms/backoffice/utils';
+import { UmbViewController } from '@umbraco-cms/backoffice/view';
+import { UMB_ROUTE_CONTEXT } from '@umbraco-cms/backoffice/router';
import type { ElementLoaderProperty } from '@umbraco-cms/backoffice/extension-api';
-import { UMB_ROUTE_CONTEXT, type IRouterSlot } from '@umbraco-cms/backoffice/router';
+import type { IRouterSlot } from '@umbraco-cms/backoffice/router';
+import type { UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils';
export interface UmbModalRejectReason {
type: string;
@@ -59,6 +62,8 @@ export class UmbModalContext<
#size = new UmbStringState('small');
public readonly size = this.#size.asObservable();
+ public readonly view;
+
constructor(
host: UmbControllerHost,
modalAlias: string | UmbModalToken,
@@ -69,6 +74,9 @@ export class UmbModalContext<
this.router = args.router ?? null;
this.alias = modalAlias;
+ this.view = new UmbViewController(this, modalAlias.toString());
+
+ let title: string | undefined = undefined;
let size = 'small';
if (this.alias instanceof UmbModalToken) {
@@ -76,8 +84,11 @@ export class UmbModalContext<
size = this.alias.getDefaultModal()?.size ?? size;
this.element = this.alias.getDefaultModal()?.element || this.element;
this.backdropBackground = this.alias.getDefaultModal()?.backdropBackground || this.backdropBackground;
+ title = this.alias.getDefaultModal()?.title ?? undefined;
}
+ this.view.setTitle(title);
+
this.type = args.modal?.type || this.type;
size = args.modal?.size ?? size;
this.element = args.modal?.element || this.element;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts
index 97b3ebb81e..bd3d4a397c 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/modal/types.ts
@@ -1,5 +1,5 @@
-import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui';
import type { ElementLoaderProperty } from '@umbraco-cms/backoffice/extension-api';
+import type { UUIModalElement, UUIModalSidebarSize } from '@umbraco-cms/backoffice/external/uui';
export type * from './extensions/types.js';
@@ -35,4 +35,9 @@ export interface UmbModalConfig {
* Set the background property of the modal backdrop
*/
backdropBackground?: string;
+
+ /**
+ * Set the title of the modal, this is used as Browser Title
+ */
+ title?: string;
}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts
index c6407d1c54..c345fd22d4 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker-input/picker-input.context.ts
@@ -1,12 +1,18 @@
import { UMB_PICKER_INPUT_CONTEXT } from './picker-input.context-token.js';
import { UmbChangeEvent } from '@umbraco-cms/backoffice/event';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
+import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
+import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory';
import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository';
-import { umbConfirmModal, umbOpenModal } from '@umbraco-cms/backoffice/modal';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import type { UmbItemRepository } from '@umbraco-cms/backoffice/repository';
-import type { UmbModalToken, UmbPickerModalData, UmbPickerModalValue } from '@umbraco-cms/backoffice/modal';
-import { UmbDeprecation } from '@umbraco-cms/backoffice/utils';
+import {
+ umbConfirmModal,
+ umbOpenModal,
+ type UmbModalToken,
+ type UmbPickerModalData,
+ type UmbPickerModalValue,
+} from '@umbraco-cms/backoffice/modal';
type PickerItemBaseType = { name: string; unique: string };
export class UmbPickerInputContext<
@@ -21,8 +27,10 @@ export class UmbPickerInputContext<
#itemManager;
- selection;
- selectedItems;
+ public readonly selection;
+ public readonly selectedItems;
+ public readonly statuses;
+ public readonly interactionMemory = new UmbInteractionMemoryManager(this);
/**
* Define a minimum amount of selected items in this input, for this input to be valid.
@@ -77,6 +85,7 @@ export class UmbPickerInputContext<
this.#itemManager = new UmbRepositoryItemsManager(this, repositoryAlias, getUniqueMethod);
this.selection = this.#itemManager.uniques;
+ this.statuses = this.#itemManager.statuses;
this.selectedItems = this.#itemManager.items;
}
@@ -100,6 +109,7 @@ export class UmbPickerInputContext<
selection: this.getSelection(),
} as PickerModalValueType,
}).catch(() => undefined);
+
if (!modalValue) return;
this.setSelection(modalValue.selection);
@@ -108,12 +118,12 @@ export class UmbPickerInputContext<
async requestRemoveItem(unique: string) {
const item = this.#itemManager.getItems().find((item) => this.#getUnique(item) === unique);
- if (!item) throw new Error('Could not find item with unique: ' + unique);
+ const name = item?.name ?? '#general_notFound';
await umbConfirmModal(this, {
color: 'danger',
- headline: `#actions_remove ${item.name}?`,
- content: `#defaultdialogs_confirmremove ${item.name}?`,
+ headline: `#actions_remove ${name}?`,
+ content: `#defaultdialogs_confirmremove ${name}?`,
confirmLabel: '#actions_remove',
});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts
index d38d1310d8..fb3be7e155 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/index.ts
@@ -1,5 +1,6 @@
export * from './constants.js';
-export * from './search/index.js';
+export * from './modal/index.js';
export * from './picker.context.js';
export * from './picker.context.token.js';
+export * from './search/index.js';
export type * from './types.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts
new file mode 100644
index 0000000000..762dc5e74c
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/index.ts
@@ -0,0 +1 @@
+export * from './picker-modal-base.element.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts
new file mode 100644
index 0000000000..85aa28e480
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/modal/picker-modal-base.element.ts
@@ -0,0 +1,64 @@
+import type { UmbPickerContext } from '../picker.context.js';
+import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal';
+import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity';
+import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
+import type { ManifestModal, UmbPickerModalData } from '@umbraco-cms/backoffice/modal';
+import { UMB_PICKER_INPUT_CONTEXT } from '@umbraco-cms/backoffice/picker-input';
+
+export abstract class UmbPickerModalBaseElement<
+ ItemType = UmbEntityModel,
+ ModalDataType extends UmbPickerModalData = UmbPickerModalData,
+ ModalValueType = unknown,
+ ModalManifestType extends ManifestModal = ManifestModal,
+> extends UmbModalBaseElement {
+ protected abstract _pickerContext: UmbPickerContext;
+
+ #pickerInputContext?: typeof UMB_PICKER_INPUT_CONTEXT.TYPE;
+
+ constructor() {
+ super();
+ this.consumeContext(UMB_PICKER_INPUT_CONTEXT, (pickerInputContext) => {
+ this.#pickerInputContext = pickerInputContext;
+ this.#observeMemoriesFromInputContext();
+ });
+ }
+
+ override connectedCallback(): void {
+ super.connectedCallback();
+ this.#observeMemoriesFromPicker();
+ }
+
+ #observeMemoriesFromPicker() {
+ this.observe(this._pickerContext.interactionMemory.memories, (memories) => {
+ this.#setMemoriesOnInputContext(memories);
+ });
+ }
+
+ #getInteractionMemoryUnique() {
+ // TODO: consider appending with a unique when we have that implemented.
+ return `UmbPickerModal`;
+ }
+
+ #observeMemoriesFromInputContext() {
+ this.observe(
+ this.#pickerInputContext?.interactionMemory.memory(this.#getInteractionMemoryUnique()),
+ (memory) => {
+ memory?.memories?.forEach((memory) => this._pickerContext.interactionMemory.setMemory(memory));
+ },
+ 'umbModalInteractionMemoryObserver',
+ );
+ }
+
+ #setMemoriesOnInputContext(pickerMemories: Array) {
+ if (pickerMemories?.length > 0) {
+ const pickerModalMemory: UmbInteractionMemoryModel = {
+ unique: this.#getInteractionMemoryUnique(),
+ memories: pickerMemories,
+ };
+
+ this.#pickerInputContext?.interactionMemory.setMemory(pickerModalMemory);
+ } else {
+ this.#pickerInputContext?.interactionMemory.deleteMemory(this.#getInteractionMemoryUnique());
+ }
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts
index e606e79b0f..63a16f123a 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/picker.context.ts
@@ -1,18 +1,23 @@
import { UMB_PICKER_CONTEXT } from './picker.context.token.js';
import { UmbPickerSearchManager } from './search/manager/picker-search.manager.js';
import { UmbContextBase } from '@umbraco-cms/backoffice/class-api';
+import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory';
+import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils';
import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content';
import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
-import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils';
export class UmbPickerContext extends UmbContextBase {
+ public readonly interactionMemory = new UmbInteractionMemoryManager(this);
public readonly selection = new UmbSelectionManager(this);
public readonly search = new UmbPickerSearchManager(this);
+
public dataType?: { unique: string };
constructor(host: UmbControllerHost) {
super(host, UMB_PICKER_CONTEXT);
+ /* TODO: Move this implementation to another place. The generic picker context shouldn't be aware of property and data types.
+ It also gives an illegal import of content module */
this.consumeContext(UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT, (context) => {
this.observe(context?.dataType, (dataType) => {
this.dataType = dataType;
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts
index 9d73c0fdba..35a4405364 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/picker/search/manager/picker-search.manager.ts
@@ -1,10 +1,9 @@
import type { UmbPickerSearchManagerConfig } from './types.js';
-import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
-import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry';
-import { UmbArrayState, UmbBooleanState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
-import type { UmbSearchProvider, UmbSearchRequestArgs, UmbSearchResultItemModel } from '@umbraco-cms/backoffice/search';
import { debounce } from '@umbraco-cms/backoffice/utils';
+import { UmbArrayState, UmbBooleanState, UmbNumberState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import type { UmbSearchProvider, UmbSearchRequestArgs, UmbSearchResultItemModel } from '@umbraco-cms/backoffice/search';
/**
* A manager for searching items in a picker.
@@ -36,15 +35,6 @@ export class UmbPickerSearchManager<
#config?: UmbPickerSearchManagerConfig;
#searchProvider?: UmbSearchProvider;
- /**
- * Creates an instance of UmbPickerSearchManager.
- * @param {UmbControllerHost} host The controller host for the search manager.
- * @memberof UmbPickerSearchManager
- */
- constructor(host: UmbControllerHost) {
- super(host);
- }
-
/**
* Set the configuration for the search manager.
* @param {UmbPickerSearchManagerConfig} config The configuration for the search manager.
@@ -187,6 +177,7 @@ export class UmbPickerSearchManager<
// ensure that config params are always included
...this.#config?.queryParams,
searchFrom: this.#config?.searchFrom,
+ // TODO: Move this implementation to another place. The generic picker search manager shouldn't be aware of data types.
dataTypeUnique: this.#config?.dataTypeUnique,
};
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts
index 6f36bd6037..016483412b 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/index.ts
@@ -2,5 +2,6 @@ export * from './components/index.js';
export * from './config/index.js';
export * from './constants.js';
export * from './events/index.js';
+export * from './interaction-memory/index.js';
export * from './ui-picker-modal/index.js';
export type * from './types.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts
new file mode 100644
index 0000000000..835e4df198
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/index.ts
@@ -0,0 +1 @@
+export * from './property-editor-ui-interaction-memory.manager.js';
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts
new file mode 100644
index 0000000000..4f775c8578
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.test.ts
@@ -0,0 +1,128 @@
+import { UmbPropertyEditorUiInteractionMemoryManager } from './property-editor-ui-interaction-memory.manager.js';
+import { UmbPropertyEditorConfigCollection } from '../config/index.js';
+import { customElement } from '@umbraco-cms/backoffice/external/lit';
+import { expect } from '@open-wc/testing';
+import { Observable } from '@umbraco-cms/backoffice/external/rxjs';
+import { UmbControllerHostElementMixin } from '@umbraco-cms/backoffice/controller-api';
+import { UmbInteractionMemoryContext } from '@umbraco-cms/backoffice/interaction-memory';
+
+@customElement('test-my-controller-host')
+class UmbTestControllerHostElement extends UmbControllerHostElementMixin(HTMLElement) {
+ constructor() {
+ super();
+ new UmbInteractionMemoryContext(this);
+ }
+}
+
+describe('UmbPropertyEditorUiInteractionMemoryManager', () => {
+ let manager: UmbPropertyEditorUiInteractionMemoryManager;
+ let childMemories = [
+ { unique: '1', value: 'Value 1' },
+ { unique: '2', value: 'Value 2' },
+ ];
+
+ beforeEach(() => {
+ const hostElement = new UmbTestControllerHostElement();
+ document.body.appendChild(hostElement);
+
+ manager = new UmbPropertyEditorUiInteractionMemoryManager(hostElement, {
+ memoryUniquePrefix: 'TestPrefix',
+ });
+
+ // A random config to generate a hash code from
+ const config = new UmbPropertyEditorConfigCollection([
+ {
+ alias: 'someAlias',
+ value: 'someValue',
+ },
+ ]);
+
+ manager.setPropertyEditorConfig(config);
+ });
+
+ describe('Public API', () => {
+ describe('properties', () => {
+ it('has a memoriesForPropertyEditor property', () => {
+ expect(manager).to.have.property('memoriesForPropertyEditor').to.be.an.instanceOf(Observable);
+ });
+ });
+
+ describe('methods', () => {
+ it('has a setPropertyEditorConfig method', () => {
+ expect(manager).to.have.property('setPropertyEditorConfig').that.is.a('function');
+ });
+
+ it('has a saveMemoriesForPropertyEditor method', () => {
+ expect(manager).to.have.property('saveMemoriesForPropertyEditor').that.is.a('function');
+ });
+
+ it('has a deleteMemoriesForPropertyEditor method', () => {
+ expect(manager).to.have.property('deleteMemoriesForPropertyEditor').that.is.a('function');
+ });
+ });
+
+ describe('saveMemoriesForPropertyEditor', () => {
+ it('creates a property editor memory based on the provided data', (done) => {
+ manager.memoriesForPropertyEditor.subscribe((memories) => {
+ if (memories.length > 0) {
+ expect(memories).to.have.lengthOf(2);
+ expect(memories).to.deep.equal(childMemories);
+ done();
+ }
+ });
+
+ manager.saveMemoriesForPropertyEditor(childMemories);
+ });
+
+ it('updates the property editor memory based on the provided data', (done) => {
+ const updatedChildMemories = [
+ { unique: '1', value: 'Updated Value 1' },
+ { unique: '2', value: 'Updated Value 2' },
+ { unique: '3', value: 'New Value 3' },
+ ];
+
+ // We start at -1 because the first call is the initial empty array
+ let callCount = -1;
+ manager.memoriesForPropertyEditor.subscribe((memories) => {
+ callCount++;
+ if (callCount === 1) {
+ // First call, after initial save
+ expect(memories).to.have.lengthOf(2);
+ expect(memories).to.deep.equal(childMemories);
+ } else if (callCount === 2) {
+ // Second call, after update
+ expect(memories).to.have.lengthOf(3);
+ expect(memories).to.deep.equal(updatedChildMemories);
+ done();
+ }
+ });
+
+ manager.saveMemoriesForPropertyEditor(childMemories);
+ manager.saveMemoriesForPropertyEditor(updatedChildMemories);
+ });
+ });
+
+ describe('deleteMemoriesForPropertyEditor', () => {
+ it('deletes all memories for this property editor', (done) => {
+ // We start at -1 because the first call is the initial empty array
+ let callCount = -1;
+ manager.memoriesForPropertyEditor.subscribe((memories) => {
+ callCount++;
+ if (callCount === 1) {
+ // First call, after initial save
+ expect(memories).to.have.lengthOf(2);
+ expect(memories).to.deep.equal(childMemories);
+ } else if (callCount === 2) {
+ // Second call, after delete
+ expect(memories).to.have.lengthOf(0);
+ expect(memories).to.deep.equal([]);
+ done();
+ }
+ });
+
+ manager.saveMemoriesForPropertyEditor(childMemories);
+ manager.deleteMemoriesForPropertyEditor();
+ });
+ });
+ });
+});
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts
new file mode 100644
index 0000000000..8d86e21b76
--- /dev/null
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property-editor/interaction-memory/property-editor-ui-interaction-memory.manager.ts
@@ -0,0 +1,93 @@
+import type { UmbPropertyEditorConfigCollection } from '../config/index.js';
+import { simpleHashCode, UmbArrayState } from '@umbraco-cms/backoffice/observable-api';
+import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api';
+import { UMB_INTERACTION_MEMORY_CONTEXT } from '@umbraco-cms/backoffice/interaction-memory';
+import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
+import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory';
+
+export interface UmbPropertyEditorUiInteractionMemoryManagerArgs {
+ memoryUniquePrefix: string;
+}
+
+export class UmbPropertyEditorUiInteractionMemoryManager extends UmbControllerBase {
+ #memories = new UmbArrayState([], (x) => x.unique);
+ memoriesForPropertyEditor = this.#memories.asObservable();
+
+ #interactionMemoryContext?: typeof UMB_INTERACTION_MEMORY_CONTEXT.TYPE;
+ #configHashCode?: number;
+ #memoryUniquePrefix: string;
+ #init?: Promise;
+
+ constructor(host: UmbControllerHost, args: UmbPropertyEditorUiInteractionMemoryManagerArgs) {
+ super(host);
+
+ this.#memoryUniquePrefix = args.memoryUniquePrefix;
+
+ this.#init = Promise.all([
+ this.consumeContext(UMB_INTERACTION_MEMORY_CONTEXT, (context) => {
+ this.#interactionMemoryContext = context;
+ }).asPromise(),
+ ]);
+ }
+
+ /**
+ * Sets the property editor config, used to create a unique hash for the interaction memory.
+ * @param {(UmbPropertyEditorConfigCollection | undefined)} config
+ * @memberof UmbPropertyEditorUiInteractionMemoryManager
+ */
+ setPropertyEditorConfig(config: UmbPropertyEditorConfigCollection | undefined) {
+ this.#setConfigHash(config);
+ this.#getInteractionMemory();
+ }
+
+ /**
+ * Creates or updates an interaction memory for this property editor based on the provided memories.
+ * @param {Array} memories - The memories to include for this property editor.
+ * @returns {Promise}
+ * @memberof UmbPropertyEditorUiInteractionMemoryManager
+ */
+ async saveMemoriesForPropertyEditor(memories: Array): Promise {
+ await this.#init;
+ const memoryUnique = this.#getInteractionMemoryUnique();
+ if (!this.#interactionMemoryContext) return;
+
+ const propertyEditorMemory: UmbInteractionMemoryModel = {
+ unique: memoryUnique,
+ memories,
+ };
+
+ this.#interactionMemoryContext.memory.setMemory(propertyEditorMemory);
+ this.#memories.setValue(memories);
+ }
+
+ /**
+ * Deletes the interaction memory for this property editor.
+ * @memberof UmbPropertyEditorUiInteractionMemoryManager
+ */
+ async deleteMemoriesForPropertyEditor(): Promise {
+ await this.#init;
+ const unique = this.#getInteractionMemoryUnique();
+ this.#interactionMemoryContext?.memory.deleteMemory(unique);
+ this.#memories.setValue([]);
+ }
+
+ #getInteractionMemoryUnique() {
+ return `${this.#memoryUniquePrefix}PropertyEditorUi${this.#configHashCode ? '-' + this.#configHashCode : ''}`;
+ }
+
+ async #getInteractionMemory() {
+ await this.#init;
+ const memoryUnique = this.#getInteractionMemoryUnique();
+ if (!memoryUnique) return;
+ if (!this.#interactionMemoryContext) return;
+
+ const memory = this.#interactionMemoryContext.memory.getMemory(memoryUnique);
+ this.#memories.setValue(memory?.memories ?? []);
+ }
+
+ #setConfigHash(config: UmbPropertyEditorConfigCollection | undefined) {
+ const configString = config ? JSON.stringify(config.toObject()) : '';
+ const hashCode = simpleHashCode(configString);
+ this.#configHashCode = hashCode;
+ }
+}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts
index 78f037a246..d2cc504675 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.context.ts
@@ -107,9 +107,9 @@ export class UmbPropertyContext extends UmbContextBase {
constructor(host: UmbControllerHost) {
super(host, UMB_PROPERTY_CONTEXT);
- this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (variantContext) => {
- this.#datasetContext = variantContext;
- this.setVariantId(variantContext?.getVariantId?.());
+ this.consumeContext(UMB_PROPERTY_DATASET_CONTEXT, (context) => {
+ this.#datasetContext = context;
+ this.setVariantId(context?.getVariantId?.());
this._generateVariantDifferenceString();
this._observeProperty();
});
@@ -179,13 +179,19 @@ export class UmbPropertyContext extends UmbContextBase {
let shareMessage;
if (contextVariantId && propertyVariantId) {
- if (contextVariantId.segment !== propertyVariantId.segment) {
- // TODO: Translate this, ideally the actual culture is mentioned in the message:
- shareMessage = 'Shared across culture';
+ // If on a Segment viewing a segment-shared property:
+ // TODO: Do not use the content variant id, but know wether the property is configured to vary by segment.
+ // Because we can view a default segment, then we do not know if the property is shared or not. [NL]
+ if (contextVariantId.segment !== null && propertyVariantId.segment === null) {
+ if (contextVariantId.culture !== null) {
+ shareMessage = 'content_sharedAcrossCultures';
+ } else {
+ shareMessage = 'content_sharedAcrossSegments';
+ }
}
- if (contextVariantId.culture !== propertyVariantId.culture) {
- // TODO: Translate this:
- shareMessage = 'Shared';
+ // TODO: Do not use the content variant id, but know wether the property is configured to vary by culture. (this is first a problem when we introduce the invariant-variant)
+ if (contextVariantId.culture !== null && propertyVariantId.culture === null) {
+ shareMessage = 'content_shared';
}
}
this.#variantDifference.setValue(shareMessage);
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts
index a20fe487d4..f13b4679e4 100644
--- a/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts
+++ b/src/Umbraco.Web.UI.Client/src/packages/core/property/components/property/property.element.ts
@@ -165,7 +165,7 @@ export class UmbPropertyElement extends UmbLitElement {
}
@state()
- private _variantDifference?: string;
+ private _variantDifferenceTerm?: string;
@state()
private _element?: ManifestPropertyEditorUi['ELEMENT_TYPE'];
@@ -238,7 +238,7 @@ export class UmbPropertyElement extends UmbLitElement {
this.observe(
this.#propertyContext.variantDifference,
(variantDifference) => {
- this._variantDifference = variantDifference;
+ this._variantDifferenceTerm = variantDifference;
},
null,
);
@@ -418,9 +418,9 @@ export class UmbPropertyElement extends UmbLitElement {
?mandatory=${this._mandatory}
?invalid=${this._invalid}>
${this.#renderPropertyActionMenu()}
- ${this._variantDifference
+ ${this._variantDifferenceTerm
? html`