diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 28e6ed25b8..c1cec3b15a 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -513,9 +513,9 @@ stages: UMBRACO__CMS__WEBROUTING__UMBRACOAPPLICATIONURL: https://localhost:44331/ ASPNETCORE_URLS: https://localhost:44331 jobs: - # E2E Tests + # E2E Smoke Tests - job: - displayName: E2E Tests (SQLite) + displayName: E2E Smoke Tests (SQLite) # currently disabled due to DB locks randomly occuring. condition: eq(${{parameters.sqliteAcceptanceTests}}, True) variables: @@ -678,7 +678,7 @@ stages: testRunTitle: "$(Agent.JobName)" - job: - displayName: E2E Tests (SQL Server) + displayName: E2E Smoke Tests (SQL Server) variables: # Connection string CONNECTIONSTRINGS__UMBRACODBDSN: Data Source=(localdb)\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\Umbraco.mdf;Integrated Security=True @@ -862,6 +862,176 @@ stages: 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)" + ############################################### ## Release ############################################### @@ -1067,4 +1237,4 @@ stages: storage: umbracoapidocs ContainerName: "$web" BlobPrefix: v$(umbracoMajorVersion)/ui-api - CleanTargetBeforeCopy: true + CleanTargetBeforeCopy: true \ No newline at end of file diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 405eb51aa4..73034b6d7c 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -12,7 +12,7 @@ schedules: - main parameters: - # Skipped due to DB locks + # Skipped due to DB locks - name: sqliteAcceptanceTests displayName: Run SQLite Acceptance Tests type: boolean @@ -482,4 +482,52 @@ stages: testResultsFormat: 'JUnit' testResultsFiles: '*.xml' searchFolder: "tests/Umbraco.Tests.AcceptanceTest/results" - testRunTitle: "$(Agent.JobName)" \ No newline at end of file + testRunTitle: "$(Agent.JobName)" + + - stage: NotifySlackBot + displayName: Notify Slack on Failure + dependsOn: E2E + # 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')) + jobs: + - job: PostToSlack + displayName: Send Slack Notification + pool: + vmImage: 'ubuntu-latest' + steps: + # We send a payload to the Slack webhook URL, which will post a message to a specific channel + - bash: | + PROJECT_NAME_ENCODED=$(echo -n "$SYSTEM_TEAMPROJECT" | jq -s -R -r @uri) + PIPELINE_URL="${SYSTEM_TEAMFOUNDATIONCOLLECTIONURI}${PROJECT_NAME_ENCODED}/_build/results?buildId=${BUILD_BUILDID}&view=ms.vss-test-web.build-test-results-tab" + + PAYLOAD="{ + \"attachments\": [ + { + \"color\": \"#ff0000\", + \"pretext\": \"Nightly E2E pipeline *${BUILD_DEFINITIONNAME}* (#${BUILD_BUILDNUMBER}) failed!\", + \"title\": \"View Failed E2E Test Results\", + \"title_link\": \"$PIPELINE_URL\", + \"fields\": [ + { + \"title\": \"Pipeline\", + \"value\": \"${BUILD_DEFINITIONNAME}\", + \"short\": true + }, + { + \"title\": \"Build ID\", + \"value\": \"${BUILD_BUILDID}\", + \"short\": true + } + ] + } + ] + }" + + echo "Sending Slack message to: $PIPELINE_URL" + curl -X POST -H 'Content-type: application/json' \ + --data "$PAYLOAD" \ + "$SLACK_WEBHOOK_URL" + env: + SLACK_WEBHOOK_URL: $(E2ESLACKWEBHOOKURL) diff --git a/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs new file mode 100644 index 0000000000..34a05dfdfb --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/ViewModels/Pagination/SubsetViewModel.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Common.ViewModels.Pagination; + +public class SubsetViewModel +{ + [Required] + public long TotalBefore { get; set; } + + [Required] + public long TotalAfter { get; set; } + + [Required] + public IEnumerable Items { get; set; } = Enumerable.Empty(); + + public static SubsetViewModel Empty() => new(); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs index 253d32e3e8..d487df48ff 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DataType/Tree/SiblingsDataTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,10 @@ public class SiblingsDataTypeTreeController : DataTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs index 3fec79bf36..850eb9f856 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/Tree/SiblingsDocumentTreeController.cs @@ -1,6 +1,7 @@ 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.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -34,7 +35,10 @@ public class SiblingsDocumentTreeController : DocumentTreeControllerBase [HttpGet("siblings")] [MapToApiVersion("1.0")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + { + IgnoreUserStartNodesForDataType(dataTypeId); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs index ac5578155f..6c5e5314b6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentBlueprint/Tree/SiblingsDocumentBlueprintTreeController.cs @@ -1,5 +1,6 @@ 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.Tree; using Umbraco.Cms.Core.Services; @@ -14,11 +15,15 @@ public class SiblingsDocumentBlueprintTreeController : DocumentBlueprintTreeCont } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, - int after) => - GetSiblings(target, before, after); + int after, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs index 7bb9c26358..c0c36ab9d8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/DocumentType/Tree/SiblingsDocumentTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,11 +14,15 @@ public class SiblingsDocumentTypeTreeController : DocumentTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, - int after) => - GetSiblings(target, before, after); + int after, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs index f5708fa638..012816dbfc 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/Tree/SiblingsMediaTreeController.cs @@ -1,5 +1,6 @@ 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.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -23,7 +24,10 @@ public class SiblingsMediaTreeController : MediaTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after, Guid? dataTypeId = null) + { + IgnoreUserStartNodesForDataType(dataTypeId); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs index 1482788b57..f4dd06d632 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/MediaType/Tree/SiblingsMediaTypeTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,7 +14,15 @@ public class SiblingsMediaTypeTreeController : MediaTypeTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings(CancellationToken cancellationToken, Guid target, int before, int after) - => GetSiblings(target, before, after); + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( + CancellationToken cancellationToken, + Guid target, + int before, + int after, + bool foldersOnly = false) + { + RenderFoldersOnly(foldersOnly); + return await GetSiblings(target, before, after); + } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs index ed27092ecb..f566dd52f6 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Template/Tree/SiblingsTemplateTreeController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core.Services; @@ -13,11 +14,11 @@ public class SiblingsTemplateTreeController : TemplateTreeControllerBase } [HttpGet("siblings")] - [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public Task>> Siblings( + [ProducesResponseType(typeof(SubsetViewModel), StatusCodes.Status200OK)] + public async Task>> Siblings( CancellationToken cancellationToken, Guid target, int before, int after) => - GetSiblings(target, before, after); + await GetSiblings(target, before, after); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs index 13bbe9bc2b..2f5a62b9bb 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/EntityTreeControllerBase.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.ViewModels.Pagination; using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Tree; @@ -44,12 +44,12 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB return Task.FromResult>>(Ok(result)); } - protected Task>> GetSiblings(Guid target, int before, int after) + protected Task>> GetSiblings(Guid target, int before, int after) { - IEntitySlim[] siblings = EntityService.GetSiblings(target, ItemObjectType, before, after, ItemOrdering).ToArray(); + IEntitySlim[] siblings = GetSiblingEntities(target, before, after, out var totalBefore, out var totalAfter); if (siblings.Length == 0) { - return Task.FromResult>>(NotFound()); + return Task.FromResult>>(NotFound()); } IEntitySlim? entity = siblings.FirstOrDefault(); @@ -57,8 +57,11 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ? EntityService.GetKey(entity.ParentId, ItemObjectType).Result : Constants.System.RootKey; - TItem[] treeItemsViewModels = MapTreeItemViewModels(parentKey, siblings); - return Task.FromResult>>(Ok(treeItemsViewModels)); + TItem[] treeItemViewModels = MapTreeItemViewModels(parentKey, siblings); + + SubsetViewModel result = SubsetViewModel(treeItemViewModels, totalBefore, totalAfter); + + return Task.FromResult>>(Ok(result)); } protected virtual async Task>> GetAncestors(Guid descendantKey, bool includeSelf = true) @@ -110,7 +113,8 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB .ToArray(); protected virtual IEntitySlim[] GetPagedChildEntities(Guid parentKey, int skip, int take, out long totalItems) => - EntityService.GetPagedChildren( + EntityService + .GetPagedChildren( parentKey, ItemObjectType, skip, @@ -119,6 +123,18 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB ordering: ItemOrdering) .ToArray(); + protected virtual IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) => + EntityService + .GetSiblings( + target, + [ItemObjectType], + before, + after, + out totalBefore, + out totalAfter, + ordering: ItemOrdering) + .ToArray(); + protected virtual TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) => entities.Select(entity => MapTreeItemViewModel(parentKey, entity)).ToArray(); @@ -141,4 +157,7 @@ public abstract class EntityTreeControllerBase : ManagementApiControllerB protected 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 }; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FolderTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FolderTreeControllerBase.cs index a7c201372c..4e52bbe4e8 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/FolderTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/FolderTreeControllerBase.cs @@ -51,6 +51,24 @@ public abstract class FolderTreeControllerBase : NamedEntityTreeControlle take, out totalItems); + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + + UmbracoObjectTypes[] siblingObjectTypes = GetObjectTypes(); + + return EntityService.GetSiblings( + target, + siblingObjectTypes, + before, + after, + out totalBefore, + out totalAfter, + ordering: ItemOrdering) + .ToArray(); + } + protected override TItem MapTreeItemViewModel(Guid? parentKey, IEntitySlim entity) { TItem viewModel = base.MapTreeItemViewModel(parentKey, entity); @@ -93,19 +111,19 @@ public abstract class FolderTreeControllerBase : NamedEntityTreeControlle { totalItems = 0; - UmbracoObjectTypes[] childObjectTypes = _foldersOnly ? [FolderObjectType] : [FolderObjectType, ItemObjectType]; + UmbracoObjectTypes[] childObjectTypes = GetObjectTypes(); - IEntitySlim[] itemEntities = EntityService.GetPagedChildren( - parentKey, - [FolderObjectType, ItemObjectType], - childObjectTypes, - skip, - take, - false, - out totalItems, - ordering: ItemOrdering) - .ToArray(); - - return itemEntities; + return EntityService.GetPagedChildren( + parentKey, + [FolderObjectType, ItemObjectType], + childObjectTypes, + skip, + take, + false, + out totalItems, + ordering: ItemOrdering) + .ToArray(); } + + private UmbracoObjectTypes[] GetObjectTypes() => _foldersOnly ? [FolderObjectType] : [FolderObjectType, ItemObjectType]; } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs index 8c15708f1f..6f95e6210e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Tree/UserStartNodeTreeControllerBase.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.Models.Entities; +using Umbraco.Cms.Api.Management.Models.Entities; using Umbraco.Cms.Api.Management.Services.Entities; using Umbraco.Cms.Api.Management.ViewModels.Tree; using Umbraco.Cms.Core; @@ -59,6 +59,26 @@ public abstract class UserStartNodeTreeControllerBase : EntityTreeControl return CalculateAccessMap(() => userAccessEntities, out _); } + protected override IEntitySlim[] GetSiblingEntities(Guid target, int before, int after, out long totalBefore, out long totalAfter) + { + if (UserHasRootAccess() || IgnoreUserStartNodes()) + { + return base.GetSiblingEntities(target, before, after, out totalBefore, out totalAfter); + } + + IEnumerable userAccessEntities = _userStartNodeEntitiesService.SiblingUserAccessEntities( + ItemObjectType, + UserStartNodePaths, + target, + before, + after, + ItemOrdering, + out totalBefore, + out totalAfter); + + return CalculateAccessMap(() => userAccessEntities, out _); + } + protected override TItem[] MapTreeItemViewModels(Guid? parentKey, IEntitySlim[] entities) { if (UserHasRootAccess() || IgnoreUserStartNodes()) diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index 1cd05c0cd8..2f0dde606a 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -1839,14 +1839,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDataTypeTreeItemResponseModel" + } + ] } } } @@ -4453,14 +4450,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentBlueprintTreeItemResponseModel" + } + ] } } } @@ -6770,14 +6764,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTypeTreeItemResponseModel" + } + ] } } } @@ -11208,6 +11199,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -11216,14 +11215,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/DocumentTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetDocumentTreeItemResponseModel" + } + ] } } } @@ -16028,14 +16024,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTypeTreeItemResponseModel" + } + ] } } } @@ -18550,6 +18543,14 @@ "type": "integer", "format": "int32" } + }, + { + "name": "dataTypeId", + "in": "query", + "schema": { + "type": "string", + "format": "uuid" + } } ], "responses": { @@ -18558,14 +18559,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/MediaTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetMediaTreeItemResponseModel" + } + ] } } } @@ -28769,14 +28767,11 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" - } - ] - } + "oneOf": [ + { + "$ref": "#/components/schemas/SubsetNamedEntityTreeItemResponseModel" + } + ] } } } @@ -36735,6 +36730,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -37021,6 +37019,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -38357,6 +38358,36 @@ }, "additionalProperties": false }, + "DocumentTypePermissionPresentationModel": { + "required": [ + "$type", + "documentTypeAlias", + "verbs" + ], + "type": "object", + "properties": { + "$type": { + "type": "string" + }, + "verbs": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, + "documentTypeAlias": { + "type": "string" + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "$type", + "mapping": { + "DocumentTypePermissionPresentationModel": "#/components/schemas/DocumentTypePermissionPresentationModel" + } + } + }, "DocumentTypePropertyTypeContainerResponseModel": { "required": [ "id", @@ -44776,6 +44807,209 @@ }, "additionalProperties": false }, + "SubsetDataTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DataTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentBlueprintTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentBlueprintTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetDocumentTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetMediaTypeTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/MediaTypeTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, + "SubsetNamedEntityTreeItemResponseModel": { + "required": [ + "items", + "totalAfter", + "totalBefore" + ], + "type": "object", + "properties": { + "totalBefore": { + "type": "integer", + "format": "int64" + }, + "totalAfter": { + "type": "integer", + "format": "int64" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/NamedEntityTreeItemResponseModel" + } + ] + } + } + }, + "additionalProperties": false + }, "TagResponseModel": { "required": [ "id", @@ -46514,6 +46748,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } @@ -46940,6 +47177,9 @@ { "$ref": "#/components/schemas/DocumentPropertyValuePermissionPresentationModel" }, + { + "$ref": "#/components/schemas/DocumentTypePermissionPresentationModel" + }, { "$ref": "#/components/schemas/UnknownTypePermissionPresentationModel" } diff --git a/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs index 69bcd284e8..c5479e604b 100644 --- a/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs +++ b/src/Umbraco.Cms.Api.Management/ServerEvents/UserConnectionManager.cs @@ -7,7 +7,7 @@ internal sealed class UserConnectionManager : IUserConnectionManager { // We use a normal dictionary instead of ConcurrentDictionary, since we need to lock the set anyways. private readonly Dictionary> _connections = new(); - private readonly object _lock = new(); + private readonly Lock _lock = new(); /// public ISet GetConnections(Guid userKey) diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs index 2753bd29b8..58e758f5c2 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/IUserStartNodeEntitiesService.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Api.Management.Models.Entities; @@ -64,6 +64,37 @@ public interface IUserStartNodeEntitiesService /// IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths); + /// + /// Calculates the applicable sibling entities for a given object type for users without root access. + /// + /// The object type. + /// The calculated start node paths for the user. + /// The key of the target. + /// The number of applicable siblings to retrieve before the target. + /// The number of applicable siblings to retrieve after the target. + /// The ordering to apply when fetching and paginating the children. + /// Outputs the total number of siblings before the target entity. + /// Outputs the total number of siblings after the target entity. + /// A list of sibling entities applicable for the user. + /// + /// The returned entities may include entities that outside of the user start node scope, but are needed to + /// for browsing to the actual user start nodes. These entities will be marked as "no access" entities. + /// + IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter) + { + totalBefore = 0; + totalAfter = 0; + return []; + } + /// /// Calculates the access level of a collection of entities for users without root access. /// diff --git a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs index c811d2c9c7..02adf136e5 100644 --- a/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/Entities/UserStartNodeEntitiesService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Entities; @@ -36,17 +36,17 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService /// public IEnumerable RootUserAccessEntities(UmbracoObjectTypes umbracoObjectType, int[] userStartNodeIds) { - // root entities for users without root access should include: + // Root entities for users without root access should include: // - the start nodes that are actual root entities (level == 1) // - the root level ancestors to the rest of the start nodes (required for browsing to the actual start nodes - will be marked as "no access") IEntitySlim[] userStartEntities = userStartNodeIds.Any() ? _entityService.GetAll(umbracoObjectType, userStartNodeIds).ToArray() : Array.Empty(); - // find the start nodes that are at root level (level == 1) + // Find the start nodes that are at root level (level == 1). IEntitySlim[] allowedTopmostEntities = userStartEntities.Where(entity => entity.Level == 1).ToArray(); - // find the root level ancestors of the rest of the start nodes, and add those as well + // Find the root level ancestors of the rest of the start nodes, and add those as well. var nonAllowedTopmostEntityIds = userStartEntities.Except(allowedTopmostEntities) .Select(entity => int.TryParse(entity.Path.Split(Constants.CharArrays.Comma).Skip(1).FirstOrDefault(), out var id) ? id : 0) .Where(id => id > 0) @@ -63,7 +63,15 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService .ToArray(); } - public IEnumerable ChildUserAccessEntities(UmbracoObjectTypes umbracoObjectType, string[] userStartNodePaths, Guid parentKey, int skip, int take, Ordering ordering, out long totalItems) + /// + public IEnumerable ChildUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid parentKey, + int skip, + int take, + Ordering ordering, + out long totalItems) { Attempt parentIdAttempt = _idKeyMap.GetIdForKey(parentKey, umbracoObjectType); if (parentIdAttempt.Success is false) @@ -83,40 +91,46 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService IEntitySlim[] children; if (userStartNodePaths.Any(path => $"{parent.Path},".StartsWith($"{path},"))) { - // the requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed + // The requested parent is one of the user start nodes (or a descendant of one), all children are by definition allowed. children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, ordering: ordering).ToArray(); return ChildUserAccessEntities(children, userStartNodePaths); } - // if one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths - // - e.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. - var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); - var allowedChildIds = userStartNodePathIds - .Where(ids => ids.Contains(parentId)) - // given the previous checks, the parent ID can never be the last in the user start node path, so this is safe - .Select(ids => ids[ids.IndexOf(parentId) + 1]) - .Distinct() - .ToArray(); + int[] allowedChildIds = GetAllowedIds(userStartNodePaths, parentId); totalItems = allowedChildIds.Length; if (allowedChildIds.Length == 0) { - // the requested parent is outside the scope of any user start nodes + // The requested parent is outside the scope of any user start nodes. return []; } - // even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children + // Even though we know the IDs of the allowed child entities to fetch, we still use a Query to yield correctly sorted children. IQuery query = _scopeProvider.CreateQuery().Where(x => allowedChildIds.Contains(x.Id)); children = _entityService.GetPagedChildren(parentKey, umbracoObjectType, skip, take, out totalItems, query, ordering).ToArray(); return ChildUserAccessEntities(children, userStartNodePaths); } + private static int[] GetAllowedIds(string[] userStartNodePaths, int parentId) + { + // If one or more of the user start nodes are descendants of the requested parent, find the "next child IDs" in those user start node paths + // that are the final entries in the path. + // E.g. given the user start node path "-1,2,3,4,5", if the requested parent ID is 3, the "next child ID" is 4. + var userStartNodePathIds = userStartNodePaths.Select(path => path.Split(Constants.CharArrays.Comma).Select(int.Parse).ToArray()).ToArray(); + return userStartNodePathIds + .Where(ids => ids.Contains(parentId)) + .Select(ids => ids[ids.IndexOf(parentId) + 1]) // Given the previous checks, the parent ID can never be the last in the user start node path, so this is safe + .Distinct() + .ToArray(); + } + /// public IEnumerable ChildUserAccessEntities(IEnumerable candidateChildren, string[] userStartNodePaths) - // child entities for users without root access should include: + + // Child or sibling entities for users without root access should include: // - children that are descendant-or-self of a user start node // - children that are ancestors of a user start node (required for browsing to the actual start nodes - will be marked as "no access") - // all other candidate children should be discarded + // All other candidate children should be discarded. => candidateChildren.Select(child => { // is descendant-or-self of a start node? @@ -134,9 +148,72 @@ public class UserStartNodeEntitiesService : IUserStartNodeEntitiesService return null; }).WhereNotNull().ToArray(); + /// + public IEnumerable SiblingUserAccessEntities( + UmbracoObjectTypes umbracoObjectType, + string[] userStartNodePaths, + Guid targetKey, + int before, + int after, + Ordering ordering, + out long totalBefore, + out long totalAfter + ) + { + Attempt targetIdAttempt = _idKeyMap.GetIdForKey(targetKey, umbracoObjectType); + if (targetIdAttempt.Success is false) + { + totalBefore = 0; + totalAfter = 0; + return []; + } + + var targetId = targetIdAttempt.Result; + IEntitySlim? target = _entityService.Get(targetId); + if (target is null) + { + totalBefore = 0; + totalAfter = 0; + return []; + } + + IEntitySlim[] siblings; + + IEntitySlim? targetParent = _entityService.Get(target.ParentId); + if (targetParent is null) // Even if the parent is the root, we still expect to get a value here. + { + totalBefore = 0; + totalAfter = 0; + return []; + } + + if (userStartNodePaths.Any(path => $"{targetParent?.Path},".StartsWith($"{path},"))) + { + // The requested parent of the target is one of the user start nodes (or a descendant of one), all siblings are by definition allowed. + siblings = _entityService.GetSiblings(targetKey, [umbracoObjectType], before, after, out totalBefore, out totalAfter, ordering: ordering).ToArray(); + return ChildUserAccessEntities(siblings, userStartNodePaths); + } + + int[] allowedSiblingIds = GetAllowedIds(userStartNodePaths, targetParent.Id); + + if (allowedSiblingIds.Length == 0) + { + // The requested target is outside the scope of any user start nodes. + totalBefore = 0; + totalAfter = 0; + return []; + } + + // Even though we know the IDs of the allowed sibling entities to fetch, we still use a Query to yield correctly sorted children. + IQuery query = _scopeProvider.CreateQuery().Where(x => allowedSiblingIds.Contains(x.Id)); + siblings = _entityService.GetSiblings(targetKey, [umbracoObjectType], before, after, out totalBefore, out totalAfter, query, ordering).ToArray(); + return ChildUserAccessEntities(siblings, userStartNodePaths); + } + /// public IEnumerable UserAccessEntities(IEnumerable entities, string[] userStartNodePaths) - // entities for users without root access should include: + + // Entities for users without root access should include: // - entities that are descendant-or-self of a user start node as regular entities // - all other entities as "no access" entities => entities.Select(entity => new UserAccessEntity(entity, IsDescendantOrSelf(entity, userStartNodePaths))).ToArray(); diff --git a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs index 245bbe5534..08353e8d02 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Composition/UmbracoEFCoreComposer.cs @@ -19,7 +19,7 @@ public class UmbracoEFCoreComposer : IComposer builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); - builder.Services.AddUmbracoDbContext((options) => + builder.Services.AddUmbracoDbContext((provider, options, connectionString, providerName) => { // Register the entity sets needed by OpenIddict. options.UseOpenIddict(); diff --git a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs index d7da8a65fe..adcfb27406 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/Extensions/UmbracoEFCoreServiceCollectionExtensions.cs @@ -17,32 +17,50 @@ public static class UmbracoEFCoreServiceCollectionExtensions /// /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. /// - /// - /// - /// - /// - public static IServiceCollection AddUmbracoDbContext(this IServiceCollection services, Action? optionsAction = null) + [Obsolete("Please use the method overload that takes all parameters for the optionsAction. Scheduled for removal in Umbraco 18.")] + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) + where T : DbContext + => AddUmbracoDbContext(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(optionsBuilder)); + + /// + /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. + /// + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) where T : DbContext { - return AddUmbracoDbContext(services, (IServiceProvider _, DbContextOptionsBuilder options) => + return AddUmbracoDbContext(services, (IServiceProvider provider, DbContextOptionsBuilder optionsBuilder, string? providerName, string? connectionString) => { - optionsAction?.Invoke(options); + ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); + optionsAction?.Invoke(optionsBuilder, connectionStrings.ConnectionString, connectionStrings.ProviderName, provider); }); } /// /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. /// - /// - /// - /// - /// - public static IServiceCollection AddUmbracoDbContext(this IServiceCollection services, Action? optionsAction = null) + [Obsolete("Please use the method overload that takes all parameters for the optionsAction. Scheduled for removal in Umbraco 18.")] + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) + where T : DbContext + => AddUmbracoDbContext(services, (sp, optionsBuilder, connectionString, providerName) => optionsAction?.Invoke(sp, optionsBuilder)); + + /// + /// Adds a EFCore DbContext with all the services needed to integrate with Umbraco scopes. + /// + public static IServiceCollection AddUmbracoDbContext( + this IServiceCollection services, + Action? optionsAction = null) where T : DbContext { - optionsAction ??= (sp, options) => { }; + optionsAction ??= (sp, optionsBuilder, connectionString, providerName) => { }; - services.AddPooledDbContextFactory(optionsAction); + + services.AddPooledDbContextFactory((provider, optionsBuilder) => SetupDbContext(optionsAction, provider, optionsBuilder)); services.AddTransient(services => services.GetRequiredService>().CreateDbContext()); services.AddUnique, AmbientEFCoreScopeStack>(); @@ -110,4 +128,25 @@ public static class UmbracoEFCoreServiceCollectionExtensions builder.UseDatabaseProvider(connectionStrings.ProviderName, connectionStrings.ConnectionString); } + + private static void SetupDbContext(Action? optionsAction, IServiceProvider provider, DbContextOptionsBuilder builder) + { + ConnectionStrings connectionStrings = GetConnectionStringAndProviderName(provider); + + optionsAction?.Invoke(provider, builder, connectionStrings.ConnectionString, connectionStrings.ProviderName); + } + + private static ConnectionStrings GetConnectionStringAndProviderName(IServiceProvider serviceProvider) + { + ConnectionStrings connectionStrings = serviceProvider.GetRequiredService>().CurrentValue; + + // Replace data directory + string? dataDirectory = AppDomain.CurrentDomain.GetData(Constants.System.DataDirectoryName)?.ToString(); + if (string.IsNullOrEmpty(dataDirectory) is false) + { + connectionStrings.ConnectionString = connectionStrings.ConnectionString?.Replace(Constants.System.DataDirectoryPlaceholder, dataDirectory); + } + + return connectionStrings; + } } diff --git a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs index 9fcaccfe37..ca69e31727 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs +++ b/src/Umbraco.Cms.Persistence.EFCore/UmbracoDbContext.cs @@ -1,4 +1,5 @@ using System.Configuration; +using System.Diagnostics; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.DependencyInjection; @@ -17,14 +18,14 @@ namespace Umbraco.Cms.Persistence.EFCore; /// and insure the 'src/Umbraco.Web.UI/appsettings.json' have a connection string set with the right provider. /// /// Create a migration for each provider. -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -c UmbracoDbContext -- --provider SqlServer +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -c UmbracoDbContext /// -/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -c UmbracoDbContext -- --provider Sqlite +/// dotnet ef migrations add %Name% -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -c UmbracoDbContext /// /// Remove the last migration for each provider. -/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer -- --provider SqlServer +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.SqlServer /// -/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite -- --provider Sqlite +/// dotnet ef migrations remove -s src/Umbraco.Web.UI -p src/Umbraco.Cms.Persistence.EFCore.Sqlite /// /// To find documentation about this way of working with the context see /// https://learn.microsoft.com/en-us/ef/core/managing-schemas/migrations/providers?tabs=dotnet-core-cli#using-one-context-type @@ -37,28 +38,35 @@ public class UmbracoDbContext : DbContext /// public UmbracoDbContext(DbContextOptions options) : base(ConfigureOptions(options)) - { - - } + { } private static DbContextOptions ConfigureOptions(DbContextOptions options) { - IOptionsMonitor connectionStringsOptionsMonitor = StaticServiceProvider.Instance.GetRequiredService>(); - - ConnectionStrings connectionStrings = connectionStringsOptionsMonitor.CurrentValue; - - if (string.IsNullOrWhiteSpace(connectionStrings.ConnectionString)) + var extensions = options.Extensions.FirstOrDefault() as Microsoft.EntityFrameworkCore.Infrastructure.CoreOptionsExtension; + IServiceProvider? serviceProvider = extensions?.ApplicationServiceProvider; + serviceProvider ??= StaticServiceProvider.Instance; + if (serviceProvider == null) { - ILogger logger = StaticServiceProvider.Instance.GetRequiredService>(); - logger.LogCritical("No connection string was found, cannot setup Umbraco EF Core context"); + // If the service provider is null, we cannot resolve the connection string or migration provider. + throw new InvalidOperationException("The service provider is not configured. Ensure that UmbracoDbContext is registered correctly."); + } + + IOptionsMonitor? connectionStringsOptionsMonitor = serviceProvider?.GetRequiredService>(); + + ConnectionStrings? connectionStrings = connectionStringsOptionsMonitor?.CurrentValue; + + if (string.IsNullOrWhiteSpace(connectionStrings?.ConnectionString)) + { + ILogger? logger = serviceProvider?.GetRequiredService>(); + logger?.LogCritical("No connection string was found, cannot setup Umbraco EF Core context"); // we're throwing an exception here to make it abundantly clear that one should never utilize (or have a // dependency on) the DbContext before the connection string has been initialized by the installer. throw new InvalidOperationException("No connection string was found, cannot setup Umbraco EF Core context"); } - IEnumerable migrationProviders = StaticServiceProvider.Instance.GetServices(); - IMigrationProviderSetup? migrationProvider = migrationProviders.FirstOrDefault(x => x.ProviderName.CompareProviderNames(connectionStrings.ProviderName)); + IEnumerable? migrationProviders = serviceProvider?.GetServices(); + IMigrationProviderSetup? migrationProvider = migrationProviders?.FirstOrDefault(x => x.ProviderName.CompareProviderNames(connectionStrings.ProviderName)); if (migrationProvider == null && connectionStrings.ProviderName != null) { diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs index 2ed07cbf02..6e326fcaaa 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs +++ b/src/Umbraco.Cms.Persistence.Sqlite/Services/SqliteSyntaxProvider.cs @@ -162,6 +162,8 @@ public class SqliteSyntaxProvider : SqlSyntaxProviderBase public override string ConvertDateToOrderableString => "{0}"; + public override string ConvertUniqueIdentifierToString => "{0}"; + public override string RenameTable => "ALTER TABLE {0} RENAME TO {1}"; /// diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index c1269aac60..db24ece2d9 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -5,7 +5,6 @@ using System.ComponentModel; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs index 3360b0e78a..368e4a99ee 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Extensions; @@ -31,7 +30,7 @@ public class PublishedValueFallback : IPublishedValueFallback /// public bool TryGetValue(IPublishedProperty property, string? culture, string? segment, Fallback fallback, T? defaultValue, out T? value) { - _variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, property.Alias, ref culture, ref segment); foreach (var f in fallback) { @@ -79,7 +78,7 @@ public class PublishedValueFallback : IPublishedValueFallback return false; } - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, alias, ref culture, ref segment); foreach (var f in fallback) { @@ -125,7 +124,7 @@ public class PublishedValueFallback : IPublishedValueFallback IPublishedPropertyType? propertyType = content.ContentType.GetPropertyType(alias); if (propertyType != null) { - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment); noValueProperty = content.GetProperty(alias); } @@ -196,7 +195,7 @@ public class PublishedValueFallback : IPublishedValueFallback { culture = null; segment = null; - _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, ref culture, ref segment); + _variationContextAccessor.ContextualizeVariation(propertyType.Variations, content.Id, alias, ref culture, ref segment); } property = content?.GetProperty(alias); diff --git a/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs b/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs index 92326ae359..395e0f9c20 100644 --- a/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs +++ b/src/Umbraco.Core/Models/PublishedContent/VariationContext.cs @@ -25,9 +25,15 @@ public class VariationContext public string Segment { get; } /// - /// Gets the segment for the content item + /// Gets the segment for the content item. /// - /// - /// + /// The content Id. public virtual string GetSegment(int contentId) => Segment; + + /// + /// Gets the segment for the content item and property alias. + /// + /// The content Id. + /// The property alias. + public virtual string GetSegment(int contentId, string propertyAlias) => Segment; } diff --git a/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs b/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs index e8f6e3bdc1..566d5e45af 100644 --- a/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs +++ b/src/Umbraco.Core/Models/PublishedContent/VariationContextAccessorExtensions.cs @@ -8,25 +8,45 @@ namespace Umbraco.Extensions; public static class VariationContextAccessorExtensions { + [Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")] public static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, ref string? culture, ref string? segment) - => variationContextAccessor.ContextualizeVariation(variations, null, ref culture, ref segment); + => variationContextAccessor.ContextualizeVariation(variations, null, null, ref culture, ref segment); + public static void ContextualizeVariation( + this IVariationContextAccessor variationContextAccessor, + ContentVariation variations, + string? propertyAlias, + ref string? culture, + ref string? segment) + => variationContextAccessor.ContextualizeVariation(variations, null, propertyAlias, ref culture, ref segment); + + [Obsolete("Please use the method overload that accepts all parameters. Scheduled for removal in Umbraco 18.")] public static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, int contentId, ref string? culture, ref string? segment) - => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, ref culture, ref segment); + => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, null, ref culture, ref segment); + + public static void ContextualizeVariation( + this IVariationContextAccessor variationContextAccessor, + ContentVariation variations, + int contentId, + string? propertyAlias, + ref string? culture, + ref string? segment) + => variationContextAccessor.ContextualizeVariation(variations, (int?)contentId, propertyAlias, ref culture, ref segment); private static void ContextualizeVariation( this IVariationContextAccessor variationContextAccessor, ContentVariation variations, int? contentId, + string? propertyAlias, ref string? culture, ref string? segment) { @@ -37,18 +57,22 @@ public static class VariationContextAccessorExtensions // use context values VariationContext? publishedVariationContext = variationContextAccessor?.VariationContext; - if (culture == null) - { - culture = variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty; - } + culture ??= variations.VariesByCulture() ? publishedVariationContext?.Culture : string.Empty; if (segment == null) { if (variations.VariesBySegment()) { - segment = contentId == null - ? publishedVariationContext?.Segment - : publishedVariationContext?.GetSegment(contentId.Value); + if (contentId == null) + { + segment = publishedVariationContext?.Segment; + } + else + { + segment = propertyAlias == null ? + publishedVariationContext?.GetSegment(contentId.Value) : + publishedVariationContext?.GetSegment(contentId.Value, propertyAlias); + } } else { diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index 9dc3148b7d..cfa564ca43 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -20,7 +20,12 @@ public static partial class Constants public const string ContentType = /*TableNamePrefix*/ "cms" + "ContentType"; public const string ContentChildType = /*TableNamePrefix*/ "cms" + "ContentTypeAllowedContentType"; public const string DocumentType = /*TableNamePrefix*/ "cms" + "DocumentType"; + + [Obsolete("Please use ContentTypeTree instead. Scheduled for removal in Umbraco 18.")] public const string ElementTypeTree = /*TableNamePrefix*/ "cms" + "ContentType2ContentType"; + + public const string ContentTypeTree = /*TableNamePrefix*/ "cms" + "ContentType2ContentType"; + public const string DataType = TableNamePrefix + "DataType"; public const string Template = /*TableNamePrefix*/ "cms" + "Template"; diff --git a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs index cdf05ca5aa..47cf15c3fc 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IEntityRepository.cs @@ -22,13 +22,29 @@ public interface IEntityRepository : IRepository /// /// Gets sibling entities of a specified target entity, within a given range before and after the target, ordered as specified. /// - /// The object type key of the entities. + /// 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 sibling entities. - IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) => []; + IEnumerable GetSiblings( + 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/Routing/SiteDomainMapper.cs b/src/Umbraco.Core/Routing/SiteDomainMapper.cs index 247319cd5a..496bd6fe43 100644 --- a/src/Umbraco.Core/Routing/SiteDomainMapper.cs +++ b/src/Umbraco.Core/Routing/SiteDomainMapper.cs @@ -65,7 +65,7 @@ namespace Umbraco.Cms.Core.Routing } } - private IEnumerable ValidateDomains(IEnumerable domains) => + private static IEnumerable ValidateDomains(IEnumerable domains) => // must use authority format w/optional scheme and port, but no path // any domain should appear only once domains.Select(domain => @@ -368,7 +368,7 @@ namespace Umbraco.Cms.Core.Routing // therefore it is safe to return and exit the configuration lock } - private DomainAndUri? MapDomain( + private static DomainAndUri? MapDomain( IReadOnlyCollection domainAndUris, Dictionary? qualifiedSites, string currentAuthority, diff --git a/src/Umbraco.Core/Serialization/IJsonSerializer.cs b/src/Umbraco.Core/Serialization/IJsonSerializer.cs index 27bd956f41..54ac7aa02c 100644 --- a/src/Umbraco.Core/Serialization/IJsonSerializer.cs +++ b/src/Umbraco.Core/Serialization/IJsonSerializer.cs @@ -16,7 +16,6 @@ public interface IJsonSerializer /// string Serialize(object? input); - /// /// Parses the text representing a single JSON value into an instance of the type specified by a generic type parameter. /// diff --git a/src/Umbraco.Core/Serialization/IJsonSerializerEncoderFactory.cs b/src/Umbraco.Core/Serialization/IJsonSerializerEncoderFactory.cs new file mode 100644 index 0000000000..43d096a2e8 --- /dev/null +++ b/src/Umbraco.Core/Serialization/IJsonSerializerEncoderFactory.cs @@ -0,0 +1,17 @@ +using System.Text.Encodings.Web; + +namespace Umbraco.Cms.Core.Serialization; + +/// +/// Provides a factory method for creating a for use in instantiating JSON serializers. +/// +public interface IJsonSerializerEncoderFactory +{ + /// + /// Creates a for use in the serialization of configuration editor JSON. + /// + /// The type of the serializer for which the encoder is being created. + /// A instance. + JavaScriptEncoder CreateEncoder() + where TSerializer : IJsonSerializer; +} diff --git a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs index 680d78d5ea..00708fb439 100644 --- a/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentTypeEditing/ContentTypeEditingServiceBase.cs @@ -520,6 +520,10 @@ internal abstract class ContentTypeEditingServiceBase model.Containers.First(c => c.Key == container.ParentKey).Name); + // Store the existing property types in a list to reference when processing properties. + // This ensures we correctly handle property types that may have been filtered out from groups. + var existingPropertyTypes = contentType.PropertyTypes.ToList(); + // handle properties in groups PropertyGroup[] propertyGroups = model.Containers.Select(container => { @@ -540,7 +544,7 @@ internal abstract class ContentTypeEditingServiceBase property.ContainerKey == container.Key) - .Select(property => MapProperty(contentType, property, propertyGroup, dataTypesByKey)) + .Select(property => MapProperty(contentType, property, propertyGroup, existingPropertyTypes, dataTypesByKey)) .ToArray(); if (properties.Any() is false && parentContainerNamesById.ContainsKey(container.Key) is false) @@ -565,8 +569,8 @@ internal abstract class ContentTypeEditingServiceBase orphanedPropertyTypeModels = model.Properties.Where (x => x.ContainerKey is null).ToArray(); - IPropertyType[] orphanedPropertyTypes = orphanedPropertyTypeModels.Select(property => MapProperty(contentType, property, null, dataTypesByKey)).ToArray(); + IEnumerable orphanedPropertyTypeModels = model.Properties.Where(x => x.ContainerKey is null).ToArray(); + IPropertyType[] orphanedPropertyTypes = orphanedPropertyTypeModels.Select(property => MapProperty(contentType, property, null, existingPropertyTypes, dataTypesByKey)).ToArray(); if (contentType.NoGroupPropertyTypes.SequenceEqual(orphanedPropertyTypes) is false) { contentType.NoGroupPropertyTypes = new PropertyTypeCollection(SupportsPublishing, orphanedPropertyTypes); @@ -588,6 +592,7 @@ internal abstract class ContentTypeEditingServiceBase existingPropertyTypes, IDictionary dataTypesByKey) { // get the selected data type @@ -598,7 +603,7 @@ internal abstract class ContentTypeEditingServiceBase pt.Key == property.Key) + IPropertyType propertyType = existingPropertyTypes.FirstOrDefault(pt => pt.Key == property.Key) ?? new PropertyType(_shortStringHelper, dataType); // We are demanding a property type key in the model, so we should probably ensure that it's the on that's actually used. @@ -617,10 +622,9 @@ internal abstract class ContentTypeEditingServiceBase(() => propertyGroup.Id, false); - } + propertyType.PropertyGroupId = propertyGroup is null + ? null + : new Lazy(() => propertyGroup.Id, false); return propertyType; } diff --git a/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs b/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs index ac7e8550b2..b0cd6af6dc 100644 --- a/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs +++ b/src/Umbraco.Core/Services/DateTypeServiceExtensions.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; @@ -8,11 +8,6 @@ public static class DateTypeServiceExtensions { public static bool IsDataTypeIgnoringUserStartNodes(this IDataTypeService dataTypeService, Guid key) { - if (DataTypeExtensions.IsBuildInDataType(key)) - { - return false; // built in ones can never be ignoring start nodes - } - IDataType? dataType = dataTypeService.GetAsync(key).GetAwaiter().GetResult(); if (dataType != null && dataType.ConfigurationObject is IIgnoreUserStartNodesConfig ignoreStartNodesConfig) diff --git a/src/Umbraco.Core/Services/EntityService.cs b/src/Umbraco.Core/Services/EntityService.cs index 0a284bdebf..6ba02cc880 100644 --- a/src/Umbraco.Core/Services/EntityService.cs +++ b/src/Umbraco.Core/Services/EntityService.cs @@ -321,9 +321,12 @@ public class EntityService : RepositoryService, IEntityService /// public IEnumerable GetSiblings( Guid key, - UmbracoObjectTypes objectType, + IEnumerable objectTypes, int before, int after, + out long totalBefore, + out long totalAfter, + IQuery? filter = null, Ordering? ordering = null) { if (before < 0) @@ -340,12 +343,17 @@ public class EntityService : RepositoryService, IEntityService using ICoreScope scope = ScopeProvider.CreateCoreScope(); + var objectTypeGuids = objectTypes.Select(x => x.GetGuid()).ToHashSet(); + IEnumerable siblings = _entityRepository.GetSiblings( - objectType.GetGuid(), + objectTypeGuids, key, before, after, - ordering); + filter, + ordering, + out totalBefore, + out totalAfter); scope.Complete(); return siblings; diff --git a/src/Umbraco.Core/Services/EntityTypeContainerService.cs b/src/Umbraco.Core/Services/EntityTypeContainerService.cs index 46532ad067..5c724e1529 100644 --- a/src/Umbraco.Core/Services/EntityTypeContainerService.cs +++ b/src/Umbraco.Core/Services/EntityTypeContainerService.cs @@ -107,7 +107,7 @@ internal abstract class EntityTypeContainerService /// The key of the target entity whose siblings are to be retrieved. - /// The object type key of the entities. + /// 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 sibling entities. IEnumerable GetSiblings( Guid key, - UmbracoObjectTypes objectType, + IEnumerable objectTypes, int before, int after, - Ordering? ordering = null) => []; + 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/Templates/HtmlLocalLinkParser.cs b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs index de573d23d1..4714ebcd2e 100644 --- a/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs +++ b/src/Umbraco.Core/Templates/HtmlLocalLinkParser.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Text.RegularExpressions; using Umbraco.Cms.Core.Routing; -using Umbraco.Cms.Core.Web; namespace Umbraco.Cms.Core.Templates; @@ -84,7 +83,7 @@ public sealed class HtmlLocalLinkParser // under normal circumstances, the type attribute is preceded by a space // to cover the rare occasion where it isn't, we first replace with a space and then without. - private string StripTypeAttributeFromTag(string tag, string type) => + private static string StripTypeAttributeFromTag(string tag, string type) => tag.Replace($" type=\"{type}\"", string.Empty) .Replace($"type=\"{type}\"", string.Empty); diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index fa466e2b40..6b48a5a13c 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -90,6 +90,9 @@ public static partial class UmbracoBuilderExtensions builder.AddNotificationAsyncHandler(); builder.AddNotificationAsyncHandler(); + // Database availability check. + builder.Services.AddUnique(); + // Add runtime mode validation builder.Services.AddSingleton(); builder.RuntimeModeValidators() @@ -124,6 +127,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddUnique(); builder.Services.AddUnique(); // register database builder diff --git a/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..f88c08d57e --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/DefaultDatabaseAvailabilityCheck.cs @@ -0,0 +1,52 @@ +using Microsoft.Extensions.Logging; + +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot using the default method of 5 attempts with a 1 second delay between each one. +/// +internal class DefaultDatabaseAvailabilityCheck : IDatabaseAvailabilityCheck +{ + private const int NumberOfAttempts = 5; + private const int DefaultAttemptDelayMilliseconds = 1000; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// + public DefaultDatabaseAvailabilityCheck(ILogger logger) => _logger = logger; + + /// + /// Gets or sets the number of milliseconds to delay between attempts. + /// + /// + /// Exposed for testing purposes, hence settable only internally. + /// + public int AttemptDelayMilliseconds { get; internal set; } = DefaultAttemptDelayMilliseconds; + + /// + public bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory) + { + bool canConnect; + for (var i = 0; ;) + { + canConnect = databaseFactory.CanConnect; + if (canConnect || ++i == NumberOfAttempts) + { + break; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("Could not immediately connect to database, trying again."); + } + + // Wait for the configured time before trying again. + Thread.Sleep(AttemptDelayMilliseconds); + } + + return canConnect; + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentType2ContentTypeDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentType2ContentTypeDto.cs index 19a3922161..e4c4bf77de 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/ContentType2ContentTypeDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/ContentType2ContentTypeDto.cs @@ -4,7 +4,7 @@ using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; -[TableName(Constants.DatabaseSchema.Tables.ElementTypeTree)] +[TableName(Constants.DatabaseSchema.Tables.ContentTypeTree)] [ExplicitColumns] internal sealed class ContentType2ContentTypeDto { diff --git a/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs new file mode 100644 index 0000000000..9261f3ca5c --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/IDatabaseAvailabilityCheck.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Infrastructure.Persistence; + +/// +/// Checks if a configured database is available on boot. +/// +public interface IDatabaseAvailabilityCheck +{ + /// + /// Checks if the database is available for Umbraco to boot. + /// + /// The . + /// + /// A value indicating whether the database is available. + /// + bool IsDatabaseAvailable(IUmbracoDatabaseFactory databaseFactory); +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs index d9dc0434be..0b0a964298 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentTypeRepositoryBase.cs @@ -438,7 +438,7 @@ AND umbracoNode.id <> @id", IEnumerable propertyTypeToDeleteIds = dbPropertyTypeIds.Except(entityPropertyTypes); foreach (var propertyTypeId in propertyTypeToDeleteIds) { - DeletePropertyType(entity.Id, propertyTypeId); + DeletePropertyType(entity, propertyTypeId); } } @@ -647,7 +647,7 @@ AND umbracoNode.id <> @id", { foreach (var id in orphanPropertyTypeIds) { - DeletePropertyType(entity.Id, id); + DeletePropertyType(entity, id); } } @@ -1410,16 +1410,27 @@ AND umbracoNode.id <> @id", } } - private void DeletePropertyType(int contentTypeId, int propertyTypeId) + private void DeletePropertyType(IContentTypeComposition contentType, int propertyTypeId) { - // first clear dependencies + // First clear dependencies. Database.Delete("WHERE propertyTypeId = @Id", new { Id = propertyTypeId }); Database.Delete("WHERE propertyTypeId = @Id", new { Id = propertyTypeId }); - // then delete the property type + // Clear the property value permissions, which aren't a hard dependency with a foreign key, but we want to ensure + // that any for removed property types are cleared. + var uniqueIdAsString = string.Format(SqlContext.SqlSyntax.ConvertUniqueIdentifierToString, "uniqueId"); + var permissionSearchString = SqlContext.SqlSyntax.GetConcat( + "(SELECT " + uniqueIdAsString + " FROM " + Constants.DatabaseSchema.Tables.PropertyType + " WHERE id = @PropertyTypeId)", + "'|%'"); + + Database.Delete( + "WHERE uniqueId = @ContentTypeKey AND permission LIKE " + permissionSearchString, + new { ContentTypeKey = contentType.Key, PropertyTypeId = propertyTypeId }); + + // Finally delete the property type. Database.Delete( "WHERE contentTypeId = @Id AND id = @PropertyTypeId", - new { Id = contentTypeId, PropertyTypeId = propertyTypeId }); + new { contentType.Id, PropertyTypeId = propertyTypeId }); } protected void ValidateAlias(TEntity entity) @@ -1555,20 +1566,16 @@ WHERE {Constants.DatabaseSchema.Tables.Content}.nodeId IN (@ids) AND cmsContentT // is included here just to be 100% sure since it has a FK on cmsPropertyType. var list = new List { - "DELETE FROM umbracoUser2NodeNotify WHERE nodeId = @id", - "DELETE FROM umbracoUserGroup2Permission WHERE userGroupKey IN (SELECT [umbracoUserGroup].[Key] FROM umbracoUserGroup WHERE Id = @id)", - "DELETE FROM umbracoUserGroup2GranularPermission WHERE userGroupKey IN (SELECT [umbracoUserGroup].[Key] FROM umbracoUserGroup WHERE Id = @id)", - "DELETE FROM cmsTagRelationship WHERE nodeId = @id", - "DELETE FROM cmsContentTypeAllowedContentType WHERE Id = @id", - "DELETE FROM cmsContentTypeAllowedContentType WHERE AllowedId = @id", - "DELETE FROM cmsContentType2ContentType WHERE parentContentTypeId = @id", - "DELETE FROM cmsContentType2ContentType WHERE childContentTypeId = @id", - "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + - " WHERE propertyTypeId IN (SELECT id FROM cmsPropertyType WHERE contentTypeId = @id)", - "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyType + - " WHERE contentTypeId = @id", - "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyTypeGroup + - " WHERE contenttypeNodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.User2NodeNotify + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.UserGroup2GranularPermission + " WHERE uniqueId IN (SELECT uniqueId FROM umbracoNode WHERE id = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.TagRelationship + " WHERE nodeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentChildType + " WHERE Id = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentChildType + " WHERE AllowedId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentTypeTree + " WHERE parentContentTypeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.ContentTypeTree + " WHERE childContentTypeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyData + " WHERE propertyTypeId IN (SELECT id FROM cmsPropertyType WHERE contentTypeId = @id)", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyType + " WHERE contentTypeId = @id", + "DELETE FROM " + Constants.DatabaseSchema.Tables.PropertyTypeGroup + " WHERE contenttypeNodeId = @id", }; return list; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs index cb1cf56a72..ca329b4e72 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityContainerRepository.cs @@ -149,7 +149,7 @@ internal class EntityContainerRepository : EntityRepositoryBase(Sql().SelectAll() .From() - .Where(dto => dto.Text == name && dto.NodeObjectType == NodeObjectTypeId && dto.ParentId == parentId)); + .Where(dto => dto.Text == name && dto.NodeObjectType == NodeObjectTypeId && dto.ParentId == parentId)); return nodeDto is not null; } diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs index c39545f6be..d98657578a 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/EntityRepository.cs @@ -95,10 +95,6 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend ApplyOrdering(ref sql, ordering); } - // TODO: we should be able to do sql = sql.OrderBy(x => Alias(x.NodeId, "NodeId")); but we can't because the OrderBy extension don't support Alias currently - // no matter what we always must have node id ordered at the end - sql = ordering.Direction == Direction.Ascending ? sql.OrderBy("NodeId") : sql.OrderByDescending("NodeId"); - // for content we must query for ContentEntityDto entities to produce the correct culture variant entity names var pageIndexToFetch = pageIndex + 1; IEnumerable dtos; @@ -146,7 +142,15 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend } /// - public IEnumerable GetSiblings(Guid objectType, Guid targetKey, int before, int after, Ordering ordering) + public IEnumerable GetSiblings( + 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. @@ -159,13 +163,28 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend Sql orderingSql = Sql(); ApplyOrdering(ref orderingSql, ordering); - // Get all children of the parent node which is not trashed, ordered by SortOrder, and assign each a row number. + // Get all children of the parent node which are not trashed and match the provided object types. + // Order by SortOrder, and assign each a row number. // These row numbers are important, we need them to select the "before" and "after" siblings of the target node. Sql rowNumberSql = Sql() .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 == false) + .WhereIn(x => x.NodeObjectType, objectTypes); + + // Apply the filter if provided. + if (filter != null) + { + foreach (Tuple filterClause in filter.GetWhereClauses()) + { + rowNumberSql.Where(filterClause.Item1, filterClause.Item2); + } + } + + // By applying additional where clauses with parameters containing an unknown number of elements, the position of the parameters in + // the final query for before and after positions will increase. So we need to calculate the offset based on the provided values. + int beforeAfterParameterIndexOffset = GetBeforeAfterParameterOffset(objectTypes, filter); // Find the specific row number of the target node. // We need this to determine the bounds of the row numbers to select. @@ -180,21 +199,66 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend IEnumerable afterArguments = targetRowSql.Arguments.Concat([after]); // Select the UniqueId of nodes which row number is within the specified range of the target node's row number. + const int BeforeAfterParameterIndex = 3; + var beforeAfterParameterIndex = BeforeAfterParameterIndex + beforeAfterParameterIndexOffset; + var beforeArgumentsArray = beforeArguments.ToArray(); + var afterArgumentsArray = afterArguments.ToArray(); Sql? mainSql = Sql() .Select("UniqueId") .From().AppendSubQuery(rowNumberSql, "NumberedNodes") - .Where($"rn >= ({targetRowSql.SQL}) - @3", beforeArguments.ToArray()) - .Where($"rn <= ({targetRowSql.SQL}) + @3", afterArguments.ToArray()) + .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 []; } - return PerformGetAll(objectType, ordering, sql => sql.WhereIn(x => x.UniqueId, keys)); + // 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) + { + int beforeAfterParameterIndexOffset = 0; + + // Increment for each object type. + beforeAfterParameterIndexOffset += objectTypes.Count; + + // Increment for the provided filter. + if (filter != null) + { + foreach (Tuple filterClause in filter.GetWhereClauses()) + { + // We need to offset by one for each non-array parameter in the filter clause. + // If a query is created using Contains or some other set based operation, we'll get both the array and the + // items in the array provided in the where clauses. It's only the latter that count for applying parameters + // to the SQL statement, and hence we should only offset by them. + beforeAfterParameterIndexOffset += filterClause.Item2.Count(x => !x.GetType().IsArray); + } + } + + return beforeAfterParameterIndexOffset; + } + + private long GetNumberOfSiblingsOutsideSiblingRange( + Sql rowNumberSql, + Sql targetRowSql, + int parameterIndex, + object[] arguments, + bool getBefore) + { + Sql? sql = Sql() + .SelectCount() + .From().AppendSubQuery(rowNumberSql, "NumberedNodes") + .Where($"rn {(getBefore ? "<" : ">")} ({targetRowSql.SQL}) {(getBefore ? "-" : "+")} @{parameterIndex}", arguments); + return Database.ExecuteScalar(sql); } @@ -270,16 +334,16 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend } private IEnumerable PerformGetAll( - Guid objectType, + Guid[] objectTypes, Ordering ordering, Action>? filter = null) { - var isContent = objectType == Constants.ObjectTypes.Document || - objectType == Constants.ObjectTypes.DocumentBlueprint; - var isMedia = objectType == Constants.ObjectTypes.Media; - var isMember = objectType == Constants.ObjectTypes.Member; + var isContent = objectTypes.Contains(Constants.ObjectTypes.Document) || + objectTypes.Contains(Constants.ObjectTypes.DocumentBlueprint); + var isMedia = objectTypes.Contains(Constants.ObjectTypes.Media); + var isMember = objectTypes.Contains(Constants.ObjectTypes.Member); - Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectType, ordering, filter); + Sql sql = GetFullSqlForEntityType(isContent, isMedia, isMember, objectTypes, ordering, filter); return GetEntities(sql, isContent, isMedia, isMember); } @@ -526,8 +590,17 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend Guid objectType, Ordering ordering, Action>? filter) + => GetFullSqlForEntityType(isContent, isMedia, isMember, [objectType], ordering, filter); + + protected Sql GetFullSqlForEntityType( + bool isContent, + bool isMedia, + bool isMember, + Guid[] objectTypes, + Ordering ordering, + Action>? filter) { - Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, new[] { objectType }); + Sql sql = GetBaseWhere(isContent, isMedia, isMember, false, filter, objectTypes); AddGroupBy(isContent, isMedia, isMember, sql, false); ApplyOrdering(ref sql, ordering); @@ -742,6 +815,8 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend Ordering? runner = ordering; + Direction lastDirection = Direction.Ascending; + bool orderingIncludesNodeId = false; do { @@ -753,7 +828,10 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend case "PATH": orderBy = SqlSyntax.GetQuotedColumn(NodeDto.TableName, "path"); break; - + case "NODEID": + orderBy = runner.OrderBy; + orderingIncludesNodeId = true; + break; default: orderBy = runner.OrderBy ?? string.Empty; break; @@ -768,11 +846,25 @@ internal sealed class EntityRepository : RepositoryBase, IEntityRepositoryExtend sql.OrderByDescending(orderBy); } + lastDirection = runner.Direction; + runner = runner.Next; } while (runner is not null); - + // If we haven't already included the node Id in the order by clause, order by node Id as well to ensure consistent results + // when the provided sort yields entities with the same value. + if (orderingIncludesNodeId is false) + { + if (lastDirection == Direction.Ascending) + { + sql.OrderBy(x => x.NodeId); + } + else + { + sql.OrderByDescending(x => x.NodeId); + } + } } #endregion diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs index d1a2c1b0d2..1b01a74535 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/ISqlSyntaxProvider.cs @@ -72,6 +72,8 @@ public interface ISqlSyntaxProvider string ConvertDecimalToOrderableString { get; } + string ConvertUniqueIdentifierToString => throw new NotImplementedException(); + /// /// Returns the default isolation level for the database /// diff --git a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs index 089bc23814..c5a98b9062 100644 --- a/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/SqlSyntax/SqlSyntaxProviderBase.cs @@ -469,6 +469,8 @@ public abstract class SqlSyntaxProviderBase : ISqlSyntaxProvider public virtual string ConvertDecimalToOrderableString => "REPLACE(STR({0}, 20, 9), SPACE(1), '0')"; + public virtual string ConvertUniqueIdentifierToString => "CONVERT(nvarchar(36), {0})"; + private DbTypes InitColumnTypeMap() { var dbTypeMap = new DbTypesFactory(); diff --git a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs index 04ba9a2584..bd1153f51a 100644 --- a/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs +++ b/src/Umbraco.Infrastructure/Runtime/RuntimeState.cs @@ -31,6 +31,7 @@ public class RuntimeState : IRuntimeState private readonly IConflictingRouteService _conflictingRouteService = null!; private readonly IEnumerable _databaseProviderMetadata = null!; private readonly IRuntimeModeValidationService _runtimeModeValidationService = null!; + private readonly IDatabaseAvailabilityCheck _databaseAvailabilityCheck = null!; /// /// The initial @@ -46,6 +47,7 @@ public class RuntimeState : IRuntimeState /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] public RuntimeState( IOptions globalSettings, IOptions unattendedSettings, @@ -56,6 +58,34 @@ public class RuntimeState : IRuntimeState IConflictingRouteService conflictingRouteService, IEnumerable databaseProviderMetadata, IRuntimeModeValidationService runtimeModeValidationService) + : this( + globalSettings, + unattendedSettings, + umbracoVersion, + databaseFactory, + logger, + packageMigrationState, + conflictingRouteService, + databaseProviderMetadata, + runtimeModeValidationService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public RuntimeState( + IOptions globalSettings, + IOptions unattendedSettings, + IUmbracoVersion umbracoVersion, + IUmbracoDatabaseFactory databaseFactory, + ILogger logger, + PendingPackageMigrations packageMigrationState, + IConflictingRouteService conflictingRouteService, + IEnumerable databaseProviderMetadata, + IRuntimeModeValidationService runtimeModeValidationService, + IDatabaseAvailabilityCheck databaseAvailabilityCheck) { _globalSettings = globalSettings; _unattendedSettings = unattendedSettings; @@ -66,6 +96,7 @@ public class RuntimeState : IRuntimeState _conflictingRouteService = conflictingRouteService; _databaseProviderMetadata = databaseProviderMetadata; _runtimeModeValidationService = runtimeModeValidationService; + _databaseAvailabilityCheck = databaseAvailabilityCheck; } /// @@ -242,7 +273,7 @@ public class RuntimeState : IRuntimeState { try { - if (!TryDbConnect(databaseFactory)) + if (_databaseAvailabilityCheck.IsDatabaseAvailable(databaseFactory) is false) { return UmbracoDatabaseState.CannotConnect; } @@ -305,27 +336,4 @@ public class RuntimeState : IRuntimeState } return CurrentMigrationState != FinalMigrationState; } - - private bool TryDbConnect(IUmbracoDatabaseFactory databaseFactory) - { - // anything other than install wants a database - see if we can connect - // (since this is an already existing database, assume localdb is ready) - bool canConnect; - var tries = 5; - for (var i = 0; ;) - { - canConnect = databaseFactory.CanConnect; - if (canConnect || ++i == tries) - { - break; - } - if (_logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Debug)) - { - _logger.LogDebug("Could not immediately connect to database, trying again."); - } - Thread.Sleep(1000); - } - - return canConnect; - } } diff --git a/src/Umbraco.Infrastructure/Serialization/DefaultJsonSerializerEncoderFactory.cs b/src/Umbraco.Infrastructure/Serialization/DefaultJsonSerializerEncoderFactory.cs new file mode 100644 index 0000000000..cf91b91f61 --- /dev/null +++ b/src/Umbraco.Infrastructure/Serialization/DefaultJsonSerializerEncoderFactory.cs @@ -0,0 +1,14 @@ +using System.Text.Encodings.Web; +using System.Text.Unicode; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Infrastructure.Serialization; + +/// +public sealed class DefaultJsonSerializerEncoderFactory : IJsonSerializerEncoderFactory +{ + /// + public JavaScriptEncoder CreateEncoder() + where TSerializer : IJsonSerializer + => JavaScriptEncoder.Create(UnicodeRanges.BasicLatin); +} diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs index 180687b1a4..98185d74a6 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextConfigurationEditorJsonSerializer.cs @@ -1,6 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Json.Serialization.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Serialization; @@ -14,11 +16,24 @@ public sealed class SystemTextConfigurationEditorJsonSerializer : SystemTextJson /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] public SystemTextConfigurationEditorJsonSerializer() + : this( + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public SystemTextConfigurationEditorJsonSerializer(IJsonSerializerEncoderFactory jsonSerializerEncoderFactory) + : base(jsonSerializerEncoderFactory) => _jsonSerializerOptions = new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = jsonSerializerEncoderFactory.CreateEncoder(), + // In some cases, configs aren't camel cased in the DB, so we have to resort to case insensitive // property name resolving when creating configuration objects (deserializing DB configs). PropertyNameCaseInsensitive = true, @@ -40,6 +55,7 @@ public sealed class SystemTextConfigurationEditorJsonSerializer : SystemTextJson .WithAddedModifier(UseAttributeConfiguredPropertyNames()), }; + /// protected override JsonSerializerOptions JsonSerializerOptions => _jsonSerializerOptions; /// diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs index 3edd4d2fc3..c363f5459b 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializer.cs @@ -1,5 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Infrastructure.Serialization; @@ -8,13 +11,27 @@ public sealed class SystemTextJsonSerializer : SystemTextJsonSerializerBase { private readonly JsonSerializerOptions _jsonSerializerOptions; + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] + public SystemTextJsonSerializer() + : this( + StaticServiceProvider.Instance.GetRequiredService()) + { + } + /// /// Initializes a new instance of the class. /// - public SystemTextJsonSerializer() + public SystemTextJsonSerializer(IJsonSerializerEncoderFactory jsonSerializerEncoderFactory) + : base(jsonSerializerEncoderFactory) => _jsonSerializerOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + + Encoder = jsonSerializerEncoderFactory.CreateEncoder(), + Converters = { new JsonStringEnumConverter(), @@ -25,5 +42,6 @@ public sealed class SystemTextJsonSerializer : SystemTextJsonSerializerBase } }; + /// protected override JsonSerializerOptions JsonSerializerOptions => _jsonSerializerOptions; } diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs index cfffb32a99..264a61eb91 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextJsonSerializerBase.cs @@ -1,6 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; +using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Serialization; using Umbraco.Extensions; @@ -8,6 +11,28 @@ namespace Umbraco.Cms.Infrastructure.Serialization; public abstract class SystemTextJsonSerializerBase : IJsonSerializer { + private readonly IJsonSerializerEncoderFactory _jsonSerializerEncoderFactory; + + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] + protected SystemTextJsonSerializerBase() + : this( + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The for creating the . + protected SystemTextJsonSerializerBase(IJsonSerializerEncoderFactory jsonSerializerEncoderFactory) + => _jsonSerializerEncoderFactory = jsonSerializerEncoderFactory; + + /// + /// Gets the . + /// protected abstract JsonSerializerOptions JsonSerializerOptions { get; } /// diff --git a/src/Umbraco.Infrastructure/Serialization/SystemTextWebhookJsonSerializer.cs b/src/Umbraco.Infrastructure/Serialization/SystemTextWebhookJsonSerializer.cs index b75c92073c..b4dc6a1c2b 100644 --- a/src/Umbraco.Infrastructure/Serialization/SystemTextWebhookJsonSerializer.cs +++ b/src/Umbraco.Infrastructure/Serialization/SystemTextWebhookJsonSerializer.cs @@ -1,5 +1,7 @@ -using System.Text.Json; +using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Serialization; namespace Umbraco.Cms.Infrastructure.Serialization; @@ -9,23 +11,38 @@ public sealed class SystemTextWebhookJsonSerializer : SystemTextJsonSerializerBa { private readonly JsonSerializerOptions _jsonSerializerOptions; + /// + /// Initializes a new instance of the class. + /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 18.")] + public SystemTextWebhookJsonSerializer() + : this( + StaticServiceProvider.Instance.GetRequiredService()) + { + } + /// /// Initializes a new instance of the class. /// - public SystemTextWebhookJsonSerializer() + public SystemTextWebhookJsonSerializer(IJsonSerializerEncoderFactory jsonSerializerEncoderFactory) + : base(jsonSerializerEncoderFactory) => _jsonSerializerOptions = new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + + Encoder = jsonSerializerEncoderFactory.CreateEncoder(), + Converters = { new JsonStringEnumConverter(), new JsonUdiConverter(), new JsonUdiRangeConverter(), new JsonObjectConverter(), // Required for block editor values - new JsonBlockValueConverter() + new JsonBlockValueConverter(), }, TypeInfoResolver = new WebhookJsonTypeResolver(), }; + /// protected override JsonSerializerOptions JsonSerializerOptions => _jsonSerializerOptions; } diff --git a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs index 167e46c253..83f063a2b4 100644 --- a/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs +++ b/src/Umbraco.PublishedCache.HybridCache/PublishedProperty.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Collections; using Umbraco.Cms.Core.Models; @@ -21,6 +21,8 @@ internal sealed class PublishedProperty : PublishedPropertyBase private readonly ContentVariation _variations; private readonly ContentVariation _sourceVariations; + private readonly string _propertyTypeAlias; + // the variant and non-variant object values private bool _interInitialized; private object? _interValue; @@ -71,6 +73,8 @@ internal sealed class PublishedProperty : PublishedPropertyBase // it must be set to the union of variance (the combination of content type and property type variance). _variations = propertyType.Variations | content.ContentType.Variations; _sourceVariations = propertyType.Variations; + + _propertyTypeAlias = propertyType.Alias; } // used to cache the CacheValues of this property @@ -89,7 +93,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase // determines whether a property has value public override bool HasValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); var value = GetSourceValue(culture, segment); var hasValue = PropertyType.IsValue(value, PropertyValueLevel.Source); @@ -103,7 +107,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetSourceValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_sourceVariations, _content.Id, _propertyTypeAlias, ref culture, ref segment); // source values are tightly bound to the property/schema culture and segment configurations, so we need to // sanitize the contextualized culture/segment states before using them to access the source values. @@ -146,7 +150,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetValue(string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(PropertyType.CacheLevel).For(culture, segment); @@ -209,7 +213,7 @@ internal sealed class PublishedProperty : PublishedPropertyBase public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, ref culture, ref segment); + _content.VariationContextAccessor.ContextualizeVariation(_variations, _content.Id, _propertyTypeAlias, ref culture, ref segment); object? value; CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); diff --git a/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs b/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs index 4b7f0a17b7..a4feac6f7d 100644 --- a/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs +++ b/src/Umbraco.Web.Common/Filters/ModelBindingExceptionAttribute.cs @@ -85,7 +85,7 @@ public sealed class ModelBindingExceptionAttribute : TypeFilterAttribute /// The application is in an unstable state and is going to be restarted. The application is restarting now. /// /// - private bool IsMessageAboutTheSameModelType(string exceptionMessage) + private static bool IsMessageAboutTheSameModelType(string exceptionMessage) { MatchCollection matches = GetPublishedModelsTypesRegex.Matches(exceptionMessage); diff --git a/src/Umbraco.Web.Common/ModelsBuilder/InMemoryAuto/InMemoryModelFactory.cs b/src/Umbraco.Web.Common/ModelsBuilder/InMemoryAuto/InMemoryModelFactory.cs index cee000e7f9..78c1b808a5 100644 --- a/src/Umbraco.Web.Common/ModelsBuilder/InMemoryAuto/InMemoryModelFactory.cs +++ b/src/Umbraco.Web.Common/ModelsBuilder/InMemoryAuto/InMemoryModelFactory.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Collections.Frozen; using System.Reflection; using System.Reflection.Emit; using System.Text; @@ -21,12 +22,24 @@ using IHostingEnvironment = Umbraco.Cms.Core.Hosting.IHostingEnvironment; namespace Umbraco.Cms.Web.Common.ModelsBuilder.InMemoryAuto { - internal sealed class InMemoryModelFactory : IAutoPublishedModelFactory, IRegisteredObject, IDisposable + internal sealed partial class InMemoryModelFactory : IAutoPublishedModelFactory, IRegisteredObject, IDisposable { - private static readonly Regex s_usingRegex = new Regex("^using(.*);", RegexOptions.Compiled | RegexOptions.Multiline); - private static readonly Regex s_aattrRegex = new Regex("^\\[assembly:(.*)\\]", RegexOptions.Compiled | RegexOptions.Multiline); - private static readonly Regex s_assemblyVersionRegex = new Regex("AssemblyVersion\\(\"[0-9]+.[0-9]+.[0-9]+.[0-9]+\"\\)", RegexOptions.Compiled); - private static readonly string[] s_ourFiles = { "models.hash", "models.generated.cs", "all.generated.cs", "all.dll.path", "models.err", "Compiled" }; + private static readonly Regex s_usingRegex = GetUsingRegex(); + + [GeneratedRegex("^using(.*);", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex GetUsingRegex(); + + private static readonly Regex s_aattrRegex = GetAssemblyRegex(); + + [GeneratedRegex("^\\[assembly:(.*)\\]", RegexOptions.Multiline | RegexOptions.Compiled)] + private static partial Regex GetAssemblyRegex(); + + private static readonly Regex s_assemblyVersionRegex = GetAssemblyVersionRegex(); + + [GeneratedRegex("AssemblyVersion\\(\"[0-9]+.[0-9]+.[0-9]+.[0-9]+\"\\)", RegexOptions.Compiled)] + private static partial Regex GetAssemblyVersionRegex(); + + private static readonly FrozenSet s_ourFiles = FrozenSet.Create("models.hash", "models.generated.cs", "all.generated.cs", "all.dll.path", "models.err", "Compiled"); private readonly ReaderWriterLockSlim _locker = new ReaderWriterLockSlim(); private readonly IProfilingLogger _profilingLogger; private readonly ILogger _logger; @@ -797,7 +810,7 @@ namespace Umbraco.Cms.Web.Common.ModelsBuilder.InMemoryAuto // } // always ignore our own file changes - if (s_ourFiles.Contains(changed)) + if (changed != null && s_ourFiles.Contains(changed)) { return; } diff --git a/src/Umbraco.Web.UI.Client/.storybook/preview.js b/src/Umbraco.Web.UI.Client/.storybook/preview.js index cc2ef5ce52..98c8ab65b5 100644 --- a/src/Umbraco.Web.UI.Client/.storybook/preview.js +++ b/src/Umbraco.Web.UI.Client/.storybook/preview.js @@ -128,6 +128,7 @@ class UmbStoryBookElement extends UmbLitElement { super(); new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'globalContext', [this]); new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'store', [this]); + // TODO: Remove this in Umbraco 18, use the repository instead new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'treeStore', [this]); new UmbExtensionsApiInitializer(this, umbExtensionsRegistry, 'itemStore', [this]); @@ -139,10 +140,10 @@ class UmbStoryBookElement extends UmbLitElement { new UmbModalManagerContext(this); new UmbNotificationContext(this); - umbLocalizationRegistry.loadLanguage('en-us'); // register default language + umbLocalizationRegistry.loadLanguage('en'); // register default language this.consumeContext(UMB_APP_LANGUAGE_CONTEXT, (appLanguageContext) => { - appLanguageContext.setLanguage('en-us'); // set default language + appLanguageContext?.setLanguage('en'); // set default language }); } diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs deleted file mode 100644 index eebdca6c75..0000000000 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/bad-type-import.cjs +++ /dev/null @@ -1,31 +0,0 @@ - /** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Ensures the use of the `import type` operator from the `src/core/models/index.ts` file.', - category: 'Best Practices', - recommended: true, - }, - fixable: 'code', - schema: [], - }, - create: function (context) { - return { - ImportDeclaration: function (node) { - if ( - node.source.parent.importKind !== 'type' && - (node.source.value.endsWith('/models') || node.source.value === 'router-slot/model') - ) { - const sourceCode = context.getSourceCode(); - const nodeSource = sourceCode.getText(node); - context.report({ - node, - message: 'Use `import type` instead of `import`.', - fix: (fixer) => fixer.replaceText(node, nodeSource.replace('import', 'import type')), - }); - } - }, - }; - }, -}; \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs deleted file mode 100644 index 2fc62a385a..0000000000 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs +++ /dev/null @@ -1,42 +0,0 @@ -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'suggestion', - docs: { - description: 'Enforce Custom Element names to start with "umb-".', - category: 'Naming', - recommended: true, - }, - schema: [], - }, - create: function (context) { - return { - CallExpression(node) { - // check if the expression is @customElement decorator - const isCustomElementDecorator = - node.callee.type === 'Identifier' && - node.callee.name === 'customElement' && - node.arguments.length === 1 && - node.arguments[0].type === 'Literal' && - typeof node.arguments[0].value === 'string'; - - if (isCustomElementDecorator) { - const elementName = node.arguments[0].value; - - // check if the element name starts with 'umb-', 'ufm-', or 'test-', to be allow tests to have custom elements: - const prefixes = ['umb-', 'ufm-', 'test-']; - const isElementNameValid = prefixes.some((prefix) => elementName.startsWith(prefix)); - - if (!isElementNameValid) { - context.report({ - node, - message: 'Custom Element name should start with "umb-" or "ufm-".', - // There is no fixer on purpose because it's not safe to automatically rename the element name. - // Renaming should be done manually with consideration of potential impacts. - }); - } - } - }, - }; - }, -}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/exported-string-constant-naming.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/exported-string-constant-naming.cjs deleted file mode 100644 index 8118a54ab1..0000000000 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/exported-string-constant-naming.cjs +++ /dev/null @@ -1,54 +0,0 @@ -/** @type {import('eslint').Rule.RuleModule}*/ -module.exports = { - meta: { - type: 'problem', - docs: { - description: - 'Ensure all exported string constants should be in uppercase with words separated by underscores and prefixed with UMB_', - }, - schema: [ - { - type: 'object', - properties: { - excludedFileNames: { - type: 'array', - items: { - type: 'string', - }, - }, - }, - additionalProperties: false, - }, - ], - }, - create: function (context) { - const excludedFileNames = context.options[0]?.excludedFileNames || []; - return { - ExportNamedDeclaration(node) { - const fileName = context.filename; - - if (excludedFileNames.some((excludedFileName) => fileName.includes(excludedFileName))) { - // Skip the rule check for files in the excluded list - return; - } - - if (node.declaration && node.declaration.type === 'VariableDeclaration') { - const declaration = node.declaration.declarations[0]; - const { id, init } = declaration; - - if (id && id.type === 'Identifier' && init && init.type === 'Literal' && typeof init.value === 'string') { - const isValidName = /^[A-Z]+(_[A-Z]+)*$/.test(id.name); - - if (!isValidName || !id.name.startsWith('UMB_')) { - context.report({ - node: id, - message: - 'Exported string constant should be in uppercase with words separated by underscores and prefixed with UMB_', - }); - } - } - } - }, - }; - }, -}; diff --git a/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs b/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs deleted file mode 100644 index b9dce9c039..0000000000 --- a/src/Umbraco.Web.UI.Client/devops/eslint/rules/umb-class-prefix.cjs +++ /dev/null @@ -1,26 +0,0 @@ -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'Ensure that all class declarations are prefixed with "Umb"', - category: 'Best Practices', - recommended: true, - }, - schema: [], - }, - create: function (context) { - function checkClassName(node) { - if (node.id && node.id.name && !node.id.name.startsWith('Umb')) { - context.report({ - node: node.id, - message: 'Class declaration should be prefixed with "Umb"', - }); - } - } - - return { - ClassDeclaration: checkClassName, - }; - }, -}; diff --git a/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs b/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs index ed3c3364ff..94e82feafc 100644 --- a/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs +++ b/src/Umbraco.Web.UI.Client/eslint-local-rules.cjs @@ -1,27 +1,19 @@ 'use strict'; -const badTypeImportRule = require('./devops/eslint/rules/bad-type-import.cjs'); const enforceElementSuffixOnElementClassNameRule = require('./devops/eslint/rules/enforce-element-suffix-on-element-class-name.cjs'); -const enforceUmbPrefixOnElementNameRule = require('./devops/eslint/rules/enforce-umb-prefix-on-element-name.cjs'); const enforceUmbracoExternalImportsRule = require('./devops/eslint/rules/enforce-umbraco-external-imports.cjs'); const ensureRelativeImportUseJsExtensionRule = require('./devops/eslint/rules/ensure-relative-import-use-js-extension.cjs'); -const exportedStringConstantNaming = require('./devops/eslint/rules/exported-string-constant-naming.cjs'); const noDirectApiImportRule = require('./devops/eslint/rules/no-direct-api-import.cjs'); const preferImportAliasesRule = require('./devops/eslint/rules/prefer-import-aliases.cjs'); const preferStaticStylesLastRule = require('./devops/eslint/rules/prefer-static-styles-last.cjs'); -const umbClassPrefixRule = require('./devops/eslint/rules/umb-class-prefix.cjs'); const noRelativeImportToImportMapModule = require('./devops/eslint/rules/no-relative-import-to-import-map-module.cjs'); module.exports = { - 'bad-type-import': badTypeImportRule, 'enforce-element-suffix-on-element-class-name': enforceElementSuffixOnElementClassNameRule, - 'enforce-umb-prefix-on-element-name': enforceUmbPrefixOnElementNameRule, 'enforce-umbraco-external-imports': enforceUmbracoExternalImportsRule, 'ensure-relative-import-use-js-extension': ensureRelativeImportUseJsExtensionRule, - 'exported-string-constant-naming': exportedStringConstantNaming, 'no-direct-api-import': noDirectApiImportRule, 'prefer-import-aliases': preferImportAliasesRule, 'prefer-static-styles-last': preferStaticStylesLastRule, - 'umb-class-prefix': umbClassPrefixRule, 'no-relative-import-to-import-map-module': noRelativeImportToImportMapModule, }; diff --git a/src/Umbraco.Web.UI.Client/eslint.config.js b/src/Umbraco.Web.UI.Client/eslint.config.js index 56b90b5f3b..123cbed404 100644 --- a/src/Umbraco.Web.UI.Client/eslint.config.js +++ b/src/Umbraco.Web.UI.Client/eslint.config.js @@ -14,10 +14,12 @@ import jsdoc from 'eslint-plugin-jsdoc'; export default [ // Recommended config applied to all files js.configs.recommended, + importPlugin.flatConfigs.recommended, ...tseslint.configs.recommended, wcPlugin.configs['flat/recommended'], litPlugin.configs['flat/recommended'], // We use the non typescript version to allow types to be defined in the jsdoc comments. This will allow js docs as an alternative to typescript types. jsdoc.configs['flat/recommended'], + ...storybook.configs['flat/recommended'], localRules.configs.all, eslintPluginPrettierRecommended, @@ -38,32 +40,18 @@ export default [ // Global config { - languageOptions: { - parserOptions: { - project: true, - tsconfigRootDir: import.meta.dirname, - }, - globals: { - ...globals.browser, - }, - }, plugins: { - import: importPlugin, 'local-rules': localRules, }, rules: { semi: ['warn', 'always'], 'prettier/prettier': ['warn', { endOfLine: 'auto' }], - 'no-unused-vars': 'off', //Let '@typescript-eslint/no-unused-vars' catch the errors to allow unused function parameters (ex: in interfaces) 'no-var': 'error', - ...importPlugin.configs.recommended.rules, 'import/namespace': 'off', 'import/no-unresolved': 'off', 'import/order': ['warn', { groups: ['builtin', 'parent', 'sibling', 'index', 'external'] }], 'import/no-self-import': 'error', 'import/no-cycle': ['error', { maxDepth: 6, allowUnsafeDynamicCyclicDependency: true }], - 'import/no-named-as-default': 'off', // Does not work with eslint 9 - 'import/no-named-as-default-member': 'off', // Does not work with eslint 9 'local-rules/prefer-static-styles-last': 'warn', 'local-rules/enforce-umbraco-external-imports': [ 'error', @@ -71,19 +59,6 @@ export default [ exceptions: ['@umbraco-cms', '@open-wc/testing', '@storybook', 'msw', '.', 'vite', 'uuid', 'diff'], }, ], - 'local-rules/exported-string-constant-naming': [ - 'error', - { - excludedFileNames: ['umbraco-package'], - }, - ], - '@typescript-eslint/no-non-null-assertion': 'off', - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': 'error', - '@typescript-eslint/consistent-type-exports': 'error', - '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/no-import-type-side-effects': 'warn', - '@typescript-eslint/no-deprecated': 'warn', 'jsdoc/check-tag-names': [ 'warn', { @@ -95,6 +70,109 @@ export default [ }, // Pattern-specific overrides + { + files: ['**/*.ts'], + ignores: ['.storybook', '**/*.stories.ts', '**/umbraco-package.ts', 'src/assets/lang/*.ts'], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + globals: { + ...globals.browser, + }, + }, + ...importPlugin.flatConfigs.typescript, + rules: { + 'no-unused-vars': 'off', //Let '@typescript-eslint/no-unused-vars' catch the errors to allow unused function parameters (ex: in interfaces) + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': 'error', + '@typescript-eslint/consistent-type-exports': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-import-type-side-effects': 'warn', + '@typescript-eslint/no-deprecated': 'warn', + '@typescript-eslint/naming-convention': [ + 'error', + // All private members should be camelCase with leading underscore + // This is to ensure that private members are not used outside the class, as they + // are not part of the public API. + // Example NOT OK: private myPrivateVariable + // Example OK: private _myPrivateVariable + { + selector: 'memberLike', + modifiers: ['private'], + format: ['camelCase'], + leadingUnderscore: 'require', + trailingUnderscore: 'forbid', + }, + // All public members and variables should be camelCase without leading underscore + // Example: myPublicVariable, myPublicMethod + { + selector: ['variableLike', 'memberLike'], + modifiers: ['public'], + filter: { + regex: '^_host$', + match: false, + }, + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allowDouble', + trailingUnderscore: 'forbid', + }, + // All #private members and variables should be camelCase without leading underscore + // Example: #myPublicVariable, #myPublicMethod + { + selector: ['variableLike', 'memberLike'], + modifiers: ['#private'], + format: ['camelCase', 'UPPER_CASE', 'PascalCase'], + leadingUnderscore: 'allowDouble', + trailingUnderscore: 'forbid', + }, + // All protected members and variables should be camelCase with optional leading underscore (if needed to be pseudo-private) + // Example: protected myPublicVariable, protected _myPublicMethod + { + selector: ['variableLike', 'memberLike'], + modifiers: ['protected'], + format: ['camelCase'], + leadingUnderscore: 'allow', + trailingUnderscore: 'forbid', + }, + // Allow quoted properties, as they are often used in JSON or when the property name is not a valid identifier + // This is to ensure that properties can be used in JSON or when the property name + // is not a valid identifier (e.g. contains spaces or special characters) + // Example: { "umb-some-component": UmbSomeComponent } + { + selector: ['objectLiteralProperty', 'typeProperty', 'enumMember'], + modifiers: ['requiresQuotes'], + format: null, + }, + // All (exported) types should be PascalCase with leading 'Umb' or 'Example' + // Example: UmbExampleType, ExampleTypeLike + { + selector: 'typeLike', + modifiers: ['exported'], + format: ['PascalCase'], + prefix: ['Umb', 'Ufm', 'Manifest', 'Meta', 'Example'] + }, + // All exported string constants should be UPPER_CASE with leading 'UMB_' + // Example: UMB_EXAMPLE_CONSTANT + { + selector: 'variable', + modifiers: ['exported', 'const'], + types: ['string', 'number', 'boolean'], + format: ['UPPER_CASE'], + prefix: ['UMB_'], + }, + // Allow destructured variables to be named as they are in the object + { + selector: "variable", + modifiers: ["destructured"], + format: null, + }, + ], + }, + }, { files: ['**/*.js'], ...tseslint.configs.disableTypeChecked, @@ -104,5 +182,4 @@ export default [ }, }, }, - ...storybook.configs['flat/recommended'], ]; diff --git a/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts b/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts index 5da08329ab..e81e34764b 100644 --- a/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts +++ b/src/Umbraco.Web.UI.Client/examples/block-custom-view/block-custom-view.ts @@ -4,11 +4,8 @@ import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; import type { UmbBlockDataType } from '@umbraco-cms/backoffice/block'; import type { UmbBlockEditorCustomViewElement } from '@umbraco-cms/backoffice/block-custom-view'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-block-custom-view') -// eslint-disable-next-line local-rules/umb-class-prefix export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implements UmbBlockEditorCustomViewElement { - // @property({ attribute: false }) content?: UmbBlockDataType; diff --git a/src/Umbraco.Web.UI.Client/examples/custom-validation-workspace-context/README.md b/src/Umbraco.Web.UI.Client/examples/custom-validation-workspace-context/README.md index ffdd2afb3e..e9396ad626 100644 --- a/src/Umbraco.Web.UI.Client/examples/custom-validation-workspace-context/README.md +++ b/src/Umbraco.Web.UI.Client/examples/custom-validation-workspace-context/README.md @@ -2,4 +2,4 @@ This example demonstrates how you can append extra validation messages to the Validation Context based on the value of a Property of a property Editor that this Customization is not interested in overriding/replacing. -See this having an effect on the All RTEs page, where too long words are invalid. \ No newline at end of file +See this having an effect on the "Rich Text Editor" page, where too long words are invalid. diff --git a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts index 20cc50236d..a03c0f9012 100644 --- a/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/dashboard-with-property-dataset/dataset-dashboard.ts @@ -1,7 +1,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -import { type UmbPropertyValueData, type UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; +import type { UmbPropertyValueData, UmbPropertyDatasetElement } from '@umbraco-cms/backoffice/property'; @customElement('example-dataset-dashboard') export class ExampleDatasetDashboard extends UmbElementMixin(LitElement) { diff --git a/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts index 5307bde407..920215eb85 100644 --- a/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/icons/icons-dashboard.ts @@ -2,9 +2,7 @@ import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { css, html, customElement, LitElement } from '@umbraco-cms/backoffice/external/lit'; import { UmbElementMixin } from '@umbraco-cms/backoffice/element-api'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-icons-dashboard') -// eslint-disable-next-line local-rules/umb-class-prefix export class ExampleIconsDashboard extends UmbElementMixin(LitElement) { override render() { return html` diff --git a/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts b/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts index 43be6cfdd4..e2af9cc43f 100644 --- a/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts +++ b/src/Umbraco.Web.UI.Client/examples/manifest-picker/manifest-picker-dashboard.ts @@ -5,10 +5,8 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { UUISelectEvent } from '@umbraco-cms/backoffice/external/uui'; -// eslint-disable-next-line local-rules/enforce-umb-prefix-on-element-name @customElement('example-manifest-picker-dashboard') -// eslint-disable-next-line local-rules/enforce-element-suffix-on-element-class-name, local-rules/umb-class-prefix -export class ExampleManifestPickerDashboard extends UmbLitElement { +export class ExampleManifestPickerDashboardElement extends UmbLitElement { #options: Array