From 103631de15a0dfd6c8fbc813f50be4eb89b50ef0 Mon Sep 17 00:00:00 2001 From: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com> Date: Tue, 6 May 2025 13:59:32 +0700 Subject: [PATCH 01/48] V15 QA Updated the build stage to align with the azure pipelines yml (#19235) --- build/nightly-E2E-test-pipelines.yml | 55 +++++++++++++++++++--------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 8cbb065cd7..54af8efc06 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -32,25 +32,17 @@ stages: - job: A displayName: Build Umbraco CMS pool: - vmImage: 'ubuntu-latest' + vmImage: "windows-latest" steps: - checkout: self - fetchDepth: 0 - submodules: true + submodules: false + lfs: false, + fetchDepth: 500 + - template: templates/backoffice-install.yml - task: UseDotNet@2 displayName: Use .NET SDK from global.json inputs: useGlobalJson: true - - template: templates/backoffice-install.yml - - script: npm run build:for:cms - displayName: Run build (Bellissima) - workingDirectory: src/Umbraco.Web.UI.Client - - script: npm ci --no-fund --no-audit --prefer-offline - displayName: Run npm ci (Login) - workingDirectory: src/Umbraco.Web.UI.Login - - script: npm run build - displayName: Run npm build (Login) - workingDirectory: src/Umbraco.Web.UI.Login - task: DotNetCoreCLI@2 displayName: Run dotnet restore inputs: @@ -62,7 +54,7 @@ stages: inputs: command: build projects: $(solution) - arguments: '--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg' + arguments: "--configuration $(buildConfiguration) --no-restore --property:ContinuousIntegrationBuild=true --property:GeneratePackageOnBuild=true --property:PackageOutputPath=$(Build.ArtifactStagingDirectory)/nupkg" - task: PublishPipelineArtifact@1 displayName: Publish nupkg inputs: @@ -74,6 +66,33 @@ stages: targetPath: $(Build.SourcesDirectory) artifactName: build_output + - job: B + displayName: Build Bellissima Package + pool: + vmImage: "ubuntu-latest" + steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 500 + - template: templates/backoffice-install.yml + - script: npm run build:for:npm + displayName: Run build:for:npm + workingDirectory: src/Umbraco.Web.UI.Client + - bash: | + echo "##[command]Running npm pack" + echo "##[debug]Output directory: $(Build.ArtifactStagingDirectory)" + mkdir $(Build.ArtifactStagingDirectory)/npm + npm pack --pack-destination $(Build.ArtifactStagingDirectory)/npm + mv .npmrc $(Build.ArtifactStagingDirectory)/npm/ + displayName: Run npm pack + workingDirectory: src/Umbraco.Web.UI.Client + - task: PublishPipelineArtifact@1 + displayName: Publish Bellissima npm artifact + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/npm + artifactName: npm + - stage: E2E displayName: E2E Tests dependsOn: Build @@ -209,8 +228,8 @@ stages: workingDirectory: tests/Umbraco.Tests.AcceptanceTest # Install Playwright and dependencies - - pwsh: npx playwright install --with-deps - displayName: Install Playwright + - pwsh: npx playwright install chromium + displayName: Install Playwright only with Chromium browser workingDirectory: tests/Umbraco.Tests.AcceptanceTest # Test @@ -381,8 +400,8 @@ stages: workingDirectory: tests/Umbraco.Tests.AcceptanceTest # Install Playwright and dependencies - - pwsh: npx playwright install --with-deps - displayName: Install Playwright + - pwsh: npx playwright install chromium + displayName: Install Playwright only with Chromium browser workingDirectory: tests/Umbraco.Tests.AcceptanceTest # Test From b106305167ede089e978b88ecef25a36018e889b Mon Sep 17 00:00:00 2001 From: Nhu Dinh <150406148+nhudinh0309@users.noreply.github.com> Date: Tue, 6 May 2025 14:15:30 +0700 Subject: [PATCH 02/48] V15 QA Added acceptance tests for tiptap style select (#19234) --- .../package-lock.json | 8 +- .../Umbraco.Tests.AcceptanceTest/package.json | 2 +- .../RichTextEditor/TiptapStyleSelect.spec.ts | 114 ++++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapStyleSelect.spec.ts diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 1353eb665a..f975ad0cbd 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json @@ -8,7 +8,7 @@ "hasInstallScript": true, "dependencies": { "@umbraco/json-models-builders": "^2.0.33", - "@umbraco/playwright-testhelpers": "^15.0.49", + "@umbraco/playwright-testhelpers": "^15.0.50", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" @@ -66,9 +66,9 @@ } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "15.0.49", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-15.0.49.tgz", - "integrity": "sha512-1At/e057u6rB3T3iH8tR6SLXnYRZJsCVjmm8jm+6sftJDvgB0Q5kXKaSDyLTU6wVuLALiDNUuNuJ86FgOOdUJw==", + "version": "15.0.50", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-15.0.50.tgz", + "integrity": "sha512-gi5bb4DShw3lmEdmQhKpPdkS6Uzg4CdNkrJDSkkUTE8CKY7T5goyE4QBTU8kj+LMHR2DnB7qyRUXrYWyS1ECiQ==", "dependencies": { "@umbraco/json-models-builders": "2.0.33", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index 98d612ca81..0396992d5c 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^2.0.33", - "@umbraco/playwright-testhelpers": "^15.0.49", + "@umbraco/playwright-testhelpers": "^15.0.50", "camelize": "^1.0.0", "dotenv": "^16.3.1", "node-fetch": "^2.6.7" diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapStyleSelect.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapStyleSelect.spec.ts new file mode 100644 index 0000000000..faab11fe5f --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/RichTextEditor/TiptapStyleSelect.spec.ts @@ -0,0 +1,114 @@ +import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; + +const contentName = 'TestContent'; +const documentTypeName = 'TestDocumentTypeForContent'; +const customDataTypeName = 'Test RTE Tiptap Style Select'; +const inputText = 'This is Tiptap test'; + +test.beforeEach(async ({umbracoApi, umbracoUi}) => { + const customDataTypeId = await umbracoApi.dataType.createTiptapDataTypeWithStyleSelect(customDataTypeName); + const documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, customDataTypeName, customDataTypeId); + await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.enterRTETipTapEditor(inputText); + await umbracoUi.content.selectAllRTETipTapEditorText(); +}) + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.dataType.ensureNameNotExists(customDataTypeName); +}); + +test('can apply page header format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Headers'); + await umbracoUi.content.clickCascadingMenuItemWithName('Page header'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); +}); + +test('can apply section header format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Headers'); + await umbracoUi.content.clickCascadingMenuItemWithName('Section header'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); +}); + +test('can apply paragraph header format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Headers'); + await umbracoUi.content.clickCascadingMenuItemWithName('Paragraph header'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); +}); + +test('can apply paragraph blocks format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Blocks'); + await umbracoUi.content.clickCascadingMenuItemWithName('Paragraph'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); +}); + +test('can apply block quote format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Containers'); + await umbracoUi.content.clickCascadingMenuItemWithName('Block quote'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('

' + inputText + '

'); +}); + +test('can apply code block format', async ({umbracoApi, umbracoUi}) => { + // Arrange + await umbracoUi.content.clickStyleSelectButton(); + + // Act + await umbracoUi.content.hoverCascadingMenuItemWithName('Containers'); + await umbracoUi.content.clickCascadingMenuItemWithName('Code block'); + await umbracoUi.content.clickSaveButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved); + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.values[0].value.markup).toEqual('
' + inputText + '

'); +}); \ No newline at end of file From b01def08724ce10e7164a2f467192cf47e145a56 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Thu, 8 May 2025 08:53:29 +0200 Subject: [PATCH 03/48] V15 QA added a fix for flaky integration tests run on SQL Server Linux (#18965) --- build/azure-pipelines.yml | 27 ++++ build/nightly-E2E-test-pipelines.yml | 218 ++++++++++++++++++++++++++- 2 files changed, 241 insertions(+), 4 deletions(-) diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index a33ee744f7..92621c30d3 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -432,6 +432,33 @@ stages: displayName: Start SQL Server Docker image (Linux) condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - powershell: | + $maxAttempts = 12 + $attempt = 0 + $status = "" + + while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) { + Start-Sleep -Seconds 5 + # We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build. + $status = docker inspect -f '{{.State.Status}}' mssql + + if ($status -ne 'running') { + Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)" + $attempt++ + } + } + + if ($status -eq 'running') { + Write-Host "SQL Server container is running" + docker ps -a + } else { + Write-Host "SQL Server did not become ready in time. Last known status: $status" + docker logs mssql + exit 1 + } + displayName: Wait for SQL Server to be ready (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - pwsh: SqlLocalDB start MSSQLLocalDB displayName: Start SQL Server LocalDB (Windows) condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) diff --git a/build/nightly-E2E-test-pipelines.yml b/build/nightly-E2E-test-pipelines.yml index 54af8efc06..0740eed585 100644 --- a/build/nightly-E2E-test-pipelines.yml +++ b/build/nightly-E2E-test-pipelines.yml @@ -93,6 +93,189 @@ stages: targetPath: $(Build.ArtifactStagingDirectory)/npm artifactName: npm + - stage: Integration + displayName: Integration Tests + dependsOn: Build + jobs: + # Integration Tests (SQLite) + - job: + timeoutInMinutes: 180 + displayName: Integration Tests (SQLite) + strategy: + matrix: + # Windows: + # vmImage: 'windows-latest' + # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline + LinuxPart1Of3: + vmImage: "ubuntu-latest" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + LinuxPart2Of3: + vmImage: "ubuntu-latest" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + LinuxPart3Of3: + vmImage: "ubuntu-latest" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + macOSPart1Of3: + vmImage: "macOS-latest" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + macOSPart2Of3: + vmImage: "macOS-latest" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + macOSPart3Of3: + vmImage: "macOS-latest" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + pool: + vmImage: $(vmImage) + variables: + Tests__Database__DatabaseType: "Sqlite" + steps: + - checkout: self + submodules: false + lfs: false, + fetchDepth: 1 + fetchFilter: tree:0 + # Setup test environment + - task: DownloadPipelineArtifact@2 + displayName: Download build artifacts + inputs: + artifact: build_output + path: $(Build.SourcesDirectory) + + - task: UseDotNet@2 + displayName: Use .NET SDK from global.json + inputs: + useGlobalJson: true + + # Test + - task: DotNetCoreCLI@2 + displayName: Run dotnet test + inputs: + command: test + projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj" + testRunTitle: Integration Tests SQLite - $(Agent.OS) + arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build' + + # Integration Tests (SQL Server) + - job: + timeoutInMinutes: 180 + displayName: Integration Tests (SQL Server) + strategy: + matrix: + # We split the tests into 3 parts for each OS to reduce the time it takes to run them on the pipeline + WindowsPart1Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + WindowsPart2Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + WindowsPart3Of3: + vmImage: "windows-latest" + Tests__Database__DatabaseType: LocalDb + Tests__Database__SQLServerMasterConnectionString: N/A + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + LinuxPart1Of3: + vmImage: "ubuntu-latest" + SA_PASSWORD: UmbracoIntegration123! + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are part of the Umbraco.Infrastructure namespace but not part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure) & (FullyQualifiedName!~Umbraco.Infrastructure.Service)" + LinuxPart2Of3: + vmImage: "ubuntu-latest" + SA_PASSWORD: UmbracoIntegration123! + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are part of the Umbraco.Infrastructure.Service namespace + testFilter: "(FullyQualifiedName~Umbraco.Infrastructure.Service)" + LinuxPart3Of3: + vmImage: "ubuntu-latest" + SA_PASSWORD: UmbracoIntegration123! + Tests__Database__DatabaseType: SqlServer + Tests__Database__SQLServerMasterConnectionString: "Server=(local);User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" + # Filter tests that are not part of the Umbraco.Infrastructure namespace. So this will run all tests that are not part of the Umbraco.Infrastructure namespace + testFilter: "(FullyQualifiedName!~Umbraco.Infrastructure)" + pool: + vmImage: $(vmImage) + steps: + # Setup test environment + - task: DownloadPipelineArtifact@2 + displayName: Download build artifacts + inputs: + artifact: build_output + path: $(Build.SourcesDirectory) + + - task: UseDotNet@2 + displayName: Use .NET SDK from global.json + inputs: + useGlobalJson: true + + # Start SQL Server + - powershell: docker run --name mssql -d -p 1433:1433 -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=$(SA_PASSWORD)" mcr.microsoft.com/mssql/server:2022-latest + displayName: Start SQL Server Docker image (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - powershell: | + $maxAttempts = 12 + $attempt = 0 + $status = "" + + while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) { + Start-Sleep -Seconds 5 + # We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build. + $status = docker inspect -f '{{.State.Status}}' mssql + + if ($status -ne 'running') { + Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)" + $attempt++ + } + } + + if ($status -eq 'running') { + Write-Host "SQL Server container is running" + docker ps -a + } else { + Write-Host "SQL Server did not become ready in time. Last known status: $status" + docker logs mssql + exit 1 + } + displayName: Wait for SQL Server to be ready (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: SqlLocalDB start MSSQLLocalDB + displayName: Start SQL Server LocalDB (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + + # Test + - task: DotNetCoreCLI@2 + displayName: Run dotnet test + inputs: + command: test + projects: "tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj" + testRunTitle: Integration Tests SQL Server - $(Agent.OS) + arguments: '--filter "$(testFilter)" --configuration $(buildConfiguration) --no-build' + + # Stop SQL Server + - pwsh: docker stop mssql + displayName: Stop SQL Server Docker image (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + + - pwsh: SqlLocalDB stop MSSQLLocalDB + displayName: Stop SQL Server LocalDB (Windows) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) + - stage: E2E displayName: E2E Tests dependsOn: Build @@ -290,17 +473,17 @@ stages: testCommand: "npm run testSqlite -- --shard=1/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" + CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" LinuxPart2Of3: testCommand: "npm run testSqlite -- --shard=2/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" + CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" LinuxPart3Of3: testCommand: "npm run testSqlite -- --shard=3/3" vmImage: "ubuntu-latest" SA_PASSWORD: $(UMBRACO__CMS__UNATTENDED__UNATTENDEDUSERPASSWORD) - CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);TrustServerCertificate=True" + CONNECTIONSTRINGS__UMBRACODBDSN: "Server=(local);Database=Umbraco;User Id=sa;Password=$(SA_PASSWORD);Encrypt=True;TrustServerCertificate=True" WindowsPart1Of3: vmImage: "windows-latest" testCommand: "npm run testSqlite -- --shard=1/3" @@ -371,6 +554,33 @@ stages: displayName: Start SQL Server Docker image (Linux) condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - powershell: | + $maxAttempts = 12 + $attempt = 0 + $status = "" + + while (($status -ne 'running') -and ($attempt -lt $maxAttempts)) { + Start-Sleep -Seconds 5 + # We use the docker inspect command to check the status of the container. If the container is not running, we wait 5 seconds and try again. And if reaches 12 attempts, we fail the build. + $status = docker inspect -f '{{.State.Status}}' mssql + + if ($status -ne 'running') { + Write-Host "Waiting for SQL Server to be ready... Attempt $($attempt + 1)" + $attempt++ + } + } + + if ($status -eq 'running') { + Write-Host "SQL Server container is running" + docker ps -a + } else { + Write-Host "SQL Server did not become ready in time. Last known status: $status" + docker logs mssql + exit 1 + } + displayName: Wait for SQL Server to be ready (Linux) + condition: and(succeeded(), eq(variables['Agent.OS'], 'Linux')) + - pwsh: SqlLocalDB start MSSQLLocalDB displayName: Start SQL Server LocalDB (Windows) condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) @@ -447,7 +657,7 @@ stages: inputs: targetPath: $(Build.ArtifactStagingDirectory) artifact: "Acceptance Test Results - $(Agent.JobName) - Attempt #$(System.JobAttempt)" - + # Publish test results - task: PublishTestResults@2 displayName: "Publish test results" From 275478066eb121b2e935adff70838b5081bf91e3 Mon Sep 17 00:00:00 2001 From: Andreas Zerbst <73799582+andr317c@users.noreply.github.com> Date: Thu, 8 May 2025 11:12:37 +0200 Subject: [PATCH 04/48] V15 QA complex block grid test (#18347) --- .../BlockGrid/ComplexBlockGridTest.spec.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ComplexBlockGridTest.spec.ts diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ComplexBlockGridTest.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ComplexBlockGridTest.spec.ts new file mode 100644 index 0000000000..78b1ea1213 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Content/BlockGrid/ComplexBlockGridTest.spec.ts @@ -0,0 +1,134 @@ +import {expect} from '@playwright/test'; +import {ConstantHelper, NotificationConstantHelper, test} from '@umbraco/playwright-testhelpers'; + +// DocumentType +const documentTypeName = 'TestDocumentType'; +let documentTypeId = ''; +const groupName = 'TestGroup'; + +// Content +const contentName = 'TestContent'; +let contentId = ''; + +// Property Value +const wrongPropertyValue = 'This is a test with wrong value**'; +const correctPropertyValue = 'Test'; + +// ElementTypes +// TextString Element Type (for Block List) +const textStringElementGroupName = 'TextStringElementGroup'; +const textStringElementTypeName = 'TestElementWithTextString'; +const textStringElementRegex = '^[a-zA-Z0-9]*$'; +let textStringElementTypeId = ''; +// Area Element Type (for Block Grid) +const areaElementTypeName = 'TestElementArea'; +const areaAlias = 'testArea'; +let areaElementTypeId = ''; +// Rich Text Editor Element Type (for Block Grid) +const richTextEditorElementGroupName = 'RichTextEditorElementGroup'; +const richTextEditorElementTypeName = 'RichTextEditorTestElement'; +let richTextEditorElementTypeId = ''; +// Block List Element Type +const blockListElementTypeName = 'BlockListElement'; +const blockListGroupName = 'BlockListGroup'; +let blockListElementTypeId = ''; + +// DataTypes +const blockGridDataTypeName = 'TestBlockGridEditor'; +const blockListDataTypeName = 'TestBlockListEditor'; +const textStringElementDataTypeName = 'Textstring'; +const richTextDataTypeName = 'Rich Text Editor'; +let blockListDataTypeId = ''; +let blockGridDataTypeId = ''; + +test.beforeEach(async ({umbracoApi}) => { + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.documentType.ensureNameNotExists(textStringElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(areaElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(richTextEditorElementTypeName); + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.dataType.ensureNameNotExists(blockListDataTypeName); + await umbracoApi.dataType.ensureNameNotExists(blockGridDataTypeName); + await umbracoApi.dataType.ensureNameNotExists(richTextDataTypeName); +}); + +test.afterEach(async ({umbracoApi}) => { + await umbracoApi.documentType.ensureNameNotExists(documentTypeName); + await umbracoApi.documentType.ensureNameNotExists(textStringElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(areaElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(richTextEditorElementTypeName); + await umbracoApi.documentType.ensureNameNotExists(blockListElementTypeName); + await umbracoApi.document.ensureNameNotExists(contentName); + await umbracoApi.dataType.ensureNameNotExists(blockListDataTypeName); + await umbracoApi.dataType.ensureNameNotExists(blockGridDataTypeName); + await umbracoApi.dataType.ensureNameNotExists(richTextDataTypeName); +}); + +test('can update property value nested in a block grid area with an RTE with a block list editor', {tag: '@smoke'}, async ({umbracoApi, umbracoUi}) => { + test.slow(); + // Arrange + // ElementType with Textstring And REGEX only accept letters and numbers + const textStringElementDataType = await umbracoApi.dataType.getByName(textStringElementDataTypeName); + textStringElementTypeId = await umbracoApi.documentType.createElementTypeWithRegexValidation(textStringElementTypeName, textStringElementGroupName, textStringElementDataTypeName, textStringElementDataType.id, textStringElementRegex); + // Block List Editor with Textstring + blockListDataTypeId = await umbracoApi.dataType.createBlockListDataTypeWithABlock(blockListDataTypeName, textStringElementTypeId); + // ElementType with Block List Editor + blockListElementTypeId = await umbracoApi.documentType.createDefaultElementType(blockListElementTypeName, blockListGroupName, blockListDataTypeName, blockListDataTypeId); + // Rich Text Editor in an ElementType, with a Block(Element Type), the block contains a Block List Editor + const richTextEditorId = await umbracoApi.dataType.createRichTextEditorWithABlock(richTextDataTypeName, blockListElementTypeId); + richTextEditorElementTypeId = await umbracoApi.documentType.createDefaultElementType(richTextEditorElementTypeName, richTextEditorElementGroupName, richTextDataTypeName, richTextEditorId); + // ElementType Area that is Empty + areaElementTypeId = await umbracoApi.documentType.createEmptyElementType(areaElementTypeName); + // Block Grid with 2 blocks, one with RTE and Inline, and one with areas + blockGridDataTypeId = await umbracoApi.dataType.createBlockGridWithABlockWithInlineEditingModeAndABlockWithAnArea(blockGridDataTypeName, richTextEditorElementTypeId, true, areaElementTypeId, areaAlias); + // Document Type with the following + documentTypeId = await umbracoApi.documentType.createDocumentTypeWithPropertyEditor(documentTypeName, blockGridDataTypeName, blockGridDataTypeId, groupName); + // Creates Content + contentId = await umbracoApi.document.createDefaultDocument(contentName, documentTypeId); + await umbracoUi.goToBackOffice(); + await umbracoUi.content.goToSection(ConstantHelper.sections.content); + + // Act + await umbracoUi.content.goToContentWithName(contentName); + await umbracoUi.content.clickAddBlockGridElementWithName(areaElementTypeName); + await umbracoUi.content.clickSelectBlockElementWithName(areaElementTypeName); + await umbracoUi.content.clickAddBlockGridElementWithName(richTextEditorElementTypeName); + await umbracoUi.content.clickExactLinkWithName(richTextEditorElementTypeName); + await umbracoUi.content.clickInsertBlockButton(); + await umbracoUi.content.clickExactLinkWithName(blockListElementTypeName); + await umbracoUi.content.clickAddBlockGridElementWithName(textStringElementTypeName); + await umbracoUi.content.clickExactLinkWithName(textStringElementTypeName); + // Enter text in the textstring block that won't match regex + await umbracoUi.content.enterPropertyValue(textStringElementDataTypeName, wrongPropertyValue); + await umbracoUi.content.clickCreateButtonForModalWithElementTypeNameAndGroupName(textStringElementTypeName, textStringElementGroupName); + await umbracoUi.content.clickCreateButtonForModalWithElementTypeNameAndGroupName(blockListElementTypeName, blockListGroupName); + await umbracoUi.content.clickCreateButtonForModalWithElementTypeNameAndGroupName(richTextEditorElementTypeName, richTextEditorElementGroupName); + await umbracoUi.content.clickSaveAndPublishButton(); + // Checks that the error notification is shown since the textstring block has the wrong value + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved, true, true); + await umbracoUi.content.doesErrorNotificationHaveText(NotificationConstantHelper.error.documentCouldNotBePublished, true, true); + // Updates the textstring block with the correct value + await umbracoUi.waitForTimeout(1000); + await umbracoUi.content.clickBlockElementWithName(blockListElementTypeName); + await umbracoUi.content.clickEditBlockListEntryWithName(textStringElementTypeName); + await umbracoUi.content.enterPropertyValue(textStringElementDataTypeName, correctPropertyValue); + await umbracoUi.content.clickUpdateButtonForModalWithElementTypeNameAndGroupName(textStringElementTypeName, textStringElementGroupName); + await umbracoUi.content.clickUpdateButtonForModalWithElementTypeNameAndGroupName(blockListElementTypeName, blockListGroupName); + await umbracoUi.content.clickSaveAndPublishButton(); + + // Assert + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.saved, true, true); + await umbracoUi.content.doesSuccessNotificationHaveText(NotificationConstantHelper.success.published, true, true); + // Checks if published + const contentData = await umbracoApi.document.getByName(contentName); + expect(contentData.variants[0].state).toBe('Published'); + // Checks if the textstring block has the correct value after reloading the page + await umbracoUi.reloadPage(); + // Waits to make sure the page has loaded + await umbracoUi.waitForTimeout(2000); + await umbracoUi.content.clickBlockElementWithName(blockListElementTypeName); + // Needs to wait to make sure it has loaded + await umbracoUi.waitForTimeout(2000); + await umbracoUi.content.clickEditBlockListEntryWithName(textStringElementTypeName); + await umbracoUi.content.doesPropertyContainValue(textStringElementDataTypeName, correctPropertyValue); +}); From 822cfe9c286a1b40e2015dab8e6ed8ddda9dca0b Mon Sep 17 00:00:00 2001 From: Mads Rasmussen Date: Thu, 8 May 2025 11:17:57 +0200 Subject: [PATCH 05/48] Batch item rest requests (#19233) --- .../src/packages/core/entity-item/index.ts | 1 + .../index.ts | 1 + .../item-data-api-get-request.controller.ts | 66 +++++++++++++++++++ .../types.ts | 6 ++ .../src/packages/core/entity-item/types.ts | 1 + .../item/item-server-data-source-base.ts | 24 ++++--- .../packages/core/resources/data-api/types.ts | 3 + .../src/packages/core/resources/index.ts | 13 ++-- .../try-execute/batch-try-execute.function.ts | 17 +++++ .../core/resources/try-execute/index.ts | 5 ++ .../try-execute.controller.ts | 8 +-- .../{ => try-execute}/tryExecute.function.ts | 2 +- .../tryExecuteAndNotify.function.ts | 2 +- .../tryXhrRequest.function.ts | 8 +-- .../src/packages/core/resources/types.ts | 1 + .../core/utils/array/batch-array.test.ts | 25 +++++++ .../packages/core/utils/array/batch-array.ts | 16 +++++ .../src/packages/core/utils/array/index.ts | 1 + .../src/packages/core/utils/index.ts | 1 + .../item/data-type-item.server.data-source.ts | 19 ++++-- .../dictionary-item.server.data-source.ts | 19 ++++-- ...ument-blueprint-item.server.data-source.ts | 24 ++++--- .../document-type-item.server.data-source.ts | 20 ++++-- .../document-item.server.data-source.ts | 19 ++++-- .../document-url.server.data-source.ts | 20 ++++-- .../item/language-item.server.data-source.ts | 19 ++++-- .../media-type-item.server.data-source.ts | 19 ++++-- .../item/media-item.server.data-source.ts | 26 +++++--- .../media-url.server.data-source.ts | 19 ++++-- .../member-group-item.server.data-source.ts | 19 ++++-- .../member-type-item.server.data-source.ts | 19 ++++-- .../member-item.server.data-source.ts | 19 ++++-- .../static-file-item.server.data-source.ts | 27 +++++--- .../item/template-item.server.data-source.ts | 19 ++++-- .../user-group-item.server.data-source.ts | 19 ++++-- .../item/user-item.server.data-source.ts | 19 ++++-- .../item/webhook-item.server.data-source.ts | 19 ++++-- 37 files changed, 450 insertions(+), 115 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/item-data-api-get-request.controller.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/resources/data-api/types.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/batch-try-execute.function.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/index.ts rename src/Umbraco.Web.UI.Client/src/packages/core/resources/{ => try-execute}/try-execute.controller.ts (86%) rename src/Umbraco.Web.UI.Client/src/packages/core/resources/{ => try-execute}/tryExecute.function.ts (93%) rename src/Umbraco.Web.UI.Client/src/packages/core/resources/{ => try-execute}/tryExecuteAndNotify.function.ts (96%) rename src/Umbraco.Web.UI.Client/src/packages/core/resources/{ => try-execute}/tryXhrRequest.function.ts (94%) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.test.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/utils/array/index.ts diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/index.ts index 5ada9eda75..a0ee69f820 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/index.ts @@ -1 +1,2 @@ +export * from './item-data-api-get-request-controller/index.js'; export * from './entity-item-ref/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/index.ts new file mode 100644 index 0000000000..5bd7a5f06f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/index.ts @@ -0,0 +1 @@ +export * from './item-data-api-get-request.controller.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/item-data-api-get-request.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/item-data-api-get-request.controller.ts new file mode 100644 index 0000000000..b10a783b97 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/item-data-api-get-request.controller.ts @@ -0,0 +1,66 @@ +import type { UmbItemDataApiGetRequestControllerArgs } from './types.js'; +import { + batchTryExecute, + tryExecute, + UmbError, + type UmbApiError, + type UmbCancelError, + type UmbDataApiResponse, +} from '@umbraco-cms/backoffice/resources'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { batchArray } from '@umbraco-cms/backoffice/utils'; +import { umbPeekError } from '@umbraco-cms/backoffice/notification'; +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; + +export class UmbItemDataApiGetRequestController< + ResponseModelType extends UmbDataApiResponse, +> extends UmbControllerBase { + #apiCallback: (args: { uniques: Array }) => Promise; + #uniques: Array; + #batchSize: number = 40; + + constructor(host: UmbControllerHost, args: UmbItemDataApiGetRequestControllerArgs) { + super(host); + this.#apiCallback = args.api; + this.#uniques = args.uniques; + } + + async request() { + if (!this.#uniques) throw new Error('Uniques are missing'); + + let data: ResponseModelType['data'] | undefined; + let error: UmbError | UmbApiError | UmbCancelError | Error | undefined; + + if (this.#uniques.length > this.#batchSize) { + const chunks = batchArray(this.#uniques, this.#batchSize); + const results = await batchTryExecute(this, chunks, (chunk) => this.#apiCallback({ uniques: chunk })); + + const errors = results.filter((promiseResult) => promiseResult.status === 'rejected'); + + if (errors.length > 0) { + error = await this.#getAndHandleErrorResult(errors); + } + + data = results + .filter((promiseResult) => promiseResult.status === 'fulfilled') + .flatMap((promiseResult) => promiseResult.value.data); + } else { + const result = await tryExecute(this, this.#apiCallback({ uniques: this.#uniques })); + data = result.data; + error = result.error; + } + + return { data, error }; + } + + async #getAndHandleErrorResult(errors: Array) { + // TODO: We currently expect all the errors to be the same, but we should handle this better in the future. + const error = errors[0]; + await umbPeekError(this, { + headline: 'Error fetching items', + message: 'An error occurred while fetching items.', + }); + + return new UmbError(error.reason); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/types.ts new file mode 100644 index 0000000000..fc4f1a47e4 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/item-data-api-get-request-controller/types.ts @@ -0,0 +1,6 @@ +import type { UmbDataApiResponse } from '@umbraco-cms/backoffice/resources'; + +export interface UmbItemDataApiGetRequestControllerArgs { + api: (args: { uniques: Array }) => Promise; + uniques: Array; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/types.ts index 31240f7f67..78aae14245 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity-item/types.ts @@ -1,4 +1,5 @@ import type { UmbNamedEntityModel } from '@umbraco-cms/backoffice/entity'; +export type * from './item-data-api-get-request-controller/types.js'; export interface UmbDefaultItemModel extends UmbNamedEntityModel { icon?: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/repository/item/item-server-data-source-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/repository/item/item-server-data-source-base.ts index 02df1f5429..8e3f1eeea5 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/repository/item/item-server-data-source-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/repository/item/item-server-data-source-base.ts @@ -1,10 +1,11 @@ +import { UmbControllerBase } from '@umbraco-cms/backoffice/class-api'; import type { UmbDataSourceResponse } from '../data-source-response.interface.js'; import type { UmbItemDataSource } from './item-data-source.interface.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; export interface UmbItemServerDataSourceBaseArgs { - getItems: (uniques: Array) => Promise>>; + getItems?: (uniques: Array) => Promise>>; mapper: (item: ServerItemType) => ClientItemType; } @@ -14,10 +15,10 @@ export interface UmbItemServerDataSourceBaseArgs + extends UmbControllerBase implements UmbItemDataSource { - #host: UmbControllerHost; - #getItems: (uniques: Array) => Promise>>; + #getItems?: (uniques: Array) => Promise>>; #mapper: (item: ServerItemType) => ClientItemType; /** @@ -27,7 +28,7 @@ export abstract class UmbItemServerDataSourceBase) { - this.#host = host; + super(host); this.#getItems = args.getItems; this.#mapper = args.mapper; } @@ -39,14 +40,17 @@ export abstract class UmbItemServerDataSourceBase) { + if (!this.#getItems) throw new Error('getItems is not implemented'); if (!uniques) throw new Error('Uniques are missing'); - const { data, error } = await tryExecute(this.#host, this.#getItems(uniques)); - if (data) { - const items = data.map((item) => this.#mapper(item)); - return { data: items }; - } + const { data, error } = await tryExecute(this, this.#getItems(uniques)); - return { error }; + return { data: this._getMappedItems(data), error }; + } + + protected _getMappedItems(items: Array | undefined): Array | undefined { + if (!items) return undefined; + if (!this.#mapper) throw new Error('Mapper is not implemented'); + return items.map((item) => this.#mapper(item)); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/data-api/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/data-api/types.ts new file mode 100644 index 0000000000..dfade83718 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/data-api/types.ts @@ -0,0 +1,3 @@ +export interface UmbDataApiResponse { + data: ResponseType['data']; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/index.ts index e1e1756111..04e74539c4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/index.ts @@ -1,12 +1,9 @@ export * from './api-interceptor.controller.js'; -export * from './resource.controller.js'; -export * from './try-execute.controller.js'; -export * from './tryExecute.function.js'; -export * from './tryExecuteAndNotify.function.js'; -export * from './tryXhrRequest.function.js'; -export * from './extractUmbNotificationColor.function.js'; -export * from './extractUmbColorVariable.function.js'; -export * from './isUmbNotifications.function.js'; export * from './apiTypeValidators.function.js'; +export * from './extractUmbColorVariable.function.js'; +export * from './extractUmbNotificationColor.function.js'; +export * from './isUmbNotifications.function.js'; +export * from './resource.controller.js'; +export * from './try-execute/index.js'; export * from './umb-error.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/batch-try-execute.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/batch-try-execute.function.ts new file mode 100644 index 0000000000..47a810ceb8 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/batch-try-execute.function.ts @@ -0,0 +1,17 @@ +import { tryExecute } from './tryExecute.function.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; + +/** + * Batches promises and returns a promise that resolves to an array of results + * @param {UmbControllerHost} host - The host to use for the request and where notifications will be shown + * @param {Array>} chunks - The array of chunks to process + * @param {(chunk: Array) => Promise} callback - The function to call for each chunk + * @returns {Promise[]>} - A promise that resolves to an array of results + */ +export function batchTryExecute( + host: UmbControllerHost, + chunks: Array>, + callback: (chunk: Array) => Promise, +): Promise[]> { + return Promise.allSettled(chunks.map((chunk) => tryExecute(host, callback(chunk), { disableNotifications: true }))); +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/index.ts new file mode 100644 index 0000000000..18d2ee9f97 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/index.ts @@ -0,0 +1,5 @@ +export * from './batch-try-execute.function.js'; +export * from './try-execute.controller.js'; +export * from './tryExecute.function.js'; +export * from './tryExecuteAndNotify.function.js'; +export * from './tryXhrRequest.function.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/try-execute.controller.ts similarity index 86% rename from src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute.controller.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/try-execute.controller.ts index 353c081138..afca34ad55 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/try-execute.controller.ts @@ -1,7 +1,7 @@ -import { isProblemDetailsLike } from './apiTypeValidators.function.js'; -import { UmbResourceController } from './resource.controller.js'; -import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js'; -import { UmbApiError, UmbCancelError } from './umb-error.js'; +import { isProblemDetailsLike } from '../apiTypeValidators.function.js'; +import { UmbResourceController } from '../resource.controller.js'; +import type { UmbApiResponse, UmbTryExecuteOptions } from '../types.js'; +import { UmbApiError, UmbCancelError } from '../umb-error.js'; export class UmbTryExecuteController extends UmbResourceController { #abortSignal?: AbortSignal; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecute.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecute.function.ts similarity index 93% rename from src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecute.function.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecute.function.ts index 07c4b26cfd..7deca7c0e4 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecute.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecute.function.ts @@ -1,5 +1,5 @@ +import type { UmbApiResponse, UmbTryExecuteOptions } from '../types.js'; import { UmbTryExecuteController } from './try-execute.controller.js'; -import type { UmbApiResponse, UmbTryExecuteOptions } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; /** diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecuteAndNotify.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecuteAndNotify.function.ts similarity index 96% rename from src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecuteAndNotify.function.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecuteAndNotify.function.ts index 1708d759c0..6bddf902de 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryExecuteAndNotify.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryExecuteAndNotify.function.ts @@ -1,5 +1,5 @@ +import type { UmbApiResponse } from '../types.js'; import { UmbTryExecuteController } from './try-execute.controller.js'; -import type { UmbApiResponse } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbDeprecation } from '@umbraco-cms/backoffice/utils'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryXhrRequest.function.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts similarity index 94% rename from src/Umbraco.Web.UI.Client/src/packages/core/resources/tryXhrRequest.function.ts rename to src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts index a2e96d5d40..2be9e84fa7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/tryXhrRequest.function.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/try-execute/tryXhrRequest.function.ts @@ -1,8 +1,8 @@ +import { UmbCancelablePromise } from '../cancelable-promise.js'; +import { UmbApiError } from '../umb-error.js'; +import { isProblemDetailsLike } from '../apiTypeValidators.function.js'; +import type { UmbApiResponse, XhrRequestOptions } from '../types.js'; import { UmbTryExecuteController } from './try-execute.controller.js'; -import { UmbCancelablePromise } from './cancelable-promise.js'; -import { UmbApiError } from './umb-error.js'; -import { isProblemDetailsLike } from './apiTypeValidators.function.js'; -import type { UmbApiResponse, XhrRequestOptions } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { umbHttpClient } from '@umbraco-cms/backoffice/http-client'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts index fd7413e8f1..6a438f0bab 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/resources/types.ts @@ -1,4 +1,5 @@ import type { UmbApiError, UmbCancelError, UmbError } from './umb-error.js'; +export type * from './data-api/types.js'; export interface XhrRequestOptions extends UmbTryExecuteOptions { baseUrl?: string; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.test.ts new file mode 100644 index 0000000000..e958f3fea1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.test.ts @@ -0,0 +1,25 @@ +import { expect } from '@open-wc/testing'; +import { batchArray } from './batch-array.js'; + +describe('batchArray', () => { + it('should split an array into chunks of the specified size', () => { + const array = [1, 2, 3, 4, 5]; + const batchSize = 2; + const result = batchArray(array, batchSize); + expect(result).to.deep.equal([[1, 2], [3, 4], [5]]); + }); + + it('should handle arrays smaller than the batch size', () => { + const array = [1]; + const batchSize = 2; + const result = batchArray(array, batchSize); + expect(result).to.deep.equal([[1]]); + }); + + it('should handle empty arrays', () => { + const array: number[] = []; + const batchSize = 2; + const result = batchArray(array, batchSize); + expect(result).to.deep.equal([]); + }); +}); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.ts new file mode 100644 index 0000000000..fde5b6703d --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/batch-array.ts @@ -0,0 +1,16 @@ +/** + * Splits an array into chunks of a specified size + * @param { Array } array - The array to split + * @param {number }batchSize - The size of each chunk + * @returns {Array>} - An array of chunks + */ +export function batchArray( + array: Array, + batchSize: number, +): Array> { + const chunks: Array> = []; + for (let i = 0; i < array.length; i += batchSize) { + chunks.push(array.slice(i, i + batchSize)); + } + return chunks; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/index.ts new file mode 100644 index 0000000000..106dce2d85 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/array/index.ts @@ -0,0 +1 @@ +export * from './batch-array.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts index aa191db2e7..a59e5dc990 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/utils/index.ts @@ -1,3 +1,4 @@ +export * from './array/index.js'; export * from './bytes/bytes.function.js'; export * from './debounce/debounce.function.js'; export * from './deprecation/index.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/item/data-type-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/item/data-type-item.server.data-source.ts index d373f16b32..4b578c8b1d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/item/data-type-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/data-type/repository/item/data-type-item.server.data-source.ts @@ -6,6 +6,7 @@ import { DataTypeService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; import type { ManifestPropertyEditorUi } from '@umbraco-cms/backoffice/property-editor'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; let manifestPropertyEditorUis: Array = []; @@ -26,7 +27,6 @@ export class UmbDataTypeItemServerDataSource extends UmbItemServerDataSourceBase constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); @@ -37,10 +37,21 @@ export class UmbDataTypeItemServerDataSource extends UmbItemServerDataSourceBase }) .unsubscribe(); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => DataTypeService.getItemDataType({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DataTypeService.getItemDataType({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: DataTypeItemResponseModel): UmbDataTypeItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/dictionary/repository/item/dictionary-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/dictionary/repository/item/dictionary-item.server.data-source.ts index c5b3714b91..245931dbfc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/dictionary/repository/item/dictionary-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/dictionary/repository/item/dictionary-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { DictionaryItemItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { DictionaryService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Dictionary items @@ -21,14 +22,24 @@ export class UmbDictionaryItemServerDataSource extends UmbItemServerDataSourceBa */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => DictionaryService.getItemDictionary({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DictionaryService.getItemDictionary({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: DictionaryItemItemResponseModel): UmbDictionaryItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/repository/item/document-blueprint-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/repository/item/document-blueprint-item.server.data-source.ts index 104ecfbee1..bc44153a60 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/repository/item/document-blueprint-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-blueprints/repository/item/document-blueprint-item.server.data-source.ts @@ -5,6 +5,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { DocumentBlueprintItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A data source for Document Blueprint items that fetches data from the server @@ -15,7 +16,6 @@ export class UmbDocumentBlueprintItemServerDataSource extends UmbItemServerDataS DocumentBlueprintItemResponseModel, UmbDocumentBlueprintItemModel > { - #host: UmbControllerHost; /** * Creates an instance of UmbDocumentBlueprintItemServerDataSource. * @param {UmbControllerHost} host - The controller host for this controller to be appended to @@ -23,16 +23,14 @@ export class UmbDocumentBlueprintItemServerDataSource extends UmbItemServerDataS */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); - this.#host = host; } async getItemsByDocumentType(unique: string) { if (!unique) throw new Error('Unique is missing'); const { data, error } = await tryExecute( - this.#host, + this, DocumentTypeService.getDocumentTypeByIdBlueprint({ path: { id: unique } }), ); @@ -47,11 +45,21 @@ export class UmbDocumentBlueprintItemServerDataSource extends UmbItemServerDataS return { error }; } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => - DocumentBlueprintService.getItemDocumentBlueprint({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DocumentBlueprintService.getItemDocumentBlueprint({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: DocumentBlueprintItemResponseModel): UmbDocumentBlueprintItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/item/document-type-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/item/document-type-item.server.data-source.ts index 1fed1d9574..f68f70d88c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/item/document-type-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/repository/item/document-type-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { DocumentTypeItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { DocumentTypeService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A data source for Document Type items that fetches data from the server @@ -20,13 +21,24 @@ export class UmbDocumentTypeItemServerDataSource extends UmbItemServerDataSource * @memberof UmbDocumentTypeItemServerDataSource */ constructor(host: UmbControllerHost) { - super(host, { getItems, mapper }); + super(host, { mapper }); + } + + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DocumentTypeService.getItemDocumentType({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; } } -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => DocumentTypeService.getItemDocumentType({ query: { id: uniques } }); - const mapper = (item: DocumentTypeItemResponseModel): UmbDocumentTypeItemModel => { return { entityType: UMB_DOCUMENT_TYPE_ENTITY_TYPE, diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/document-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/document-item.server.data-source.ts index a9e2dfe005..5c6453b35e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/document-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/item/repository/document-item.server.data-source.ts @@ -4,6 +4,7 @@ import type { DocumentItemResponseModel } from '@umbraco-cms/backoffice/external import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A data source for Document items that fetches data from the server @@ -21,14 +22,24 @@ export class UmbDocumentItemServerDataSource extends UmbItemServerDataSourceBase */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => DocumentService.getItemDocument({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DocumentService.getItemDocument({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: DocumentItemResponseModel): UmbDocumentItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts index bcf61c137d..8891d6b6c1 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/documents/url/repository/document-url.server.data-source.ts @@ -3,6 +3,7 @@ import { DocumentService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; import type { DocumentUrlInfoResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Document URLs @@ -19,11 +20,22 @@ export class UmbDocumentUrlServerDataSource extends UmbItemServerDataSourceBase< * @memberof UmbDocumentUrlServerDataSource */ constructor(host: UmbControllerHost) { - super(host, { getItems, mapper }); + super(host, { mapper }); + } + + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => DocumentService.getDocumentUrls({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; } } -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => DocumentService.getDocumentUrls({ query: { id: uniques } }); - const mapper = (item: DocumentUrlInfoResponseModel): UmbDocumentUrlsModel => ({ unique: item.id, urls: item.urlInfos }); diff --git a/src/Umbraco.Web.UI.Client/src/packages/language/repository/item/language-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/language/repository/item/language-item.server.data-source.ts index 3e9b7e5fe9..b38c275278 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/language/repository/item/language-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/language/repository/item/language-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { LanguageItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { LanguageService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Language items @@ -21,14 +22,24 @@ export class UmbLanguageItemServerDataSource extends UmbItemServerDataSourceBase */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => LanguageService.getItemLanguage({ query: { isoCode: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => LanguageService.getItemLanguage({ query: { isoCode: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: LanguageItemResponseModel): UmbLanguageItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/item/media-type-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/item/media-type-item.server.data-source.ts index 8897295e51..402cb410a8 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/item/media-type-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media-types/repository/item/media-type-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { MediaTypeItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { MediaTypeService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A data source for Media Type items that fetches data from the server @@ -21,14 +22,24 @@ export class UmbMediaTypeItemServerDataSource extends UmbItemServerDataSourceBas */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MediaTypeService.getItemMediaType({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MediaTypeService.getItemMediaType({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MediaTypeItemResponseModel): UmbMediaTypeItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.server.data-source.ts index e56a7d9567..d4edb0f5fc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/repository/item/media-item.server.data-source.ts @@ -5,6 +5,7 @@ import { MediaService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; import { tryExecute } from '@umbraco-cms/backoffice/resources'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A data source for Media items that fetches data from the server @@ -15,7 +16,6 @@ export class UmbMediaItemServerDataSource extends UmbItemServerDataSourceBase< MediaItemResponseModel, UmbMediaItemModel > { - #host: UmbControllerHost; /** * Creates an instance of UmbMediaItemServerDataSource. * @param {UmbControllerHost} host - The controller host for this controller to be appended to @@ -23,10 +23,8 @@ export class UmbMediaItemServerDataSource extends UmbItemServerDataSourceBase< */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); - this.#host = host; } /** @@ -38,17 +36,25 @@ export class UmbMediaItemServerDataSource extends UmbItemServerDataSourceBase< * ``` */ async search({ query, skip, take }: { query: string; skip: number; take: number }) { - const { data, error } = await tryExecute( - this.#host, - MediaService.getItemMediaSearch({ query: { query, skip, take } }), - ); + const { data, error } = await tryExecute(this, MediaService.getItemMediaSearch({ query: { query, skip, take } })); const mapped = data?.items.map((item) => mapper(item)); return { data: mapped, error }; } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MediaService.getItemMedia({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MediaService.getItemMedia({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MediaItemResponseModel): UmbMediaItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/media/media/url/repository/media-url.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/media/media/url/repository/media-url.server.data-source.ts index d52e8952c6..24571d50d3 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/media/media/url/repository/media-url.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/media/media/url/repository/media-url.server.data-source.ts @@ -1,5 +1,6 @@ import type { UmbMediaUrlModel } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; import { MediaService, type MediaUrlInfoResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; @@ -19,14 +20,24 @@ export class UmbMediaUrlServerDataSource extends UmbItemServerDataSourceBase< */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MediaService.getMediaUrls({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MediaService.getMediaUrls({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MediaUrlInfoResponseModel): UmbMediaUrlModel => { const url = item.urlInfos.length ? item.urlInfos[0].url : undefined; diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/repository/item/member-group-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/repository/item/member-group-item.server.data-source.ts index 3bdf68a8ee..6840673866 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-group/repository/item/member-group-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-group/repository/item/member-group-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { MemberGroupItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { MemberGroupService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Member Group items @@ -21,14 +22,24 @@ export class UmbMemberGroupItemServerDataSource extends UmbItemServerDataSourceB */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MemberGroupService.getItemMemberGroup({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MemberGroupService.getItemMemberGroup({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MemberGroupItemResponseModel): UmbMemberGroupItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/item/member-type-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/item/member-type-item.server.data-source.ts index c83d7443b9..68444ad9ef 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/item/member-type-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member-type/repository/item/member-type-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { MemberTypeItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { MemberTypeService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Member Type items @@ -21,14 +22,24 @@ export class UmbMemberTypeItemServerDataSource extends UmbItemServerDataSourceBa */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MemberTypeService.getItemMemberType({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MemberTypeService.getItemMemberType({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MemberTypeItemResponseModel): UmbMemberTypeItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/members/member/item/repository/member-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/members/member/item/repository/member-item.server.data-source.ts index 2853948b74..adc992d919 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/members/member/item/repository/member-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/members/member/item/repository/member-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { MemberItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { MemberService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Member items @@ -21,14 +22,24 @@ export class UmbMemberItemServerDataSource extends UmbItemServerDataSourceBase< */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => MemberService.getItemMember({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => MemberService.getItemMember({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: MemberItemResponseModel): UmbMemberItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/static-file/repository/item/static-file-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/static-file/repository/item/static-file-item.server.data-source.ts index 3eba1cef8b..5e38b171e7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/static-file/repository/item/static-file-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/static-file/repository/item/static-file-item.server.data-source.ts @@ -4,6 +4,7 @@ import type { StaticFileItemResponseModel } from '@umbraco-cms/backoffice/extern import { StaticFileService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; import { UmbServerFilePathUniqueSerializer } from '@umbraco-cms/backoffice/server-file-system'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Static File items @@ -21,20 +22,28 @@ export class UmbStaticFileItemServerDataSource extends UmbItemServerDataSourceBa */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } + + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const serializer = new UmbServerFilePathUniqueSerializer(); + const paths = uniques.map((unique) => serializer.toServerPath(unique)!); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => StaticFileService.getItemStaticFile({ query: { path: args.uniques } }), + uniques: paths, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } } -const getItems = (uniques: Array) => { - const serializer = new UmbServerFilePathUniqueSerializer(); - const path = uniques.map((unique) => serializer.toServerPath(unique)!); - - /* eslint-disable local-rules/no-direct-api-import */ - return StaticFileService.getItemStaticFile({ query: { path } }); -}; - const mapper = (item: StaticFileItemResponseModel): UmbStaticFileItemModel => { const serializer = new UmbServerFilePathUniqueSerializer(); return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/repository/item/template-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/repository/item/template-item.server.data-source.ts index 2e5fc3c7c2..9355611023 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/templating/templates/repository/item/template-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/templating/templates/repository/item/template-item.server.data-source.ts @@ -4,6 +4,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { TemplateItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { TemplateService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Template items @@ -21,14 +22,24 @@ export class UmbTemplateItemServerDataSource extends UmbItemServerDataSourceBase */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => TemplateService.getItemTemplate({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => TemplateService.getItemTemplate({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: TemplateItemResponseModel): UmbTemplateItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/item/user-group-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/item/user-group-item.server.data-source.ts index fdb9236739..aad5b6ef54 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/item/user-group-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user-group/repository/item/user-group-item.server.data-source.ts @@ -3,6 +3,7 @@ import type { UserGroupItemResponseModel } from '@umbraco-cms/backoffice/externa import { UserGroupService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for User Group items @@ -20,14 +21,24 @@ export class UmbUserGroupItemServerDataSource extends UmbItemServerDataSourceBas */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => UserGroupService.getItemUserGroup({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => UserGroupService.getItemUserGroup({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: UserGroupItemResponseModel): UmbUserGroupItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/item/user-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/item/user-item.server.data-source.ts index 18f74820c8..323c08f8a6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/item/user-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/user/user/repository/item/user-item.server.data-source.ts @@ -1,6 +1,7 @@ import { UMB_USER_ENTITY_TYPE } from '../../entity.js'; import type { UmbUserItemModel } from './types.js'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; import type { UserItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { UserService } from '@umbraco-cms/backoffice/external/backend-api'; import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository'; @@ -18,14 +19,24 @@ export class UmbUserItemServerDataSource extends UmbItemServerDataSourceBase) => UserService.getItemUser({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => UserService.getItemUser({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: UserItemResponseModel): UmbUserItemModel => { return { diff --git a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/item/webhook-item.server.data-source.ts b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/item/webhook-item.server.data-source.ts index 91775f3ac7..f6115c194f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/item/webhook-item.server.data-source.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/webhook/webhook/repository/item/webhook-item.server.data-source.ts @@ -3,6 +3,7 @@ import { UmbItemServerDataSourceBase } from '@umbraco-cms/backoffice/repository' import type { WebhookItemResponseModel } from '@umbraco-cms/backoffice/external/backend-api'; import { WebhookService } from '@umbraco-cms/backoffice/external/backend-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbItemDataApiGetRequestController } from '@umbraco-cms/backoffice/entity-item'; /** * A server data source for Webhook items @@ -20,14 +21,24 @@ export class UmbWebhookItemServerDataSource extends UmbItemServerDataSourceBase< */ constructor(host: UmbControllerHost) { super(host, { - getItems, mapper, }); } -} -/* eslint-disable local-rules/no-direct-api-import */ -const getItems = (uniques: Array) => WebhookService.getItemWebhook({ query: { id: uniques } }); + override async getItems(uniques: Array) { + if (!uniques) throw new Error('Uniques are missing'); + + const itemRequestManager = new UmbItemDataApiGetRequestController(this, { + // eslint-disable-next-line local-rules/no-direct-api-import + api: (args) => WebhookService.getItemWebhook({ query: { id: args.uniques } }), + uniques, + }); + + const { data, error } = await itemRequestManager.request(); + + return { data: this._getMappedItems(data), error }; + } +} const mapper = (item: WebhookItemResponseModel): UmbWebhookItemModel => { return { From e9a5dafc6213a35007aeec0b115ba11e69741dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 8 May 2025 11:27:59 +0200 Subject: [PATCH 06/48] Fix #19221 (#19254) --- .../examples/block-custom-view/block-custom-view.ts | 8 ++++++++ .../block-grid-entry/block-grid-entry.element.ts | 6 ++++++ .../block-list-entry/block-list-entry.element.ts | 6 ++++++ .../components/block-rte-entry/block-rte-entry.element.ts | 7 +++++++ 4 files changed, 27 insertions(+) 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 8f0e4a11e7..5da08329ab 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 @@ -29,7 +29,9 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen UmbTextStyles, css` :host { + position: relative; display: block; + z-index: 10000; height: 100%; box-sizing: border-box; background-color: red; @@ -38,6 +40,12 @@ export class ExampleBlockCustomView extends UmbElementMixin(LitElement) implemen padding: 12px; } + :host > div { + position: relative; + display: block; + z-index: 10000; + } + .align-center { text-align: center; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts index 1f30e8298e..f0f5a9de01 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-grid/components/block-grid-entry/block-grid-entry.element.ts @@ -427,6 +427,7 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper #extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer) => { if (ext.component) { ext.component.classList.add('umb-block-grid__block--view'); + ext.component.setAttribute('part', 'component'); } if (this._exposed) { return ext.component; @@ -641,6 +642,11 @@ export class UmbBlockGridEntryElement extends UmbLitElement implements UmbProper border-color: var(--uui-color-invalid); } + umb-extension-slot::part(component) { + position: relative; + z-index: 0; + } + #invalidLocation { position: absolute; top: -1em; diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts index a493b21cac..998b1f4acf 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-list/components/block-list-entry/block-list-entry.element.ts @@ -346,6 +346,7 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper }; #extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer) => { + ext.component?.setAttribute('part', 'component'); if (this._exposed) { return ext.component; } else { @@ -511,6 +512,11 @@ export class UmbBlockListEntryElement extends UmbLitElement implements UmbProper border-color: var(--uui-color-invalid); } + umb-extension-slot::part(component) { + position: relative; + z-index: 0; + } + uui-action-bar { position: absolute; top: var(--uui-size-2); diff --git a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts index a056c7045f..6970c554e2 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/block/block-rte/components/block-rte-entry/block-rte-entry.element.ts @@ -243,6 +243,7 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert }; #extensionSlotRenderMethod = (ext: UmbExtensionElementInitializer) => { + ext.component?.setAttribute('part', 'component'); if (this._exposed) { return ext.component; } else { @@ -345,6 +346,12 @@ export class UmbBlockRteEntryElement extends UmbLitElement implements UmbPropert outline: 3px solid var(--uui-color-focus); } } + + umb-extension-slot::part(component) { + position: relative; + z-index: 0; + } + uui-action-bar { position: absolute; top: var(--uui-size-2); From 367a13b0e72dddcb84232b7949764794725aea18 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Thu, 8 May 2025 16:14:26 +0200 Subject: [PATCH 07/48] Correct the display of pending package migrations (#19276) --- .../Services/Implement/PackagingService.cs | 3 ++- .../installed/installed-packages-section-view-item.element.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs b/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs index cd82fa6c8b..3d46df8f01 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/PackagingService.cs @@ -326,8 +326,9 @@ public class PackagingService : IPackagingService PackageName = group.Key.PackageName, }; + var packageKey = Constants.Conventions.Migrations.KeyValuePrefix + (group.Key.PackageId ?? group.Key.PackageName); var currentState = keyValues? - .GetValueOrDefault(Constants.Conventions.Migrations.KeyValuePrefix + group.Key.PackageId); + .GetValueOrDefault(packageKey); package.PackageMigrationPlans = group .Select(plan => new InstalledPackageMigrationPlans diff --git a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts index 6b6f3a8a2c..6402ba7be6 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/packages/package-section/views/installed/installed-packages-section-view-item.element.ts @@ -131,7 +131,7 @@ export class UmbInstalledPackagesSectionViewItemElement extends UmbLitElement { .state=${this._migrationButtonState} color="warning" look="primary" - label=${this.localize.term('packageMigrationsRun')}>` + label=${this.localize.term('packager_packageMigrationsRun')}>` : nothing} From a463c99e4c1b7ef92848dfe470ac713ce4b01c70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Thu, 8 May 2025 17:24:45 +0200 Subject: [PATCH 08/48] Feature: ability to drag across tabs (#19183) Co-authored-by: Mads Rasmussen --- .../content-type-structure-manager.class.ts | 64 +-- ...t-type-design-editor-properties.element.ts | 45 +- .../content-type-design-editor-tab.element.ts | 32 +- .../content-type-design-editor.element.ts | 16 +- .../core/sorter/sorter.controller.test.ts | 6 +- .../packages/core/sorter/sorter.controller.ts | 387 +++++++++++++----- 6 files changed, 409 insertions(+), 141 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts index 8b85120378..7b2cae8563 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/structure/content-type-structure-manager.class.ts @@ -433,38 +433,37 @@ export class UmbContentTypeStructureManager< sortOrder: sortOrder ?? 0, }; - // Ensure - this.ensureContainerNames(contentTypeUnique, type, parentId); - - const contentTypes = this.#contentTypes.getValue(); - const containers = [...(contentTypes.find((x) => x.unique === contentTypeUnique)?.containers ?? [])]; - containers.push(container); - - this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial); - - return container; + return this.insertContainer(contentTypeUnique, container); } - /*async insertContainer(contentTypeUnique: string | null, container: UmbPropertyTypeContainerModel) { + async insertContainer(contentTypeUnique: string | null, container: UmbPropertyTypeContainerModel) { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; + const newContainer = { ...container }; + const type = newContainer.type; + const parentId = newContainer.parent?.id ?? null; // If we have a parent, we need to ensure it exists, and then update the parent property with the new container id. - if (container.parent) { - const parentContainer = await this.ensureContainerOf(container.parent.id, contentTypeUnique); + if (newContainer.parent) { + const parentContainer = await this.ensureContainerOf(newContainer.parent.id, contentTypeUnique); if (!parentContainer) { throw new Error('Container for inserting property could not be found or created'); } - container.parent.id = parentContainer.id; + newContainer.parent.id = parentContainer.id; } + // Ensure + this.ensureContainerNames(contentTypeUnique, type, parentId); + const frozenContainers = this.#contentTypes.getValue().find((x) => x.unique === contentTypeUnique)?.containers ?? []; - const containers = appendToFrozenArray(frozenContainers, container, (x) => x.id === container.id); + const containers = appendToFrozenArray(frozenContainers, newContainer, (x) => x.id === newContainer.id); this.#contentTypes.updateOne(contentTypeUnique, { containers } as Partial); - }*/ + + return newContainer; + } makeEmptyContainerName( containerId: string, @@ -537,7 +536,11 @@ export class UmbContentTypeStructureManager< this.#contentTypes.updateOne(contentTypeUnique, { containers }); } - async removeContainer(contentTypeUnique: string | null, containerId: string | null = null) { + async removeContainer( + contentTypeUnique: string | null, + containerId: string | null = null, + args?: { preventRemovingProperties?: boolean }, + ): Promise { await this.#init; contentTypeUnique = contentTypeUnique ?? this.#ownerContentTypeUnique!; this.#editedTypes.appendOne(contentTypeUnique); @@ -552,12 +555,15 @@ export class UmbContentTypeStructureManager< .map((x) => x.id); const containers = frozenContainers.filter((x) => x.id !== containerId && x.parent?.id !== containerId); - const frozenProperties = contentType.properties; - const properties = frozenProperties.filter((x) => - x.container ? !removedContainerIds.some((ids) => ids === x.container?.id) : true, - ); + const updates: Partial = { containers } as Partial; - this.#contentTypes.updateOne(contentTypeUnique, { containers, properties } as Partial); + if (args?.preventRemovingProperties !== true) { + updates.properties = contentType.properties.filter((x) => + x.container ? !removedContainerIds.some((ids) => ids === x.container?.id) : true, + ); + } + + this.#contentTypes.updateOne(contentTypeUnique, updates); } async insertProperty(contentTypeUnique: string | null, property: UmbPropertyTypeModel) { @@ -655,6 +661,11 @@ export class UmbContentTypeStructureManager< return undefined; } + async getOwnerPropertyById(propertyUnique: string | null): Promise { + await this.#init; + return this.getOwnerContentType()?.properties?.find((property) => property.unique === propertyUnique); + } + async getPropertyStructureByAlias(propertyAlias: string) { await this.#init; for (const docType of this.#contentTypes.getValue()) { @@ -719,7 +730,14 @@ export class UmbContentTypeStructureManager< ); } - getOwnerContainers(containerType: UmbPropertyContainerTypes, parentId: string | null) { + getOwnerContainerById(id: string | null): UmbPropertyTypeContainerModel | undefined { + return this.getOwnerContentType()?.containers?.find((x) => x.id === id); + } + + getOwnerContainers( + containerType: UmbPropertyContainerTypes, + parentId: string | null, + ): Array | undefined { return this.getOwnerContentType()?.containers?.filter( (x) => (parentId ? x.parent?.id === parentId : x.parent === null) && x.type === containerType, ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts index 0f92a8e7a6..cbfedf1878 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-properties.element.ts @@ -97,6 +97,35 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { i++; } }, + onRequestDrop: async ({ unique }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + return context.structure.getOwnerPropertyById(unique); + }, + requestExternalRemove: async ({ item }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + return await context.structure.removeProperty(null, item.unique).then( + () => true, + () => false, + ); + }, + requestExternalInsert: async ({ item }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + const parent = this._containerId ? { id: this._containerId } : null; + const updatedItem = { ...item, parent }; + return await context.structure.insertProperty(null, updatedItem).then( + () => true, + () => false, + ); + }, }); private _containerId: string | null | undefined; @@ -152,7 +181,7 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { constructor() { super(); - this.#sorter.disable(); + //this.#sorter.disable(); this.consumeContext(UMB_CONTENT_TYPE_DESIGN_EDITOR_CONTEXT, (context) => { this.observe( @@ -160,9 +189,9 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { (isSorting) => { this._sortModeActive = isSorting; if (isSorting) { - this.#sorter.enable(); + //this.#sorter.enable(); } else { - this.#sorter.disable(); + //this.#sorter.disable(); } }, '_observeIsSorting', @@ -305,6 +334,16 @@ export class UmbContentTypeDesignEditorPropertiesElement extends UmbLitElement { static override styles = [ UmbTextStyles, css` + :host { + display: block; + } + + #property-list { + /* enables dropping things into this despite it begin empty. */ + margin-top: -20px; + padding-top: 20px; + } + #btn-add { width: 100%; --uui-button-height: var(--uui-size-14); diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-tab.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-tab.element.ts index 7494c47cf6..78a74aa86f 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-tab.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor-tab.element.ts @@ -72,6 +72,35 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement { i++; } }, + onRequestDrop: async ({ unique }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + return context.structure.getOwnerContainerById(unique); + }, + requestExternalRemove: async ({ item }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + return await context.structure.removeContainer(null, item.id, { preventRemovingProperties: true }).then( + () => true, + () => false, + ); + }, + requestExternalInsert: async ({ item }) => { + const context = await this.getContext(UMB_CONTENT_TYPE_WORKSPACE_CONTEXT); + if (!context) { + throw new Error('Could not get Workspace Context'); + } + const parent = this.#containerId ? { id: this.#containerId } : null; + const updatedItem = { ...item, parent }; + return await context.structure.insertContainer(null, updatedItem).then( + () => true, + () => false, + ); + }, }); #workspaceModal?: UmbModalRouteRegistrationController< @@ -231,9 +260,10 @@ export class UmbContentTypeDesignEditorTabElement extends UmbLitElement { .container-list { display: grid; gap: 10px; + align-content: start; } - #convert-to-tab { + .container-list #convert-to-tab { margin-bottom: var(--uui-size-layout-1); display: flex; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts index 909af69582..e8eb09a220 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/views/design/content-type-design-editor.element.ts @@ -423,6 +423,12 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements ]); } + #onDragOver(event: DragEvent, path: string) { + if (this._activePath === path) return; + event.preventDefault(); + window.history.replaceState(null, '', path); + } + override render() { return html` @@ -499,8 +505,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements } renderRootTab() { - const rootTabPath = this._routerPath + '/root'; - const rootTabActive = rootTabPath === this._activePath; + const path = this._routerPath + '/root'; + const rootTabActive = path === this._activePath; if (!this._hasRootGroups && !this._sortModeActive) { // If we don't have any root groups and we are not in sort mode, then we don't want to render the root tab. return nothing; @@ -512,7 +518,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements class=${this._hasRootGroups || rootTabActive ? '' : 'content-tab-is-empty'} label=${this.localize.term('general_generic')} .active=${rootTabActive} - href=${rootTabPath}> + href=${path} + @dragover=${(event: DragEvent) => this.#onDragOver(event, path)}> ${this.localize.term('general_generic')} `; @@ -529,7 +536,8 @@ export class UmbContentTypeDesignEditorElement extends UmbLitElement implements href=${path} data-umb-tab-id=${ifDefined(tab.id)} data-mark="tab:${tab.name}" - ?sortable=${ownedTab}> + ?sortable=${ownedTab} + @dragover=${(event: DragEvent) => this.#onDragOver(event, path)}> ${this.renderTabInner(tab, tabActive, ownedTab)} `; } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.test.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.test.ts index 64c5db6c34..2d797072c7 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.test.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.test.ts @@ -46,7 +46,7 @@ describe('UmbSorterController', () => { beforeEach(async () => { element = await fixture(html``); - await aTimeout(10); + //await aTimeout(10); }); it('is defined with its own instance', () => { @@ -104,8 +104,8 @@ describe('UmbSorterController', () => { expect(element.sorter).to.have.property('notifyDisallowed').that.is.a('function'); }); - it('has a notifyRequestDrop method', () => { - expect(element.sorter).to.have.property('notifyRequestDrop').that.is.a('function'); + it('has a notifyRequestMove method', () => { + expect(element.sorter).to.have.property('notifyRequestMove').that.is.a('function'); }); it('has a destroy method', () => { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts index 9382acd2eb..8b7546a4fc 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/sorter/sorter.controller.ts @@ -7,8 +7,9 @@ const autoScrollSpeed = 16; /** * - * @param el - * @param includeSelf + * @param {Element} el - The element to check for ability to scroll + * @param {Boolean} includeSelf - If true, the element itself will be included in the check + * @returns {Element | null} */ function getParentScrollElement(el: Element, includeSelf: boolean) { if (!el || !el.getBoundingClientRect) return null; @@ -45,8 +46,8 @@ function getParentScrollElement(el: Element, includeSelf: boolean) { /** * - * @param element - * @param ignorerSelectors + * @param {HTMLElement} element - The element to check + * @param {string} ignorerSelectors - A comma separated list of selectors to ignore */ function setupIgnorerElements(element: HTMLElement, ignorerSelectors: string) { const selectors = ignorerSelectors.split(','); @@ -57,8 +58,8 @@ function setupIgnorerElements(element: HTMLElement, ignorerSelectors: string) { } /** * - * @param element - * @param ignorerSelectors + * @param {HTMLElement} element - The element to check + * @param {string} ignorerSelectors - A comma separated list of selectors to ignore */ function destroyIgnorerElements(element: HTMLElement, ignorerSelectors: string) { const selectors = ignorerSelectors.split(','); @@ -69,7 +70,7 @@ function destroyIgnorerElements(element: HTMLElement, ignorerSelectors: string) } /** * - * @param element + * @param {Element} element - The element to check */ function setupPreventEvent(element: Element) { (element as HTMLElement).draggable = false; @@ -77,7 +78,7 @@ function setupPreventEvent(element: Element) { } /** * - * @param element + * @param {Element} element - The element to check */ function destroyPreventEvent(element: Element) { (element as HTMLElement).draggable = false; @@ -97,7 +98,7 @@ export type UmbSorterResolvePlacementArgs = { /** * This callback is executed when an item is moved from another container to this container. */ - onContainerChange?: (argument: { item: T; model: Array; from: UmbSorterController }) => void; + onContainerChange?: (argument: { + item: T; + model: Array; + from: UmbSorterController | undefined; + }) => void; onEnd?: (argument: { item: T; element: ElementType }) => void; itemHasNestedContainersResolver?: (element: HTMLElement) => boolean; /** @@ -190,6 +195,18 @@ type INTERNAL_UmbSorterConfig = { * Callback when user tries to move an item from another Sorter to this Sorter, return true or false to allow or disallow the move. */ onRequestMove?: (argument: { item: T }) => boolean; + /** + * Callback when user tries to drop an item from another window/tab/source. + */ + onRequestDrop?: (argument: { unique: string }) => Promise; + /** + * Callback when user tries to remove an item from another Sorter to this Sorter, return true or false to allow or disallow the move. + */ + requestExternalRemove?: (argument: { item: T }) => Promise; + /** + * Callback when user tries to remove an item from another Sorter to this Sorter, return true or false to allow or disallow the move. + */ + requestExternalInsert?: (argument: { item: T }) => Promise; /** * This callback is executed when an item is hovered within this container. * The callback should return true if the item should be placed after the hovered item, or false if it should be placed before the hovered item. @@ -337,7 +354,7 @@ export class UmbSorterController} + * @returns {Array} The model of this sorter. * @memberof UmbSorterController */ getModel(): Array { @@ -376,6 +393,7 @@ export class UmbSorterController { if (this.#isConnected === false) return; + if (this.#containerElement) { + // This can happen, so no need to show an error as it seems to be happening in some cases. We will just reject. [NL] + //console.error('Container element already initialized', this.#containerElement); + return; + } - const containerEl = - (this.#config.containerSelector - ? this.#host.shadowRoot!.querySelector(this.#config.containerSelector) - : this.#host) ?? this.#host; + const containerEl = this.#config.containerSelector + ? this.#host.shadowRoot!.querySelector(this.#config.containerSelector) + : this.#host; + + if (!containerEl) { + if (this.#config.containerSelector) { + throw new Error( + `Sorter could not find the container element, using this query selector '${this.#config.containerSelector}'.`, + ); + } else { + throw new Error('Sorter could not get its host element.'); + } + return; + } this.#containerElement = containerEl as HTMLElement; this.#useContainerShadowRoot = this.#containerElement === this.#host; @@ -405,6 +438,7 @@ export class UmbSorterController)) { + UmbSorterController.activeSorter = undefined; + if (UmbSorterController.activeElement) { + this.#handleDragEnd(); + } + } + + if (UmbSorterController.dropSorter === (this as unknown as UmbSorterController)) { + // If we are the drop sorter, we can now remove out self to get into pure Native Drag n' drop. + UmbSorterController.dropSorter = undefined; + } + + if (UmbSorterController.lastIndicationSorter === (this as unknown as UmbSorterController)) { + // If we are the lastIndicationSorter, we can now remove out self to get into pure Native Drag n' drop. + UmbSorterController.lastIndicationSorter = undefined; + } + this.#observer.disconnect(); + + // For auto scroller: + this.#scrollElement = null; + if (this.#containerElement) { // Only look at the shadowRoot if the containerElement is host. const containerElement = this.#useContainerShadowRoot @@ -429,15 +484,75 @@ export class UmbSorterController this.destroyItem(item)); } - #itemDraggedOver = (e: DragEvent) => { - //if(UmbSorterController.activeSorter === this) return; + async #obtainIncomingItem(e: DragEvent) { + if ( + !UmbSorterController.dropSorter && + e.dataTransfer?.types.includes('text/umb-sorter-identifier#' + this.identifier.toString()) + ) { + // If we have no drop-sorter, and we share the same identifier, then we like to accept this drag. + const activeType: string | undefined = e.dataTransfer?.types.find((x) => + x.startsWith('text/umb-sorter-item-unique#'), + ); + if (activeType) { + const activeUnique = activeType.split('#')?.[1]; + + let activeItem = this.#model.find((x) => this.#config.getUniqueOfModel(x) === activeUnique); + if (activeItem) { + UmbSorterController.activeSorter = this as unknown as UmbSorterController; + } + // test if unique is already in the model: + if (!activeItem) { + // Find the active item: + activeItem = await this.#config.onRequestDrop?.({ unique: activeUnique }); + UmbSorterController.activeSorter = undefined; // Important as we use this to know if we can remove the item via these Sorter references or if it is a Native Drop. + if (!activeItem) { + // Then we assume this item was not part of this sorters scope. This is the spot where inserting a new item from dataTransfer could be implemented. + return false; + } + } + + if (this.hasItem(activeUnique)) { + return false; + } + + e.dataTransfer.setData('text/umb-sorter-item-accepted', 'true'); + + // Set states: + UmbSorterController.activeItem = activeItem; + UmbSorterController.activeElement = undefined; + UmbSorterController.activeDragElement = undefined; + UmbSorterController.dropSorter = this as unknown as UmbSorterController; + UmbSorterController.originalIndex = undefined; + UmbSorterController.originalSorter = undefined; + + //UmbSorterController.activeSorter = this as unknown as UmbSorterController; + //UmbSorterController.originalSorter = this as unknown as UmbSorterController; + window.addEventListener('mouseup', this.#handleMouseUp); + window.addEventListener('mouseout', this.#handleMouseUp); + window.addEventListener('mouseleave', this.#handleMouseUp); + window.addEventListener('mousemove', this.#handleMouseMove); + + if (!this.#scrollElement) { + this.#scrollElement = getParentScrollElement(this.#containerElement, true); + } + return true; + } + } + + return false; + } + + #itemDraggedOver = async (e: DragEvent) => { + const newDrop = await this.#obtainIncomingItem(e); const dropSorter = UmbSorterController.dropSorter as unknown as UmbSorterController; + if (!dropSorter || dropSorter.identifier !== this.identifier) return; if (dropSorter === this) { @@ -447,7 +562,7 @@ export class UmbSorterController { + this.#handleMoveEnd(); + }; + #getDraggableElement(element: HTMLElement) { if (this.#config.draggableSelector) { // Concept for enabling getting element within ShadowRoot: (But it might need to be configurable, so its still possible to get light dom element(slotted), despite the host is a web-component with shadow-dom.) [NL] @@ -509,6 +629,10 @@ export class UmbSorterController; // Notice, it is acceptable here to get index via object reference, but only cause there has been no change at this stage, otherwise we cannot trust the object instance is represented in the model — it could have mutated or been cloned [NL] - UmbSorterController.originalIndex = this.#model.indexOf(UmbSorterController.activeItem); - - if (!UmbSorterController.activeItem) { - console.error('Could not find item related to this element.', UmbSorterController.activeElement); - return; - } + UmbSorterController.originalIndex = this.#model.findIndex((x) => this.#config.getUniqueOfModel(x) === activeUnique); // Get the current index of the item: UmbSorterController.activeIndex = UmbSorterController.originalIndex; @@ -711,8 +841,11 @@ export class UmbSorterController { UmbSorterController.rqaId = undefined; - if (!UmbSorterController.activeElement || !UmbSorterController.activeItem) { + if (!UmbSorterController.activeItem) { return; } @@ -820,7 +962,7 @@ export class UmbSorterController this.#config.getUniqueOfModel(x) === activeUnique); if (activeIndex === -1) { activeIndex = null; } @@ -912,7 +1055,7 @@ export class UmbSorterController this.#config.getUniqueOfModel(x) !== itemUnique); + if (this.#model.length !== newModel.length) { this.#model = newModel; this.#config.onChange?.({ model: newModel, item }); return true; @@ -1118,7 +1261,7 @@ export class UmbSorterController x !== item).length > 0; } - public async moveItemInModel(newIndex: number, fromCtrl: UmbSorterController) { + public async moveItemInModel(newIndex: number, fromCtrl: UmbSorterController | undefined) { if (!UmbSorterController.activeItem) { console.error('There is no active item to move'); return false; @@ -1128,33 +1271,80 @@ export class UmbSorterController this.#config.getUniqueOfModel(x) === itemUnique) as T | undefined; + if (!item) { + return false; + } } - if (this.notifyRequestDrop({ item }) === false) { + // If we do not have a formCtrl, then it means that we dont know where it comes from, like via native drag across the Sorters awareness. + + if (this.notifyRequestMove({ item }) === false) { return false; } - const localMove = fromCtrl === (this as any); + let localMove = fromCtrl === (this as any); if (!localMove) { // Not a local move, so we have to switch container to continue: - if ((await fromCtrl.removeItem(item)) !== true) { - console.error('Sync could not remove item when moving to a new container'); - return false; - } + // Notice if fromCtrl is not defined this is properly a native drop. + if (fromCtrl) { + if ((await fromCtrl.removeItem(item)) !== true) { + console.error('Sorter could not remove item before moving to a new container'); + return false; + } + } else if (!fromCtrl) { + // Before we react to the external factor, lets see if we already got it in our model. + // Remove from the external model. + if (!this.#config.requestExternalRemove) { + console.error( + 'Sorter needs the requestExternalRemove to be defined, therefor we cannot drop the external item', + ); + return false; + } + UmbSorterController.activeSorter = this as unknown as UmbSorterController; + fromCtrl = this as unknown as UmbSorterController; + + if ((await this.#config.requestExternalRemove({ item })) !== true) { + console.error('Sorter could not remove the item before moving to a new container'); + return false; + } + if ((await this.#config.requestExternalInsert?.({ item })) !== true) { + console.error('Sorter could not insert the item into the new container'); + return false; + } + // This requestExternalInsert ^^ could have updated the model already. if so we should skip ahead to just move the item as a local move. + if (this.#model.find((x) => this.#config.getUniqueOfModel(x) === itemUnique)) { + localMove = true; + } + } + } + if (!localMove) { if (this.#config.performItemInsert) { const result = await this.#config.performItemInsert({ item, newIndex }); if (result === false) { console.error('Sync could not insert after a move a new container'); return false; } + + // If everything went well, we can set the new activeSorter (and dropSorter) to this, as we are switching container. [NL] + UmbSorterController.activeSorter = this as unknown as UmbSorterController; + UmbSorterController.dropSorter = this as unknown as UmbSorterController; + UmbSorterController.activeIndex = + this.#model.findIndex((x) => this.#config.getUniqueOfModel(x) === itemUnique) ?? 0; } else { const newModel = [...this.#model]; newModel.splice(newIndex, 0, item); @@ -1163,7 +1353,7 @@ export class UmbSorterController, + from: fromCtrl as unknown as UmbSorterController | undefined, }); this.#config.onChange?.({ model: newModel, item }); @@ -1178,7 +1368,7 @@ export class UmbSorterController this.#config.getUniqueOfModel(x) === itemUnique); if (oldIndex === -1) { console.error('Could not find item in model when performing internal move', this.getHostElement(), this.#model); return false; @@ -1213,7 +1403,7 @@ export class UmbSorterController; - if (this.notifyRequestDrop({ item: item }) === true) { + if (this.notifyRequestMove({ item: item }) === true) { this.notifyAllowed(); return true; } @@ -1305,27 +1495,10 @@ export class UmbSorterController Date: Fri, 9 May 2025 09:25:39 +0200 Subject: [PATCH 09/48] setup context tokens to support menu structure workspace (#19172) --- src/Umbraco.Web.UI.Client/eslint.config.js | 1 + .../content-type-workspace-context-base.ts | 2 +- .../content-detail-workspace-base.ts | 4 +- .../default/collection-default.context.ts | 20 ++++- .../src/packages/core/entity/constants.ts | 1 + .../core/entity/contexts/parent/constants.ts | 1 + .../core/entity/contexts/parent/index.ts | 1 + .../parent/parent.entity-context-token.ts | 4 + .../contexts/parent/parent.entity-context.ts | 38 ++++++++ .../src/packages/core/entity/index.ts | 2 + .../src/packages/core/menu/index.ts | 2 + ...ructure-workspace-context.context-token.ts | 7 ++ ...u-tree-structure-workspace-context-base.ts | 71 ++++++++++++--- ...ructure-workspace-context.context-token.ts | 10 +++ ...t-structure-workspace-context.interface.ts | 7 ++ ...t-tree-structure-workspace-context-base.ts | 87 ++++++++++++++----- ...idebar-menu-with-entity-actions.element.ts | 20 ++++- .../tree-item-base/tree-item-context-base.ts | 10 ++- .../workspace-menu-breadcrumb.element.ts | 16 ++-- ...rkspace-variant-menu-breadcrumb.element.ts | 14 +-- .../core/workspace/contexts/tokens/index.ts | 4 +- ...tree-entity-workspace-context.interface.ts | 43 +++++++++ ...ble-tree-entity-workspace.context-token.ts | 13 +++ .../entity-detail-workspace-base.ts | 78 ++++++++++++++--- .../menu/language-menu-structure.context.ts | 7 +- 25 files changed, 389 insertions(+), 74 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/constants.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/index.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-structure-workspace-context.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.context-token.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.interface.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/tokens/submittable-tree-entity-workspace-context.interface.ts create mode 100644 src/Umbraco.Web.UI.Client/src/packages/core/workspace/contexts/tokens/submittable-tree-entity-workspace.context-token.ts diff --git a/src/Umbraco.Web.UI.Client/eslint.config.js b/src/Umbraco.Web.UI.Client/eslint.config.js index e0f4913ab9..450a95e71e 100644 --- a/src/Umbraco.Web.UI.Client/eslint.config.js +++ b/src/Umbraco.Web.UI.Client/eslint.config.js @@ -80,6 +80,7 @@ export default [ '@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', }, }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts index a2b0cd669f..068eaed33c 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/workspace/content-type-workspace-context-base.ts @@ -82,7 +82,7 @@ export abstract class UmbContentTypeWorkspaceContextBase< ): Promise { this.resetState(); this.loading.addState({ unique: LOADING_STATE_UNIQUE, message: `Creating ${this.getEntityType()} scaffold` }); - this.setParent(args.parent); + this._internal_setCreateUnderParent(args.parent); const request = this.structure.createScaffold(args.preset); this._getDataPromise = request; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts index 3c257fbb1c..1f2fa3151d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content/workspace/content-detail-workspace-base.ts @@ -754,7 +754,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< // We ask the server first to get a concatenated set of validation messages. So we see both front-end and back-end validation messages [NL] if (this.getIsNew()) { - const parent = this.getParent(); + const parent = this._internal_getCreateUnderParent(); if (!parent) throw new Error('Parent is not set'); await this.#serverValidation.askServerForValidation( saveData, @@ -885,7 +885,7 @@ export abstract class UmbContentDetailWorkspaceContextBase< async #create(variantIds: Array, saveData: DetailModelType) { if (!this._detailRepository) throw new Error('Detail repository is not set'); - const parent = this.getParent(); + const parent = this._internal_getCreateUnderParent(); if (!parent) throw new Error('Parent is not set'); const { data, error } = await this._detailRepository.create(saveData, parent.unique); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts index 9967dead34..ddea42cdfb 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/collection/default/collection-default.context.ts @@ -25,7 +25,7 @@ import { } from '@umbraco-cms/backoffice/entity-action'; import type { UmbActionEventContext } from '@umbraco-cms/backoffice/action'; import { UMB_ACTION_EVENT_CONTEXT } from '@umbraco-cms/backoffice/action'; -import { UMB_ENTITY_CONTEXT } from '@umbraco-cms/backoffice/entity'; +import { UMB_ENTITY_CONTEXT, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; import { UMB_WORKSPACE_MODAL } from '@umbraco-cms/backoffice/workspace'; import { UmbModalRouteRegistrationController, type UmbModalRouteBuilder } from '@umbraco-cms/backoffice/router'; @@ -83,6 +83,7 @@ export class UmbDefaultCollectionContext< }); #actionEventContext: UmbActionEventContext | undefined; + #parentEntityContext = new UmbParentEntityContext(this); constructor(host: UmbControllerHost, defaultViewAlias: string, defaultFilter: Partial = {}) { super(host, UMB_COLLECTION_CONTEXT); @@ -92,6 +93,23 @@ export class UmbDefaultCollectionContext< this.pagination.addEventListener(UmbChangeEvent.TYPE, this.#onPageChange); this.#listenToEntityEvents(); + + // The parent entity context is used to get the parent entity for the collection items + // All items in the collection are children of the current entity context + this.consumeContext(UMB_ENTITY_CONTEXT, (context) => { + const currentEntityUnique = context?.getUnique(); + const currentEntityType = context?.getEntityType(); + + const parent: UmbEntityModel | undefined = + currentEntityUnique && currentEntityType + ? { + unique: currentEntityUnique, + entityType: currentEntityType, + } + : undefined; + + this.#parentEntityContext?.setParent(parent); + }); } setupView(viewElement: UmbControllerHost) { diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts index 3ed4ee95ed..f804358649 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/constants.ts @@ -1 +1,2 @@ export * from './contexts/ancestors/constants.js'; +export * from './contexts/parent/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/constants.ts new file mode 100644 index 0000000000..31caa05f93 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/constants.ts @@ -0,0 +1 @@ +export { UMB_PARENT_ENTITY_CONTEXT } from './parent.entity-context-token.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/index.ts new file mode 100644 index 0000000000..d499a54ed5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/index.ts @@ -0,0 +1 @@ +export { UmbParentEntityContext } from './parent.entity-context.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context-token.ts new file mode 100644 index 0000000000..8a28553f5a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context-token.ts @@ -0,0 +1,4 @@ +import type { UmbParentEntityContext } from './parent.entity-context.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_PARENT_ENTITY_CONTEXT = new UmbContextToken('UmbParentEntityContext'); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context.ts new file mode 100644 index 0000000000..efc1fed494 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/contexts/parent/parent.entity-context.ts @@ -0,0 +1,38 @@ +import type { UmbEntityModel } from '../../types.js'; +import { UMB_PARENT_ENTITY_CONTEXT } from './parent.entity-context-token.js'; +import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; +import { UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; + +/** + * A entity context for the parent + * @class UmbParentEntityContext + * @augments {UmbContextBase} + * @implements {UmbParentEntityContext} + */ +export class UmbParentEntityContext extends UmbContextBase { + #parent = new UmbObjectState(undefined); + parent = this.#parent.asObservable(); + + constructor(host: UmbControllerHost) { + super(host, UMB_PARENT_ENTITY_CONTEXT); + } + + /** + * Gets the parent state + * @returns {UmbEntityModel | undefined} - The parent state + * @memberof UmbParentEntityContext + */ + getParent(): UmbEntityModel | undefined { + return this.#parent.getValue(); + } + + /** + * Sets the parent state + * @param {UmbEntityModel | undefined} parent - The parent state + * @memberof UmbParentEntityContext + */ + setParent(parent: UmbEntityModel | undefined): void { + this.#parent.setValue(parent); + } +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts index d064b7c97a..046856fa0e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/entity/index.ts @@ -2,4 +2,6 @@ export { UMB_ENTITY_CONTEXT } from './entity.context-token.js'; export { UmbEntityContext } from './entity.context.js'; export * from './constants.js'; export * from './contexts/ancestors/index.js'; +export * from './contexts/parent/index.js'; + export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/index.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/index.ts index 46a6c42b4b..c7d6c36f9e 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/index.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/index.ts @@ -1,5 +1,7 @@ export * from './components/index.js'; export * from './menu-tree-structure-workspace-context-base.js'; +export * from './menu-structure-workspace-context.context-token.js'; +export * from './menu-variant-structure-workspace-context.context-token.js'; export * from './menu-variant-tree-structure-workspace-context-base.js'; export type * from './types.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-structure-workspace-context.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-structure-workspace-context.context-token.ts new file mode 100644 index 0000000000..e04fca4af2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-structure-workspace-context.context-token.ts @@ -0,0 +1,7 @@ +import type { UmbMenuStructureWorkspaceContext } from './menu-structure-workspace-context.interface.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT = new UmbContextToken( + 'UmbWorkspaceContext', + 'UmbMenuStructure', +); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts index d461bff391..5ebd0dc2ae 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-tree-structure-workspace-context-base.ts @@ -1,32 +1,41 @@ import type { UmbStructureItemModel } from './types.js'; +import { UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT } from './menu-structure-workspace-context.context-token.js'; import type { UmbTreeRepository, UmbTreeItemModel, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import { UMB_SUBMITTABLE_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; +import { UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; +import { UmbAncestorsEntityContext, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; interface UmbMenuTreeStructureWorkspaceContextBaseArgs { treeRepositoryAlias: string; } +// TODO: introduce base class for all menu structure workspaces to handle ancestors and parent export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContextBase { - #workspaceContext?: typeof UMB_SUBMITTABLE_WORKSPACE_CONTEXT.TYPE; + #workspaceContext?: typeof UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT.TYPE; #args: UmbMenuTreeStructureWorkspaceContextBaseArgs; #structure = new UmbArrayState([], (x) => x.unique); public readonly structure = this.#structure.asObservable(); #parent = new UmbObjectState(undefined); + /** + * @deprecated Will be removed in v.18: Use UMB_PARENT_ENTITY_CONTEXT instead. + */ public readonly parent = this.#parent.asObservable(); + #parentContext = new UmbParentEntityContext(this); + #ancestorContext = new UmbAncestorsEntityContext(this); + constructor(host: UmbControllerHost, args: UmbMenuTreeStructureWorkspaceContextBaseArgs) { - // TODO: set up context token - super(host, 'UmbMenuStructureWorkspaceContext'); + super(host, UMB_MENU_STRUCTURE_WORKSPACE_CONTEXT); + // 'UmbMenuStructureWorkspaceContext' is Obsolete, will be removed in v.18 + this.provideContext('UmbMenuStructureWorkspaceContext', this); this.#args = args; - // TODO: set up context token that supports parentEntityType, parentUnique, entityType. - this.consumeContext(UMB_SUBMITTABLE_WORKSPACE_CONTEXT, (instance) => { + this.consumeContext(UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT, (instance) => { this.#workspaceContext = instance; this.observe(this.#workspaceContext?.unique, (value) => { if (!value) return; @@ -59,14 +68,16 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex const isNew = this.#workspaceContext?.getIsNew(); const entityTypeObservable = isNew - ? (this.#workspaceContext as any)?.parentEntityType - : (this.#workspaceContext as any).entityType; + ? this.#workspaceContext?._internal_createUnderParentEntityType + : this.#workspaceContext?.entityType; const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string; if (!entityType) throw new Error('Entity type is not available'); // If the entity type is different from the root entity type, then we can request the ancestors. if (entityType !== root?.entityType) { - const uniqueObservable = isNew ? (this.#workspaceContext as any)?.parentUnique : this.#workspaceContext?.unique; + const uniqueObservable = isNew + ? this.#workspaceContext?._internal_createUnderParentEntityUnique + : this.#workspaceContext?.unique; const unique = (await this.observe(uniqueObservable, () => {})?.asPromise()) as string; if (!unique) throw new Error('Unique is not available'); @@ -83,11 +94,49 @@ export abstract class UmbMenuTreeStructureWorkspaceContextBase extends UmbContex }); structureItems.push(...ancestorItems); + + this.#structure.setValue(structureItems); + this.#setParentData(structureItems); + this.#setAncestorData(data); } } + } - const parent = structureItems[structureItems.length - 2]; + #setParentData(structureItems: Array) { + /* If the item is not new, the current item is the last item in the array. + We filter out the current item unique to handle any case where it could show up */ + const parent = structureItems.filter((item) => item.unique !== this.#workspaceContext?.getUnique()).pop(); + + // TODO: remove this when the parent gets removed from the structure interface this.#parent.setValue(parent); - this.#structure.setValue(structureItems); + + const parentEntity = parent + ? { + unique: parent.unique, + entityType: parent.entityType, + } + : undefined; + + this.#parentContext.setParent(parentEntity); + } + + /* Notice: ancestors are based on the server "data" ancestors and are not based on the full Menu (UI) structure. + This will mean that any item placed in the data root will not have any ancestors. But will have a parent based on the UI structure. + */ + #setAncestorData(ancestors: Array) { + const ancestorEntities = ancestors + .map((treeItem) => { + const entity: UmbEntityModel = { + unique: treeItem.unique, + entityType: treeItem.entityType, + }; + + return entity; + }) + /* If the item is not new, the current item is the last item in the array. + We filter out the current item unique to handle any case where it could show up */ + .filter((item) => item.unique !== this.#workspaceContext?.getUnique()); + + this.#ancestorContext.setAncestors(ancestorEntities); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.context-token.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.context-token.ts new file mode 100644 index 0000000000..8cd8e1c750 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.context-token.ts @@ -0,0 +1,10 @@ +import type { UmbMenuVariantStructureWorkspaceContext } from './menu-variant-structure-workspace-context.interface.js'; +import { UmbContextToken } from '@umbraco-cms/backoffice/context-api'; + +export const UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT = + new UmbContextToken( + 'UmbWorkspaceContext', + 'UmbMenuStructure', + (context): context is UmbMenuVariantStructureWorkspaceContext => + 'IS_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT' in context, + ); diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.interface.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.interface.ts new file mode 100644 index 0000000000..ade079e3cc --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-structure-workspace-context.interface.ts @@ -0,0 +1,7 @@ +import type { UmbVariantStructureItemModel } from './types.js'; +import type { UmbContext } from '@umbraco-cms/backoffice/class-api'; +import type { Observable } from '@umbraco-cms/backoffice/external/rxjs'; + +export interface UmbMenuVariantStructureWorkspaceContext extends UmbContext { + structure: Observable; +} diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts index 8268a9accc..1e483db90d 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/menu-variant-tree-structure-workspace-context-base.ts @@ -1,36 +1,44 @@ import type { UmbVariantStructureItemModel } from './types.js'; -import type { UmbTreeRepository, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; +import { UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT } from './menu-variant-structure-workspace-context.context-token.js'; +import type { UmbTreeItemModel, UmbTreeRepository, UmbTreeRootModel } from '@umbraco-cms/backoffice/tree'; import { createExtensionApiByAlias } from '@umbraco-cms/backoffice/extension-registry'; import { UmbContextBase } from '@umbraco-cms/backoffice/class-api'; -import { UMB_VARIANT_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; import { UmbArrayState, UmbObjectState } from '@umbraco-cms/backoffice/observable-api'; import type { UmbControllerHost } from '@umbraco-cms/backoffice/controller-api'; -import { UmbAncestorsEntityContext } from '@umbraco-cms/backoffice/entity'; +import { UmbAncestorsEntityContext, UmbParentEntityContext, type UmbEntityModel } from '@umbraco-cms/backoffice/entity'; +import { UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT } from '@umbraco-cms/backoffice/workspace'; interface UmbMenuVariantTreeStructureWorkspaceContextBaseArgs { treeRepositoryAlias: string; } +// TODO: introduce base class for all menu structure workspaces to handle ancestors and parent export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends UmbContextBase { - // TODO: add correct interface - #workspaceContext?: typeof UMB_VARIANT_WORKSPACE_CONTEXT.TYPE; + // + #workspaceContext?: typeof UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT.TYPE; #args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs; #structure = new UmbArrayState([], (x) => x.unique); public readonly structure = this.#structure.asObservable(); #parent = new UmbObjectState(undefined); + /** + * @deprecated Will be removed in v.18: Use UMB_PARENT_ENTITY_CONTEXT instead. + */ public readonly parent = this.#parent.asObservable(); + #parentContext = new UmbParentEntityContext(this); #ancestorContext = new UmbAncestorsEntityContext(this); + public readonly IS_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT = true; + constructor(host: UmbControllerHost, args: UmbMenuVariantTreeStructureWorkspaceContextBaseArgs) { - // TODO: set up context token - super(host, 'UmbMenuStructureWorkspaceContext'); + super(host, UMB_MENU_VARIANT_STRUCTURE_WORKSPACE_CONTEXT); + // 'UmbMenuStructureWorkspaceContext' is Obsolete, will be removed in v.18 + this.provideContext('UmbMenuStructureWorkspaceContext', this); this.#args = args; - // TODO: Implement a Context Token that supports parentUnique, parentEntityType, entityType - this.consumeContext(UMB_VARIANT_WORKSPACE_CONTEXT, (instance) => { + this.consumeContext(UMB_SUBMITTABLE_TREE_ENTITY_WORKSPACE_CONTEXT, (instance) => { this.#workspaceContext = instance; this.observe( this.#workspaceContext?.unique, @@ -45,10 +53,12 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um async #requestStructure() { const isNew = this.#workspaceContext?.getIsNew(); - const uniqueObservable = isNew ? (this.#workspaceContext as any)?.parentUnique : this.#workspaceContext?.unique; + const uniqueObservable = isNew + ? this.#workspaceContext?._internal_createUnderParentEntityType + : this.#workspaceContext?.unique; const entityTypeObservable = isNew - ? (this.#workspaceContext as any)?.parentEntityType - : (this.#workspaceContext as any)?.entityType; + ? this.#workspaceContext?._internal_createUnderParentEntityUnique + : this.#workspaceContext?.entityType; let structureItems: Array = []; @@ -58,7 +68,7 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um const entityType = (await this.observe(entityTypeObservable, () => {})?.asPromise()) as string; if (!entityType) throw new Error('Entity type is not available'); - // TODO: add correct tree variant item model + // TODO: introduce variant tree item model const treeRepository = await createExtensionApiByAlias>( this, this.#args.treeRepositoryAlias, @@ -79,7 +89,7 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um const { data } = await treeRepository.requestTreeItemAncestors({ treeItem: { unique, entityType } }); if (data) { - const ancestorItems = data.map((treeItem) => { + const treeItemAncestors = data.map((treeItem) => { return { unique: treeItem.unique, entityType: treeItem.entityType, @@ -93,20 +103,49 @@ export abstract class UmbMenuVariantTreeStructureWorkspaceContextBase extends Um }; }); - const ancestorEntities = data.map((treeItem) => { - return { + structureItems.push(...treeItemAncestors); + + this.#structure.setValue(structureItems); + this.#setParentData(structureItems); + this.#setAncestorData(data); + } + } + + #setParentData(structureItems: Array) { + /* If the item is not new, the current item is the last item in the array. + We filter out the current item unique to handle any case where it could show up */ + const parent = structureItems.filter((item) => item.unique !== this.#workspaceContext?.getUnique()).pop(); + + // TODO: remove this when the parent gets removed from the structure interface + this.#parent.setValue(parent); + + const parentEntity = parent + ? { + unique: parent.unique, + entityType: parent.entityType, + } + : undefined; + + this.#parentContext.setParent(parentEntity); + } + + /* Notice: ancestors are based on the server "data" ancestors and are not based on the full Menu (UI) structure. + This will mean that any item placed in the data root will not have any ancestors. But will have a parent based on the UI structure. + */ + #setAncestorData(ancestors: Array) { + const ancestorEntities = ancestors + .map((treeItem) => { + const entity: UmbEntityModel = { unique: treeItem.unique, entityType: treeItem.entityType, }; - }); - this.#ancestorContext.setAncestors(ancestorEntities); + return entity; + }) + /* If the item is not new, the current item is the last item in the array. + We filter out the current item unique to handle any case where it could show up */ + .filter((item) => item.unique !== this.#workspaceContext?.getUnique()); - structureItems.push(...ancestorItems); - - const parent = structureItems[structureItems.length - 2]; - this.#parent.setValue(parent); - this.#structure.setValue(structureItems); - } + this.#ancestorContext.setAncestors(ancestorEntities); } } diff --git a/src/Umbraco.Web.UI.Client/src/packages/core/menu/section-sidebar-menu-with-entity-actions/section-sidebar-menu-with-entity-actions.element.ts b/src/Umbraco.Web.UI.Client/src/packages/core/menu/section-sidebar-menu-with-entity-actions/section-sidebar-menu-with-entity-actions.element.ts index d54c73fa52..4fbe32d815 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/core/menu/section-sidebar-menu-with-entity-actions/section-sidebar-menu-with-entity-actions.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/core/menu/section-sidebar-menu-with-entity-actions/section-sidebar-menu-with-entity-actions.element.ts @@ -1,8 +1,9 @@ import { UmbSectionSidebarMenuElement } from '../section-sidebar-menu/section-sidebar-menu.element.js'; import type { ManifestSectionSidebarAppMenuWithEntityActionsKind } from '../section-sidebar-menu/types.js'; -import { css, html, customElement } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, type PropertyValues, state } from '@umbraco-cms/backoffice/external/lit'; import type { UmbExtensionManifestKind } from '@umbraco-cms/backoffice/extension-registry'; import { umbExtensionsRegistry } from '@umbraco-cms/backoffice/extension-registry'; +import { UmbParentEntityContext } from '@umbraco-cms/backoffice/entity'; const manifestWithEntityActions: UmbExtensionManifestKind = { type: 'kind', @@ -18,13 +19,28 @@ umbExtensionsRegistry.register(manifestWithEntityActions); @customElement('umb-section-sidebar-menu-with-entity-actions') export class UmbSectionSidebarMenuWithEntityActionsElement extends UmbSectionSidebarMenuElement { + @state() + _unique = null; + + @state() + _entityType?: string | null; + + #parentContext = new UmbParentEntityContext(this); + + protected override updated(_changedProperties: PropertyValues): void { + if (_changedProperties.has('manifest')) { + const entityType = this.manifest?.meta.entityType; + this.#parentContext.setParent(entityType ? { unique: this._unique, entityType } : undefined); + } + } + override renderHeader() { return html`