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. ![Create a pull request](img/createpullrequest.png) -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`
- ${this._variantDifference} + ${this.localize.term(this._variantDifferenceTerm)}
` : ''} ${this.#renderPropertyEditor()} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts index bc3536a3e9..6e3121f680 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/repository-items.manager.ts @@ -1,4 +1,5 @@ import type { UmbItemRepository } from './item/index.js'; +import type { UmbRepositoryItemsStatus } from './types.js'; import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; @@ -10,14 +11,6 @@ import { UmbEntityUpdatedEvent } from '@umbraco-cms/backoffice/entity-action'; const ObserveRepositoryAlias = Symbol(); -interface UmbRepositoryItemsStatus { - state: { - type: 'success' | 'error' | 'loading'; - error?: string; - }; - unique: string; -} - export class UmbRepositoryItemsManager extends UmbControllerBase { // repository?: UmbItemRepository; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts index 3980abcf10..83bafd3c7d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/types.ts @@ -17,6 +17,14 @@ export interface UmbRepositoryResponse extends UmbDataSourceResponse {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface UmbRepositoryErrorResponse extends UmbDataSourceErrorResponse {} +export interface UmbRepositoryItemsStatus { + state: { + type: 'success' | 'error' | 'loading'; + error?: string; + }; + unique: string; +} + /** * Interface for a repository that can return a paged model. * @template T - The type of items in the paged model. diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts index 685fff6ed0..a9bcb5a78b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section-main-views/section-main-views.element.ts @@ -111,6 +111,10 @@ export class UmbSectionMainViewElement extends UmbLitElement { : nothing; } + #getDashboardName(dashboard: ManifestDashboard) { + return dashboard.meta?.label ? this.localize.string(dashboard.meta.label) : (dashboard.name ?? dashboard.alias); + } + #renderDashboards() { // Only show dashboards if there are more than one dashboard or if there are both dashboards and views return (this._dashboards.length > 0 && this._views.length > 0) || this._dashboards.length > 1 @@ -124,10 +128,10 @@ export class UmbSectionMainViewElement extends UmbLitElement { return html` + label="${this.#getDashboardName(dashboard)}" + ?active="${isActive}" + >${this.#getDashboardName(dashboard)} `; })}
diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts index 510697e146..483fecd772 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/section/section.context.ts @@ -1,10 +1,11 @@ import type { ManifestSection } from './extensions/section.extension.js'; import { UMB_SECTION_CONTEXT } from './section.context.token.js'; -import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbExtensionsApiInitializer } from '@umbraco-cms/backoffice/extension-api'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbStringState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbSectionContext extends UmbContextBase { #manifestAlias = new UmbStringState(undefined); @@ -14,17 +15,21 @@ export class UmbSectionContext extends UmbContextBase { public readonly pathname = this.#manifestPathname.asObservable(); public readonly label = this.#manifestLabel.asObservable(); + #viewContext = new UmbViewContext(this, null); #sectionContextExtensionController?: UmbExtensionsApiInitializer; constructor(host: UmbControllerHost) { super(host, UMB_SECTION_CONTEXT); + this.#createSectionContextExtensions(); } public setManifest(manifest?: ManifestSection) { this.#manifestAlias.setValue(manifest?.alias); this.#manifestPathname.setValue(manifest?.meta?.pathname); - this.#manifestLabel.setValue(manifest ? manifest.meta?.label || manifest.name : undefined); + const sectionLabel = manifest ? manifest.meta?.label || manifest.name : undefined; + this.#manifestLabel.setValue(sectionLabel); + this.#viewContext.setTitle(sectionLabel); } getPathname() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts new file mode 100644 index 0000000000..54d4d1fd8a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/index.ts @@ -0,0 +1,3 @@ +export * from './shortcut.context-token.js'; +export * from './shortcut.context.js'; +export * from './shortcut.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts new file mode 100644 index 0000000000..c682a7d1b4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context-token.ts @@ -0,0 +1,4 @@ +import type { UmbShortcutController } from './shortcut.controller.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_SHORTCUT_CONTEXT = new UmbContextToken('UmbShortcutContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts new file mode 100644 index 0000000000..fd3144d627 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.context.ts @@ -0,0 +1,10 @@ +import { UMB_SHORTCUT_CONTEXT } from './shortcut.context-token.js'; +import { UmbShortcutController } from './shortcut.controller.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +export class UmbShortcutContext extends UmbShortcutController { + constructor(host: UmbControllerHost) { + super(host); + this.provideContext(UMB_SHORTCUT_CONTEXT, this as unknown as UmbShortcutContext); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts new file mode 100644 index 0000000000..8e79808ae0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/context/shortcut.controller.ts @@ -0,0 +1,189 @@ +import type { UmbShortcut } from '../types.js'; +import { UMB_SHORTCUT_CONTEXT } from './shortcut.context-token.js'; +import { UmbArrayState } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbPartialSome } from '@umbraco-cms/backoffice/utils'; + +type IncomingShortcutType = UmbPartialSome; + +const IsMac = navigator.userAgent ? /Mac/i.test(navigator.userAgent) : navigator.platform.toUpperCase().includes('MAC'); + +export class UmbShortcutController extends UmbControllerBase { + // + #inUnprovidingState = false; + + #parent?: UmbShortcutController; + + readonly #shortcuts = new UmbArrayState([], (x) => x.unique); + public readonly all = this.#shortcuts.asObservable(); + + constructor(host: UmbControllerHost) { + super(host); + + this.#shortcuts.sortBy((a, b) => (b.weight || 0) - (a.weight || 0)); + } + + #providerCtrl?: UmbContextProviderController; + #currentProvideHost?: UmbClassInterface; + /** + * Provide this validation context to a specific controller host. + * This can be used to Host a validation context in a Workspace, but provide it on a certain scope, like a specific Workspace View. + * @param {UmbClassInterface} controllerHost - The controller host to provide this validation context to. + */ + provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_SHORTCUT_CONTEXT, this as any); + } + + unprovide(): void { + if (this.#providerCtrl) { + // We need to set this in Unprovide state, so this context can be provided again later. + this.#inUnprovidingState = true; + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + this.#inUnprovidingState = false; + this.#currentProvideHost = undefined; + } + } + + inherit(): void { + this.consumeContext(UMB_SHORTCUT_CONTEXT, (parent) => { + this.inheritFrom(parent); + }).skipHost(); + // Notice skipHost ^^, this is because we do not want it to consume it self, as this would be a match for this consumption, instead we will look at the parent and above. [NL] + } + + inheritFrom(parent: UmbShortcutController | undefined): void { + if (this.#parent === parent) return; + this.#parent = parent; + } + + initiateChange() { + this.#shortcuts.mute(); + } + finishChange() { + this.#shortcuts.unmute(); + } + + /** + * Add a new hint + * @param {IncomingShortcutType} shortcut - The hint to add + * @returns {UmbShortcut['unique']} Unique value of the hint + */ + addOne(shortcut: IncomingShortcutType): string | symbol { + const newShortcut = { ...shortcut } as unknown as UmbShortcut; + newShortcut.unique ??= Symbol(); + newShortcut.weight ??= 0; + newShortcut.modifier ??= false; + newShortcut.shift ??= false; + newShortcut.alt ??= false; + this.#shortcuts.appendOne(newShortcut); + return shortcut.unique!; + } + + /** + * Add multiple rules + * @param {IncomingShortcutType[]} shortcuts - Array of hints to add + */ + add(shortcuts: IncomingShortcutType[]) { + this.#shortcuts.mute(); + shortcuts.forEach((hint) => this.addOne(hint)); + this.#shortcuts.unmute(); + } + + /** + * Remove a hint + * @param {UmbShortcut['unique']} unique Unique value of the hint to remove + */ + removeOne(unique: UmbShortcut['unique']) { + this.#shortcuts.removeOne(unique); + } + + /** + * Remove multiple hints + * @param {UmbShortcut['unique'][]} uniques Array of unique values to remove + */ + remove(uniques: UmbShortcut['unique'][]) { + this.#shortcuts.remove(uniques); + } + + /** + * Check if a hint exists + * @param {UmbShortcut['unique']} unique Unique value of the hint to check + * @returns {boolean} True if the hint exists, false otherwise + */ + has(unique: UmbShortcut['unique']): boolean { + return this.#shortcuts.getHasOne(unique); + } + + /** + * Get all hints + * @returns {UmbShortcut[]} Array of hints + */ + getAll(): UmbShortcut[] { + return this.#shortcuts.getValue(); + } + + /** + * Get all hints + * @param key + * @param modifier + * @param shift + * @param alt + * @returns {UmbShortcut[]} Array of hints + */ + findShortcut(key: string, modifier: boolean, shift: boolean = false, alt: boolean = false): UmbShortcut | undefined { + const shortcuts = this.#shortcuts.getValue(); + for (const s of shortcuts) { + if (s.key.toLowerCase() === key.toLowerCase() && s.modifier === modifier && s.shift === shift && s.alt === alt) { + return s; + } + } + + return undefined; + } + + /** + * Clear all hints + */ + clear(): void { + this.#shortcuts.setValue([]); + } + + activate() { + window.addEventListener('keydown', this.#onKeyDown); + } + + deactivate() { + window.removeEventListener('keydown', this.#onKeyDown); + } + + #onKeyDown = (e: KeyboardEvent) => { + const keyDown = e.key.toLowerCase(); + const modifierDown = IsMac ? e.metaKey : e.ctrlKey; + + const shortcut = this.findShortcut(keyDown, modifierDown, e.shiftKey, e.altKey); + if (shortcut) { + e.preventDefault(); + shortcut.action(); + } + }; + + override destroy(): void { + super.destroy(); + if (this.#inUnprovidingState === true) { + // TODO: What is it i'm doing here, check if it actually makes sense, if so add a comment on why [NL] + return; + } + this.unprovide(); + this.#parent = undefined; + + this.#shortcuts.destroy(); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts new file mode 100644 index 0000000000..66e7bbbc85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/index.ts @@ -0,0 +1,2 @@ +export * from './context/index.js'; +export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts new file mode 100644 index 0000000000..a645cdb6a7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/shortcut/types.ts @@ -0,0 +1,12 @@ +export interface UmbShortcut { + unique: string | symbol; + key: string; + modifier: boolean; + shift: boolean; + alt: boolean; + label?: string; + weight?: number; + action: () => void | Promise; + // TODO: Consider implementing a global option, to make a shortcut be available despite children setting up their own inheritance scopes. [NL] + // TODO: Addition thought, also a bit dangerous cause how do you know the interest of the children. [NL] +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts index ea43233236..1c237cec9c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/default/default-tree.context.ts @@ -155,7 +155,6 @@ export class UmbDefaultTreeContext< #debouncedLoadTree(reload = false) { const hasStartNode = this.getStartNode(); const hideTreeRoot = this.getHideTreeRoot(); - if (hasStartNode || hideTreeRoot) { if (reload) { this.#treeItemChildrenManager.reloadChildren(); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts new file mode 100644 index 0000000000..7d4d3f7070 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker-expansion.manager.ts @@ -0,0 +1,94 @@ +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbEntityExpansionManager } from '@umbraco-cms/backoffice/utils'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbEntityExpansionModel } from '@umbraco-cms/backoffice/utils'; +import type { + UmbInteractionMemoryManager, + UmbInteractionMemoryModel, +} from '@umbraco-cms/backoffice/interaction-memory'; + +export interface UmbTreeItemPickerExpansionManagerArgs { + interactionMemoryManager?: UmbInteractionMemoryManager; +} + +export class UmbTreeItemPickerExpansionManager extends UmbControllerBase { + #manager = new UmbEntityExpansionManager(this); + public readonly expansion = this.#manager.expansion; + + #interactionMemoryManager?: UmbInteractionMemoryManager; + #interactionMemoryUnique: string = 'UmbTreeItemPickerExpansion'; + #muteMemoryObservation = false; + + constructor(host: UmbControllerHost, args?: UmbTreeItemPickerExpansionManagerArgs) { + super(host); + this.#interactionMemoryManager = args?.interactionMemoryManager; + + if (this.#interactionMemoryManager) { + this.#observeInteractionMemory(); + } + } + + /** + * Sets the full expansion state + * @param {UmbEntityExpansionModel} expansion - The full expansion state to set + * @memberof UmbTreeItemPickerExpansionManager + */ + setExpansion(expansion: UmbEntityExpansionModel): void { + this.#manager.setExpansion(expansion); + + // Store the latest expansion state in interaction memory + if (expansion.length > 0) { + this.#setExpansionMemory(); + } else { + this.#removeExpansionMemory(); + } + } + + /** + * Gets the current expansion state + * @returns {UmbEntityExpansionModel} The full expansion state + * @memberof UmbTreeItemPickerExpansionManager + */ + getExpansion(): UmbEntityExpansionModel { + return this.#manager.getExpansion(); + } + + #observeInteractionMemory() { + this.observe(this.#interactionMemoryManager?.memory(this.#interactionMemoryUnique), (memory) => { + if (this.#muteMemoryObservation) return; + + if (memory) { + this.#applyExpansionInteractionMemory(memory); + } + }); + } + + #setExpansionMemory() { + if (!this.#interactionMemoryManager) return; + + // Add a memory entry with the latest expansion state + const memory: UmbInteractionMemoryModel = { + unique: this.#interactionMemoryUnique, + value: { + expansion: this.getExpansion(), + }, + }; + + this.#muteMemoryObservation = true; + this.#interactionMemoryManager?.setMemory(memory); + this.#muteMemoryObservation = false; + } + + #removeExpansionMemory() { + if (!this.#interactionMemoryManager) return; + this.#interactionMemoryManager.deleteMemory(this.#interactionMemoryUnique); + } + + #applyExpansionInteractionMemory(memory: UmbInteractionMemoryModel) { + const memoryExpansion = memory?.value?.expansion as UmbEntityExpansionModel | undefined; + + if (memoryExpansion) { + this.#manager.setExpansion(memoryExpansion); + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts index 99f163c25c..bd30dcf638 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item-picker/tree-item-picker.context.ts @@ -1,10 +1,10 @@ +import { UmbTreeItemPickerExpansionManager } from './tree-item-picker-expansion.manager.js'; import { UmbPickerContext } from '@umbraco-cms/backoffice/picker'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; export class UmbTreeItemPickerContext extends UmbPickerContext { - constructor(host: UmbControllerHost) { - super(host); - } + public readonly expansion = new UmbTreeItemPickerExpansionManager(this, { + interactionMemoryManager: this.interactionMemory, + }); } export { UmbTreeItemPickerContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts index 580c47e563..6a69dd17f8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-item/tree-item-base/tree-item-element-base.ts @@ -165,7 +165,7 @@ export abstract class UmbTreeItemElementBase< .loading=${this._isLoading} .hasChildren=${this._hasChildren} .showChildren=${this._isOpen} - .caretLabel=${this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + this._label} + .caretLabel=${this._isOpen ? this.localize.term('visuallyHiddenTexts_collapseChildItems') + ' ' + this._label: this.localize.term('visuallyHiddenTexts_expandChildItems') + ' ' + this._label} label=${ifDefined(this._label)} href="${ifDefined(this._isSelectableContext ? undefined : this._href)}"> ${this.#renderLoadPrevButton()} ${this.renderIconContainer()} ${this.renderLabel()} ${this.#renderActions()} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts index bbd95cd19c..fb71dd349d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/tree/tree-picker-modal/tree-picker-modal.element.ts @@ -1,15 +1,18 @@ -import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration } from '../types.js'; import { UmbTreeItemPickerContext } from '../tree-item-picker/index.js'; +import type { UmbTreeElement } from '../tree.element.js'; +import type { UmbTreeItemModelBase, UmbTreeSelectionConfiguration } from '../types.js'; import type { UmbTreePickerModalData, UmbTreePickerModalValue } from './tree-picker-modal.token.js'; -import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; -import { html, customElement, state, ifDefined, nothing } from '@umbraco-cms/backoffice/external/lit'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { customElement, html, ifDefined, nothing, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbDeselectedEvent, UmbSelectedEvent } from '@umbraco-cms/backoffice/event'; +import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; +import { UmbPickerModalBaseElement } from '@umbraco-cms/backoffice/picker'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import type { PropertyValueMap } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbEntityExpansionModel, UmbExpansionChangeEvent } from '@umbraco-cms/backoffice/utils'; @customElement('umb-tree-picker-modal') -export class UmbTreePickerModalElement extends UmbModalBaseElement< +export class UmbTreePickerModalElement extends UmbPickerModalBaseElement< + TreeItemType, UmbTreePickerModalData, UmbTreePickerModalValue > { @@ -32,16 +35,20 @@ export class UmbTreePickerModalElement { + this._pickerContext.selection.setSelectable(true); + this.observe(this._pickerContext.selection.hasSelection, (hasSelection) => { this._hasSelection = hasSelection; }); this.#observePickerSelection(); this.#observeSearch(); + this.#observeExpansion(); } override connectedCallback(): void { @@ -54,15 +61,15 @@ export class UmbTreePickerModalElement { this.updateValue({ selection }); this.requestUpdate(); @@ -93,7 +100,7 @@ export class UmbTreePickerModalElement { this._searchQuery = query?.query; }, @@ -101,16 +108,26 @@ export class UmbTreePickerModalElement { + this._treeExpansion = value; + }, + 'umbTreeItemPickerExpansionObserver', + ); + } + // Tree Selection #onTreeItemSelected(event: UmbSelectedEvent) { event.stopPropagation(); - this.#pickerContext.selection.select(event.unique); + this._pickerContext.selection.select(event.unique); this.modalContext?.dispatchEvent(new UmbSelectedEvent(event.unique)); } #onTreeItemDeselected(event: UmbDeselectedEvent) { event.stopPropagation(); - this.#pickerContext.selection.deselect(event.unique); + this._pickerContext.selection.deselect(event.unique); this.modalContext?.dispatchEvent(new UmbDeselectedEvent(event.unique)); } @@ -149,6 +166,12 @@ export class UmbTreePickerModalElement @@ -181,9 +204,11 @@ export class UmbTreePickerModalElement + @deselected=${this.#onTreeItemDeselected} + @expansion-change=${this.#onTreeItemExpansionChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts index 1971488ed3..2e5596dec7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/context/server-model-validator.context.ts @@ -94,6 +94,13 @@ export class UmbServerModelValidatorContext extends UmbContextBase implements Um path = '$.' + path; } } + + // Correct the end of the path to ensure the `.value` properties start with a lowercase v. (notice it the server in some cases returns it with an upperCase V): [NL] + // This is surely a Hack but the Backend seem not to be able to solve this. [NL] + if (path.endsWith('.Value')) { + path = path.slice(0, -6) + '.value'; + } + newBodies.forEach((body: string) => messages.push({ type: 'server', key: UmbId.new(), path, body })); //this.#context!.messages.addMessages('server', path, errorBody.errors[path]); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts index 357b47a0ca..9bf4434665 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/controllers/validation-path-translation/validation-property-path-translation.controller.test.ts @@ -62,11 +62,11 @@ describe('UmbValidationPropertyPathTranslationController', () => { }); it('returns Value', async () => { - const paths: Array = ['$[0].Value']; + const paths: Array = ['$[0].value']; const result = await ctrl.translateProperties(paths, propertiesData, UmbDataPathPropertyValueQuery); - expect(result[0]).to.be.equal(`$[${UmbDataPathPropertyValueQuery(propertiesData[0])}].Value`); + expect(result[0]).to.be.equal(`$[${UmbDataPathPropertyValueQuery(propertiesData[0])}].value`); }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts index 51e925802a..c2259ca66b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/validation/mixins/form-control.mixin.ts @@ -263,6 +263,14 @@ export function UmbFormControlMixin< * @returns {void} */ protected addFormControlElement(element: UmbNativeFormControlElement) { + if (!element) { + throw new Error('Element is null or undefined'); + } + if (!element.validity) { + console.log(element); + throw new Error('Element is not a Form Control'); + } + if (this.#formCtrlElements.includes(element)) return; this.#formCtrlElements.push(element); element.addEventListener(UmbValidationInvalidEvent.TYPE, this.#runValidatorsCallback); element.addEventListener(UmbValidationValidEvent.TYPE, this.#runValidatorsCallback); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts index 3fff04337f..49147ec12b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/index.ts @@ -1,2 +1,3 @@ +export * from './view.controller.js'; export * from './view.context.js'; export * from './view.context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts index 6930c4fa94..d2833afe12 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.context.ts @@ -1,80 +1,9 @@ -import { UMB_VIEW_CONTEXT } from './view.context-token.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbControllerBase, type UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; -import { UmbClassState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; -import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; -import { UmbHintController, type UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import { UmbViewController } from './view.controller.js'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; -/** - * - * TODO: - * Include Shortcuts - * - * Browser Title? - * - */ -export class UmbViewContext extends UmbControllerBase { - // - #providerCtrl: any; - #currentProvideHost?: UmbClassInterface; - - public readonly viewAlias: string; - #variantId = new UmbClassState(undefined); - protected readonly variantId = this.#variantId.asObservable(); - - public hints; - - readonly firstHintOfVariant; - - constructor(host: UmbControllerHost, viewAlias: string) { - super(host); - this.viewAlias = viewAlias; - this.hints = new UmbHintController(this, { - viewAlias: viewAlias, - }); - this.firstHintOfVariant = mergeObservables([this.variantId, this.hints.hints], ([variantId, hints]) => { - // Notice, because we in UI have invariant fields on Variants, then we will accept invariant hints on variants. - if (variantId) { - return hints.find((hint) => - hint.variantId ? hint.variantId.equal(variantId!) || hint.variantId.isInvariant() : true, - ); - } else { - return hints[0]; - } - }); - } - - setVariantId(variantId: UmbVariantId | undefined): void { - this.#variantId.setValue(variantId); - this.hints.updateScaffold({ variantId: variantId }); - } - - provideAt(controllerHost: UmbClassInterface): void { - if (this.#currentProvideHost === controllerHost) return; - - this.unprovide(); - - this.#currentProvideHost = controllerHost; - this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); - this.hints.provideAt(controllerHost); - } - - unprovide(): void { - if (this.#providerCtrl) { - this.#providerCtrl.destroy(); - this.#providerCtrl = undefined; - } - this.hints.unprovide(); - } - - inheritFrom(context?: UmbViewContext): void { - this.observe( - context?.variantId, - (variantId) => { - this.setVariantId(variantId); - }, - 'observeParentVariantId', - ); - this.hints.inheritFrom(context?.hints); +export class UmbViewContext extends UmbViewController { + constructor(host: UmbClassInterface, viewAlias: string | null) { + super(host, viewAlias); + this.provideAt(host); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts new file mode 100644 index 0000000000..269ac2393c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/view/context/view.controller.ts @@ -0,0 +1,359 @@ +import { UmbShortcutController } from '../../shortcut/context/shortcut.controller.js'; +import { UMB_VIEW_CONTEXT } from './view.context-token.js'; +import { UmbClassState, UmbStringState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbHintController } from '@umbraco-cms/backoffice/hint'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; +import type { UmbContextConsumerController, UmbContextProviderController } from '@umbraco-cms/backoffice/context-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; +import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; + +/** + * + * The View Context handles the aspects of three Features: + * Browser Titles — Provide a title for this view and it will be set or joint with parent views depending on the inheritance setting. + * Hints — Holds Hints for this view, depending on the inheritance setting it will propagate the hints to be displayed at parent views. + * Shortcuts — Not implemented yet. + * + */ +export class UmbViewController extends UmbControllerBase { + // + static #ActiveView?: UmbViewController; + // + #attached = false; + #providerCtrl?: UmbContextProviderController; + #consumeParentCtrl?: UmbContextConsumerController; + #currentProvideHost?: UmbClassInterface; + #localize = new UmbLocalizationController(this); + + // State used to know if the context can be auto activated when attached. + #autoActivate = true; + #active = false; + get isActive() { + return this.#active; + } + #setActive() { + this.#active = true; + if (this.#inherit) { + // Secure the parent in the inheritance chain is active. + this.#parentView?._internal_activate(); + } else { + // This is for a single, or top level of the inheritance chain, so we can disable the previous active view. + if (UmbViewController.#ActiveView && UmbViewController.#ActiveView !== this) { + UmbViewController.#ActiveView._internal_deactivate(); + UmbViewController.#ActiveView = undefined; + } + UmbViewController.#ActiveView = this; + } + } + #removeActive() { + this.#active = false; + if (!this.#inherit) { + if (UmbViewController.#ActiveView === this) { + UmbViewController.#ActiveView = undefined; + } + } + } + + #inherit = false; + #explicitInheritance?: boolean; + #parentView?: UmbViewController; + #title?: string; + #computedTitle = new UmbStringState(undefined); + readonly computedTitle = this.#computedTitle.asObservable(); + + public readonly viewAlias: string | null; + + #variantId = new UmbClassState(undefined); + protected readonly variantId = this.#variantId.asObservable(); + + public readonly hints; + + public readonly shortcuts = new UmbShortcutController(this); + + public readonly firstHintOfVariant; + + constructor(host: UmbControllerHost, viewAlias: string | null) { + super(host); + this.viewAlias = viewAlias; + this.hints = new UmbHintController(this, { + viewAlias: viewAlias, + }); + this.firstHintOfVariant = mergeObservables([this.variantId, this.hints.hints], ([variantId, hints]) => { + // Notice, because we in UI have invariant fields on Variants, then we will accept invariant hints on variants. + if (variantId) { + return hints.find((hint) => + hint.variantId ? hint.variantId.equal(variantId!) || hint.variantId.isInvariant() : true, + ); + } else { + return hints[0]; + } + }); + + this.#consumeParentCtrl = this.consumeContext(UMB_VIEW_CONTEXT, (parentView) => { + // In case of explicit inheritance we do not want to overview the parent view. + if (this.#explicitInheritance) return; + if (parentView) { + this.#setParentView(parentView); + } + // only activate if we had an incoming parentView, cause if not we are in a disassembling state. [NL] + if (parentView && this.#attached && this.#autoActivate) { + this._internal_requestActivate(); + } + }).skipHost(); + } + + #setParentView(view: UmbViewController | undefined) { + if (this.#parentView === view) return; + this.#parentView = view; + + if (this.#inherit) { + this.#inheritFromParent(); + } + } + + public setVariantId(variantId: UmbVariantId | undefined): void { + this.#variantId.setValue(variantId); + this.hints.updateScaffold({ variantId: variantId }); + } + + public setTitle(title: string | undefined): void { + if (this.#title === title) return; + this.#title = title; + this.#computeTitle(); + this.#updateTitle(); + } + + public provideAt(controllerHost: UmbClassInterface): void { + if (this.#currentProvideHost === controllerHost) return; + + this.unprovide(); + + this.#autoActivate = true; + this.#currentProvideHost = controllerHost; + this.#providerCtrl = controllerHost.provideContext(UMB_VIEW_CONTEXT, this); + this.hints.provideAt(controllerHost); + this.shortcuts.provideAt(controllerHost); + + if (this.#attached) { + this._internal_requestActivate(); + } + } + + public unprovide(): void { + if (this.#providerCtrl) { + this.#providerCtrl.destroy(); + this.#providerCtrl = undefined; + } + this.hints.unprovide(); + this.shortcuts.unprovide(); + + this._internal_deactivate(); + this.#requestActivateParent(); + } + + override hostConnected(): void { + const wasActive = this.isActive; + const wasAttached = this.#attached; + this.#attached = true; + super.hostConnected(); + if (!wasAttached) { + this.#parentView?._internal_addChild(this); + } + // Check that we have a providerController, otherwise this is not provided. [NL] + if (this.#autoActivate && !wasActive) { + this._internal_requestActivate(); + } + } + + override hostDisconnected(): void { + const wasAttached = this.#attached; + this.#attached = false; + if (wasAttached) { + this.#parentView?._internal_removeChild(this); + } + + this._internal_deactivate(); + super.hostDisconnected(); + this.#autoActivate = true; + this.#requestActivateParent(); + } + + public isInheriting() { + return this.#inherit; + } + + public inherit() { + this.#inherit = true; + } + + public inheritFrom(context?: UmbViewController): void { + this.#inherit = true; + this.#explicitInheritance = true; + this.#consumeParentCtrl?.destroy(); + this.#consumeParentCtrl = undefined; + this.#setParentView(context); + } + + #inheritFromParent(): void { + this.observe( + this.#parentView?.variantId, + (variantId) => { + this.setVariantId(variantId); + }, + 'observeParentVariantId', + ); + this.observe( + this.#parentView?.computedTitle, + () => { + this.#computeTitle(); + // Check for parent view as it is undefined in a disassembling state and we do not want to update the title in that situation. [NL] + if (this.#providerCtrl && this.#parentView && this.isActive) { + this.#updateTitle(); + } + }, + 'observeParentTitle', + ); + this.hints.inheritFrom(this.#parentView?.hints); + } + + #requestActivateParent() { + if (!this.#inherit) { + if (this.#parentView) { + this.#parentView._internal_requestActivate(); + } + } + } + + /** + * @internal + * Notify that a view context has been activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_requestActivate(): boolean { + if (!this.#providerCtrl) { + // If we are not provided we should not be activated. [NL] + return false; + } + // TODO: Check this one: We do not want a parent to auto activate if a child is having the activation. [NL], well maybe it not that bad because of the asking of the children... + this.#autoActivate = true; + if (this.isActive) { + return true; + } + // If not attached then propagate the activation to the parent. [NL] + if (this.#attached === false) { + if (!this.#parentView) { + throw new Error('Cannot activate a view that is not attached to the DOM.'); + } + } else { + // Check if any of the children likes to be activated instead: + // A reverse loop ensures latest added child gets first chance to activate. This may matter in some future issue-scenario, I will say it could be that it is not the right way to determine if multiple children wants to be active. [NL] + let i = this.#children.length; + while (i--) { + const child = this.#children[i]; + if (child._internal_requestActivate()) { + // If we have an active child we should not update the title. + return true; + } + } + // if not then check your self: + if (this.#autoActivate && this.#attached) { + this._internal_activate(); + return true; + } + } + return false; + } + + /** + * @internal + * Notify that a view context has been activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_activate() { + if (this.#attached) { + this.#autoActivate = true; + this.#setActive(); + this.#updateTitle(); + this.shortcuts.activate(); + } + } + + /** + * @internal + * Deactivate the view context. + * We cannot conclude that this means the parent should be activated, it can be because of a child being activated. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_deactivate() { + if (!this.isActive) return; + this.#autoActivate = false; + + // Deactive children: + this.#children.forEach((child) => { + if (child.isInheriting()) { + child._internal_deactivate(); + } + }); + this.shortcuts.deactivate(); + this.#removeActive(); + } + + #updateTitle() { + if (!this.#active || this.#hasActiveChildren()) { + return; + } + const localTitle = this.getComputedTitle(); + document.title = (localTitle ? localTitle + ' | ' : '') + 'Umbraco'; + } + + #computeTitle() { + const titles = []; + if (this.#inherit && this.#parentView) { + titles.push(this.#parentView.getComputedTitle()); + } + if (this.#title) { + titles.push(this.#localize.string(this.#title)); + } + this.#computedTitle.setValue(titles.length > 0 ? titles.join(' | ') : undefined); + } + + public getComputedTitle(): string | undefined { + return this.#computedTitle.getValue(); + } + + #children: UmbViewController[] = []; + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_addChild(child: UmbViewController) { + this.#children.push(child); + if (this.isActive) { + child._internal_activate(); + } + } + // eslint-disable-next-line @typescript-eslint/naming-convention + public _internal_removeChild(child: UmbViewController) { + const index = this.#children.indexOf(child); + if (index !== -1) { + this.#children.splice(index, 1); + } + // update title? + if (this.#active && !this.#hasActiveChildren()) { + this.#updateTitle(); + } + } + #hasActiveChildren() { + return this.#children.some((child) => child.isActive); + } + + override destroy(): void { + this.#inherit = false; + this.#removeActive(); + this.#autoActivate = false; + (this as any).provideAt = undefined; + this.unprovide(); + super.destroy(); + this.#consumeParentCtrl = undefined; + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts index 6df35d3cd1..0cc97b1c69 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/vite.config.ts @@ -26,15 +26,16 @@ export default defineConfig({ 'entity-action/index': './entity-action/index.ts', 'entity-bulk-action/index': './entity-bulk-action/index.ts', 'entity-create-option-action/index': './entity-create-option-action/index.ts', - 'entity/index': './entity/index.ts', 'entity-item/index': './entity-item/index.ts', + 'entity/index': './entity/index.ts', 'entry-point': 'entry-point.ts', 'event/index': './event/index.ts', 'extension-registry/index': './extension-registry/index.ts', - 'http-client/index': './http-client/index.ts', 'hint/index': './hint/index.ts', + 'http-client/index': './http-client/index.ts', 'icon-registry/index': './icon-registry/index.ts', 'id/index': './id/index.ts', + 'interaction-memory/index': './interaction-memory/index.ts', 'lit-element/index': './lit-element/index.ts', 'localization/index': './localization/index.ts', 'menu/index': './menu/index.ts', @@ -52,8 +53,9 @@ export default defineConfig({ 'resources/index': './resources/index.ts', 'router/index': './router/index.ts', 'section/index': './section/index.ts', - 'server/index': './server/index.ts', 'server-file-system/index': './server-file-system/index.ts', + 'server/index': './server/index.ts', + 'shortcut/index': './shortcut/index.ts', 'sorter/index': './sorter/index.ts', 'store/index': './store/index.ts', 'style/index': './style/index.ts', diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts index e4b9905186..5cd20e0976 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-action/common/save/save.action.ts @@ -49,6 +49,6 @@ export class UmbSaveWorkspaceAction< override async execute() { await this._retrieveWorkspaceContext; - return await this._workspaceContext?.requestSave(); + await this._workspaceContext?.requestSave(); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts index 73ac95d563..6ddf055ad7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.context.ts @@ -1,14 +1,12 @@ import type { ManifestWorkspaceView } from '../../types.js'; import { UmbWorkspaceViewContext } from './workspace-view.context.js'; import { UMB_WORKSPACE_EDITOR_CONTEXT } from './workspace-editor.context-token.js'; -import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import { UmbBasicState, mergeObservables } from '@umbraco-cms/backoffice/observable-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbExtensionsManifestInitializer } from '@umbraco-cms/backoffice/extension-api'; -import { UmbHintController } from '@umbraco-cms/backoffice/hint'; +import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbDeepPartialObject } from '@umbraco-cms/backoffice/utils'; -import type { UmbVariantHint } from '@umbraco-cms/backoffice/hint'; import type { UmbVariantId } from '@umbraco-cms/backoffice/variant'; export class UmbWorkspaceEditorContext extends UmbContextBase { @@ -26,9 +24,13 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { let contexts = this.#contexts; // remove ones that are no longer contained in the workspaceViews (And thereby make the new array): - const contextsToKeep = contexts.filter( - (view) => !manifests.some((manifest) => manifest.alias === view.manifest.alias), - ); + const contextsToKeep = contexts.filter((view) => { + const keep = manifests.some((manifest) => manifest.alias === view.manifest.alias); + if (!keep) { + view.destroy(); + } + return keep; + }); const hasDiff = contextsToKeep.length !== manifests.length; if (hasDiff) { @@ -40,7 +42,8 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { .forEach((manifest) => { const context = new UmbWorkspaceViewContext(this, manifest); context.setVariantId(this.#variantId); - context.hints.inheritFrom(this.#hints); + context.setTitle(manifest.meta.label); + context.inherit(); contexts.push(context); }); } @@ -82,13 +85,10 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { #contexts = new Array(); #variantId?: UmbVariantId; - #hints = new UmbHintController(this, {}); constructor(host: UmbControllerHost) { super(host, UMB_WORKSPACE_EDITOR_CONTEXT); - this.#hints.inherit(); - this.#init = new UmbExtensionsManifestInitializer( this, umbExtensionsRegistry, @@ -102,7 +102,6 @@ export class UmbWorkspaceEditorContext extends UmbContextBase { setVariantId(variantId: UmbVariantId | undefined): void { this.#variantId = variantId; - this.#hints.updateScaffold({ variantId }); this.#contexts.forEach((view) => { view.hints.updateScaffold({ variantId }); }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts index 300a8fdc15..5f69e62840 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-editor.element.ts @@ -62,6 +62,9 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { public set overrides(value: Array> | undefined) { this.#navigationContext.setOverrides(value); } + public get overrides(): Array> | undefined { + return undefined; + } @state() private _workspaceViews: Array = []; @@ -110,6 +113,7 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { ); } + #currentProvidedView?: UmbWorkspaceViewContext; #createRoutes() { let newRoutes: UmbRoute[] = []; @@ -120,7 +124,11 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { path: UMB_WORKSPACE_VIEW_PATH_PATTERN.generateLocal({ viewPathname: manifest.meta.pathname }), component: () => createExtensionElement(manifest), setup: (component?: any) => { + if (this.#currentProvidedView !== context) { + this.#currentProvidedView?.unprovide(); + } if (component) { + this.#currentProvidedView = context; context.provideAt(component); component.manifest = manifest; } @@ -188,8 +196,8 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { data-mark="workspace:view-link:${manifest.alias}">
${hint && !active - ? html`${hint.text}${hint.text}` : nothing}
@@ -270,12 +278,9 @@ export class UmbWorkspaceEditorElement extends UmbLitElement { position: relative; } - uui-badge { - position: absolute; + umb-badge { font-size: var(--uui-type-small-size); - top: -0.5em; - right: auto; - left: calc(50% + 0.8em); + right: -1.5em; } umb-extension-slot[slot='actions'] { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts index eafe506578..bf84734cd8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-editor/workspace-view.context.ts @@ -1,13 +1,14 @@ import type { ManifestWorkspaceView } from '../../types.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { UmbClassInterface } from '@umbraco-cms/backoffice/class-api'; + export class UmbWorkspaceViewContext extends UmbViewContext { public readonly IS_WORKSPACE_VIEW_CONTEXT = true as const; // Note: manifest can change later, but because we currently only use the alias from it, it's not something we need to handle. [NL] public manifest: ManifestWorkspaceView; - constructor(host: UmbControllerHost, manifest: ManifestWorkspaceView) { + constructor(host: UmbClassInterface, manifest: ManifestWorkspaceView) { super(host, manifest.alias); this.manifest = manifest; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts index 06252f0472..7a9ba0caa6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-entity-action-menu/workspace-entity-action-menu.element.ts @@ -31,11 +31,10 @@ export class UmbWorkspaceEntityActionMenuElement extends UmbLitElement { if (!this._entityType) return nothing; if (this._unique === undefined) return nothing; - return html` - + return html` + `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts index 3e2ff19967..8c0b26eea3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/components/workspace-split-view/workspace-split-view-variant-selector.element.ts @@ -10,6 +10,8 @@ import { UUIInputEvent } from '@umbraco-cms/backoffice/external/uui'; import type { UmbEntityVariantModel, UmbEntityVariantOptionModel } from '@umbraco-cms/backoffice/variant'; import type { UUIInputElement, UUIPopoverContainerElement } from '@umbraco-cms/backoffice/external/uui'; import type { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UMB_HINT_CONTEXT } from '@umbraco-cms/backoffice/hint'; +import type { UmbHint, UmbVariantHint } from '@umbraco-cms/backoffice/hint'; @customElement('umb-workspace-split-view-variant-selector') export class UmbWorkspaceSplitViewVariantSelectorElement< @@ -96,8 +98,35 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< this.#observeDatasetContext(); this.#observeCurrentVariant(); }); + + this.consumeContext(UMB_HINT_CONTEXT, (context) => { + this.observe( + context?.descendingHints(), + (hints) => { + this._hintMap.clear(); + hints?.forEach((hint) => { + if (this.#isVariantHint(hint) && hint.variantId) { + // Add the hint if there is no existing hint for this variantId or if the existing hint has a lower weight + const existingHint = this._hintMap.get(hint.variantId.toString()); + if (!existingHint || existingHint.weight < hint.weight) { + this._hintMap.set(hint.variantId.toString(), hint); + } + } + }); + this.requestUpdate('_hintMap'); + }, + 'umbObserveHints', + ); + }); } + #isVariantHint(hint: UmbHint): hint is UmbVariantHint { + return hint && 'variantId' in hint; + } + + @state() + private _hintMap = new Map(); + async #observeVariants(workspaceContext?: UmbVariantDatasetWorkspaceContext) { this.observe( workspaceContext?.variantOptions, @@ -302,6 +331,16 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< override render() { if (!this._variantId) return nothing; + let firstHintOnInactiveVariant: UmbVariantHint | undefined; + + if (this._activeVariant) { + const hintsOrderedByWeight = Array.from(this._hintMap.values()).sort((a, b) => (b.weight || 0) - (a.weight || 0)); + firstHintOnInactiveVariant = hintsOrderedByWeight.find((hint) => { + if (!hint.variantId) return false; + return !hint.variantId.isInvariant() && this.#isVariantActive(hint.variantId) === false; + }); + } + return html` + ${!this._variantSelectorOpen ? this.#renderHintBadge(firstHintOnInactiveVariant) : nothing} ${this._activeVariants.length > 1 ? html` @@ -360,8 +400,11 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< const variantId = UmbVariantId.Create(variantOption); const notCreated = this.#isCreateMode(variantOption, variantId); const subVariantOptions = this.#getSegmentVariantOptionsForCulture(variantOption, variantId); + const hint = this._hintMap.get(variantId.toString()); + const active = this.#isVariantActive(variantId); + return html` -
+
${this._variesBySegment && this.#isCreated(variantOption) && subVariantOptions.length > 0 ? html`
${this.#renderExpandToggle(variantId)}
` : nothing} @@ -382,7 +425,7 @@ export class UmbWorkspaceSplitViewVariantSelectorElement<
${this.#getVariantSpecInfo(variantOption)}
- ${this.#renderSplitViewButton(variantOption)} + ${this.#renderHintBadge(!active ? hint : undefined)} ${this.#renderSplitViewButton(variantOption)}
${this.#isVariantExpanded(variantId) ? html` ${subVariantOptions.map((option) => this.#renderSegmentVariantOption(option))} ` @@ -390,6 +433,13 @@ export class UmbWorkspaceSplitViewVariantSelectorElement< `; } + #renderHintBadge(hint?: UmbVariantHint) { + if (!hint) return nothing; + return html` ${hint.text}`; + } + #isCreated(variantOption: VariantOptionModelType) { return ( variantOption.variant?.state && diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts index 215eecc03f..f98f542817 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/controllers/workspace-split-view-manager.controller.ts @@ -67,7 +67,8 @@ export class UmbWorkspaceSplitViewManager { .map((v) => UmbVariantId.Create(v).toString()) .join(UBM_VARIANT_DELIMITER); - history.pushState(null, '', `${workspaceRoute}/${variantPart}`); + const additionalPathname = this.#getAdditionalPathname(); + history.pushState(null, '', `${workspaceRoute}/${variantPart}${additionalPathname}`); return true; } } @@ -119,4 +120,27 @@ export class UmbWorkspaceSplitViewManager { const variantId = UmbVariantId.FromString(folderPart); this.setActiveVariant(index, variantId.culture, variantId.segment); } + + #getCurrentVariantPathname() { + const workspaceRoute = this.getWorkspaceRoute(); + const activeVariants = this.getActiveVariants(); + const currentVariantPart: string = activeVariants + .map((v) => UmbVariantId.Create(v).toString()) + .join(UBM_VARIANT_DELIMITER); + + return `${workspaceRoute}/${currentVariantPart}`; + } + + #getAdditionalPathname() { + const currentUrl = new URL(window.location.href); + const currentFullPathname = currentUrl.pathname; + const currentVariantPathname = this.#getCurrentVariantPathname(); + + if (currentVariantPathname && currentFullPathname.startsWith(currentVariantPathname)) { + return currentFullPathname.substring(currentVariantPathname.length); + } + + // If the currentVariantPathname is not a prefix, return empty string + return ''; + } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts index 0cf1573409..9b8bef46c1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/entity-detail/entity-named-detail-workspace-base.ts @@ -2,9 +2,9 @@ import type { UmbNamableWorkspaceContext } from '../types.js'; import { UmbNameWriteGuardManager } from '../namable/index.js'; import { UmbEntityDetailWorkspaceContextBase } from './entity-detail-workspace-base.js'; import type { UmbEntityDetailWorkspaceContextArgs, UmbEntityDetailWorkspaceContextCreateArgs } from './types.js'; -import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; -import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import type { UmbDetailRepository } from '@umbraco-cms/backoffice/repository'; +import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; export abstract class UmbEntityNamedDetailWorkspaceContextBase< NamedDetailModelType extends UmbNamedEntityModel = UmbNamedEntityModel, @@ -26,6 +26,13 @@ export abstract class UmbEntityNamedDetailWorkspaceContextBase< constructor(host: UmbControllerHost, args: UmbEntityDetailWorkspaceContextArgs) { super(host, args); this.nameWriteGuard.fallbackToPermitted(); + this.observe( + this.name, + (name) => { + this.view.setTitle(name); + }, + null, + ); } getName() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts index 00a1906991..6c3e0185bf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/kinds/default/default-workspace.context.ts @@ -1,22 +1,26 @@ import { UMB_WORKSPACE_CONTEXT } from '../../workspace.context-token.js'; import type { UmbWorkspaceContext } from '../../workspace-context.interface.js'; -import type { ManifestWorkspace } from '../../extensions/types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { UmbEntityContext, type UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; +import type { ManifestWorkspaceDefaultKind } from './types.js'; export class UmbDefaultWorkspaceContext extends UmbContextBase implements UmbWorkspaceContext { public workspaceAlias!: string; #entityContext = new UmbEntityContext(this); + public readonly view = new UmbViewContext(this, null); + constructor(host: UmbControllerHost) { super(host, UMB_WORKSPACE_CONTEXT.toString()); } - set manifest(manifest: ManifestWorkspace) { + set manifest(manifest: ManifestWorkspaceDefaultKind) { this.workspaceAlias = manifest.alias; this.setEntityType(manifest.meta.entityType); + this.view.setTitle(manifest.meta.headline); } setUnique(unique: UmbEntityUnique): void { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts index 445cead0f0..d62ec59335 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/workspace/submittable/submittable-workspace-context-base.ts @@ -8,6 +8,7 @@ import type { UmbModalContext } from '@umbraco-cms/backoffice/modal'; import { UMB_MODAL_CONTEXT } from '@umbraco-cms/backoffice/modal'; import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; import type { UmbValidationController } from '@umbraco-cms/backoffice/validation'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; export abstract class UmbSubmittableWorkspaceContextBase extends UmbContextBase @@ -20,6 +21,8 @@ export abstract class UmbSubmittableWorkspaceContextBase #validationContexts: Array = []; + public readonly view = new UmbViewContext(this, null); + /** * Appends a validation context to the workspace. * @param context @@ -54,6 +57,13 @@ export abstract class UmbSubmittableWorkspaceContextBase this.consumeContext(UMB_MODAL_CONTEXT, (context) => { (this.modalContext as UmbModalContext | undefined) = context; }); + + this.view.shortcuts.addOne({ + key: 's', + modifier: true, + action: () => this.requestSubmit(), + label: '#general_submit', + }); } protected resetState() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts index 543be6c6dc..6e80892127 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/tree/data-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDataTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDataTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts index 457af615cd..5d19c10733 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/tree/dictionary-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDictionaryTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDictionaryTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts index a869a8a612..5ef576378b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/tree/document-blueprint-tree.repository.ts @@ -18,7 +18,7 @@ export class UmbDocumentBlueprintTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentBlueprintTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts index 84c3a9f389..251b3dc5be 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/tree/document-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts index 83d7ab091c..0f91dd516d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/collection/repository/document-collection.server.data-source.ts @@ -48,7 +48,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS unique: item.id, entityType: UMB_DOCUMENT_ENTITY_TYPE, contentTypeAlias: item.documentType.alias, - createDate: new Date(variant.createDate), + createDate: item.variants + .map((v) => new Date(v.createDate)) + .reduce((earliest, current) => (current < earliest ? current : earliest)), creator: item.creator, icon: item.documentType.icon, isProtected: item.isProtected, @@ -56,7 +58,9 @@ export class UmbDocumentCollectionServerDataSource implements UmbCollectionDataS name: variant.name, sortOrder: item.sortOrder, state: variant.state, - updateDate: new Date(variant.updateDate), + updateDate: item.variants + .map((v) => new Date(v.updateDate)) + .reduce((latest, current) => (current > latest ? current : latest)), updater: item.updater, values: item.values.map((item) => { return { alias: item.alias, value: item.value as string }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts index 52406deee8..52161c7974 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/components/input-document/input-document.element.ts @@ -1,13 +1,17 @@ import type { UmbDocumentItemModel } from '../../item/types.js'; import { UmbDocumentPickerInputContext } from './input-document.context.js'; import { css, customElement, html, nothing, property, repeat, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'; -import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import { UMB_DOCUMENT_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/document-type'; +import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbRepositoryItemsStatus } from '@umbraco-cms/backoffice/repository'; @customElement('umb-input-document') export class UmbInputDocumentElement extends UmbFormControlMixin( @@ -37,10 +41,10 @@ export class UmbInputDocumentElement extends UmbFormControlMixin) { - this.#pickerContext.setSelection(ids); + this.#pickerInputContext.setSelection(ids); this.#sorter.setModel(ids); } public get selection(): Array { - return this.#pickerContext.getSelection(); + return this.#pickerInputContext.getSelection(); } @property({ type: Object, attribute: false }) @@ -122,10 +126,24 @@ export class UmbInputDocumentElement extends UmbFormControlMixin | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _items?: Array; - #pickerContext = new UmbDocumentPickerInputContext(this); + @state() + private _statuses?: Array; + + #pickerInputContext = new UmbDocumentPickerInputContext(this); constructor() { super(); @@ -142,12 +160,37 @@ export class UmbInputDocumentElement extends UmbFormControlMixin !!this.max && this.selection.length > this.max, ); - this.observe(this.#pickerContext.selection, (selection) => (this.value = selection.join(',')), '_observeSelection'); - this.observe(this.#pickerContext.selectedItems, (selectedItems) => (this._items = selectedItems), '_observerItems'); + this.observe( + this.#pickerInputContext.selection, + (selection) => (this.value = selection.join(',')), + '_observeSelection', + ); + + this.observe( + this.#pickerInputContext.selectedItems, + (selectedItems) => (this._items = selectedItems), + '_observerItems', + ); + + this.observe(this.#pickerInputContext.statuses, (statuses) => (this._statuses = statuses), '_observerStatuses'); + + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); } #openPicker() { - this.#pickerContext.openPicker( + this.#pickerInputContext.openPicker( { hideTreeRoot: true, startNode: this.startNode, @@ -162,8 +205,8 @@ export class UmbInputDocumentElement extends UmbFormControlMixin ${repeat( - this._items, - (item) => item.unique, - (item) => - html` status.unique, + (status) => { + const unique = status.unique; + const item = this._items?.find((x) => x.unique === unique); + return html` ${when( @@ -205,11 +252,12 @@ export class UmbInputDocumentElement extends UmbFormControlMixin this.#onRemove(item)}> + @click=${() => this.#onRemove(unique)}>
`, )} - `, + `; + }, )} `; @@ -220,7 +268,6 @@ export class UmbInputDocumentElement extends UmbFormControlMixin - - -
- - -
- `; + return html` + + +
+ + +
+
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts index 567d59739c..b5ebbff03d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/property-editors/document-picker/property-editor-ui-document-picker.element.ts @@ -1,8 +1,10 @@ -import type { UmbInputDocumentElement } from '../../components/input-document/input-document.element.js'; import { UMB_DOCUMENT_ENTITY_TYPE } from '../../entity.js'; +import type { UmbInputDocumentElement } from '../../components/input-document/input-document.element.js'; +import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; -import { html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbPropertyEditorUiInteractionMemoryManager } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; import type { UmbPropertyEditorConfigCollection, @@ -16,6 +18,8 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl public value?: string; public set config(config: UmbPropertyEditorConfigCollection | undefined) { + this.#interactionMemoryManager.setPropertyEditorConfig(config); + if (!config) return; const minMax = config.getValueByAlias('validationLimit'); @@ -47,11 +51,37 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl @state() private _startNodeId?: string; + @state() + private _interactionMemories: Array = []; + + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbDocumentPicker', + }); + + constructor() { + super(); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); + } + #onChange(event: CustomEvent & { target: UmbInputDocumentElement }) { this.value = event.target.value; this.dispatchEvent(new UmbChangeEvent()); } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputDocumentElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { const startNode: UmbTreeStartNode | undefined = this._startNodeId ? { unique: this._startNodeId, entityType: UMB_DOCUMENT_ENTITY_TYPE } @@ -64,7 +94,9 @@ export class UmbPropertyEditorUIDocumentPickerElement extends UmbLitElement impl .startNode=${startNode} .value=${this.value} @change=${this.#onChange} - ?readonly=${this.readonly}> + ?readonly=${this.readonly} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts index 755a366b4f..84923fd865 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/publish/modal/document-publish-modal.element.ts @@ -2,9 +2,10 @@ import { UmbDocumentVariantState, type UmbDocumentVariantOptionModel } from '../ import { isNotPublishedMandatory } from '../../utils.js'; import type { UmbDocumentPublishModalData, UmbDocumentPublishModalValue } from './document-publish-modal.token.js'; import { css, customElement, html, state, when } from '@umbraco-cms/backoffice/external/lit'; +import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { UmbSelectionManager } from '@umbraco-cms/backoffice/utils'; +import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import '../../../modals/shared/document-variant-language-picker.element.js'; @@ -103,33 +104,34 @@ export class UmbDocumentPublishModalElement extends UmbModalBaseElement< override render() { const headline = this.data?.headline ?? this.localize.term('content_publishModalTitle'); - return html` -

- -

+ return html` + +

- ${when( - !this._isInvariant, - () => - html` `, - )} + ${when( + !this._isInvariant, + () => + html``, + )} -
- - -
-
`; +
+ + +
+
+ `; } static override styles = [ diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts index fe7ae40fca..dfafaf0c4a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/constants.ts @@ -1 +1,3 @@ export * from './document-publishing.workspace-context.token.js'; + +export const UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE = 'umb-document-publishing-shortcut'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts index 0bd43d3764..0173b8e100 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/publishing/workspace-context/document-publishing.workspace-context.ts @@ -11,21 +11,22 @@ import { UMB_DOCUMENT_PUBLISH_WITH_DESCENDANTS_MODAL } from '../publish-with-des import { UMB_DOCUMENT_PUBLISH_MODAL } from '../publish/constants.js'; import { UmbUnpublishDocumentEntityAction } from '../unpublish/index.js'; import { UMB_DOCUMENT_PUBLISHING_WORKSPACE_CONTEXT } from './document-publishing.workspace-context.token.js'; -import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE } from './constants.js'; +import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; +import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; +import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; import { UmbRequestReloadChildrenOfEntityEvent, UmbRequestReloadStructureForEntityEvent, } from '@umbraco-cms/backoffice/entity-action'; -import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UmbVariantId } from '@umbraco-cms/backoffice/variant'; +import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; -import { firstValueFrom } from '@umbraco-cms/backoffice/external/rxjs'; -import { observeMultiple } from '@umbraco-cms/backoffice/observable-api'; -import { DocumentVariantStateModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import type { UmbEntityUnique } from '@umbraco-cms/backoffice/entity'; -import { UmbLocalizationController } from '@umbraco-cms/backoffice/localization-api'; export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { /** @@ -48,7 +49,18 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase { this.#init = Promise.all([ this.consumeContext(UMB_DOCUMENT_WORKSPACE_CONTEXT, async (context) => { + if (this.#documentWorkspaceContext) { + // remove shortcut: + this.#documentWorkspaceContext.view.shortcuts.removeOne(UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE); + } this.#documentWorkspaceContext = context; + this.#documentWorkspaceContext?.view.shortcuts.addOne({ + unique: UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE, + label: this.#localize.term('content_saveAndPublishShortcut'), + key: 'p', + modifier: true, + action: () => this.saveAndPublish(), + }); this.#initPendingChanges(); }) .asPromise({ preventTimeout: true }) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts index 23a40ffe6b..f4c1339dc2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/recycle-bin/tree/data/document-recycle-bin-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentRecycleBinTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts index 028acd070c..1d5454e836 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/tree/document-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbDocumentTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbDocumentTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts b/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts index 375daeb232..84f8a9c371 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/extension-insights/collection/extension-collection.element.ts @@ -49,19 +49,18 @@ export class UmbExtensionCollectionElement extends UmbCollectionDefaultElement { static override styles = [ css` #toolbar { - flex: 1; display: flex; gap: var(--uui-size-space-5); justify-content: space-between; align-items: center; - } - umb-collection-filter-field { - width: 100%; - } + umb-collection-filter-field { + flex: 1; + } - uui-select { - width: 100%; + uui-select { + flex: 1; + } } `, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts index a776f4499c..e07e40afd4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/components/log-viewer-date-range-selector.element.ts @@ -102,7 +102,7 @@ export class UmbLogViewerDateRangeSelectorElement extends UmbLitElement { :host([horizontal]) .input-container { display: flex; - align-items: baseline; + align-items: center; gap: var(--uui-size-space-3); } `, diff --git a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts index e765508dd2..9d7199c471 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/log-viewer/workspace/logviewer-workspace.context.ts @@ -15,6 +15,7 @@ import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; import { query } from '@umbraco-cms/backoffice/router'; import type { UmbWorkspaceContext } from '@umbraco-cms/backoffice/workspace'; import { UMB_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { UmbViewContext } from '@umbraco-cms/backoffice/view'; export type UmbPoolingInterval = 0 | 2000 | 5000 | 10000 | 20000 | 30000; export interface UmbPoolingConfig { @@ -31,6 +32,8 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW public readonly workspaceAlias: string = 'Umb.Workspace.LogViewer'; #repository: UmbLogViewerRepository; + public readonly view = new UmbViewContext(this, null); + getEntityType() { return 'log-viewer'; } @@ -108,6 +111,8 @@ export class UmbLogViewerWorkspaceContext extends UmbContextBase implements UmbW // TODO: Revisit usage of workspace for this case... currently no other workspace context provides them self with their own token, we need to update UMB_APP_LOG_VIEWER_CONTEXT to become a workspace context. [NL] this.provideContext(UMB_WORKSPACE_CONTEXT, this); this.#repository = new UmbLogViewerRepository(host); + + this.view.setTitle('#treeHeaders_logViewer'); } override hostConnected() { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts index a9390ce91b..a17981de49 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/tree/media-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMediaTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMediaTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts index dc2bfcbf1b..177fd5e022 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-media/input-media.element.ts @@ -10,15 +10,18 @@ import { repeat, state, } from '@umbraco-cms/backoffice/external/lit'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbModalRouteRegistrationController } from '@umbraco-cms/backoffice/router'; import { UmbSorterController, UmbSorterResolvePlacementAsGrid } from '@umbraco-cms/backoffice/sorter'; -import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; -import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/media-type'; +import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; import '@umbraco-cms/backoffice/imaging'; @@ -61,10 +64,10 @@ export class UmbInputMediaElement extends UmbFormControlMixin) { - this.#pickerContext.setSelection(ids); + this.#pickerInputContext.setSelection(ids); this.#sorter.setModel(ids); } public get selection(): Array { - return this.#pickerContext.getSelection(); + return this.#pickerInputContext.getSelection(); } @property({ type: Array }) @@ -146,13 +149,24 @@ export class UmbInputMediaElement extends UmbFormControlMixin | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _editMediaPath = ''; @state() private _cards: Array = []; - #pickerContext = new UmbMediaPickerInputContext(this); + #pickerInputContext = new UmbMediaPickerInputContext(this); constructor() { super(); @@ -166,15 +180,29 @@ export class UmbInputMediaElement extends UmbFormControlMixin (this.value = selection.join(','))); + this.observe(this.#pickerInputContext.selection, (selection) => (this.value = selection.join(','))); - this.observe(this.#pickerContext.selectedItems, async (selectedItems) => { + this.observe(this.#pickerInputContext.selectedItems, async (selectedItems) => { const missingCards = selectedItems.filter((item) => !this._cards.find((card) => card.unique === item.unique)); if (selectedItems?.length && !missingCards.length) return; this._cards = selectedItems ?? []; }); + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); + this.addValidator( 'rangeUnderflow', () => this.minMessage, @@ -188,7 +216,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin 1, startNode: this.startNode, @@ -204,7 +232,7 @@ export class UmbInputMediaElement extends UmbFormControlMixin x.unique !== item.unique); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts index 353400e686..d11cd168be 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-rich-media/input-rich-media.element.ts @@ -18,6 +18,11 @@ import { UmbRepositoryItemsManager } from '@umbraco-cms/backoffice/repository'; import { UMB_MEDIA_TYPE_ENTITY_TYPE } from '@umbraco-cms/backoffice/media-type'; import '@umbraco-cms/backoffice/imaging'; +import { + UmbInteractionMemoriesChangeEvent, + type UmbInteractionMemoryModel, +} from '@umbraco-cms/backoffice/interaction-memory'; +import { jsonStringComparison } from '@umbraco-cms/backoffice/observable-api'; type UmbRichMediaCardModel = { unique: string; @@ -102,7 +107,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< public override set value(value: Array | undefined) { super.value = value; this.#sorter.setModel(value); - this.#pickerContext.setSelection(value?.map((item) => item.mediaKey) ?? []); + this.#pickerInputContext.setSelection(value?.map((item) => item.mediaKey) ?? []); this.#itemManager.setUniques(value?.map((x) => x.mediaKey)); // Maybe the new value is using an existing media, and there we need to update the cards despite no repository update. this.#populateCards(); @@ -171,6 +176,17 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< } #readonly = false; + @property({ type: Array, attribute: false }) + public get interactionMemories(): Array | undefined { + return this.#pickerInputContext.interactionMemory.getAllMemories(); + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + value?.forEach((memory) => this.#pickerInputContext.interactionMemory.setMemory(memory)); + } + + #interactionMemories?: Array = []; + @state() private _cards: Array = []; @@ -179,7 +195,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< readonly #itemManager = new UmbRepositoryItemsManager(this, UMB_MEDIA_ITEM_REPOSITORY_ALIAS); - readonly #pickerContext = new UmbMediaPickerInputContext(this); + readonly #pickerInputContext = new UmbMediaPickerInputContext(this); constructor() { super(); @@ -234,10 +250,24 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< this._routeBuilder = routeBuilder; }); - this.observe(this.#pickerContext.selection, (selection) => { + this.observe(this.#pickerInputContext.selection, (selection) => { this.#addItems(selection); }); + this.observe( + this.#pickerInputContext.interactionMemory.memories, + (memories) => { + // only dispatch the event if the interaction memories have actually changed + const isIdentical = jsonStringComparison(memories, this.#interactionMemories); + + if (!isIdentical) { + this.#interactionMemories = memories; + this.dispatchEvent(new UmbInteractionMemoriesChangeEvent()); + } + }, + '_observeMemories', + ); + this.addValidator( 'valueMissing', () => this.requiredMessage ?? UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, @@ -312,7 +342,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< } #openPicker() { - this.#pickerContext.openPicker( + this.#pickerInputContext.openPicker( { multiple: this.multiple, startNode: this.startNode, @@ -330,7 +360,7 @@ export class UmbInputRichMediaElement extends UmbFormControlMixin< async #onRemove(item: UmbRichMediaCardModel) { try { - await this.#pickerContext.requestRemoveItem(item.media); + await this.#pickerInputContext.requestRemoveItem(item.media); this.value = this.value?.filter((x) => x.key !== item.unique); this.dispatchEvent(new UmbChangeEvent()); } catch { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts index ed92ba6453..6accb523ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/components/input-upload-field/input-upload-field.element.ts @@ -16,19 +16,26 @@ import type { } from '@umbraco-cms/backoffice/dropzone'; import type { UmbTemporaryFileModel } from '@umbraco-cms/backoffice/temporary-file'; import { UMB_SERVER_CONTEXT } from '@umbraco-cms/backoffice/server'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; @customElement('umb-input-upload-field') -export class UmbInputUploadFieldElement extends UmbLitElement { +export class UmbInputUploadFieldElement extends UmbFormControlMixin( + UmbLitElement, +) { @property({ type: Object, attribute: false }) - set value(value: UmbMediaValueType) { + override set value(value: UmbMediaValueType | undefined) { + super.value = value; this.#src = value?.src ?? ''; this.#setPreviewAlias(); } - get value(): UmbMediaValueType { - return { - src: this.#src, - temporaryFileId: this.temporaryFile?.temporaryUnique, - }; + override get value(): UmbMediaValueType | undefined { + if (this.#src || this.temporaryFile?.temporaryUnique) { + return { + src: this.#src, + temporaryFileId: this.temporaryFile?.temporaryUnique, + }; + } + return undefined; } #src = ''; @@ -86,7 +93,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { } async #getPreviewElementAlias() { - if (!this.value.src) return; + if (!this.value?.src) return; const manifests = await this.#getManifests(); const fallbackAlias = manifests.find((manifest) => stringOrStringArrayContains(manifest.forMimeTypes, '*/*'), @@ -158,7 +165,7 @@ export class UmbInputUploadFieldElement extends UmbLitElement { } override render() { - if (!this.temporaryFile && !this.value.src) { + if (!this.temporaryFile && !this.value?.src) { return this.#renderDropzone(); } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts index 7ad378394b..4274263cda 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/constants.ts @@ -1,3 +1,4 @@ export { UMB_IMAGE_CROPPER_EDITOR_MODAL } from './image-cropper-editor/index.js'; -export * from './media-caption-alt-text/constants.js'; export { UMB_MEDIA_PICKER_MODAL } from './media-picker/index.js'; +export * from './media-caption-alt-text/constants.js'; +export * from './media-picker/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts new file mode 100644 index 0000000000..252317b059 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/constants.ts @@ -0,0 +1 @@ +export * from './media-picker.context.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts index 3ac92136f9..a9a70f8f33 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker-modal.element.ts @@ -1,31 +1,34 @@ import { UmbMediaItemRepository } from '../../repository/index.js'; import { UmbMediaTreeRepository } from '../../tree/media-tree.repository.js'; import { UMB_MEDIA_ROOT_ENTITY_TYPE } from '../../entity.js'; -import type { UmbMediaTreeItemModel, UmbMediaSearchItemModel, UmbMediaItemModel } from '../../types.js'; import { UmbMediaSearchProvider } from '../../search/index.js'; import type { UmbDropzoneMediaElement } from '../../dropzone/index.js'; +import type { UmbMediaTreeItemModel, UmbMediaSearchItemModel, UmbMediaItemModel } from '../../types.js'; +import { UmbMediaPickerContext } from './media-picker.context.js'; import type { UmbMediaPathModel } from './types.js'; import type { UmbMediaPickerFolderPathElement } from './components/media-picker-folder-path.element.js'; import type { UmbMediaPickerModalData, UmbMediaPickerModalValue } from './media-picker-modal.token.js'; -import type { UmbDropzoneChangeEvent, UmbUploadableItem } from '@umbraco-cms/backoffice/dropzone'; import { css, - html, customElement, - state, - repeat, + html, ifDefined, - query, - type PropertyValues, nothing, + query, + repeat, + state, } from '@umbraco-cms/backoffice/external/lit'; import { debounce, UmbPaginationManager } from '@umbraco-cms/backoffice/utils'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; -import type { UUIInputEvent, UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import { isUmbracoFolder } from '@umbraco-cms/backoffice/media-type'; -import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { UmbPickerModalBaseElement } from '@umbraco-cms/backoffice/picker'; +import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; import { UMB_VARIANT_CONTEXT } from '@umbraco-cms/backoffice/variant'; +import type { PropertyValues } from '@umbraco-cms/backoffice/external/lit'; +import type { UmbDropzoneChangeEvent, UmbUploadableItem } from '@umbraco-cms/backoffice/dropzone'; +import type { UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbPickerContext } from '@umbraco-cms/backoffice/picker'; +import type { UUIInputEvent, UUIPaginationEvent } from '@umbraco-cms/backoffice/external/uui'; import '@umbraco-cms/backoffice/imaging'; @@ -33,11 +36,19 @@ const root: UmbMediaPathModel = { name: 'Media', unique: null, entityType: UMB_M // TODO: investigate how we can reuse the picker-search-field element, picker context etc. @customElement('umb-media-picker-modal') -export class UmbMediaPickerModalElement extends UmbModalBaseElement { +export class UmbMediaPickerModalElement extends UmbPickerModalBaseElement< + UmbMediaItemModel, + UmbMediaPickerModalData, + UmbMediaPickerModalValue +> { #mediaTreeRepository = new UmbMediaTreeRepository(this); #mediaItemRepository = new UmbMediaItemRepository(this); #mediaSearchProvider = new UmbMediaSearchProvider(this); + /* TODO: We currently only rely on the interactionMemory manager in the picker interface which is correctly implemented in the Media Picker + Remove this type cast when MediaPicker has implemented the full PickerContext interface */ + protected override _pickerContext = new UmbMediaPickerContext(this) as unknown as UmbPickerContext; + #dataType?: { unique: string }; @state() @@ -78,6 +89,7 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement(); #contextCulture?: string | null; + #locationInteractionMemoryUnique: string = 'UmbMediaItemPickerLocation'; constructor() { super(); @@ -106,25 +118,36 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement x !== null && x !== undefined, + ); - if (this._startNode) { + if (uniquesToRequest.length > 0) { + const { data } = await this.#mediaItemRepository.requestItems(uniquesToRequest); + + this._startNode = data?.find((x) => x.unique === startNode?.unique); + const locationMemoryItem = data?.find((x) => x.unique === locationFromMemory?.entity.unique); + + // TODO: We probably need to check if the location item is within the start node. If not then fall back to start node. + const source = locationMemoryItem || this._startNode; + + if (source) { this._currentMediaEntity = { - name: this._startNode.name, - unique: this._startNode.unique, - entityType: this._startNode.entityType, + name: source.name, + unique: source.unique, + entityType: source.entityType, }; - this._searchFrom = { unique: this._startNode.unique, entityType: this._startNode.entityType }; + this._searchFrom = { unique: source.unique, entityType: source.entityType }; } } this.#loadChildrenOfCurrentMediaItem(); } + // TODO: move to location manager in context async #loadChildrenOfCurrentMediaItem(selectedItems?: Array) { const key = this._currentMediaEntity.entityType + this._currentMediaEntity.unique; let paginationManager = this.#pagingMap.get(key); @@ -166,13 +189,14 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement { this.#searchMedia(); }, 500); + // TODO: move to search manager in context #onSearch(e: UUIInputEvent) { this._searchQuery = (e.target.value as string).toLocaleLowerCase(); this._searching = true; @@ -260,6 +290,8 @@ export class UmbMediaPickerModalElement extends UmbModalBaseElement diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts new file mode 100644 index 0000000000..f1bf040da9 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.token.ts @@ -0,0 +1,8 @@ +import type { UmbMediaPickerContext } from './media-picker.context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_MEDIA_PICKER_CONTEXT = new UmbContextToken( + 'UmbPickerContext', + undefined, + (context): context is UmbMediaPickerContext => context.IS_MEDIA_PICKER_CONTEXT, +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts new file mode 100644 index 0000000000..88ef119fdc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/modals/media-picker/media-picker.context.ts @@ -0,0 +1,18 @@ +import { UMB_MEDIA_PICKER_CONTEXT } from './media-picker.context.token.js'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbInteractionMemoryManager } from '@umbraco-cms/backoffice/interaction-memory'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +// TODO: extend UmbTreeItemPickerContext +export class UmbMediaPickerContext extends UmbContextBase { + // For context token safety: + public readonly IS_MEDIA_PICKER_CONTEXT = true; + + public readonly interactionMemory = new UmbInteractionMemoryManager(this); + + constructor(host: UmbControllerHost) { + super(host, UMB_MEDIA_PICKER_CONTEXT); + } +} + +export { UmbMediaPickerContext as api }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts index a5428232cb..f17f453110 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/image-crops/property-editor-ui-image-crops.element.ts @@ -265,6 +265,7 @@ export class UmbPropertyEditorUIImageCropsElement extends UmbLitElement implemen font-size: var(--uui-type-small-size); display: flex; align-items: center; + height: 100%; } .action-wrapper { display: flex; diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts index d2e5d0a576..29e8c9c72d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/media-picker/property-editor-ui-media-picker.element.ts @@ -2,30 +2,31 @@ import type { UmbInputRichMediaElement } from '../../components/input-rich-media import type { UmbCropModel, UmbMediaPickerValueModel } from '../types.js'; import { UMB_MEDIA_ENTITY_TYPE } from '../../entity.js'; import { customElement, html, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UmbPropertyEditorUiInteractionMemoryManager } from '@umbraco-cms/backoffice/property-editor'; import { UMB_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/property'; -import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; +import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbNumberRangeValueType } from '@umbraco-cms/backoffice/models'; import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; -import { UMB_VALIDATION_EMPTY_LOCALIZATION_KEY, UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; import '../../components/input-rich-media/input-rich-media.element.js'; -import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; - -const elementName = 'umb-property-editor-ui-media-picker'; - /** * @element umb-property-editor-ui-media-picker */ -@customElement(elementName) +@customElement('umb-property-editor-ui-media-picker') export class UmbPropertyEditorUIMediaPickerElement extends UmbFormControlMixin(UmbLitElement) implements UmbPropertyEditorUiElement { public set config(config: UmbPropertyEditorConfigCollection | undefined) { + this.#interactionMemoryManager.setPropertyEditorConfig(config); + if (!config) return; this._allowedMediaTypes = config.getValueByAlias('filter')?.split(',') ?? []; @@ -87,6 +88,13 @@ export class UmbPropertyEditorUIMediaPickerElement @state() private _variantId?: string; + @state() + private _interactionMemories: Array = []; + + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbMediaPicker', + }); + constructor() { super(); @@ -94,6 +102,10 @@ export class UmbPropertyEditorUIMediaPickerElement this.observe(context?.alias, (alias) => (this._alias = alias)); this.observe(context?.variantId, (variantId) => (this._variantId = variantId?.toString() || 'invariant')); }); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); } override firstUpdated() { @@ -110,6 +122,17 @@ export class UmbPropertyEditorUIMediaPickerElement this.dispatchEvent(new UmbChangeEvent()); } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputRichMediaElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { return html` + ?readonly=${this.readonly} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> `; } @@ -136,6 +161,6 @@ export { UmbPropertyEditorUIMediaPickerElement as element }; declare global { interface HTMLElementTagNameMap { - [elementName]: UmbPropertyEditorUIMediaPickerElement; + ['umb-property-editor-ui-media-picker']: UmbPropertyEditorUIMediaPickerElement; } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts index 3390df2062..09d2ba58b9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/property-editors/upload-field/property-editor-ui-upload-field.element.ts @@ -1,21 +1,24 @@ import type { UmbInputUploadFieldElement } from '../../components/input-upload-field/input-upload-field.element.js'; import type { UmbMediaValueType } from './types.js'; -import { html, customElement, property, state } from '@umbraco-cms/backoffice/external/lit'; +import { html, customElement, state } from '@umbraco-cms/backoffice/external/lit'; import type { UmbPropertyEditorUiElement, UmbPropertyEditorConfigCollection, } from '@umbraco-cms/backoffice/property-editor'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; +import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; + +import '../../components/input-upload-field/input-upload-field.element.js'; /** * @element umb-property-editor-ui-upload-field */ @customElement('umb-property-editor-ui-upload-field') -export class UmbPropertyEditorUIUploadFieldElement extends UmbLitElement implements UmbPropertyEditorUiElement { - @property({ type: Object }) - value: UmbMediaValueType = {}; - +export class UmbPropertyEditorUIUploadFieldElement + extends UmbFormControlMixin(UmbLitElement) + implements UmbPropertyEditorUiElement +{ @state() private _fileExtensions?: Array; @@ -29,6 +32,10 @@ export class UmbPropertyEditorUIUploadFieldElement extends UmbLitElement impleme } } + override firstUpdated() { + this.addFormControlElement(this.shadowRoot!.querySelector('umb-input-upload-field')!); + } + #onChange(event: CustomEvent) { this.value = (event.target as UmbInputUploadFieldElement).value; this.dispatchEvent(new UmbChangeEvent()); diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts index a15f5cd2ea..0347877cb9 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/recycle-bin/tree/media-recycle-bin-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMediaRecycleBinTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts index 00f5852875..f930d0d167 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/validation/media-validation.server.data-source.ts @@ -49,6 +49,9 @@ export class UmbMediaValidationServerDataSource { MediaService.postMediaValidate({ body, }), + { + disableNotifications: true, + }, ); if (data && typeof data === 'string') { @@ -86,6 +89,9 @@ export class UmbMediaValidationServerDataSource { path: { id: model.unique }, body, }), + { + disableNotifications: true, + }, ); if (data && typeof data === 'string') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts index df30ab9c48..20b4fc9f13 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/tree/media-tree.repository.ts @@ -25,7 +25,7 @@ export class UmbMediaTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMediaTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts index 0140e663b7..a53f5a914d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/tree/member-type-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbMemberTypeTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbMemberTypeTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts index 62ec20d00d..995f5d7900 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/collection/repository/member-collection.repository.ts @@ -19,7 +19,7 @@ export class UmbMemberCollectionRepository extends UmbMemberRepositoryBase imple const { data, error } = await this.#collectionSource.getCollection(filter); if (data) { - this.detailStore!.appendItems(data.items); + this.detailStore?.appendItems(data.items); } return { data, error, asObservable: () => this.detailStore!.all() }; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts index b6889b741f..544069add2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/repository/validation/member-validation.server.data-source.ts @@ -94,6 +94,7 @@ export class UmbMemberValidationServerDataSource { path: { id: model.unique }, body, }), + { disableNotifications: true }, ); if (data && typeof data === 'string') { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts index 94cf5e3744..d70d96f69a 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/member-workspace.context.ts @@ -146,7 +146,7 @@ export class UmbMemberWorkspaceContext messages.forEach((message) => { if (this.#hintedMsgs.has(message.key)) return; - this.hints.addOne({ + this.view.hints.addOne({ unique: message.key, path: [UMB_MEMBER_WORKSPACE_VIEW_MEMBER_ALIAS], text: '!', @@ -158,7 +158,7 @@ export class UmbMemberWorkspaceContext this.#hintedMsgs.forEach((key) => { if (!messages.some((msg) => msg.key === key)) { this.#hintedMsgs.delete(key); - this.hints.removeOne(key); + this.view.hints.removeOne(key); } }); }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts index ab101f6c0e..8deea0f5ec 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/workspace/member/views/member/member-workspace-view-member.element.ts @@ -92,7 +92,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement @input=${() => this.#onPasswordUpdate()} value=${this._workspaceContext.newPassword} required - ${umbBindToValidation(this, '$.password', this._workspaceContext.newPassword)}>
+ ${umbBindToValidation( + this, + "$.values[?(@.alias == 'password' && @.culture == null && @.segment == null)].value", + this._workspaceContext.newPassword, + )}> @@ -162,7 +166,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement name="login" label=${this.localize.term('general_username')} value=${this._workspaceContext.username} - ${umbBindToValidation(this, '$.username', this._workspaceContext.username)} + ${umbBindToValidation( + this, + "$.values[?(@.alias == 'username' && @.culture == null && @.segment == null)].value", + this._workspaceContext.username, + )} required required-message=${this.localize.term('user_loginnameRequired')} @input=${(e: Event) => this.#onChange('username', (e.target as HTMLInputElement).value)}> @@ -174,7 +182,11 @@ export class UmbMemberWorkspaceViewMemberElement extends UmbLitElement implement name="email" label=${this.localize.term('general_email')} value=${this._workspaceContext.email} - ${umbBindToValidation(this, '$.email', this._workspaceContext.email)} + ${umbBindToValidation( + this, + "$.values[?(@.alias == 'email' && @.culture == null && @.segment == null)].value", + this._workspaceContext.email, + )} required required-message=${this.localize.term('user_emailRequired')} @input=${(e: Event) => this.#onChange('email', (e.target as HTMLInputElement).value)}> diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts index 6402ba7be6..ad05d682ab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts @@ -122,7 +122,7 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { name=${ifDefined(this.name)} version="${ifDefined(this.version ?? undefined)}" @open=${this.#onConfigure} - ?disabled="${!this._packageView}"> + ?readonly="${!this._packageView}"> ${this.customIcon ? html`` : nothing}
${this.hasPendingMigrations diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts index cb519d7b9d..08c3309c59 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/index.ts @@ -1 +1,3 @@ +import './input-content/input-content.element.js'; + export * from './input-content/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts index 91e8f177d5..a557488c1e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/components/input-content/input-content.element.ts @@ -1,9 +1,11 @@ import type { UmbContentPickerSource } from '../../types.js'; -import { css, html, customElement, property } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, property } from '@umbraco-cms/backoffice/external/lit'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UmbInteractionMemoriesChangeEvent } from '@umbraco-cms/backoffice/interaction-memory'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbReferenceByUniqueAndType } from '@umbraco-cms/backoffice/models'; import type { UmbTreeStartNode } from '@umbraco-cms/backoffice/tree'; @@ -72,7 +74,17 @@ export class UmbInputContentElement extends UmbFormControlMixin | undefined { + return this.#interactionMemories; + } + public set interactionMemories(value: Array | undefined) { + this.#interactionMemories = value; + } + + #interactionMemories: Array | undefined; #entityTypeLookup = { content: 'document', media: 'media', member: 'member' }; @@ -88,6 +100,15 @@ export class UmbInputContentElement extends UmbFormControlMixin + @change=${this.#onChange} + .interactionMemories=${this.#interactionMemories} + @interaction-memories-change=${this.#onInteractionMemoriesChange}> `; } @@ -126,7 +149,9 @@ export class UmbInputContentElement extends UmbFormControlMixin + @change=${this.#onChange} + .interactionMemories=${this.#interactionMemories} + @interaction-memories-change=${this.#onInteractionMemoriesChange}> `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts new file mode 100644 index 0000000000..d6d9a8e5e3 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/constants.ts @@ -0,0 +1 @@ +export * from './dynamic-root/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts new file mode 100644 index 0000000000..ea358318ca --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/constants.ts @@ -0,0 +1 @@ +export * from './modals/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts new file mode 100644 index 0000000000..d88e7115f5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/dynamic-root/types.ts @@ -0,0 +1 @@ +export type * from './dynamic-root.extension.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts index ea811ee51c..753539c814 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/index.ts @@ -1,4 +1,5 @@ export * from './components/index.js'; export * from './config/source-content/index.js'; +export * from './constants.js'; export * from './dynamic-root/index.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts index f34caa2ca1..a805823960 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/property-editor-ui-content-picker.element.ts @@ -10,6 +10,8 @@ import { UMB_ANCESTORS_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; import { UMB_DOCUMENT_ENTITY_TYPE } from '@umbraco-cms/backoffice/document'; import { UMB_MEDIA_ENTITY_TYPE } from '@umbraco-cms/backoffice/media'; import { UMB_MEMBER_ENTITY_TYPE } from '@umbraco-cms/backoffice/member'; +import { UmbPropertyEditorUiInteractionMemoryManager } from '@umbraco-cms/backoffice/property-editor'; +import type { UmbInteractionMemoryModel } from '@umbraco-cms/backoffice/interaction-memory'; import type { UmbPropertyEditorConfigCollection, UmbPropertyEditorUiElement, @@ -65,6 +67,9 @@ export class UmbPropertyEditorUIContentPickerElement @state() private _invalidData?: UmbContentPickerValueType; + @state() + private _interactionMemories: Array = []; + #dynamicRoot?: UmbContentPickerSource['dynamicRoot']; #dynamicRootRepository = new UmbContentPickerDynamicRootRepository(this); @@ -74,7 +79,21 @@ export class UmbPropertyEditorUIContentPickerElement member: UMB_MEMBER_ENTITY_TYPE, }; + #interactionMemoryManager = new UmbPropertyEditorUiInteractionMemoryManager(this, { + memoryUniquePrefix: 'UmbContentPicker', + }); + + constructor() { + super(); + + this.observe(this.#interactionMemoryManager.memoriesForPropertyEditor, (interactionMemories) => { + this._interactionMemories = interactionMemories ?? []; + }); + } + public set config(config: UmbPropertyEditorConfigCollection | undefined) { + this.#interactionMemoryManager.setPropertyEditorConfig(config); + if (!config) return; const startNode = config.getValueByAlias('startNode'); @@ -160,6 +179,17 @@ export class UmbPropertyEditorUIContentPickerElement this.readonly = false; } + async #onInputInteractionMemoriesChange(event: UmbChangeEvent) { + const target = event.target as UmbInputContentElement; + const interactionMemories = target.interactionMemories; + + if (interactionMemories && interactionMemories.length > 0) { + await this.#interactionMemoryManager.saveMemoriesForPropertyEditor(interactionMemories); + } else { + await this.#interactionMemoryManager.deleteMemoriesForPropertyEditor(); + } + } + override render() { const startNode: UmbTreeStartNode | undefined = this._rootUnique && this._rootEntityType @@ -177,7 +207,9 @@ export class UmbPropertyEditorUIContentPickerElement .startNode=${startNode} .allowedContentTypeIds=${this._allowedContentTypeUniques ?? ''} ?readonly=${this.readonly} - @change=${this.#onChange}> + @change=${this.#onChange} + .interactionMemories=${this._interactionMemories} + @interaction-memories-change=${this.#onInputInteractionMemoriesChange}> ${this.#renderInvalidData()} `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts index e13c1ac857..1ba3ccf670 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/content-picker/types.ts @@ -1,3 +1,5 @@ +export type * from './dynamic-root/types.js'; + export type UmbContentPickerSourceType = 'content' | 'member' | 'media'; export type UmbContentPickerSource = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts index e077081df6..5622585166 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/dropdown/property-editor-ui-dropdown.element.ts @@ -78,7 +78,7 @@ export class UmbPropertyEditorUIDropdownElement // If selection includes a value that is not in the list, add it to the list this.#selection.forEach((value) => { - if (!this._options.find((item) => item.value === value)) { + if (value !== '' && !this._options.find((item) => item.value === value)) { this._options.push({ name: `${value} (${this.localize.term('validation_legacyOption')})`, value, @@ -109,7 +109,7 @@ export class UmbPropertyEditorUIDropdownElement this.#setValue(value ? [value] : []); } - #onChangeMulitple(event: Event & { target: HTMLSelectElement }) { + #onChangeMultiple(event: Event & { target: HTMLSelectElement }) { const selected = event.target.selectedOptions; const value = selected ? Array.from(selected).map((option) => option.value) : []; this.#setValue(value); @@ -155,7 +155,7 @@ export class UmbPropertyEditorUIDropdownElement } return html` - ${map( this._options, (item) => html``, diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts index 0575dfc63a..932e48b20f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/manifests.ts @@ -1,5 +1,3 @@ -import { manifests as modalManifests } from './modal/manifests.js'; - export const manifests: Array = [ { type: 'propertyEditorUi', @@ -14,5 +12,4 @@ export const manifests: Array = [ supportsReadOnly: true, }, }, - ...modalManifests, ]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts deleted file mode 100644 index fb0853adfa..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './missing-editor-modal.token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts deleted file mode 100644 index 3ef10f367f..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/manifests.ts +++ /dev/null @@ -1,8 +0,0 @@ -export const manifests: Array = [ - { - type: 'modal', - alias: 'Umb.Modal.MissingPropertyEditor', - name: 'Missing Property Editor Modal', - element: () => import('./missing-editor-modal.element.js'), - }, -]; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts deleted file mode 100644 index f71d9769aa..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.element.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { UmbMissingPropertyModalData, UmbMissingPropertyModalResult } from './missing-editor-modal.token.js'; -import { html, customElement, css } from '@umbraco-cms/backoffice/external/lit'; -import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; -import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; -import { umbFocus } from '@umbraco-cms/backoffice/lit-element'; - -@customElement('umb-missing-property-editor-modal') -export class UmbMissingPropertyEditorModalElement extends UmbModalBaseElement< - UmbMissingPropertyModalData, - UmbMissingPropertyModalResult -> { - override render() { - return html` - - - ${this.data?.value} - - - `; - } - - static override styles = [ - UmbTextStyles, - css` - uui-dialog-layout { - max-inline-size: 60ch; - } - #codeblock { - max-height: 300px; - overflow: auto; - } - `, - ]; -} - -export { UmbMissingPropertyEditorModalElement as element }; - -declare global { - interface HTMLElementTagNameMap { - 'umb-missing-property-editor-modal': UmbMissingPropertyEditorModalElement; - } -} diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts deleted file mode 100644 index 9792759058..0000000000 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/modal/missing-editor-modal.token.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { UmbModalToken } from '@umbraco-cms/backoffice/modal'; - -export interface UmbMissingPropertyModalData { - value: string | undefined; -} - -export type UmbMissingPropertyModalResult = undefined; - -export const UMB_MISSING_PROPERTY_EDITOR_MODAL = new UmbModalToken< - UmbMissingPropertyModalData, - UmbMissingPropertyModalResult ->('Umb.Modal.MissingPropertyEditor', { - modal: { - type: 'dialog', - size: 'small', - }, -}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts index 5ec66cbf83..337dd9fe9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/missing/property-editor-ui-missing.element.ts @@ -1,50 +1,129 @@ -import { UMB_MISSING_PROPERTY_EDITOR_MODAL } from './modal/missing-editor-modal.token.js'; -import { customElement, html } from '@umbraco-cms/backoffice/external/lit'; +import { css, customElement, html, nothing, property, query, state } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; -import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; import type { UmbPropertyEditorUiElement } from '@umbraco-cms/backoffice/property-editor'; -import { UmbFormControlMixin } from '@umbraco-cms/backoffice/validation'; +import { UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT } from '@umbraco-cms/backoffice/content'; +import { UmbDataTypeDetailRepository, type UmbDataTypeDetailModel } from '@umbraco-cms/backoffice/data-type'; +import type { UmbPropertyTypeModel } from '@umbraco-cms/backoffice/content-type'; /** * @element umb-property-editor-ui-missing */ @customElement('umb-property-editor-ui-missing') -export class UmbPropertyEditorUIMissingElement - extends UmbFormControlMixin(UmbLitElement, undefined) - implements UmbPropertyEditorUiElement -{ +export class UmbPropertyEditorUIMissingElement extends UmbLitElement implements UmbPropertyEditorUiElement { + @property() + value = ''; + + @state() + private _expanded = false; + + @query('#details') + focalPointElement!: HTMLElement; + + private _dataTypeDetailModel?: UmbDataTypeDetailModel | undefined; + private _dataTypeDetailRepository = new UmbDataTypeDetailRepository(this); + constructor() { super(); - this.addValidator( - 'customError', - () => this.localize.term('errors_propertyHasErrors'), - () => true, - ); - - this.pristine = false; + this.consumeContext(UMB_PROPERTY_TYPE_BASED_PROPERTY_CONTEXT, (propertyContext) => { + if (!propertyContext?.dataType) return; + this.observe(propertyContext.dataType, (dt) => { + if (!dt?.unique) return; + this._updateEditorAlias(dt); + }); + }); } - async #onDetails(event: Event) { - event.stopPropagation(); + private async _updateEditorAlias(dataType: UmbPropertyTypeModel['dataType']) { + this.observe(await this._dataTypeDetailRepository.byUnique(dataType.unique), (dataType) => { + this._dataTypeDetailModel = dataType; + }); + } - await umbOpenModal(this, UMB_MISSING_PROPERTY_EDITOR_MODAL, { - data: { - // If the value is an object, we stringify it to make sure we can display it properly. - // If it's a primitive value, we just convert it to string. - value: typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value), - }, - }).catch(() => undefined); + async #onDetails() { + this._expanded = !this._expanded; + if (this._expanded) { + await this.updateComplete; + this.focalPointElement?.focus(); + } } override render() { - return html` + return html` +
+ ${this.localize.term('missingEditor_title')} +
+
+ + ${this._expanded ? this._renderDetails() : nothing} +
+ `; + compact + label="${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')}" + @click=${this.#onDetails}> + ${this.localize.term(this._expanded ? 'missingEditor_detailsHide' : 'missingEditor_detailsShow')} + +
`; } + + private _renderDetails() { + return html`
+ +

+ +

+

+ : + ${this._dataTypeDetailModel?.name}
+ : + ${this._dataTypeDetailModel?.editorAlias} +

+ ${typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : String(this.value)} +
`; + } + + static override styles = [ + css` + :host { + display: flex; + flex-direction: column; + gap: var(--uui-size-space-3); + --uui-box-default-padding: 0; + } + #content { + padding: var(--uui-size-space-5); + padding-bottom: var(--uui-size-space-3); + } + #alert { + padding-right: var(--uui-size-space-2); + } + #details-button { + float: right; + } + #details { + margin-top: var(--uui-size-space-5); + } + #details-title { + font-weight: 800; + } + #expand-symbol { + transform: rotate(90deg); + } + #expand-symbol[open] { + transform: rotate(180deg); + } + #codeblock { + max-height: 400px; + display: flex; + flex-direction: column; + } + `, + ]; } export default UmbPropertyEditorUIMissingElement; diff --git a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts index 8ace36f856..d628cd8325 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/property-editors/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ 'entry-point': 'entry-point.ts', 'umbraco-package': 'umbraco-package.ts', manifests: 'manifests.ts', + 'content-picker/index': './content-picker/index.ts', }, }), }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts index 0161ca254a..86eac34b70 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/tree/static-file-tree.repository.ts @@ -15,7 +15,7 @@ export class UmbStaticFileTreeRepository } async requestTreeRoot() { - const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 1 }); + const { data: treeRootData } = await this._treeSource.getRootItems({ skip: 0, take: 0 }); const hasChildren = treeRootData ? treeRootData.total > 0 : false; const data: UmbStaticFileTreeRootModel = { diff --git a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts index 9efb1b6962..5a84f467c6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/tags/components/tags-input/tags-input.element.ts @@ -1,19 +1,20 @@ import { UmbTagRepository } from '../../repository/tag.repository.js'; import { css, + customElement, html, nothing, - customElement, property, query, queryAll, - state, repeat, + state, } from '@umbraco-cms/backoffice/external/lit'; -import type { UUIInputElement, UUIInputEvent, UUITagElement } from '@umbraco-cms/backoffice/external/uui'; -import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; +import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; +import { UUIFormControlMixin } from '@umbraco-cms/backoffice/external/uui'; import type { TagResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; +import type { UUIInputElement, UUIInputEvent, UUITagElement } from '@umbraco-cms/backoffice/external/uui'; @customElement('umb-tags-input') export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') { @@ -61,6 +62,9 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') @queryAll('.options') private _optionCollection?: HTMLCollectionOf; + @queryAll('.tag') + private _tagEls?: NodeListOf; + #repository = new UmbTagRepository(this); public override focus() { @@ -78,18 +82,29 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this._matches = data.items; } - #onKeydown(e: KeyboardEvent) { - //Prevent tab away if there is a input. - if (e.key === 'Tab' && (this._tagInput.value as string).trim().length && !this._matches.length) { + #onInputKeydown(e: KeyboardEvent) { + const inputLength = (this._tagInput.value as string).trim().length; + + //Prevent tab away if there is a text in the input. + if (e.key === 'Tab' && inputLength && !this._matches.length) { e.preventDefault(); this.#createTag(); return; } + + //If the input is empty we can navigate out of it using tab + if (e.key === 'Tab' && !inputLength) { + return; + } + + //Create a new tag when enter to the input if (e.key === 'Enter') { this.#createTag(); return; } - if (e.key === 'ArrowDown' || e.key === 'Tab') { + + //This one to show option collection if there is any + if (e.key === 'ArrowDown') { e.preventDefault(); this._currentInput = this._optionCollection?.item(0)?.value ?? this._currentInput; this._optionCollection?.item(0)?.focus(); @@ -98,6 +113,54 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this.#inputError(false); } + #focusTag(index: number) { + const tag = this._tagEls?.[index]; + if (!tag) return; + + // Find the current element with the class .tab and tabindex=0 (will be the previous tag) + const active = this.renderRoot.querySelector('.tag[tabindex="0"]'); + + // Return it is tabindex to -1 + active?.setAttribute('tabindex', '-1'); + + // Set the tabindex to 0 in the current target + tag.setAttribute('tabindex', '0'); + + tag.focus(); + } + + #onTagsWrapperKeydown(e: KeyboardEvent) { + if ((e.key === 'Enter' || e.key === 'ArrowDown') && this.items.length) { + e.preventDefault(); + this.#focusTag(0); + } + } + + #onTagKeydown(e: KeyboardEvent, idx: number) { + if (e.key === 'ArrowRight') { + e.preventDefault(); + if (idx < this.items.length - 1) { + this.#focusTag(idx + 1); + } + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + if (idx > 0) { + this.#focusTag(idx - 1); + } + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + if (this.#items.length - 1 === idx) { + this.#focusTag(idx - 1); + } + this.#delete(this.#items[idx]); + this.#focusTag(idx + 1); + } + } + #onInput(e: UUIInputEvent) { this._currentInput = e.target.value as string; if (!this._currentInput || !this._currentInput.length) { @@ -128,7 +191,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') this.items = [...this.items, newTag]; this._tagInput.value = ''; this._currentInput = ''; - this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + this.dispatchEvent(new UmbChangeEvent()); } #inputError(error: boolean) { @@ -150,7 +213,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') } else { this.items = []; } - this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true })); + this.dispatchEvent(new UmbChangeEvent()); } /** Dropdown */ @@ -196,7 +259,7 @@ export class UmbTagsInputElement extends UUIFormControlMixin(UmbLitElement, '') override render() { return html`
- ${this.#enteredTags()} + ${this.#renderTags()}