diff --git a/build/azure-pipelines.yml b/build/azure-pipelines.yml index 489377f5db..6ad103f6db 100644 --- a/build/azure-pipelines.yml +++ b/build/azure-pipelines.yml @@ -432,11 +432,7 @@ stages: docker build -t $(dockerImageName):$sha -f $(dockerfile) . mkdir -p $(Build.ArtifactStagingDirectory)/docker-images docker save -o $(Build.ArtifactStagingDirectory)/docker-images/$(dockerImageName).$sha.tar $(dockerImageName):$sha - - # Manually generate HTTPS development certificate on Linux dotnet dev-certs https -ep ${HOME}/.aspnet/https/aspnetapp.pfx -p UmbracoAcceptance123! - dotnet dev-certs https --trust - docker run --name $(dockerImageName) -dp 8080:5000 -dp 8443:5001 -e UMBRACO__CMS__GLOBAL__ID=$(UMBRACO__CMS__GLOBAL__ID) -e ASPNETCORE_Kestrel__Certificates__Default__Password="UmbracoAcceptance123!" -e ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx -v ${HOME}/.aspnet/https:/https/ $(dockerImageName):$sha docker ps # Urls matching docker setup. @@ -451,6 +447,7 @@ stages: dotnet new umbraco --name Playwright --no-restore --output . dotnet restore --configfile ./nuget.config dotnet build --configuration $(buildConfiguration) --no-restore + dotnet dev-certs https Start-Process -FilePath "dotnet" -ArgumentList "run --configuration $(buildConfiguration) --no-build --no-launch-profile --urls $(PLAYWRIGHT_BASE_URL)" - task: PowerShell@2 displayName: Wait for app @@ -473,19 +470,22 @@ stages: targetType: inline workingDirectory: tests/Umbraco.Tests.AcceptanceTest script: 'npm run test --ignore-certificate-errors' - - bash: | - if [ -f $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/results/ ]; then - echo "##vso[task.setVariable variable=myfileexists]true" - fi + - task: PowerShell@2 + displayName: Check if artifacts folder exists + inputs: + targetType: inline + script: | + $MyVariable = Test-Path -Path $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/results + Write-Host "##vso[task.setvariable variable=resultFolderExists;]$MyVariable" - task: CopyFiles@2 displayName: Prepare artifacts - condition: eq(variables.myfileexists, 'true') + condition: eq(variables.resultFolderExists, 'True') inputs: sourceFolder: $(Build.SourcesDirectory)/tests/Umbraco.Tests.AcceptanceTest/results/ targetFolder: $(Build.ArtifactStagingDirectory)/playwright - task: PublishPipelineArtifact@1 displayName: "Publish test artifacts" - condition: eq(variables.myfileexists, 'true') + condition: eq(variables.resultFolderExists, 'True') inputs: targetPath: $(Build.ArtifactStagingDirectory) artifact: 'E2E artifacts - $(Agent.OS) - Attempt #$(System.JobAttempt)' diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index 54d62245dd..f95312c693 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -9,6 +9,7 @@ + @@ -21,16 +22,4 @@ - - - - $(UserProfile)\.nuget\packages\ - - - - - - - - diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/BuildModelsBuilderController.cs b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/BuildModelsBuilderController.cs new file mode 100644 index 0000000000..c219a5128e --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/BuildModelsBuilderController.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Infrastructure.ModelsBuilder; +using Umbraco.Cms.Infrastructure.ModelsBuilder.Building; +using Umbraco.Extensions; + +namespace Umbraco.Cms.ManagementApi.Controllers.ModelsBuilderDashboard; + +public class BuildModelsBuilderController : ModelsBuilderControllerBase +{ + private ModelsBuilderSettings _modelsBuilderSettings; + private readonly ModelsGenerationError _mbErrors; + private readonly ModelsGenerator _modelGenerator; + + public BuildModelsBuilderController( + IOptionsMonitor modelsBuilderSettings, + ModelsGenerationError mbErrors, + ModelsGenerator modelGenerator) + { + _mbErrors = mbErrors; + _modelGenerator = modelGenerator; + _modelsBuilderSettings = modelsBuilderSettings.CurrentValue; + + modelsBuilderSettings.OnChange(x => _modelsBuilderSettings = x); + } + + [HttpPost("build")] + [ProducesResponseType(typeof(CreatedResult), StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status428PreconditionRequired)] + [MapToApiVersion("1.0")] + public async Task BuildModels() + { + try + { + if (!_modelsBuilderSettings.ModelsMode.SupportsExplicitGeneration()) + { + var problemDetailsModel = new ProblemDetails + { + Title = "Models generation is not enabled", + Detail = "ModelsBuilderMode is not set to SourceCodeManual or SourceCodeAuto", + Status = StatusCodes.Status428PreconditionRequired, + Type = "Error", + }; + + return await Task.FromResult(new ObjectResult(problemDetailsModel) { StatusCode = StatusCodes.Status428PreconditionRequired }); + } + + _modelGenerator.GenerateModels(); + _mbErrors.Clear(); + } + catch (Exception e) + { + _mbErrors.Report("Failed to build models.", e); + } + + return await Task.FromResult(Created("api/v1/modelsBuilderDashboard", null)); + } +} diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/GetModelsBuilderController.cs b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/GetModelsBuilderController.cs new file mode 100644 index 0000000000..aa993cd1f4 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/GetModelsBuilderController.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.ManagementApi.Factories; +using Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; + +namespace Umbraco.Cms.ManagementApi.Controllers.ModelsBuilderDashboard; + +public class GetModelsBuilderController : ModelsBuilderControllerBase +{ + private readonly IModelsBuilderViewModelFactory _modelsBuilderViewModelFactory; + + public GetModelsBuilderController(IModelsBuilderViewModelFactory modelsBuilderViewModelFactory) => _modelsBuilderViewModelFactory = modelsBuilderViewModelFactory; + + [HttpGet] + [ProducesResponseType(typeof(CreatedResult), StatusCodes.Status200OK)] + [MapToApiVersion("1.0")] + public async Task> GetDashboard() => await Task.FromResult(Ok(_modelsBuilderViewModelFactory.Create())); +} diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/ModelsBuilderControllerBase.cs b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/ModelsBuilderControllerBase.cs new file mode 100644 index 0000000000..69039afd7d --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/ModelsBuilderControllerBase.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; +using Umbraco.New.Cms.Web.Common.Routing; + +namespace Umbraco.Cms.ManagementApi.Controllers.ModelsBuilderDashboard; + +[ApiController] +[VersionedApiBackOfficeRoute("modelsBuilder")] +[OpenApiTag("ModelsBuilder")] +[ApiVersion("1.0")] + +public class ModelsBuilderControllerBase : ManagementApiControllerBase +{ +} diff --git a/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/StatusModelsBuilderController.cs b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/StatusModelsBuilderController.cs new file mode 100644 index 0000000000..9b2cb22e0a --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Controllers/ModelsBuilderDashboard/StatusModelsBuilderController.cs @@ -0,0 +1,29 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Infrastructure.ModelsBuilder; +using Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.ManagementApi.Controllers.ModelsBuilderDashboard; + +public class StatusModelsBuilderController : ModelsBuilderControllerBase +{ + private readonly OutOfDateModelsStatus _outOfDateModelsStatus; + + public StatusModelsBuilderController(OutOfDateModelsStatus outOfDateModelsStatus) => _outOfDateModelsStatus = outOfDateModelsStatus; + + [HttpGet("status")] + [ProducesResponseType(typeof(OutOfDateStatusViewModel), StatusCodes.Status200OK)] + [MapToApiVersion("1.0")] + public async Task> GetModelsOutOfDateStatus() + { + OutOfDateStatusViewModel status = _outOfDateModelsStatus.IsEnabled + ? _outOfDateModelsStatus.IsOutOfDate + ? new OutOfDateStatusViewModel { Status = OutOfDateType.OutOfDate } + : new OutOfDateStatusViewModel { Status = OutOfDateType.Current } + : new OutOfDateStatusViewModel { Status = OutOfDateType.Unknown }; + + return await Task.FromResult(Ok(status)); + } +} diff --git a/src/Umbraco.Cms.ManagementApi/DependencyInjection/FactoryBuilderExtensions.cs b/src/Umbraco.Cms.ManagementApi/DependencyInjection/FactoryBuilderExtensions.cs index 5df44f4c8a..199abcb309 100644 --- a/src/Umbraco.Cms.ManagementApi/DependencyInjection/FactoryBuilderExtensions.cs +++ b/src/Umbraco.Cms.ManagementApi/DependencyInjection/FactoryBuilderExtensions.cs @@ -9,9 +9,9 @@ public static class FactoryBuilderExtensions { internal static IUmbracoBuilder AddFactories(this IUmbracoBuilder builder) { + builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); return builder; } - } diff --git a/src/Umbraco.Cms.ManagementApi/Factories/IModelsBuilderViewModelFactory.cs b/src/Umbraco.Cms.ManagementApi/Factories/IModelsBuilderViewModelFactory.cs new file mode 100644 index 0000000000..14f013e907 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Factories/IModelsBuilderViewModelFactory.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; + +namespace Umbraco.Cms.ManagementApi.Factories; + +public interface IModelsBuilderViewModelFactory +{ + ModelsBuilderViewModel Create(); +} diff --git a/src/Umbraco.Cms.ManagementApi/Factories/ModelsBuilderViewModelFactory.cs b/src/Umbraco.Cms.ManagementApi/Factories/ModelsBuilderViewModelFactory.cs new file mode 100644 index 0000000000..802b05e785 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/Factories/ModelsBuilderViewModelFactory.cs @@ -0,0 +1,39 @@ +using System.Text; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Infrastructure.ModelsBuilder; +using Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; +using Umbraco.Extensions; + +namespace Umbraco.Cms.ManagementApi.Factories; + +public class ModelsBuilderViewModelFactory : IModelsBuilderViewModelFactory +{ + private ModelsBuilderSettings _modelsBuilderSettings; + private readonly ModelsGenerationError _mbErrors; + private readonly OutOfDateModelsStatus _outOfDateModels; + + public ModelsBuilderViewModelFactory(IOptionsMonitor modelsBuilderSettings, ModelsGenerationError mbErrors, OutOfDateModelsStatus outOfDateModels) + { + _mbErrors = mbErrors; + _outOfDateModels = outOfDateModels; + _modelsBuilderSettings = modelsBuilderSettings.CurrentValue; + + modelsBuilderSettings.OnChange(x => _modelsBuilderSettings = x); + } + + + public ModelsBuilderViewModel Create() => + new() + { + Mode = _modelsBuilderSettings.ModelsMode, + CanGenerate = _modelsBuilderSettings.ModelsMode.SupportsExplicitGeneration(), + OutOfDateModels = _outOfDateModels.IsOutOfDate, + LastError = _mbErrors.GetLastError(), + Version = ApiVersion.Current.Version.ToString(), + ModelsNamespace = _modelsBuilderSettings.ModelsNamespace, + TrackingOutOfDateModels = _modelsBuilderSettings.FlagOutOfDateModels, + }; +} diff --git a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs index 450f1494b5..f9e411784e 100644 --- a/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs +++ b/src/Umbraco.Cms.ManagementApi/ManagementApiComposer.cs @@ -8,6 +8,9 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; using NSwag.AspNetCore; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Configuration.Models; @@ -37,6 +40,7 @@ public class ManagementApiComposer : IComposer .AddNewInstaller() .AddUpgrader() .AddExamineManagement() + .AddFactories() .AddTrees() .AddFactories() .AddServices() diff --git a/src/Umbraco.Cms.ManagementApi/OpenApi.json b/src/Umbraco.Cms.ManagementApi/OpenApi.json index 4fce2998b6..0451c0de64 100644 --- a/src/Umbraco.Cms.ManagementApi/OpenApi.json +++ b/src/Umbraco.Cms.ManagementApi/OpenApi.json @@ -210,7 +210,7 @@ }, "/umbraco/api/v1/trackedReferences/{id}": { "get": { - "description": "Used by info tabs on content, media etc. and for the delete and unpublish of single items.\nThis is basically finding parents of relations.", + "description": "Used by info tabs on content, media etc. and for the delete and unpublish of single items.\nThis is basically finding parents of relations.\n ", "operationId": "ForItemTrackedReferences_Get", "parameters": [ { @@ -271,7 +271,7 @@ }, "/umbraco/api/v1/trackedReferences/descendants/{parentId}": { "get": { - "description": "Used when deleting and unpublishing a single item to check if this item has any descending items that are in any\nkind of relation.\nThis is basically finding the descending items which are children in relations.", + "description": "Used when deleting and unpublishing a single item to check if this item has any descending items that are in any\nkind of relation.\nThis is basically finding the descending items which are children in relations.\n ", "operationId": "DescendantsTrackedReferences_Descendants", "parameters": [ { @@ -332,7 +332,7 @@ }, "/umbraco/api/v1/trackedReferences/multiple": { "get": { - "description": "Used when bulk deleting content/media and bulk unpublishing content (delete and unpublish on List view).\nThis is basically finding children of relations.", + "description": "Used when bulk deleting content/media and bulk unpublishing content (delete and unpublish on List view).\nThis is basically finding children of relations.\n ", "operationId": "MultipleTrackedReferences_GetPagedReferencedItems", "parameters": [ { @@ -3721,6 +3721,78 @@ } } } + }, + "/umbraco/api/v1/modelsBuilder/build": { + "post": { + "tags": [ + "ModelsBuilder" + ], + "operationId": "BuildModelsBuilder_BuildModels", + "responses": { + "201": { + "description": "", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "428": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/umbraco/api/v1/modelsBuilder": { + "get": { + "tags": [ + "ModelsBuilder" + ], + "operationId": "GetModelsBuilder_GetDashboard", + "responses": { + "200": { + "description": "", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/umbraco/api/v1/modelsBuilder/status": { + "get": { + "tags": [ + "ModelsBuilder" + ], + "operationId": "StatusModelsBuilder_GetModelsOutOfDateStatus", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OutOfDateStatusViewModel" + } + } + } + } + } + } } }, "components": { @@ -4519,6 +4591,9 @@ { "name": "MemberType" }, + { + "name": "ModelsBuilder" + }, { "name": "PartialView" }, diff --git a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj index 52a41491b4..5956f2ee7a 100644 --- a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj +++ b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/ModelsBuilderViewModel.cs b/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/ModelsBuilderViewModel.cs new file mode 100644 index 0000000000..bb0f7c12f7 --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/ModelsBuilderViewModel.cs @@ -0,0 +1,20 @@ +using Umbraco.Cms.Core.Configuration; + +namespace Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; + +public class ModelsBuilderViewModel +{ + public ModelsMode Mode { get; set; } + + public bool CanGenerate { get; set; } + + public bool OutOfDateModels { get; set; } + + public string? LastError { get; set; } + + public string? Version { get; set; } + + public string? ModelsNamespace { get; set; } + + public bool TrackingOutOfDateModels { get; set; } +} diff --git a/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/OutOfDateStatusViewModel.cs b/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/OutOfDateStatusViewModel.cs new file mode 100644 index 0000000000..951a7f090c --- /dev/null +++ b/src/Umbraco.Cms.ManagementApi/ViewModels/ModelsBuilderDashboard/OutOfDateStatusViewModel.cs @@ -0,0 +1,8 @@ +using Umbraco.New.Cms.Core.Models; + +namespace Umbraco.Cms.ManagementApi.ViewModels.ModelsBuilderDashboard; + +public class OutOfDateStatusViewModel +{ + public OutOfDateType Status { get; set; } +} diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index da91785ef0..d57c0b26fe 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoHeadlineBlock.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoHeadlineBlock.cshtml new file mode 100644 index 0000000000..d895d04d6b --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoHeadlineBlock.cshtml @@ -0,0 +1,4 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + +

@Model.Content.Value("headline")

diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoImageBlock.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoImageBlock.cshtml new file mode 100644 index 0000000000..725fbf185e --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoImageBlock.cshtml @@ -0,0 +1,12 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + +@{ + var typedMediaPickerSingle = Model.Content.Value("image"); + if (typedMediaPickerSingle != null) + { + + } else { +

Missing image

+ } +} diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoRichTextBlock.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoRichTextBlock.cshtml new file mode 100644 index 0000000000..d66726774d --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoRichTextBlock.cshtml @@ -0,0 +1,6 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + +
+@Model.Content.Value("richText") +
diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoTwoColumnLayoutBlock.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoTwoColumnLayoutBlock.cshtml new file mode 100644 index 0000000000..f7bbde7e01 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/Components/umbBlockGridDemoTwoColumnLayoutBlock.cshtml @@ -0,0 +1,6 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage + +
+ @await Html.GetBlockGridItemAreasHtmlAsync(Model) +
diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/areas.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/areas.cshtml new file mode 100644 index 0000000000..1afd941a76 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/areas.cshtml @@ -0,0 +1,19 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage +@{ + if (Model?.Areas.Any() != true) { return; } +} + +
+ @foreach (var area in Model.Areas) + { +
+ @await Html.GetBlockGridItemsHtmlAsync(area) +
+ } +
diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/default.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/default.cshtml new file mode 100644 index 0000000000..4065bfdd03 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/default.cshtml @@ -0,0 +1,11 @@ +@using Umbraco.Extensions +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage +@{ + if (Model?.Any() != true) { return; } +} + +
+ @await Html.GetBlockGridItemsHtmlAsync(Model) +
diff --git a/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/items.cshtml b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/items.cshtml new file mode 100644 index 0000000000..165044553a --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/Views/Partials/blockgrid/items.cshtml @@ -0,0 +1,39 @@ +@using Umbraco.Cms.Core.Models.Blocks +@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage> +@{ + if (Model?.Any() != true) { return; } +} + +
+ @foreach (var item in Model) + { + bool attrForceLeft = item.ForceLeft; + bool attrForceRight = item.ForceRight; +
+ @{ + var partialViewName = "blockgrid/Components/" + item.Content.ContentType.Alias; + try + { + @await Html.PartialAsync(partialViewName, item) + } + catch (InvalidOperationException) + { +

+ Could not render component of type: @(item.Content.ContentType.Alias) +
+ This likely happened because the partial view @partialViewName could not be found. +

+ } + } +
+ } +
diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html new file mode 100644 index 0000000000..523b0fe7d3 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html @@ -0,0 +1,37 @@ + + + + \ No newline at end of file diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html new file mode 100644 index 0000000000..521d7c9b09 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html @@ -0,0 +1,92 @@ + + + + + \ No newline at end of file diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html new file mode 100644 index 0000000000..dbe157cf84 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html @@ -0,0 +1,34 @@ + + +
+
+ \ No newline at end of file diff --git a/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html new file mode 100644 index 0000000000..ccc3af22e8 --- /dev/null +++ b/src/Umbraco.Cms.StaticAssets/wwwroot/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html @@ -0,0 +1,50 @@ + +
+ + +
\ No newline at end of file diff --git a/src/Umbraco.Cms/Umbraco.Cms.csproj b/src/Umbraco.Cms/Umbraco.Cms.csproj index cf0d6aeacc..843540b7ac 100644 --- a/src/Umbraco.Cms/Umbraco.Cms.csproj +++ b/src/Umbraco.Cms/Umbraco.Cms.csproj @@ -19,4 +19,18 @@ + + + + + + + + $(MSBuildThisFileDirectory)appsettings-schema.json + $(MSBuildThisFileDirectory)..\JsonSchema\ + + + + + diff --git a/src/Umbraco.Core/Constants-PropertyEditors.cs b/src/Umbraco.Core/Constants-PropertyEditors.cs index 2bb53b3299..a42f2d198e 100644 --- a/src/Umbraco.Core/Constants-PropertyEditors.cs +++ b/src/Umbraco.Core/Constants-PropertyEditors.cs @@ -41,6 +41,11 @@ public static partial class Constants public const string BlockList = "Umbraco.BlockList"; /// + /// Block Grid. + /// + public const string BlockGrid = "Umbraco.BlockGrid"; + + /// /// CheckBox List. /// public const string CheckBoxList = "Umbraco.CheckBoxList"; diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index 93b0f95af2..387f1ef652 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -1962,6 +1962,7 @@ Mange hilsner fra Umbraco robotten Selvvalgt validering %1% mere.]]> %1% for mange.]]> + Ét eller flere områder lever ikke op til kravene for antal indholdselementer. Slå URL tracker fra @@ -2184,6 +2185,12 @@ Mange hilsner fra Umbraco robotten Indholdet vil stadigt eksistere, men redigering af dette indhold vil ikke være muligt. Indholdet vil blive vist som ikke understøttet indhold. + + + %0% og blok konfigurationer?]]> + Indholdet af gruppens blokke vil stadigt eksistere, men redigering af dette indhold vil ikke + være muligt. Indholdet vil blive vist som ikke understøttet indhold. + Kan ikke redigeres fordi elementtypen ikke eksisterer. Billede @@ -2193,6 +2200,7 @@ Mange hilsner fra Umbraco robotten Indstillinger Avanceret Skjul indholdseditoren + Skjul indholds redigerings knappen samt indholdseditoren i Blok Redigerings vinduet Du har lavet ændringer til dette indhold. Er du sikker på at du vil kassere dem? Annuller oprettelse? @@ -2201,6 +2209,64 @@ Mange hilsner fra Umbraco robotten Tilføj indhold Tilføj %0% Feltet %0% bruger editor %1% som ikke er supporteret for blokke. + Fokusér på den ydre blok + Identifikation + Validering + %0% skal tilføjes minimum %2% gang(e).]]> + %0% må maksimalt tilføjes %3% gang(e).]]> + Antal blokke + Tillad kun specifikke blok-typer + Tilladte blok-typer + Vælg de blok-typer, der er tilladt i dette område, og evt. også hvor mange af hver type, redaktørerne skal tilføje til området. + Er du sikker på, at du vil slette dette område? + Alle blokke, der er oprettet i dette område, vil blive slettet. + Layout-opsætning + Struktur + Størrelses opsætning + Tilgængelige kolonne-størrelser + Vælg de forskellige antal kolonner denne blok må optage i layoutet. Dette forhindre ikke blokken i at optræde i et mindre område. + TIlgængelige række-størrelser + Vælg hvor mange rækker denne blok på optage i layoutet. + Tillad på rodniveay + Gør denne blok tilgængelig i layoutets rodniveau. Hvis dette ikke er valgt, kan denne blok kun bruges inden for andre blokkes definerede områder. + Blok-områder + Layout-kolonner + Vælg hvor mange layout-kolonnner der skal være tilgængelig for blokkens områder. Hvis der ikke er valgt noget her, benyttes det antal layout-kolonner der er valgt for hele layoutet. + Opsætning af områder + Hvis det skal være muligt at indsætte nye blokke indeni denne blok, skal der oprettes ét eller flere områder til at indsætte de nye blokke i. + Ikke tilladt placering. + Standart layout stylesheet + Ikke tilladt indhold blev afvist + + + + + Påtving placering i venstre side + Påtving placering i højre side + Fjern påtvungen placering i venstre side + Fjern påtvungen placering i højre side + Skift påtvungen placering i venstre side + Skift påtvungen placering i højre side + Træk for at skalere + Tilføj indhold label + Overskriv labellen for tilføj indholds knappen i dette område. + Tilføj skalerings muligheder + Tilføj områder + Tilføj katalog udseende + Tilføj Blok + Tilføj gruppe + Tilføj gruppe eller block + Sæt minimum krav for denne tilladelse + Set maksimum krav for denne tilladelse + Blok + Blok + Indstillinger + Områder + Avanceret + Tilladelser + Installer demo konfiguration + Dette indeholder Blokke for Overskrift, Beriget-Tekst, Billede og To-Koloners-Layout.]]> + Installer Hvad er Indholdsskabeloner? diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index a686b4eb56..82311e4311 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -2275,6 +2275,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Custom validation %1% more.]]> %1% too many.]]> + The content amount requirements are not met for one or more areas. - + diff --git a/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less b/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less index 1d8588caca..33e1156648 100644 --- a/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less +++ b/src/Umbraco.Web.UI.Client/src/views/components/umb-property-info-button/umb-property-info-button.less @@ -3,10 +3,6 @@ umb-property-info-button { display: inline-block; vertical-align: text-bottom; - .control-label + & { - margin-left: -8px; - } - > .__button { position: relative; display: inline-flex; @@ -75,6 +71,7 @@ umb-property-info-button { // hide umb-info-button if inside umb-property .umb-property umb-property-info-button { opacity: 0; + transition: opacity 120ms; } .umb-property:focus-within umb-property-info-button, @@ -90,6 +87,7 @@ umb-property-info-button { // hide umb-info-button if inside .umb-control-group umb-property-info-button { opacity: 0; + transition: opacity 120ms; } .umb-control-group:focus-within umb-property-info-button, @@ -101,3 +99,18 @@ umb-property-info-button { .umb-control-group:hover .umb-control-group:not(:hover) umb-property-info-button { opacity: 0; } + + + +.umb-group-panel > .umb-group-panel__header umb-property-info-button { + opacity: 0; +} +.umb-group-panel > .umb-group-panel__header umb-property-info-button:focus { + opacity: 1; +} +.umb-group-panel:focus-within, +.umb-group-panel:hover { + > .umb-group-panel__header umb-property-info-button { + opacity: 1; + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html b/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html index 594f976401..b4422dc49e 100644 --- a/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html +++ b/src/Umbraco.Web.UI.Client/src/views/dataTypes/views/datatype.settings.html @@ -52,7 +52,7 @@ - + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html new file mode 100644 index 0000000000..05432fa862 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgrid.html @@ -0,0 +1,4 @@ + + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html new file mode 100644 index 0000000000..a4efb41717 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridblock/gridblock.editor.html @@ -0,0 +1,69 @@ + + +
+ + + + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js new file mode 100644 index 0000000000..2a77e81b5c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.controller.js @@ -0,0 +1,29 @@ +(function () { + 'use strict'; + + function GridInlineBlockEditor($scope, $element) { + + const vm = this; + + vm.$onInit = function() { + const host = $element[0].getRootNode(); + + console.log(document.styleSheets) + + for (const stylesheet of document.styleSheets) { + + console.log(stylesheet); + const styleEl = document.createElement('link'); + styleEl.setAttribute('rel', 'stylesheet'); + styleEl.setAttribute('type', stylesheet.type); + styleEl.setAttribute('href', stylesheet.href); + + host.appendChild(styleEl); + } + } + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockEditor.GridInlineBlockEditor", GridInlineBlockEditor); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html new file mode 100644 index 0000000000..ca12c9dd98 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.html @@ -0,0 +1,93 @@ +
+ +
+ +
+
+ + + + + + + +
+ + + + + + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.less new file mode 100644 index 0000000000..b8ffcff3ec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/gridinlineblock/gridinlineblock.editor.less @@ -0,0 +1,173 @@ +.blockelement-inlineblock-editor { + display: block; + margin-bottom: 4px; + margin-top: 4px; + border: 1px solid @gray-9; + border-radius: @baseBorderRadius; + transition: border-color 120ms, background-color 120ms; + + .umb-block-list__block:not(.--active) &:hover { + border-color: @gray-8; + } + + .umb-editor-tab-bar { + margin: 0; + position: static; + padding: 0; + } + + > button { + width: 100%; + min-height: 48px; + cursor: pointer; + color: @ui-action-discreet-type; + text-align: left; + padding-left: 10px; + padding-bottom: 2px; + user-select: none; + background-color: white; + + .caret { + vertical-align: middle; + transform: rotate(-90deg); + transition: transform 80ms ease-out; + } + + .icon { + font-size: 1.1rem; + display: inline-block; + vertical-align: middle; + } + + span.name { + position: relative; + display: inline-block; + vertical-align: middle; + } + + &:hover { + color: @ui-action-discreet-type-hover; + border-color: @gray-8; + } + } + + ng-form.ng-invalid-val-server-match-content > .umb-block-list__block > .umb-block-list__block--content > div > & { + > button { + color: @formErrorText; + + .show-validation-type-warning & { + color: @formWarningText; + } + + span.caret { + border-top-color: @formErrorText; + + .show-validation-type-warning & { + border-top-color: @formWarningText; + } + } + } + } + + ng-form.ng-invalid-val-server-match-content > .umb-block-list__block:not(.--active) > .umb-block-list__block--content > div > & { + > button { + span.name { + &::after { + content: "!"; + text-align: center; + position: absolute; + top: -6px; + right: -15px; + min-width: 10px; + color: @white; + background-color: @ui-active-type; + border: 2px solid @white; + border-radius: 50%; + font-size: 10px; + font-weight: bold; + padding: 2px; + line-height: 10px; + background-color: @formErrorText; + + .show-validation-type-warning & { + background-color: @formWarningText; + } + + font-weight: 900; + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-name: blockelement-inlineblock-editor--badge-bounce; + animation-timing-function: ease; + + @keyframes blockelement-inlineblock-editor--badge-bounce { + 0% { + transform: translateY(0); + } + + 20% { + transform: translateY(-4px); + } + + 40% { + transform: translateY(0); + } + + 55% { + transform: translateY(-2px); + } + + 70% { + transform: translateY(0); + } + + 100% { + transform: translateY(0); + } + } + } + } + } + } +} + +.umb-block-list__block.--active { + border-color: @gray-8; + box-shadow: 0 0 2px 0px rgba(0, 0, 0, 0.05); + + > .umb-block-list__block--content { + > .umb-block-list__block--view { + > .blockelement-inlineblock-editor { + > button { + > .caret { + transform: rotate(0deg); + } + } + } + } + } +} + +.blockelement-inlineblock-editor__inner { + border-top: 1px solid @gray-8; + background-color: @gray-12; + + > * > * > * > .umb-group-panel { + background-color: transparent; + box-shadow: none; + margin-top: 10px; + margin-bottom: 0; + > .umb-group-panel__content .umb-property { + margin-bottom: 20px; + } + } + .umb-group-panel + .umb-group-panel { + margin-top: 20px; + } + &.--singleGroup > * > * > * > .umb-group-panel { + margin-top: 0; + > .umb-group-panel__header { + display: none; + } + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/heroblock/heroblock.editor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/heroblock/heroblock.editor.controller.js new file mode 100644 index 0000000000..2dc717d8ef --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/heroblock/heroblock.editor.controller.js @@ -0,0 +1,45 @@ +(function () { + 'use strict'; + + function HeroBlockEditor($scope, mediaResource, mediaHelper) { + + var unsubscribe = []; + + const bc = this; + + $scope.$watch("block.data.image", function(newValue, oldValue) { + if (newValue !== oldValue) { + bc.retrieveMedia(); + } + }, true); + + bc.retrieveMedia = function() { + + if($scope.block.data.image && $scope.block.data.image.length > 0) { + mediaResource.getById($scope.block.data.image[0].mediaKey).then(function (mediaEntity) { + + var mediaPath = mediaEntity.mediaLink; + + //set a property on the 'scope' for the returned media object. + bc.mediaName = mediaEntity.name; + bc.isImage = mediaHelper.detectIfImageByExtension(mediaPath); + bc.imageSource = mediaPath; + }); + } + } + + bc.retrieveMedia(); + + + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockEditor.HeroBlockEditor", HeroBlockEditor); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/mediablock/mediablock.editor.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/mediablock/mediablock.editor.controller.js new file mode 100644 index 0000000000..2f79f3b9f1 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/mediablock/mediablock.editor.controller.js @@ -0,0 +1,47 @@ +(function () { + 'use strict'; + + function MediaBlockEditor($scope, mediaResource, mediaHelper) { + + var unsubscribe = []; + + const bc = this; + + $scope.$watch("block.data.image", function(newValue, oldValue) { + if (newValue !== oldValue) { + bc.retrieveMedia(); + } + }, true); + + bc.retrieveMedia = function() { + + if($scope.block.data.image && $scope.block.data.image.length > 0) { + mediaResource.getById($scope.block.data.image[0].mediaKey).then(function (mediaEntity) { + + var mediaPath = mediaEntity.mediaLink; + + //set a property on the 'scope' for the returned media object + bc.icon = mediaEntity.contentType.icon; + bc.mediaName = mediaEntity.name; + bc.fileExtension = mediaHelper.getFileExtension(mediaPath); + bc.isImage = mediaHelper.detectIfImageByExtension(mediaPath); + bc.imageSource = mediaHelper.getThumbnailFromPath(mediaPath); + }); + } + } + + bc.retrieveMedia(); + + + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockEditor.MediaBlockEditor", MediaBlockEditor); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.html new file mode 100644 index 0000000000..0925b39680 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.html @@ -0,0 +1,75 @@ + + +
+
+ + {{block.config.label}} +
+
+ This content is no longer supported in this context.
+ You might want to remove this block, or contact your developer to take actions for making this block available again.

+ +
Block data:
+
{{block.data | json : 4 }}
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.less new file mode 100644 index 0000000000..8b8fcef9e7 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridentryeditors/unsupportedblock/unsupportedblock.editor.less @@ -0,0 +1,46 @@ +.blockelement-unsupportedblock-editor { + position: relative; + display: block; + box-sizing: border-box; + margin-bottom: 4px; + margin-top: 4px; + width: 100%; + border: 1px solid @gray-9; + border-radius: @baseBorderRadius; + + > .__header { + display: flex; + align-items: center; + padding-left: 20px; + padding-bottom: 2px; + min-height: 48px; + border-bottom: 1px solid @gray-9; + background-color: @gray-11; + color: @gray-6; + + .icon { + font-size: 22px; + margin-right: 5px; + display: inline-block; + vertical-align: middle; + } + span { + display: inline-block; + vertical-align: middle; + } + } + + > .__body { + padding: 20px; + background-color: @gray-11; + + a { + text-decoration: underline; + color: @ui-action-type; + + &:hover { + color: @ui-action-type-hover; + } + } + } +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridui.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridui.less new file mode 100644 index 0000000000..f23632389c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/blockgridui.less @@ -0,0 +1,957 @@ +@import "../../../less/variables.less"; +@import "../../../less/mixins.less"; +@import "../../../less/buttons.less"; +@import "../../../less/accessibility/sr-only.less"; +@import "../../../less/components/umb-icon.less"; + +@umb-block-grid__item_minimum_height: 48px; + +.umb-block-grid { + padding-bottom:10px; +} + +.umb-block-grid .umb-load-indicator { + margin-bottom:20px; +} + +.umb-block-grid { + position: relative; +} + +.umb-block-grid__layout-item { + position: relative; + &:hover { + z-index: 3; +/* + > .umb-block-grid__force-left, + > .umb-block-grid__force-right { + z-index: 4; + } + */ + } +} + +.umb-block-grid__block--validation-border { + display:none; +} +ng-form.ng-invalid-val-server-match-settings > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--validation-border, +ng-form.ng-invalid-val-server-match-content > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--validation-border { + display: block; + position: absolute; + inset: 0; + border-radius: 3px; + border: 1px solid @formErrorText; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .7), inset 0 0 0 1px rgba(255, 255, 255, .7); + /* TODO: not working cause we are in a shadow DOM: */ + .show-validation-type-warning & { + border-color: @formWarningText; + } + pointer-events: none; +} + +/*.umb-block-grid__block--validation-badge { + display:none; +} +ng-form.ng-invalid-val-server-match-settings > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--validation-badge, +ng-form.ng-invalid-val-server-match-content > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--validation-badge { + display:block; + text-align: center; + position: absolute; + top: -9px; + right: -9px; + min-width: 10px; + color: @white; + border: 2px solid @white; + border-radius: 50%; + font-size: 10px; + font-weight: bold; + padding: 2px; + line-height: 10px; + background-color: @formErrorText; + .show-validation-type-warning & { + background-color: @formWarningText; + } + font-weight: 900; + pointer-events: none; + + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-name: blockelement-inlineblock-editor--badge-bounce; + animation-timing-function: ease; + @keyframes blockelement-inlineblock-editor--badge-bounce { + 0% { transform: translateY(0); } + 20% { transform: translateY(-4px); } + 40% { transform: translateY(0); } + 55% { transform: translateY(-2px); } + 70% { transform: translateY(0); } + 100% { transform: translateY(0); } + } +} +*/ + +.umb-block-grid__block { + position: relative; + width: 100%; + height: 100%; + + --umb-block-grid__block--show-ui: 0;// Publicly available. + --umb-block-grid--hint-area-ui: 0; + + &::after { + content: ''; + position: absolute; + z-index: 1; + pointer-events: none; + display: none; + inset: 0; + border: 1px solid transparent; + border-radius: 3px; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .7), inset 0 0 0 1px rgba(255, 255, 255, .7); + + transition: border-color 240ms ease-in; + } + + > .umb-block-grid__block--actions { + opacity: 0; + transition: opacity 120ms; + } + > .umb-block-grid__force-left, + > .umb-block-grid__force-right { + opacity: 0; + transition: opacity 120ms; + } + > .umb-block-grid__block--context { + opacity: 0; + transition: opacity 120ms; + } + > umb-block-grid-block > umb-block-grid-entries > .umb-block-grid__block--context { + opacity: 0; + transition: opacity 120ms; + } + + &:hover, + &:focus, + &:focus-within { + + --umb-block-grid--hint-area-ui: 1; + + &::after { + /*border-color: @blueDark;*/ + display: var(--umb-block-grid--block-ui-display, block); + animation: umb-block-grid__block__border-pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__block__border-pulse { + 0% { border-color: rgba(@blueDark, 1); } + 100% { border-color: rgba(@blueDark, 0.66); } + } + } + } + &.--active { + &::after { + display: block; + border-color: @pinkLight; + animation: none; + } + + > .umb-block-grid__block--context { + .__context-bar { + background-color: @ui-active-border; + color: @ui-active-type; + } + } + } + + &.--scale-mode { + &::after { + display: block; + + z-index: 10; + cursor: nwse-resize; + pointer-events: all; + + transition: background-color 240ms ease-in; + animation: umb-block-grid__scalebox__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__scalebox__pulse { + 0% { background-color: rgba(@blueMidLight, 0.33); } + 100% { background-color: rgba(@blueMidLight, 0.22); } + } + } + + > .umb-block-grid__block--context { + opacity: 1; + } + > .umb-block-grid__scale-handler { + opacity: 1; + } + > .umb-block-grid__scale-label { + opacity: 1; + } + > .umb-block-grid__force-left, + > .umb-block-grid__force-right { + opacity: 1; + } + } + + /** make sure to hide child block ui: */ + &.--hovering-area:not(.--block-ui-visible) { + &::after { + display: none; + } + > .umb-block-grid__scale-handler { + display: none; + } + > .umb-block-grid__block--context { + display: none; + } + > .umb-block-grid__block--actions { + display: none; + } + > .umb-block-grid__force-left, + > .umb-block-grid__force-right { + display: none; + } + } + &.--block-ui-visible { + > .umb-block-grid__block--context { + /* take full width to prevent interaction with elements behind.*/ + left: 0; + } + .umb-block-grid__area-container, .umb-block-grid__block--view::part(area-container) { + --umb-block-grid--block-ui-display: none; + .umb-block-grid__layout-item { + pointer-events: none; + } + .umb-block-grid__block { + pointer-events: none; + } + } + } + + &.--hovering-area.--block-ui-visible:hover, + &.--hovering-area.--block-ui-visible:focus, + &.--hovering-area.--block-ui-visible:focus-within, + + &:not(.--hovering-area):hover, + &:not(.--hovering-area):focus, + &:not(.--hovering-area):focus-within, + &.--active { + + > .umb-block-grid__block--context { + opacity: 1; + } + &:not(.--scale-mode) { + > .umb-block-grid__block--actions { + opacity: 1; + } + + > umb-block-grid-block > umb-block-grid-entries > .umb-block-grid__layout-container > .umb-block-grid__area-actions { + opacity: 1; + } + } + + > .umb-block-grid__scale-handler { + opacity: 1; + } + > .umb-block-grid__force-left, + > .umb-block-grid__force-right { + opacity: 1; + } + } + + /* + &.--show-validation { + ng-form.ng-invalid-val-server-match-content > & { + border: 2px solid @formErrorText; + border-radius: @baseBorderRadius; + } + } + */ +} + +ng-form.ng-invalid > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--actions { + opacity: 1; +} +/* +ng-form.ng-invalid > .umb-block-grid__block:not(.--active) > .umb-block-grid__block--context { + opacity: 1; +} +*/ + +.umb-block-grid__block--view { + height: 100%; + width: 100%; + display: block; +} + +.umb-block-grid__block--context { + position: absolute; + top: -20px; + right: 0; + font-size: 12px; + z-index: 2; + display: var(--umb-block-grid--block-ui-display, flex); + justify-content: end; + + .__context-bar { + padding: 0 9px; + padding-top: 1px; + background-color: #3544B1; + color: white; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + display: inline-block; + .action { + color: currentColor; + font-size: 12px; + } + .__divider { + display: inline-block; + margin: 0 3px; + } + .__label { + display: inline-flex; + align-items: center; + padding: 0; + font-weight: 700; + user-select: none; + + .icon { + font-size: 22px; + margin-right: 5px; + display: inline-block; + } + } + } +} + +.umb-block-grid__block--actions { + position: absolute; + top: 0px; + padding-top:10px;/** set to make sure programmatic scrolling gets the top of the block into view. */ + + right: 10px; + + /* + If child block, it can be hidden if a parents sets: --umb-block-grid--block-ui-display: none; + */ + display: var(--umb-block-grid--block-ui-display, flex); + opacity: 1; + z-index:3; + + font-size: 0; + background-color: rgba(255, 255, 255, .96); + border-radius: 16px; + align-items: center; + padding: 0 5px; + margin-top:10px; + + .action { + color: @ui-action-discreet-type; + font-size: 18px; + padding: 5px; + &:hover { + color: @ui-action-discreet-type-hover; + } + } + + .action { + position: relative; + display: inline-block; + + &.--error { + color: @errorBackground; + /** TODO: warning color class does not work in shadowDOM. */ + .show-validation-type-warning & { + color: @warningBackground; + } + } + + > .__error-badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 8px; + color: @white; + background-color: @ui-active-type; + border: 2px solid @white; + border-radius: 50%; + font-size: 8px; + font-weight: bold; + padding: 2px; + line-height: 8px; + background-color: @errorBackground; + .show-validation-type-warning & { + background-color: @warningBackground; + } + display: none; + font-weight: 900; + } + &.--error > .__error-badge { + display: block; + + animation-duration: 1.4s; + animation-iteration-count: infinite; + animation-name: umb-block-grid__action--badge-bounce; + animation-timing-function: ease; + @keyframes umb-block-grid__action--badge-bounce { + 0% { transform: translateY(0); } + 20% { transform: translateY(-4px); } + 40% { transform: translateY(0); } + 55% { transform: translateY(-2px); } + 70% { transform: translateY(0); } + 100% { transform: translateY(0); } + } + + } + } +} + +.umb-block-grid__force-left, +.umb-block-grid__force-right { + position: absolute; + z-index: 2; + top: 50%; + height: 30px; + width: 15px; + margin-top:-15px; + background-color: transparent; + color: @blueDark; + border: 1px solid rgba(255, 255, 255, .2); + font-size: 12px; + padding: 0; + cursor: pointer; + box-sizing: border-box; + border-radius: 8px; + display: var(--umb-block-grid--block-ui-display, flex); + justify-content: center; + align-items: center; + pointer-events: all; + + opacity: 0; + transition: background-color 120ms, border-color 120ms, color 120ms, opacity 120ms; + + .icon { + position: relative; + display: inline-block; + pointer-events: none; + opacity: 0; + transition: transform 120ms ease-in-out, opacity 120ms; + ::before { + content: ''; + position: absolute; + background-color:currentColor; + width:2px; + height: 8px; + top: 2px; + transition: transform 120ms ease-in-out; + } + } + + &:hover { + opacity: 1; + color: @blueDark; + background-color: @white; + } + &:hover, + &.--active { + .icon { + opacity: 1; + transform: translateX(0); + ::before { + transform: translateX(0); + } + } + } + + &.--active { + background-color: @blueDark; + color: white; + &:hover { + color: white; + } + } +} + +.umb-block-grid__force-left { + left: 0; + border-top-left-radius: 0; + border-bottom-left-radius: 0; + .icon { + transform: translateX(3px); + ::before { + left: 2px; + transform: translateX(-3px); + } + } + &:hover, + &.--active { + border-left-color: @blueDark; + } +} +.umb-block-grid__force-right { + right: 0; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + .icon { + margin-right: 1px; + transform: translateX(-3px); + ::before { + right: 2px; + transform: translateX(3px); + } + } + &:hover, + &.--active { + border-right-color: @blueDark; + } +} + +/* +umb-block-grid-block { + + > div { + position: relative; + width: 100%; + min-height: @umb-block-grid__item_minimum_height; + background-color: @white; + border-radius: @baseBorderRadius; + box-sizing: border-box; + } + +} +*/ + +/* +.blockelement__draggable-element { + cursor: grab; +} +*/ + + +.umb-block-grid__scale-handler { + cursor: nwse-resize; + position: absolute; + display: var(--umb-block-grid--block-ui-display, block); + Z-index:10; + bottom: -4px; + right: -4px; + width:8px; + height:8px; + padding: 0; + background-color: white; + border: @blueDark solid 1px; + opacity: 0; + transition: opacity 120ms; +} +.umb-block-grid__scale-handler:focus { + opacity: 1; +} +.umb-block-grid__scale-handler::after { + content: ''; + position: absolute; + inset: -10px; + background-color: rgba(0,0,0,0); +} + +.umb-block-grid__scale-handler:focus + .umb-block-grid__scale-label, +.umb-block-grid__scale-handler:hover + .umb-block-grid__scale-label { + opacity: 1; +} + + +.umb-block-grid__scale-label { + position: absolute; + display: block; + left: 100%; + margin-left: 6px; + margin-top: 6px; + z-index: 2; + + background-color: white; + color: black; + font-size: 12px; + padding: 0px 6px; + border-radius: 3px; + opacity: 0; + transition: opacity 120ms; + + pointer-events: none; + white-space: nowrap; +} + + +.umb-block-grid__block--inline-create-button { + top: 0px; + position: absolute; + z-index: 1; +} +.umb-block-grid__block--inline-create-button.--above { + left: 0; + width: 100%; +} +.umb-block-grid__block--inline-create-button.--above.--at-root { + /* If at root, and full-width then become 40px wider: */ + --calc: clamp(0, calc(var(--umb-block-grid--item-column-span) - (var(--umb-block-grid--grid-columns)-1)), 1); + left: calc(-20px * var(--calc)); + width: calc(100% + 40px * var(--calc)); +} +.umb-block-grid__block--inline-create-button.--after { + right: 1px; +} +.umb-block-grid__block--inline-create-button.--after.--detector { + width: 10px; + margin-right: -10px; + height: 100%; + z-index: 0; +} +.umb-block-grid__block--inline-create-button.--after.--at-root { + /* If at root, and full-width then move a little out to the right: */ + --calc: clamp(0, calc(var(--umb-block-grid--item-column-span) - (var(--umb-block-grid--grid-columns)-1)), 1); + right: calc(-2px * var(--calc)); +} +.umb-block-grid__block--inline-create-button.--after.--hide { + /** If at right side, hide, but do still act on mouseOver so we can reevaluate if it should be shown again.*/ + opacity: 0; + cursor: move !important; +} +.umb-block-grid__block--inline-create-button.--after.--hide > * { + pointer-events: none; +} + +.umb-block-grid__block--after-inline-create-button { + z-index:2; + width: 100%; + /* Move inline create button slightly up, to avoid collision with others*/ + margin-bottom: -7px; + margin-top: -13px; +} + + + +.umb-block-grid__actions { + display:flex; + border: 1px dashed @ui-action-discreet-border; + border-radius: @baseBorderRadius; + box-sizing: border-box; + + clear: both;// needed for layouts using float. + + &:hover { + border-color: transparent; + > button { + + border-color: @ui-action-discreet-border; + + &.umb-block-grid__clipboard-button { + opacity: 1; + } + } + + } + + > button { + position: relative; + display: flex; + //width: 100%; + align-items: center; + justify-content: center; + + color: @ui-action-discreet-type; + font-weight: bold; + margin: -1px; + padding: 5px 15px; + + border: 1px dashed transparent; + border-radius: @baseBorderRadius; + box-sizing: border-box; + + &:not([disabled]):hover { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + text-decoration: none; + z-index: 1; + } + + &[disabled], + &[disabled]:hover { + color: @gray-7; + border-color: @gray-7; + cursor: default; + } + + &.umb-block-grid__create-button { + flex-grow: 1; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &.umb-block-grid__clipboard-button { + margin-left: 0; + padding: 5px 12px; + font-size: 18px;// Align with block action buttons. + border-top-left-radius: 0; + border-bottom-left-radius: 0; + + opacity: 0; + &:hover, &:focus { + opacity: 1; + } + + &.--jump { + + @keyframes umb-block-grid__jump-clipboard-button { + 0% { opacity: 0; } + 10% { opacity: 1; } + 90% { opacity: 1; } + 100% { opacity: 0; } + } + animation: umb-block-grid__jump-clipboard-button 2s; + + i{ + @keyframes umb-block-grid__jump-clipboard-button-i { + 10% { transform: scale(1); } + 10% { transform: scale(1.33); } + 20% { transform: scale(.82); } + 30% { transform: scale(1.24); } + 40% { transform: scale(.94); } + 50% { transform: scale(1); } + } + animation: umb-block-grid__jump-clipboard-button-i 2s; + } + } + + } + } +} + +.umb-block-grid__area-actions { + grid-column: span var(--umb-block-grid--area-column-span); + opacity: calc(var(--umb-block-grid--hint-area-ui, 0) * .5 + var(--umb-block-grid--show-area-ui, 0)); + transition: opacity 120ms; + + &:focus, + &:focus-within { + opacity: 1; + } + &.--highlight { + opacity: 1; + } + + > button { + position: relative; + display: flex; + //width: 100%; + align-items: center; + justify-content: center; + + /* TODO: dont use --umb-text-color, its temporary to inherit UI */ + color: var(--umb-text-color, @ui-action-discreet-type); + font-weight: bold; + padding: 5px 15px; + + /* TODO: dont use --umb-text-color, its temporary to inherit UI */ + border: 1px dashed var(--umb-text-color, @ui-action-discreet-border); + border-radius: @baseBorderRadius; + box-sizing: border-box; + + width: 100%; + height: 100%; + + &:hover { + color: var(--umb-text-color, @ui-action-discreet-type-hover); + border-color: var(--umb-text-color, @ui-action-discreet-border-hover); + text-decoration: none; + z-index: 1; + } + } +} + + +/** make sure block with areas stay on top, so they don't look like they are 'not-allowed'*/ +/* +.umb-block-grid__layout-container.--droppable-indication { + .umb-block-grid__area-actions { + display: none; + } +} +*/ + +.umb-block-grid__layout-item-placeholder { + background: transparent; + border-radius: 3px; + + border: @blueDark solid 1px; + border-radius: 3px; + + height: 100%; +} + +.umb-block-grid__layout-item-placeholder > * { + display: none; +} +.umb-block-grid__layout-item-placeholder::before { + content: ''; + position:absolute; + z-index:1; + inset: 0; + border-radius: 3px; + background-color: white; +} +.umb-block-grid__layout-item-placeholder::after { + content: ''; + position:absolute; + z-index:1; + inset: 0; + border-radius: 3px; + + transition: background-color 240ms ease-in; + animation: umb-block-grid__placeholder__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__placeholder__pulse { + 0% { background-color: rgba(@blueMidLight, 0.33); } + 100% { background-color: rgba(@blueMidLight, 0.22); } + } +} +.umb-block-grid__layout-item-placeholder .indicateForceLeft, +.umb-block-grid__layout-item-placeholder .indicateForceRight { + position:absolute; + + z-index: 2; + height: 100%; + width: 15px; + + background-color: @blueDark; + + background-position: center center; + background-repeat: no-repeat; + display: block !important; + + animation: umb-block-grid__indicateForce__pulse 400ms ease-in-out alternate infinite; +} + +.umb-block-grid__layout-item-placeholder .indicateForceLeft { + left:0; + background-image: url("data:image/svg+xml;utf8,"); +} +.umb-block-grid__layout-item-placeholder .indicateForceRight { + right:0; + background-image: url("data:image/svg+xml;utf8,"); +} + +@keyframes umb-block-grid__indicateForce__pulse { + 0% { background-color: rgba(@blueDark, 1); } + 100% { background-color: rgba(@blueDark, 0.5); } +} + + +.umb-block-grid__area { + position: relative; + --umb-block-grid--show-area-ui: 0; +} +.umb-block-grid__area:focus, +.umb-block-grid__area:focus-within, +.umb-block-grid__area:hover { + --umb-block-grid--show-area-ui: 1; +} +.umb-block-grid__area::after { + content: ''; + position: absolute; + inset: 0; + /* Moved slightly in to align with the inline-create button, which is moved slightly in to avoid collision with other create buttons. */ + top:2px; + bottom: 2px; + border-radius: 3px; + border: 1px solid rgba(@gray-5, 0.3); + pointer-events: none; + opacity: var(--umb-block-grid--show-area-ui, 0); + transition: opacity 240ms; +} +.umb-block-grid__area.--highlight::after { + opacity: 1; + border-color: @blueDark; + box-shadow: 0 0 0 1px rgba(255, 255, 255, .7), inset 0 0 0 1px rgba(255, 255, 255, .7); +} + +.umb-block-grid__scalebox-backdrop { + position: absolute; + top:0; + left:0; + bottom:0; + right:0; + z-index: 1; + cursor: nwse-resize; +} +/* +.umb-block-grid__scalebox { + position: absolute; + top:0; + left:0; + z-index: 10; + cursor: nwse-resize; + + transition: background-color 240ms ease-in; + animation: umb-block-grid__scalebox__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__scalebox__pulse { + 0% { background-color: rgba(@blueMidLight, 0.33); } + 100% { background-color: rgba(@blueMidLight, 0.22); } + } +} +*/ + + + + +/* +.umb-block-grid__layout-container { + +} +*/ + + +/** make sure block with areas stay on top, so they don't look like they are 'not-allowed'*/ +.umb-block-grid__layout-container.--not-allowing-drop { + .umb-block-grid__layout-item.--has-areas { + z-index: 2; + } +} + +.umb-block-grid__layout-container .umb-block-grid__layout-item:not([depth='0']):first-of-type .umb-block-grid__block--inline-create-button.--above { + /* Move first above inline create button slightly up, to avoid collision with others*/ + margin-top: -7px; +} + +.umb-block-grid__not-allowed-box { + position: absolute; + inset: 0; + z-index:1; + border: 1px solid @red; + pointer-events: none; + cursor: no-drop; + + animation: umb-block-grid__not-allowed-box__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__not-allowed-box__pulse { + 0% { background-color: rgba(@red, 0.33); } + 100% { background-color: rgba(@red, 0.22); } + } +} + + +.show-validation umb-block-grid-entries.--invalid { + border: 2px solid @errorBackground; + border-radius: 3px; + + box-shadow: 0 0 0 1px rgba(255, 255, 255, .2), inset 0 0 0 1px rgba(255, 255, 255, .2); +} + +.umb-block-grid__entries-validation-message { + padding: 3px 6px; + user-select: none; + + border-radius: 6px; + color: @errorBackground; +} + +.show-validation .umb-block-grid__entries-validation-message { + background-color: @errorBackground; + color: @errorText; + border-radius: 0; + border-top: 1px solid rgba(255, 255, 255, .2); +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/datamodels.temp b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/datamodels.temp new file mode 100644 index 0000000000..df19432413 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/datamodels.temp @@ -0,0 +1,88 @@ + +//Pre-values model: + +gridColumns: 12, +validationLimit: { + min: 1, + max: 14 +}, +blocks: [ + { + contentElementTypeKey: 123, + allowAtRoot: true, + rowMinSpan: null, + rowMaxSpan: null, + columnSpanOptions: [ + {columnSpan: 12}, + ... + ], + areaGridColumns: null, + areas: [ + { + key: "ABD1", + alias: "", + columnSpan: 6, + rowSpan: 1, + minAllowed: 1, + maxAllowed: 2, + specifiedAllowance: [{ + elementTypeKey: 345; + minAllowed: 1, + maxAllowed: 4, + }, + ... + ] + }, + ... + ] + } +] + + + +// property value model (model.value): +layout: [ + { + contentUdi: "1234-1234-1234", + columnSpan: 12, + // grid 2 column + // Section with area A B + areas: [ + { + key: "1234-1234-1234"// Generated GUID + items: [ + { + contentUdi: "1234-1234-1234", + columnSpan: 6, + rowSpan: 1, + areas: [ + { + key: "1234-1234-1234"// Generated GUID + items: [ + ... + ] + } + ] + }, + ... + ] + }, + ... + ] + }, + ... +], +contentData: [ + { + udi: "1234-1234-1234", + contentTypeKey: "element://1234-1234-1234" + }, + ... +], +settingsData: [ + { + udi: "1234-1234-1234", + contentTypeKey: "element://1234-1234-1234" + }, + ... +], diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.controller.js new file mode 100644 index 0000000000..6a466cf052 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.controller.js @@ -0,0 +1,53 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockGrid.BlockConfigurationOverlayController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function BlockConfigurationAreaOverlayController($scope) { + + var unsubscribe = []; + + var vm = this; + + vm.area = $scope.model.area; + vm.area.specifiedAllowance = vm.area.specifiedAllowance || []; + + vm.minMaxModel = { + hideLabel: true, + view: "numberrange", + value: {min:vm.area.minAllowed, max:vm.area.maxAllowed} + } + + vm.submit = function() { + if ($scope.model && $scope.model.submit) { + + // Transfer minMaxModel to area: + vm.area.minAllowed = vm.minMaxModel.value.min; + vm.area.maxAllowed = vm.minMaxModel.value.max; + + $scope.model.submit($scope.model); + } + }; + + vm.close = function() { + if ($scope.model && $scope.model.close) { + $scope.model.close($scope.model); + } + }; + + $scope.$on('$destroy', function() { + unsubscribe.forEach(u => { u(); }); + }); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockGrid.BlockConfigurationAreaOverlayController", BlockConfigurationAreaOverlayController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.html new file mode 100644 index 0000000000..cccceb7afb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.html @@ -0,0 +1,119 @@ +
+ +
+ + + + + + + +
+ +
+ +
+ Identification +
+ +
+ + +
+
+ + + The alias will be printed by GetBlockGridHTML(), use the alias to target the Element representing this area. Ex. .umb-block-grid__area[data-area-alias="MyAreaAlias"] { ... } + +
+ +
+
+
+ + +
+
+ + + Overwrite the label on the create button of this Area. + +
+ +
+
+
+ +
+ +
+ +
+ +
+ Validation +
+ +
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + + Define the types of blocks that are allowed in this area, and optionally how many of each type that should be present. + +
+ +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + +
+
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js new file mode 100644 index 0000000000..84a64a7669 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.controller.js @@ -0,0 +1,433 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockGrid.BlockConfigurationController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function TransferProperties(fromObject, toObject) { + for (var p in fromObject) { + toObject[p] = fromObject[p]; + } + } + + const DEFAULT_GRID_COLUMNS = 12; + + const DEFAULT_BLOCKTYPE_OBJECT = { + "columnSpanOptions": [], + "allowAtRoot": true, + "allowInAreas": true, + "rowMinSpan": 1, + "rowMaxSpan": 1, + "contentElementTypeKey": null, + "settingsElementTypeKey": null, + "label": "", + "view": null, + "stylesheet": null, + "editorSize": "medium", + "iconColor": null, + "backgroundColor": null, + "thumbnail": null, + "areaGridColumns": null, + "areas": [], + "groupKey": null + }; + + + function BlockConfigurationController($scope, $element, $http, elementTypeResource, overlayService, localizationService, editorService, eventsService, udiService, dataTypeResource, umbRequestHelper) { + + var unsubscribe = []; + + var vm = this; + vm.openBlock = null; + vm.showSampleDataCTA = false; + + function onInit() { + + $element.closest('.umb-control-group').addClass('-no-border'); + + // Somehow the preValues models are different, so we will try to match either key or alias. + vm.gridColumnsPreValue = $scope.preValues.find(x => x.key ? x.key === "gridColumns" : x.alias === "gridColumns"); + const blockGroupModel = $scope.preValues.find(x => x.key ? x.key === "blockGroups" : x.alias === "blockGroups"); + if (blockGroupModel.value == null) { + blockGroupModel.value = []; + } + vm.blockGroups = blockGroupModel.value; + + if (!$scope.model.value) { + $scope.model.value = []; + } + + // Ensure good values: + $scope.model.value.forEach(block => { + block.columnSpanOptions = block.columnSpanOptions || []; + }); + $scope.model.value.forEach(block => { + block.areas = block.areas || []; + }); + + loadElementTypes(); + + } + + + function loadElementTypes() { + return elementTypeResource.getAll().then(function (elementTypes) { + vm.elementTypes = elementTypes; + }); + } + + function updateUsedElementTypes(event, args) { + var key = args.documentType.key; + for (var i = 0; i { + blockType.areas.forEach(area => { + area.specifiedAllowance = area.specifiedAllowance?.filter(allowance => + allowance.elementTypeKey !== contentElementTypeKey + ) || []; + }); + }); + } + + function removeReferencesToGroupKey(groupKey) { + // Clean up references to this one: + $scope.model.value.forEach(blockType => { + blockType.areas.forEach(area => { + area.specifiedAllowance = area.specifiedAllowance?.filter(allowance => + allowance.groupKey !== groupKey + ) || []; + }); + }); + } + + vm.requestRemoveBlockByIndex = function (index) { + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockTypeMessage", "blockEditor_confirmDeleteBlockTypeNotice"]).then(function (data) { + var contentElementType = vm.getElementTypeByKey($scope.model.value[index].contentElementTypeKey); + overlayService.confirmDelete({ + title: data[0], + content: localizationService.tokenReplace(data[1], [contentElementType ? contentElementType.name : "(Unavailable ElementType)"]), + confirmMessage: data[2], + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeBlockByIndex(index); + overlayService.close(); + } + }); + }); + } + + vm.removeBlockByIndex = function (index) { + const blockType = $scope.model.value[index]; + if(blockType) { + $scope.model.value.splice(index, 1); + removeReferencesToElementTypeKey(blockType.contentElementTypeKey); + } + }; + + const defaultOptions = { + axis: '', + tolerance: "pointer", + opacity: 0.7, + scroll: true + }; + vm.groupSortableOptions = { + ...defaultOptions, + handle: '.__handle', + items: ".umb-block-card-group", + cursor: "grabbing", + placeholder: 'umb-block-card-group --sortable-placeholder' + }; + vm.blockSortableOptions = { + ...defaultOptions, + "ui-floating": true, + connectWith: ".umb-block-card-grid", + items: "umb-block-card", + cursor: "grabbing", + placeholder: '--sortable-placeholder', + forcePlaceHolderSize: true, + stop: function(e, ui) { + if(ui.item.sortable.droptarget && ui.item.sortable.droptarget.length > 0) { + // We do not want sortable to actually move the data, as we are using the same ng-model. Instead we just change the groupKey and cancel the transfering. + ui.item.sortable.model.groupKey = ui.item.sortable.droptarget[0].dataset.groupKey || null; + ui.item.sortable.cancel(); + } + } + }; + + + vm.getAvailableElementTypes = function () { + return vm.elementTypes.filter(function (type) { + return !$scope.model.value.find(function (entry) { + return type.key === entry.contentElementTypeKey; + }); + }); + }; + + vm.getElementTypeByKey = function(key) { + if (vm.elementTypes) { + return vm.elementTypes.find(function (type) { + return type.key === key; + }) || null; + } + }; + + vm.openAddDialog = function (groupKey) { + + localizationService.localize("blockEditor_headlineCreateBlock").then(function(localizedTitle) { + + const contentTypePicker = { + title: localizedTitle, + section: "settings", + treeAlias: "documentTypes", + entityType: "documentType", + isDialog: true, + filter: function (node) { + if (node.metaData.isElement === true) { + var key = udiService.getKey(node.udi); + // If a Block with this ElementType as content already exists, we will emit it as a possible option. + return $scope.model.value.find(function (entry) { + return key === entry.contentElementTypeKey; + }); + } + return true; + }, + filterCssClass: "not-allowed", + select: function (node) { + vm.addBlockFromElementTypeKey(udiService.getKey(node.udi), groupKey); + editorService.close(); + }, + close: function () { + editorService.close(); + }, + extraActions: [ + { + style: "primary", + labelKey: "blockEditor_labelcreateNewElementType", + action: function () { + vm.createElementTypeAndCallback((documentTypeKey) => { + vm.addBlockFromElementTypeKey(documentTypeKey, groupKey); + + // At this point we will close the contentTypePicker. + editorService.close(); + }); + } + } + ] + }; + editorService.treePicker(contentTypePicker); + + }); + }; + + vm.createElementTypeAndCallback = function(callback) { + const editor = { + create: true, + infiniteMode: true, + noTemplate: true, + isElement: true, + noTemplate: true, + submit: function (model) { + loadElementTypes().then( function () { + callback(model.documentTypeKey); + }); + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + } + + vm.addBlockFromElementTypeKey = function(key, groupKey) { + + const blockType = { ...DEFAULT_BLOCKTYPE_OBJECT, "contentElementTypeKey": key, "groupKey": groupKey || null} + + $scope.model.value.push(blockType); + + vm.openBlockOverlay(blockType); + + }; + + + + + + vm.openBlockOverlay = function (block, openAreas) { + + var elementType = vm.getElementTypeByKey(block.contentElementTypeKey); + + if(elementType) { + localizationService.localize("blockEditor_blockConfigurationOverlayTitle", [elementType.name]).then(function (data) { + + var clonedBlockData = Utilities.copy(block); + vm.openBlock = block; + + var overlayModel = { + block: clonedBlockData, + allBlockTypes: $scope.model.value, + allBlockGroups: vm.blockGroups, + loadedElementTypes: vm.elementTypes, + gridColumns: vm.gridColumnsPreValue.value || DEFAULT_GRID_COLUMNS, + title: data, + openAreas: openAreas, + view: "views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.html", + size: "large", + submit: function(overlayModel) { + loadElementTypes()// lets load elementType again, to ensure we are up to date. + TransferProperties(overlayModel.block, block);// transfer properties back to block object. (Doing this cause we dont know if block object is added to model jet, therefor we cant use index or replace the object.) + + overlayModel.close(); + }, + close: function() { + editorService.close(); + vm.openBlock = null; + } + }; + + // open property settings editor + editorService.open(overlayModel); + + }); + } else { + alert("Cannot be edited cause ElementType does not exist."); + } + + }; + + + vm.requestRemoveGroup = function(blockGroup) { + if(blockGroup.key) { + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockGroupMessage", "blockEditor_confirmDeleteBlockGroupNotice"]).then(function (data) { + overlayService.confirmDelete({ + title: data[0], + content: localizationService.tokenReplace(data[1], [blockGroup.name ? blockGroup.name : "'Unnamed group'"]), + confirmMessage: data[2], + close: function () { + overlayService.close(); + }, + submit: function () { + + // Remove all blocks of this group: + $scope.model.value = $scope.model.value.filter(block => { + if (block.groupKey === blockGroup.key) { + // Clean up references to this one: + removeReferencesToElementTypeKey(block.contentElementTypeKey); + + return false; + } else { + return true; + } + } + ); + + // Remove any special allowance for this + + // Then remove group: + const groupIndex = vm.blockGroups.indexOf(blockGroup); + if(groupIndex !== -1) { + vm.blockGroups.splice(groupIndex, 1); + removeReferencesToGroupKey(blockGroup.key); + } + + // Remove any special allowance for this. + + overlayService.close(); + } + }); + }); + } + } + + + + dataTypeResource.getAll().then(function(dataTypes) { + if(dataTypes.filter(x => x.alias === "Umbraco.BlockGrid").length === 0) { + vm.showSampleDataCTA = true; + } + }); + + vm.setupSample = function() { + umbRequestHelper.resourcePromise( + $http.post(umbRequestHelper.getApiUrl("contentTypeApiBaseUrl", "PostCreateBlockGridSample")), + 'Failed to create content types for block grid sample').then(function (data) { + + loadElementTypes().then(() => { + + const groupName = "Demo Blocks"; + var sampleGroup = vm.blockGroups.find(x => x.name === groupName); + if(!sampleGroup) { + sampleGroup = { + key: String.CreateGuid(), + name: groupName + }; + vm.blockGroups.push(sampleGroup); + } + + function initSampleBlock(udi, groupKey, options) { + const key = udiService.getKey(udi); + if ($scope.model.value.find(X => X.contentElementTypeKey === key) === undefined) { + const blockType = { ...DEFAULT_BLOCKTYPE_OBJECT, "contentElementTypeKey": key, "groupKey": groupKey, ...options}; + $scope.model.value.push(blockType); + } + } + + initSampleBlock(data.umbBlockGridDemoHeadlineBlock, sampleGroup.key, {"label": "Headline ({{headline | truncate:true:36}})", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoHeadlineBlock.html"}); + initSampleBlock(data.umbBlockGridDemoImageBlock, sampleGroup.key, {"label": "Image", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoImageBlock.html"}); + initSampleBlock(data.umbBlockGridDemoRichTextBlock, sampleGroup.key, { "label": "Rich Text ({{richText | ncRichText | truncate:true:36}})", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoRichTextBlock.html"}); + const twoColumnLayoutAreas = [ + { + 'key': String.CreateGuid(), + 'alias': 'left', + 'columnSpan': 6, + 'rowSpan': 1, + 'minAllowed': 0, + 'maxAllowed': null, + 'specifiedAllowance': [] + }, + { + 'key': String.CreateGuid(), + 'alias': 'right', + 'columnSpan': 6, + 'rowSpan': 1, + 'minAllowed': 0, + 'maxAllowed': null, + 'specifiedAllowance': [] + } + ]; + initSampleBlock(data.umbBlockGridDemoTwoColumnLayoutBlock, sampleGroup.key, {"label": "Two Column Layout", "view": "~/App_Plugins/Umbraco.BlockGridEditor.DefaultCustomViews/umbBlockGridDemoTwoColumnLayoutBlock.html", "allowInAreas": false, "areas": twoColumnLayoutAreas}); + + vm.showSampleDataCTA = false; + }); + + }); + } + + + $scope.$on('$destroy', function () { + unsubscribe.forEach(u => { u(); }); + }); + + onInit(); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockGrid.BlockConfigurationController", BlockConfigurationController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html new file mode 100644 index 0000000000..bfb9fdc90c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.html @@ -0,0 +1,93 @@ +
+ +
+

Install Sample Configuration

+ This will add basic Blocks and help you get started with the Block Grid Editor. You'll get Blocks for Headline, Rich Text, Image, as well as a Two Column Layout. + +
+ +
+ +
+ + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + + + +
+ +
+ + +
+ + +
+
+ + +
+ +
+
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.less new file mode 100644 index 0000000000..aa09b5238e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.less @@ -0,0 +1,51 @@ +.umb-block-grid-block-configuration { + + margin-bottom: 20px; + + .__add-button { + position: relative; + display: inline-flex; + width: 100%; + height: 100%; + margin-right: 20px; + margin-bottom: 20px; + + color: @ui-action-discreet-type; + border: 1px dashed @ui-action-discreet-border; + border-radius: @doubleBorderRadius; + + align-items: center; + justify-content: center; + + padding: 5px 15px; + box-sizing: border-box; + font-weight: bold; + } + + .__add-button:hover { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + } + + .__get-sample-box { + position:relative; + border: 1px solid @gray-10; + border-radius: 6px; + box-shadow: 3px 3px 6px rgba(0, 0, 0, .07); + + padding-left: 40px; + padding-right: 40px; + padding-top: 15px; + padding-bottom: 20px; + margin-bottom: 40px; + max-width: 480px; + + umb-button { + margin-left: auto; + margin-right: 0; + display: block; + width: fit-content; + } + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.controller.js new file mode 100644 index 0000000000..0bb9f6e703 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.controller.js @@ -0,0 +1,366 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockGrid.BlockConfigurationOverlayController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function BlockConfigurationOverlayController($scope, overlayService, localizationService, editorService, elementTypeResource, eventsService, udiService, angularHelper) { + + var unsubscribe = []; + + var vm = this; + + vm.navigation = []; + + localizationService.localizeMany(["blockEditor_tabBlockTypeSettings", "blockEditor_tabAreas", "blockEditor_tabAdvanced"]).then( + function (data) { + + vm.navigation = [{ + "alias": "block", + "name": data[0], + "icon": "icon-settings", + "view": "" + }, + { + "alias": "areas", + "name": data[1], + "icon": "icon-layout", + "view": "" + }, + { + "alias": "advance", + "name": data[2], + "icon": "icon-lab", + "view": "" + }]; + + vm.activeTab = vm.navigation[$scope.model.openAreas === true ? 1 : 0]; + vm.activeTab.active = true; + } + ); + + vm.onNavigationChanged = function (tab) { + vm.activeTab.active = false; + vm.activeTab = tab; + vm.activeTab.active = true; + }; + + + vm.block = $scope.model.block; + + vm.colorPickerOptions = { + type: "color", + allowEmpty: true, + showAlpha: true + }; + + vm.rowMinMaxModel = { + hideLabel: true, + view: "numberrange", + value: {min: vm.block.rowMinSpan || 1, max: vm.block.rowMaxSpan || 1} + } + + vm.showSizeOptions = vm.block.columnSpanOptions.length > 0 || vm.rowMinMaxModel.value.min !== 1 || vm.rowMinMaxModel.value.max !== 1; + vm.showAreaOptions = vm.block.areas.length > 0; + vm.showAppearanceOptions = !!vm.block.backgroundColor || !!vm.block.iconColor || !!vm.block.thumbnail; + + loadElementTypes(); + + function loadElementTypes() { + return elementTypeResource.getAll().then(function(elementTypes) { + vm.elementTypes = elementTypes; + + vm.contentPreview = vm.getElementTypeByKey(vm.block.contentElementTypeKey); + vm.settingsPreview = vm.getElementTypeByKey(vm.block.settingsElementTypeKey); + }); + } + + vm.getElementTypeByKey = function(key) { + return vm.elementTypes.find(function (type) { + return type.key === key; + }); + }; + + vm.openElementType = function(elementTypeKey) { + var elementType = vm.getElementTypeByKey(elementTypeKey); + if (elementType) { + var elementTypeId = elementType.id; + const editor = { + id: elementTypeId, + submit: function (model) { + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + } + }; + + vm.createElementTypeAndCallback = function(callback) { + const editor = { + create: true, + infiniteMode: true, + isElement: true, + noTemplate: true, + submit: function (model) { + callback(model.documentTypeKey); + editorService.close(); + }, + close: function () { + editorService.close(); + } + }; + editorService.documentTypeEditor(editor); + }; + + vm.addSettingsForBlock = function($event, block) { + + localizationService.localize("blockEditor_headlineAddSettingsElementType").then(localizedTitle => { + + const settingsTypePicker = { + title: localizedTitle, + entityType: "documentType", + isDialog: true, + filter: node => { + if (node.metaData.isElement === true) { + return false; + } + return true; + }, + filterCssClass: "not-allowed", + select: node => { + vm.applySettingsToBlock(block, udiService.getKey(node.udi)); + editorService.close(); + }, + close: () => editorService.close(), + extraActions: [ + { + style: "primary", + labelKey: "blockEditor_labelcreateNewElementType", + action: () => { + vm.createElementTypeAndCallback((key) => { + vm.applySettingsToBlock(block, key); + + // At this point we will close the contentTypePicker. + editorService.close(); + }); + } + } + ] + }; + + editorService.contentTypePicker(settingsTypePicker); + + }); + }; + + vm.applySettingsToBlock = function(block, key) { + block.settingsElementTypeKey = key; + vm.settingsPreview = vm.getElementTypeByKey(vm.block.settingsElementTypeKey); + }; + + vm.requestRemoveSettingsForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + + var settingsElementType = vm.getElementTypeByKey(block.settingsElementTypeKey); + + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [(settingsElementType ? settingsElementType.name : "(Unavailable ElementType)")]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeSettingsForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeSettingsForBlock = function(block) { + block.settingsElementTypeKey = null; + }; + + function updateUsedElementTypes(event, args) { + var key = args.documentType.key; + for (var i = 0; i { + + const filePicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + return !(i.name.indexOf(".html") !== -1); + }, + filterCssClass: "not-allowed", + select: node => { + const filepath = decodeURIComponent(node.id.replace(/\+/g, " ")); + block.view = "~/" + filepath.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(filePicker); + + }); + }; + + vm.requestRemoveViewForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [block.view]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeViewForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeViewForBlock = function(block) { + block.view = null; + }; + + vm.addStylesheetForBlock = function(block) { + localizationService.localize("blockEditor_headlineAddCustomStylesheet").then(localizedTitle => { + + const filePicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + return !(i.name.indexOf(".css") !== -1); + }, + filterCssClass: "not-allowed", + select: node => { + const filepath = decodeURIComponent(node.id.replace(/\+/g, " ")); + block.stylesheet = "~/" + filepath.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(filePicker); + + }); + }; + + vm.requestRemoveStylesheetForBlock = function(block) { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [block.stylesheet]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.removeStylesheetForBlock(block); + overlayService.close(); + } + }); + }); + }; + + vm.removeStylesheetForBlock = function(block) { + block.stylesheet = null; + }; + + vm.addThumbnailForBlock = function(block) { + + localizationService.localize("blockEditor_headlineAddThumbnail").then(localizedTitle => { + + let allowedFileExtensions = ['jpg', 'jpeg', 'png', 'svg', 'webp', 'gif']; + + const thumbnailPicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + let ext = i.name.substr((i.name.lastIndexOf('.') + 1)); + return allowedFileExtensions.includes(ext) === false; + }, + filterCssClass: "not-allowed", + select: file => { + const id = decodeURIComponent(file.id.replace(/\+/g, " ")); + block.thumbnail = "~/" + id.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(thumbnailPicker); + + }); + }; + + vm.removeThumbnailForBlock = function(entry) { + entry.thumbnail = null; + }; + + vm.changeIconColor = function (color) { + angularHelper.safeApply($scope, function () { + vm.block.iconColor = color ? color.toString() : null; + }); + }; + + vm.changeBackgroundColor = function (color) { + angularHelper.safeApply($scope, function () { + vm.block.backgroundColor = color ? color.toString() : null; + }); + }; + + vm.submit = function() { + if ($scope.model && $scope.model.submit) { + + // Transfer minMaxModel to area: + vm.block.rowMinSpan = vm.rowMinMaxModel.value.min; + vm.block.rowMaxSpan = vm.rowMinMaxModel.value.max; + + $scope.model.submit($scope.model); + } + }; + + vm.close = function() { + if ($scope.model && $scope.model.close) { + $scope.model.close($scope.model); + } + }; + + $scope.$on('$destroy', function() { + unsubscribe.forEach(u => { u(); }); + }); + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockGrid.BlockConfigurationOverlayController", BlockConfigurationOverlayController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.html new file mode 100644 index 0000000000..29b3f4b53f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.html @@ -0,0 +1,403 @@ +
+ +
+ + + + + + + +
+ +
+ +
+ General +
+ +
+ + +
+
+ +
+ +
+
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
+
+ + +
+
+ +
+
+ +
+ + +
+
+ +
+
+
+ +
+ +
+ + + + +
+ +
+
+ Size options + + Define one or more size options, this enables resizing of the Block + +
+
+ +
+ + + + +
+
+ + + Define the different number of layout columns this block is allowed to span across. + +
+ +
+
+
+ + +
+
+ + + Define the range of layout rows this block is allowed to span across. + +
+ +
+
+
+ +
+ +
+ + + +
+ +
+ Catalogue appearance +
+ +
+ + + + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ +
+ +
+ + +
+ +
+ Allowance +
+ +
+ + +
+
+ + + Make this block available in the root of the layout. + +
+ + +
+
+
+ + + +
+
+ + + Make this block available within other Blocks. + +
+ + +
+
+
+ +
+ +
+ +
+ +
+ +
+ + +
+ Areas +
+ +
+ + +
+
+ + + Define how many layout columns that will be available for areas. If not defined, the number of layout columns defined for the entire layout will be used. + +
+ +
+
+
+ + +
+
+ + + To enable nesting of blocks within this block, define one or more areas for blocks to be nested within. Areas follow their own layout witch is defined by the Area Layout Columns. Each Areas column and row span can be adjusted by using the scale-handler in the bottom right. + +
+ +
+
+
+
+ +
+
+ +
+
+ +
+ Advanced +
+ +
+ + +
+
+ + + Overwrite how this block appears in the BackOffice UI. Pick a .html file containing your presentation. + +
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ +
+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + + Define the range of layout rows this block is allowed to span across. + +
+ +
+
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.less new file mode 100644 index 0000000000..57507f8044 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.overlay.less @@ -0,0 +1,128 @@ +.umb-block-grid-block-configuration-overlay { + + .umb-block-grid-block-configuration-layout { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + grid-gap: 0 20px; + grid-auto-flow: row; + grid-auto-rows: minmax(50px, auto); + + + } + + + .umb-block-grid-block-configuration__umb-group-panel { + @media (max-width: 1024px) { + grid-column: span 2; + } + &.--span-two-cols { + grid-column: span 2; + } + &.--span-two-rows { + grid-row: span 2; + } + } + + .umb-node-preview { + flex-grow: 1; + } + + .__control-actions { + position: absolute; + display: flex; + align-items: center; + top:0; + bottom: 0; + right: 0; + background-color: rgba(255, 255, 255, 0.8); + opacity: 0; + transition: opacity 120ms; + } + .control-group:hover, + .control-group:focus, + .control-group:focus-within { + .__control-actions { + opacity: 1; + } + } + .__control-actions-btn { + position: relative; + color: @ui-action-discreet-type; + height: 32px; + width: 26px; + &:hover { + color: @ui-action-discreet-type-hover; + } + &:last-of-type { + margin-right: 7px; + } + } + + .umb-node-preview { + border-bottom: none; + } + + .__settings-input { + position: relative; + padding: 5px 8px; + margin-bottom: 10px; + color: @ui-action-discreet-type; + border: 1px dashed @ui-action-discreet-border; + width: 100%; + font-weight: bold; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + + localize { + width: 100%; + } + + .umb-node-preview { + padding: 3px 0; + margin-left: 5px; + overflow: hidden; + } + + + + &.--noValue { + text-align: center; + border-radius: @baseBorderRadius; + transition: color 120ms; + &.--hideText { + color: white; + } + &:hover, &:focus { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + } + } + + &.--hasValue { + border: 1px solid @inputBorder; + padding: 0; + } + } + + .__add-button { + width:100%; + color: @ui-action-discreet-type; + border: 1px dashed @ui-action-discreet-border; + border-radius: @baseBorderRadius; + display: flex; + align-items: center; + justify-content: center; + padding: 5px 15px; + box-sizing: border-box; + margin: 20px 0; + font-weight: bold; + } + + .__add-button:hover { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + } + +} diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.controller.js new file mode 100644 index 0000000000..3775aff28b --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.controller.js @@ -0,0 +1,18 @@ +(function () { + "use strict"; + + function GroupConfigurationController($scope) { + + var vm = this; + vm.addGroup = function() { + $scope.model.value.push({ + key: String.CreateGuid(), + name: "Unnamed group" + }); + } + + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockGrid.GroupConfigurationController", GroupConfigurationController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.html new file mode 100644 index 0000000000..86c975639c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.groupconfiguration.html @@ -0,0 +1,5 @@ +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.controller.js new file mode 100644 index 0000000000..60595efca2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.controller.js @@ -0,0 +1,71 @@ +/** + * @ngdoc controller + * @name Umbraco.Editors.BlockGrid.BlockConfigurationController + * @function + * + * @description + * The controller for the content type editor property settings dialog + */ + +(function () { + "use strict"; + + function StylesheetPickerController($scope, localizationService, editorService, overlayService) { + + //var unsubscribe = []; + + var vm = this; + + vm.addStylesheet = function() { + localizationService.localize("blockEditor_headlineAddCustomStylesheet").then(localizedTitle => { + + const filePicker = { + title: localizedTitle, + isDialog: true, + filter: i => { + return !(i.name.indexOf(".css") !== -1); + }, + filterCssClass: "not-allowed", + select: node => { + const filepath = decodeURIComponent(node.id.replace(/\+/g, " ")); + $scope.model.value = "~/" + filepath.replace("wwwroot/", ""); + editorService.close(); + }, + close: () => editorService.close() + }; + + editorService.staticFilePicker(filePicker); + + }); + }; + + vm.requestRemoveStylesheet = function() { + localizationService.localizeMany(["general_remove", "defaultdialogs_confirmremoveusageof"]).then(function (data) { + overlayService.confirmRemove({ + title: data[0], + content: localizationService.tokenReplace(data[1], [$scope.model.value]), + close: function () { + overlayService.close(); + }, + submit: function () { + vm.requestRemoveStylesheet(); + overlayService.close(); + } + }); + }); + }; + + vm.requestRemoveStylesheet = function() { + $scope.model.value = null; + }; + +/* + $scope.$on('$destroy', function () { + unsubscribe.forEach(u => { u(); }); + }); +*/ + } + + angular.module("umbraco").controller("Umbraco.PropertyEditors.BlockGrid.StylesheetPickerController", StylesheetPickerController); + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.html new file mode 100644 index 0000000000..ed17cd0bb2 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/blockgrid.stylesheetpicker.html @@ -0,0 +1,16 @@ +
+
+ + +
+ +
+
+ + + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html new file mode 100644 index 0000000000..c288afc3ff --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html @@ -0,0 +1,68 @@ +
+ + + +
+ + +
+ + + + + +
+
+ + + +
+ + + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.less new file mode 100644 index 0000000000..11907dfcb0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.less @@ -0,0 +1,43 @@ + + + +.umb-block-grid-area-allowance-editor > button { + width: 100%; +} + +.umb-block-grid-area-allowance-editor .__list.--disabled { + +} + +.umb-block-grid-area-allowance-editor__entry { + display:flex; + align-items: baseline; + select { + margin-right: 15px; + width: 260px; + } + > span { + margin: 0 3px; + } + input { + width: 40px; + } +} + +.umb-block-grid-area-allowance-editor__entry button { + border-radius: 3px; + color: @ui-action-type; + + height: 32px; + display: inline-flex; + justify-content: center; + align-items: center; + width: 32px; + + margin-left: 5px; + align-self: normal; +} +.umb-block-grid-area-allowance-editor__entry button:hover { + background-color: @ui-action-hover; + color: @ui-action-type-hover; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.html new file mode 100644 index 0000000000..64a7909b7a --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.html @@ -0,0 +1,35 @@ +
+ + + +
+ + + + + + +
+ +
+ + + diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.less new file mode 100644 index 0000000000..d4a1487b40 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.less @@ -0,0 +1,239 @@ + + +/* +Grid part: +*/ +/* +.umb-block-grid__layout-container { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + grid-gap: 10px; + + grid-auto-flow: row; +} + + +.umb-block-grid__layout-item { + grid-column: span 4; +} +*/ + +.umb-block-grid-area-editor__grid-wrapper { + position: relative; + display: grid; + grid-template-columns: repeat(var(--umb-block-grid--block-grid-columns, 1), minmax(0, 1fr)); + + grid-gap: 0px; + grid-auto-flow: row; + grid-auto-rows: minmax(50px, auto); +} +.umb-block-grid-area-editor__grid-wrapper > * { + grid-column: span var(--umb-block-grid--area-column-span, 12); + grid-row: span var(--umb-block-grid--area-row-span, 1); +} + +.umb-block-grid-area-editor__area { + position: relative; + background-color: @gray-11; + box-sizing: border-box; + cursor: move; + + &::after { + content: ''; + position: absolute; + pointer-events: none; + inset: 0; + border: 1px solid @gray-7; + border-radius: 3px; + } + + .umb-block-grid__area--actions { + opacity: 0; + transition: opacity 120ms; + } + + &:hover, + &:focus, + &:focus-within, + &.--active { + .umb-block-grid__area--actions { + opacity: 1; + } + } +} +.umb-block-grid-area-editor__area:hover { + background-color: @gray-9; + &::after { + border-color: @blueDark; + } +} +.umb-block-grid-area-editor__area .__label { + margin-top: 15px; + margin-left: 20px; + opacity: 60%; + pointer-events: none; + user-select: none; +} + +.umb-block-grid-area-editor__area-placeholder { + background: transparent; + border-radius: 3px; + + border: @blueDark solid 1px; + border-radius: 3px; + + ::after { + content: ''; + position:absolute; + z-index:1; + inset: 0; + border-radius: 3px; + + transition: background-color 240ms ease-in; + animation: umb-block-grid-area-editor__placeholder__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid-area-editor__placeholder__pulse { + 0% { background-color: rgba(@blueMidLight, 0.33); } + 100% { background-color: rgba(@blueMidLight, 0.22); } + } + } +} + + +.umb-block-grid__area--actions { + position: absolute; + top: 10px; + right: 10px; + font-size: 0; + background-color: rgba(255, 255, 255, .96); + border-radius: 16px; + padding-left: 5px; + padding-right: 5px; + .action { + position: relative; + display: inline-block; + color: @ui-action-discreet-type; + font-size: 18px; + padding: 5px; + &:hover { + color: @ui-action-discreet-type-hover; + } + } +} + +.umb-block-grid-area-editor__scale-handler { + cursor: nwse-resize; + position: absolute; + Z-index:10; + bottom: -4px; + right: -4px; + width:8px; + height:8px; + padding: 0; + background-color: white; + border: @blueDark solid 1px; + opacity: 0; + transition: opacity 120ms; +} + +.umb-block-grid-area-editor__scale-handler::after { + content: ''; + position: absolute; + inset: -10px; + background-color: rgba(0,0,0,0); +} +.umb-block-grid-area-editor__area:focus-within, +.umb-block-grid-area-editor__area:hover { + .umb-block-grid-area-editor__scale-handler { + opacity: 1; + } +} +.umb-block-grid-area-editor__scale-handler:focus { + opacity: 1; +} +/* When inside scalebox its always visible: */ +.umb-block-grid-area-editor__scalebox .umb-block-grid-area-editor__scale-handler { + opacity: 1; +} + +.umb-block-grid-area-editor__scale-handler:focus + .umb-block-grid-area-editor__scale-label, +.umb-block-grid-area-editor__scale-handler:hover + .umb-block-grid-area-editor__scale-label { + opacity: 1; +} + +.umb-block-grid-area-editor__scale-label { + position: absolute; + display: block; + left: 100%; + margin-left: 6px; + margin-top: 6px; + z-index: 2; + + background-color: white; + color: black; + font-size: 12px; + padding: 0px 6px; + border-radius: 3px; + opacity: 0; + transition: opacity 120ms; + + pointer-events: none; + white-space: nowrap; +} + + +.umb-block-grid-area-editor__scalebox-backdrop { + background-color: rgba(255, 255, 255, 0.1); + position: absolute; + top:0; + left:0; + bottom:0; + right:0; + z-index: 1; + cursor: nwse-resize; +} +.umb-block-grid-area-editor__scalebox { + position: absolute; + top:0; + left:0; + z-index: 10; + cursor: nwse-resize; + + border: @blueDark solid 1px; + border-radius: 3px; + + transition: background-color 240ms ease-in; + animation: umb-block-grid__scalebox__pulse 400ms ease-in-out alternate infinite; + @keyframes umb-block-grid__scalebox__pulse { + 0% { background-color: rgba(@blueMidLight, 0.33); } + 100% { background-color: rgba(@blueMidLight, 0.22); } + } +} + + + +.umb-block-grid-area-editor__create-button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + color: @ui-action-discreet-type; + font-weight: bold; + margin: -1px; + padding: 5px 15px; + + border: 1px dashed @ui-action-discreet-border; + border-radius: @baseBorderRadius; + box-sizing: border-box; + + &:not([disabled]):hover { + color: @ui-action-discreet-type-hover; + border-color: @ui-action-discreet-border-hover; + text-decoration: none; + z-index: 1; + } +} +.umb-block-grid-area-editor__create-button[disabled] { + color: @ui-disabled-type; + border-color: @ui-disabled-border; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor-option.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor-option.html new file mode 100644 index 0000000000..ab63757bb5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor-option.html @@ -0,0 +1,26 @@ +
+
+ + + {{vm.columnSpanOption.columnSpan}} + +
+ +
+
+ + {{vm.column}} + +
+ +
+
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html new file mode 100644 index 0000000000..097e4780a0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html @@ -0,0 +1,15 @@ +
+
+ + +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.less new file mode 100644 index 0000000000..4241c7e8c0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.less @@ -0,0 +1,116 @@ +.umb-block-grid-column-editor { + + position: relative; + width: 100%; + --umb-block-grid-column-editor-ui-height: 22px; + padding-bottom: 10px; + + .__grid-box { + border: 1px solid grey; + margin-top: var(--umb-block-grid-column-editor-ui-height); + width: 100%; + height: 40px; + } + + .__column { + + position: absolute; + top: 1px; + height: 40px; + pointer-events: none; + width: 100%; + //z-index: calc(var(--umb-block-grid--gridColumns) - var(--umb-block-grid--columnN)); + + &::before { + content: ''; + position: absolute; + inset: 0; + //z-index: calc(var(--umb-block-grid--gridColumns) - var(--umb-block-grid--columnN)); + transition: background-color 120ms; + width: calc((100% / var(--umb-block-grid--gridColumns)) * (var(--umb-block-grid--columnN))); + pointer-events:none; + } + + + > div { + position: absolute; + left: calc((100% / var(--umb-block-grid--gridColumns)) * (var(--umb-block-grid--columnN) - 0.5)); + top: 0; + width: calc(100% / var(--umb-block-grid--gridColumns)); + display: flex; + flex-direction: column; + align-items: center; + > span { + display: block; + font-size: 13px; + font-weight: 700; + opacity: 18%; + height: var(--umb-block-grid-column-editor-ui-height); + margin-top: calc(var(--umb-block-grid-column-editor-ui-height) * -1); + transition: opacity 120ms; + } + .__border { + position:absolute; + height:40px; + right: 50%; + margin-right: -2px; + width:1px; + background-color: @blueExtraDark; + border-left: 1px solid white; + z-index: calc(var(--umb-block-grid--gridColumns) + 2); + opacity: 18%; + transition: opacity 120ms, border-left-width 120ms, right 120ms; + } + button { + width: 100%; + height:40px; + margin-top: 3px; + pointer-events: all; + opacity: 18%; + transition: opacity 120ms; + } + } + + + &.--active { + &::before { + background-color: @blueExtraDark; + } + > div { + .__border { + border-left-width: 2px; + opacity: 100% !important; + } + > span { + opacity: 80%; + } + } + button { + opacity: 18%; + } + } + + + &:hover { + &::before { + z-index: calc(var(--umb-block-grid--gridColumns) + 1); + background-color: @blueMid; + transition-delay: 0ms; + } + > div { + .__border { + border-left-width: 2px; + opacity: 50%; + } + > span { + opacity: 100%; + } + } + button { + opacity: 100%; + } + } + + } + +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-configuration-area-entry.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-configuration-area-entry.html new file mode 100644 index 0000000000..524be4f4cd --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umb-block-grid-configuration-area-entry.html @@ -0,0 +1,28 @@ +
+ +
{{vm.area.alias}}
+ +
+ + + +
+ + +
+ {{vm.area.columnSpan}} x {{vm.area.rowSpan}} +
+
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaAllowanceEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaAllowanceEditor.component.js new file mode 100644 index 0000000000..693cdcc223 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaAllowanceEditor.component.js @@ -0,0 +1,106 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridAreaEditor + * @function + * + * @description + * The component for the block grid area prevalue editor. + */ + angular + .module("umbraco") + .component("umbBlockGridAreaAllowanceEditor", { + templateUrl: "views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-allowance-editor.html", + controller: BlockGridAreaAllowanceController, + controllerAs: "vm", + bindings: { + model: "=", + allBlockTypes: "<", + allBlockGroups: "<", + loadedElementTypes: "<", + disabled: "<" + }, + require: { + propertyForm: "^form" + } + }); + + function BlockGridAreaAllowanceController($scope, $element, assetsService, localizationService, editorService) { + + var unsubscribe = []; + + var vm = this; + vm.loading = true; + + vm.$onInit = function() { + vm.loading = false; + + vm.model.forEach((x) => { + x['$key'] = String.CreateGuid(); + + // transfer the chosen key onto the $chosenValue property. + if(x.groupKey) { + x['$chosenValue'] = "groupKey:"+x.groupKey; + } else if (x.elementTypeKey) { + x['$chosenValue'] = "elementTypeKey:"+x.elementTypeKey; + } + }); + }; + + vm.getElementTypeByKey = function(key) { + if (vm.loadedElementTypes) { + return vm.loadedElementTypes.find(function (type) { + return type.key === key; + }) || null; + } + }; + + vm.deleteAllowance = function(allowance) { + const index = vm.model.indexOf(allowance); + if(index !== -1) { + vm.model.splice(index, 1); + } + } + + vm.onNewAllowanceClick = function() { + const allowance = { + $key: String.CreateGuid(), + elementTypeKey: null, + groupKey: null, + min: 0, + max: 0 + }; + vm.model.push(allowance); + setDirty(); + } + + function setDirty() { + if (vm.propertyForm) { + vm.propertyForm.$setDirty(); + } + } + + $scope.$on("$destroy", function () { + + // Set groupKey or elementTypeKey based on $chosenValue. + vm.model.forEach((x) => { + const value = x['$chosenValue']; + if (value.indexOf('groupKey:') === 0) { + x.groupKey = value.slice(9); + x.elementTypeKey = null; + } else if (value.indexOf('elementTypeKey:') === 0) { + x.groupKey = null; + x.elementTypeKey = value.slice(15); + } + }); + + for (const subscription of unsubscribe) { + subscription(); + } + }); + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaEditor.component.js new file mode 100644 index 0000000000..e8ae592d8f --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridAreaEditor.component.js @@ -0,0 +1,178 @@ +(function () { + "use strict"; + + function TransferProperties(fromObject, toObject) { + for (var p in fromObject) { + toObject[p] = fromObject[p]; + } + } + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridAreaEditor + * @function + * + * @description + * The component for the block grid area prevalue editor. + */ + angular + .module("umbraco") + .component("umbBlockGridAreaEditor", { + templateUrl: "views/propertyeditors/blockgrid/prevalue/umb-block-grid-area-editor.html", + controller: BlockGridAreaController, + controllerAs: "vm", + bindings: { + model: "=", + block: "<", + allBlockTypes: "<", + allBlockGroups: "<", + loadedElementTypes: "<", + gridColumns: "<" + }, + require: { + propertyForm: "^form" + } + }); + + function BlockGridAreaController($scope, $element, assetsService, localizationService, editorService, overlayService) { + + var unsubscribe = []; + + var vm = this; + vm.loading = true; + vm.rootLayoutColumns = 12; + + vm.$onInit = function() { + + vm.rootLayoutColumns = vm.gridColumns; + + assetsService.loadJs('lib/sortablejs/Sortable.min.js', $scope).then(onLoaded); + }; + + function onLoaded() { + vm.loading = false; + initializeSortable(); + } + + function initializeSortable() { + + const gridLayoutContainerEl = $element[0].querySelector('.umb-block-grid-area-editor__grid-wrapper'); + + const sortable = Sortable.create(gridLayoutContainerEl, { + sort: true, // sorting inside list + animation: 150, // ms, animation speed moving items when sorting, `0` — without animation + easing: "cubic-bezier(1, 0, 0, 1)", // Easing for animation. Defaults to null. See https://easings.net/ for examples. + cancel: '', + draggable: ".umb-block-grid-area-editor__area", // Specifies which items inside the element should be draggable + ghostClass: "umb-block-grid-area-editor__area-placeholder" + }); + + // TODO: setDirty if sort has happend. + + } + + vm.editArea = function(area) { + vm.openAreaOverlay(area); + } + + vm.requestDeleteArea = function (area) { + // TODO: Translations + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockAreaMessage", "blockEditor_confirmDeleteBlockAreaNotice"]).then(function (data) { + overlayService.confirmDelete({ + title: data[0], + content: localizationService.tokenReplace(data[1], [area.alias]), + confirmMessage: data[2], + close: function () { + overlayService.close(); + }, + submit: function () { + vm.deleteArea(area); + overlayService.close(); + } + }); + }); + } + vm.deleteArea = function(area) { + const index = vm.model.findIndex(x => x.key === area.key); + if(index !== -1) { + vm.model.splice(index, 1); + } + setDirty(); + } + + vm.onNewAreaClick = function() { + + const areaGridColumns = (vm.block.areaGridColumns || vm.rootLayoutColumns) + const columnSpan = areaGridColumns/2 === Math.round(areaGridColumns/2) ? areaGridColumns/2 : areaGridColumns; + + const newArea = { + 'key': String.CreateGuid(), + 'alias': '', + 'columnSpan': columnSpan, + 'rowSpan': 1, + 'minAllowed': 0, + 'maxAllowed': null, + 'specifiedAllowance': [ + /*{ + 'elementTypeKey': 345, + 'min': 0, + 'max': null + }*/ + ] + + }; + vm.model.push(newArea) + vm.openAreaOverlay(newArea); + setDirty(); + } + + vm.openArea = null; + vm.openAreaOverlay = function (area) { + + // TODO: use the right localization key: + localizationService.localize("blockEditor_blockConfigurationOverlayTitle", [area.alias]).then(function (localized) { + + var clonedAreaData = Utilities.copy(area); + vm.openArea = area; + + var overlayModel = { + area: clonedAreaData, + title: localized, + allBlockTypes: vm.allBlockTypes, + allBlockGroups: vm.allBlockGroups, + loadedElementTypes: vm.loadedElementTypes, + view: "views/propertyeditors/blockgrid/prevalue/blockgrid.blockconfiguration.area.overlay.html", + size: "small", + submit: function(overlayModel) { + TransferProperties(overlayModel.area, area); + overlayModel.close(); + setDirty(); + }, + close: function() { + editorService.close(); + vm.openArea = null; + } + }; + + // open property settings editor + editorService.open(overlayModel); + + }); + + }; + + function setDirty() { + if (vm.propertyForm) { + vm.propertyForm.$setDirty(); + } + } + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditor.component.js new file mode 100644 index 0000000000..864f479009 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditor.component.js @@ -0,0 +1,69 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridColumnSpanEditor + * @function + * + * @description + * The component for the block grid column span prevalue editor. + */ + angular + .module("umbraco") + .component("umbBlockGridColumnEditor", { + templateUrl: "views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor.html", + controller: BlockGridColumnController, + controllerAs: "vm", + bindings: { + model: "=", + block: "<", + gridColumns: "<" + }, + require: { + propertyForm: "^form" + } + }); + + function BlockGridColumnController($scope) { + + //var unsubscribe = []; + + var vm = this; + + vm.$onInit = function() { + + vm.emptyGridColumnArray = Array.from(Array(vm.gridColumns + 1).keys()).slice(1); + + vm.block.columnSpanOptions = vm.block.columnSpanOptions.filter( + (value, index, self) => { + return value.columnSpan <= vm.gridColumns && + self.findIndex(v => v.columnSpan === value.columnSpan) === index; + } + ); + }; + + vm.addSpanOption = function(colN) { + vm.block.columnSpanOptions.push({'columnSpan': colN}); + setDirty(); + } + vm.removeSpanOption = function(colN) { + vm.block.columnSpanOptions = vm.block.columnSpanOptions.filter(value => value.columnSpan !== colN); + setDirty(); + } + + function setDirty() { + if (vm.propertyForm) { + vm.propertyForm.$setDirty(); + } + } + + /*$scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + });*/ + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditorOption.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditorOption.component.js new file mode 100644 index 0000000000..78d27a6055 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridColumnEditorOption.component.js @@ -0,0 +1,37 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridColumnSpanEditorOption + * @function + * + * @description + * A component for the block grid column span prevalue editor. + */ + angular + .module("umbraco") + .component("umbBlockGridColumnEditorOption", { + templateUrl: "views/propertyeditors/blockgrid/prevalue/umb-block-grid-column-editor-option.html", + controller: BlockGridColumnOptionController, + controllerAs: "vm", + bindings: { + columnSpanOption: "<", + column: "<", + onClickAdd: "&", + onClickRemove: "&" + } + }); + + function BlockGridColumnOptionController() { + + var vm = this; + + vm.$onInit = function() { + + }; + + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridConfigurationAreaEntry.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridConfigurationAreaEntry.component.js new file mode 100644 index 0000000000..f7c1351c47 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/prevalue/umbBlockGridConfigurationAreaEntry.component.js @@ -0,0 +1,259 @@ +(function () { + "use strict"; + + /** + * + * Note for new backoffice: there is a lot of similarities between the Area configuration and the Block entry, as they both share Grid scaling features. + * TODO: Can we already as part of this PR make it shared as a dictionary or something? + */ + + + /** + * Helper method that takes a weight map and finds the index of the value. + * @param {number} position - the value to find the index of. + * @param {number[]} weights - array of numbers each representing the weight/length. + * @returns {number} - the index of the weight that contains the accumulated value + */ + function getInterpolatedIndexOfPositionInWeightMap(target, weights) { + const map = [0]; + weights.reduce((a, b, i) => { return map[i+1] = a+b; }, 0); + const foundValue = map.reduce((a, b) => { + let aDiff = Math.abs(a - target); + let bDiff = Math.abs(b - target); + + if (aDiff === bDiff) { + return a < b ? a : b; + } else { + return bDiff < aDiff ? b : a; + } + }); + + // Adding interpolation to the index, to get + const foundIndex = map.indexOf(foundValue); + const targetDiff = (target-foundValue); + let interpolatedIndex = foundIndex; + if (targetDiff < 0 && foundIndex === 0) { + // Don't adjust. + } else if (targetDiff > 0 && foundIndex === map.length-1) { + // Don't adjust. + } else { + const foundInterpolationWeight = weights[targetDiff >= 0 ? foundIndex : foundIndex-1]; + interpolatedIndex += foundInterpolationWeight === 0 ? interpolatedIndex : (targetDiff/foundInterpolationWeight) + } + return interpolatedIndex; + } + + function getAccumulatedValueOfIndex(index, weights) { + let i = 0, len = Math.min(index, weights.length), calc = 0; + while(i Number(x)); + gridRows = computedStyles.gridTemplateRows.trim().split("px").map(x => Number(x)); + + // remove empties: + if(gridColumns[gridColumns.length-1] === 0) { + gridColumns.pop(); + } + if(gridRows[gridRows.length-1] === 0) { + gridRows.pop(); + } + + // Add extra options for the ability to extend beyond current content: + gridRows.push(50); + gridRows.push(50); + gridRows.push(50); + } + + vm.scaleHandlerMouseDown = function($event) { + $event.originalEvent.preventDefault(); + + window.addEventListener('mousemove', vm.onMouseMove); + window.addEventListener('mouseup', vm.onMouseUp); + window.addEventListener('mouseleave', vm.onMouseUp); + + + layoutContainer = $element[0].closest('.umb-block-grid-area-editor__grid-wrapper'); + if(!layoutContainer) { + console.error($element[0], 'could not find area-container'); + } + + updateGridLayoutData(); + + scaleBoxBackdropEl = document.createElement('div'); + scaleBoxBackdropEl.className = 'umb-block-grid-area-editor__scalebox-backdrop'; + layoutContainer.appendChild(scaleBoxBackdropEl); + + scaleBoxEl = document.createElement('div'); + scaleBoxEl.className = 'umb-block-grid-area-editor__scalebox'; + + const scaleBoxScaleHandler = document.createElement('button'); + scaleBoxScaleHandler.className = 'umb-block-grid-area-editor__scale-handler'; + scaleBoxEl.appendChild(scaleBoxScaleHandler); + + $element[0].appendChild(scaleBoxEl); + + } + vm.onMouseMove = function(e) { + + updateGridLayoutData(); + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + + const startX = layoutItemRect.left - layoutContainerRect.left; + const startY = layoutItemRect.top - layoutContainerRect.top; + const endX = e.pageX - layoutContainerRect.left; + const endY = e.pageY - layoutContainerRect.top; + + const newSpans = getNewSpans(startX, startY, endX, endY); + const endCol = newSpans.startCol + newSpans.columnSpan; + const endRow = newSpans.startRow + newSpans.rowSpan; + + + const startCellX = getAccumulatedValueOfIndex(newSpans.startCol, gridColumns); + const startCellY = getAccumulatedValueOfIndex(newSpans.startRow, gridRows); + const endCellX = getAccumulatedValueOfIndex(endCol, gridColumns); + const endCellY = getAccumulatedValueOfIndex(endRow, gridRows); + + scaleBoxEl.style.width = Math.round(endCellX-startCellX)+'px'; + scaleBoxEl.style.height = Math.round(endCellY-startCellY)+'px'; + + // update as we go: + vm.area.columnSpan = newSpans.columnSpan; + vm.area.rowSpan = newSpans.rowSpan; + + $scope.$evalAsync(); + } + + vm.onMouseUp = function(e) { + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + + const startX = layoutItemRect.left - layoutContainerRect.left; + const startY = layoutItemRect.top - layoutContainerRect.top; + const endX = e.pageX - layoutContainerRect.left; + const endY = e.pageY - layoutContainerRect.top; + + const newSpans = getNewSpans(startX, startY, endX, endY); + + // Remove listeners: + window.removeEventListener('mousemove', vm.onMouseMove); + window.removeEventListener('mouseup', vm.onMouseUp); + window.removeEventListener('mouseleave', vm.onMouseUp); + + layoutContainer.removeChild(scaleBoxBackdropEl); + $element[0].removeChild(scaleBoxEl); + + // Clean up variables: + layoutContainer = null; + gridColumns = null; + gridRows = null; + scaleBoxEl = null; + scaleBoxBackdropEl = null; + + // Update block size: + vm.area.columnSpan = newSpans.columnSpan; + vm.area.rowSpan = newSpans.rowSpan; + $scope.$evalAsync(); + } + + + + vm.scaleHandlerKeyUp = function($event) { + + let addCol = 0; + let addRow = 0; + + switch ($event.originalEvent.key) { + case 'ArrowUp': + addRow = -1; + break; + case 'ArrowDown': + addRow = 1; + break; + case 'ArrowLeft': + addCol = -1; + break; + case 'ArrowRight': + addCol = 1; + break; + } + + // Todo: Ensure value fit with configuration. + vm.area.columnSpan = Math.max(vm.area.columnSpan + addCol, 1); + vm.area.rowSpan = Math.max(vm.area.rowSpan + addRow, 1); + + $event.originalEvent.stopPropagation(); + } + + + + vm.onEditClick = function($event) { + $event.stopPropagation(); + vm.onEdit(); + } + + vm.onDeleteClick = function($event) { + $event.stopPropagation(); + vm.onDelete(); + } + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/razorhtml.temp.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/razorhtml.temp.html new file mode 100644 index 0000000000..9c7a95a101 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/razorhtml.temp.html @@ -0,0 +1,58 @@ +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ +
+ +
+ + +
+ +
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html new file mode 100644 index 0000000000..b4ef7b9266 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entries.html @@ -0,0 +1,129 @@ + +
+ + + +
+ +
+
+ +
+ +
+ + + + +
+ + +
+ + + + + + +
+ +
+
+ Minimum %0% entries, needs %1% more. +
+ +
+ +
+
+ Maximum %0% entries, %1% too many. +
+ +
+ +
+
+
+ %0% must be present between %2% – %3% times. +
+
+ +
+ +
+
\ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html new file mode 100644 index 0000000000..9c8860eaa5 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-entry.html @@ -0,0 +1,189 @@ + + + + +
+ + + + + + + +
+ + +
+
+ +
/
+
+ {{vm.layoutEntry.$block.label}} +
+
+
+ +
+ + + + + + +
+ + + + + + +
+ {{vm.layoutEntry.columnSpan}} x {{vm.layoutEntry.rowSpan}} +
+ +
+
+ + + +
+
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html new file mode 100644 index 0000000000..767be6d559 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.html @@ -0,0 +1,40 @@ +
+ + + +
+ +
+ + + + +
+ + + + + +
+
+ Minimum %0% entries, needs %1% more. +
+ > +
+
+
+ Maximum %0% entries, %1% too many. +
+ +
+ + +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less new file mode 100644 index 0000000000..be3d1cc9ec --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umb-block-grid-property-editor.less @@ -0,0 +1,4 @@ +.umb-block-grid__wrapper { + position: relative; + max-width: 1200px; +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js new file mode 100644 index 0000000000..57ddea9757 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbBlockGridPropertyEditor.component.js @@ -0,0 +1,1287 @@ +(function () { + "use strict"; + + function GetAreaAtBlock(parentBlock, areaKey) { + if(parentBlock != null) { + var area = parentBlock.layout.areas.find(x => x.key === areaKey); + if(!area) { + return null; + } + + return area; + } + return null; + } + + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridPropertyEditor + * @function + * + * @description + * The component for the block grid property editor. + */ + angular + .module("umbraco") + .component("umbBlockGridPropertyEditor", { + templateUrl: "views/propertyeditors/blockgrid/umb-block-grid-property-editor.html", + controller: BlockGridController, + controllerAs: "vm", + bindings: { + model: "=" + }, + require: { + propertyForm: "^form", + umbProperty: "?^umbProperty", + umbVariantContent: '?^^umbVariantContent', + umbVariantContentEditors: '?^^umbVariantContentEditors', + umbElementEditorContent: '?^^umbElementEditorContent', + valFormManager: '?^^valFormManager' + } + }); + function BlockGridController($element, $attrs, $scope, $timeout, $q, editorService, clipboardService, localizationService, overlayService, blockEditorService, udiService, serverValidationManager, angularHelper, eventsService, assetsService, umbRequestHelper) { + + var unsubscribe = []; + var modelObject; + + // Property actions: + var copyAllBlocksAction = null; + var deleteAllBlocksAction = null; + + var liveEditing = true; + + var shadowRoot; + + var vm = this; + + vm.readonly = false; + + $attrs.$observe('readonly', (value) => { + vm.readonly = value !== undefined; + + vm.blockEditorApi.readonly = vm.readonly; + + if (deleteAllBlocksAction) { + deleteAllBlocksAction.isDisabled = vm.readonly; + } + }); + + vm.loading = true; + + vm.currentBlockInFocus = null; + vm.setBlockFocus = function (block) { + if (vm.currentBlockInFocus !== null) { + vm.currentBlockInFocus.focus = false; + } + vm.currentBlockInFocus = block; + block.focus = true; + }; + + vm.showAreaHighlight = function(parentBlock, areaKey) { + const area = GetAreaAtBlock(parentBlock, areaKey) + if(area) { + area.$highlight = true; + } + } + vm.hideAreaHighlight = function(parentBlock, areaKey) { + const area = GetAreaAtBlock(parentBlock, areaKey) + if(area) { + area.$highlight = false; + } + } + + vm.supportCopy = clipboardService.isSupported(); + vm.clipboardItems = []; + unsubscribe.push(eventsService.on("clipboardService.storageUpdate", updateClipboard)); + unsubscribe.push($scope.$on("editors.content.splitViewChanged", (event, eventData) => { + var compositeId = vm.umbVariantContent.editor.compositeId; + if(eventData.editors.some(x => x.compositeId === compositeId)) { + updateAllBlockObjects(); + } + })); + + vm.layout = []; // The layout object specific to this Block Editor, will be a direct reference from Property Model. + vm.availableBlockTypes = []; // Available block entries of this property editor. + vm.labels = {}; + vm.options = { + createFlow: false + }; + + localizationService.localizeMany(["grid_addElement", "content_createEmpty", "blockEditor_addThis"]).then(function (data) { + vm.labels.grid_addElement = data[0]; + vm.labels.content_createEmpty = data[1]; + vm.labels.blockEditor_addThis = data[2] + }); + + vm.$onInit = function() { + + + //listen for form validation changes + vm.valFormManager.onValidationStatusChanged(function (evt, args) { + vm.showValidation = vm.valFormManager.showValidation; + }); + //listen for the forms saving event + unsubscribe.push($scope.$on("formSubmitting", function (ev, args) { + vm.showValidation = true; + })); + + //listen for the forms saved event + unsubscribe.push($scope.$on("formSubmitted", function (ev, args) { + vm.showValidation = false; + })); + + if (vm.umbProperty && !vm.umbVariantContent) {// if we don't have vm.umbProperty, it means we are in the DocumentTypeEditor. + // not found, then fallback to searching the scope chain, this may be needed when DOM inheritance isn't maintained but scope + // inheritance is (i.e.infinite editing) + var found = angularHelper.traverseScopeChain($scope, s => s && s.vm && s.vm.constructor.name === "umbVariantContentController"); + vm.umbVariantContent = found ? found.vm : null; + if (!vm.umbVariantContent) { + throw "Could not find umbVariantContent in the $scope chain"; + } + } + + // set the onValueChanged callback, this will tell us if the block grid model changed on the server + // once the data is submitted. If so we need to re-initialize + vm.model.onValueChanged = onServerValueChanged; + + liveEditing = vm.model.config.useLiveEditing; + + vm.validationLimit = vm.model.config.validationLimit; + vm.gridColumns = vm.model.config.gridColumns || 12; + vm.createLabel = vm.model.config.createLabel || ""; + vm.blockGroups = vm.model.config.blockGroups; + vm.uniqueEditorKey = String.CreateGuid(); + + vm.editorWrapperStyles = {}; + + if (vm.model.config.maxPropertyWidth) { + vm.editorWrapperStyles['max-width'] = vm.model.config.maxPropertyWidth; + } + + if (vm.model.config.layoutStylesheet) { + vm.layoutStylesheet = umbRequestHelper.convertVirtualToAbsolutePath(vm.model.config.layoutStylesheet); + } else { + vm.layoutStylesheet = "assets/css/umbraco-blockgridlayout.css"; + } + + // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated. + if(typeof vm.model.value !== 'object' || vm.model.value === null) {// testing if we have null or undefined value or if the value is set to another type than Object. + vm.model.value = {}; + } + + var scopeOfExistence = $scope; + if(vm.umbVariantContentEditors && vm.umbVariantContentEditors.getScope) { + scopeOfExistence = vm.umbVariantContentEditors.getScope(); + } else if(vm.umbElementEditorContent && vm.umbElementEditorContent.getScope) { + scopeOfExistence = vm.umbElementEditorContent.getScope(); + } + + copyAllBlocksAction = { + labelKey: "clipboard_labelForCopyAllEntries", + labelTokens: [vm.model.label], + icon: "documents", + method: requestCopyAllBlocks, + isDisabled: true + }; + + deleteAllBlocksAction = { + labelKey: 'clipboard_labelForRemoveAllEntries', + labelTokens: [], + icon: 'trash', + method: requestDeleteAllBlocks, + isDisabled: true + }; + + var propertyActions = [ + copyAllBlocksAction, + deleteAllBlocksAction + ]; + + if (vm.umbProperty) { + vm.umbProperty.setPropertyActions(propertyActions); + } + + // Create Model Object, to manage our data for this Block Editor. + modelObject = blockEditorService.createModelObject(vm.model.value, vm.model.editor, vm.model.config.blocks, scopeOfExistence, $scope); + + $q.all([modelObject.load(), assetsService.loadJs('lib/sortablejs/Sortable.min.js', $scope)]).then(onLoaded); + + }; + + // Called when we save the value, the server may return an updated data and our value is re-synced + // we need to deal with that here so that our model values are all in sync so we basically re-initialize. + function onServerValueChanged(newVal, oldVal) { + + // We need to ensure that the property model value is an object, this is needed for modelObject to receive a reference and keep that updated. + if (typeof newVal !== 'object' || newVal === null) {// testing if we have null or undefined value or if the value is set to another type than Object. + vm.model.value = newVal = {}; + } + + modelObject.update(vm.model.value, $scope); + onLoaded(); + } + + + + function onLoaded() { + + // Store a reference to the layout model, because we need to maintain this model. + vm.layout = modelObject.getLayout([]); + + + initializeLayout(vm.layout); + + vm.availableContentTypesAliases = modelObject.getAvailableAliasesForBlockContent(); + vm.availableBlockTypes = modelObject.getAvailableBlocksForBlockPicker(); + + updateClipboard(true); + + vm.loading = false; + + window.requestAnimationFrame(() => { + shadowRoot = $element[0].querySelector('umb-block-grid-root').shadowRoot; + }) + + } + + + + function initializeLayout(layoutList, parentBlock, areaKey) { + + // reference the invalid items of this list, to be removed after the loop. + var invalidLayoutItems = []; + + // Append the blockObjects to our layout. + layoutList.forEach(layoutEntry => { + + var block = initializeLayoutEntry(layoutEntry, parentBlock, areaKey); + if(!block) { + // then we need to filter this out and also update the underlying model. This could happen if the data is invalid. + invalidLayoutItems.push(layoutEntry); + } + }); + + // remove the ones that are invalid + invalidLayoutItems.forEach(entry => { + var index = layoutList.findIndex(x => x === entry); + if (index >= 0) { + layoutList.splice(index, 1); + } + }); + } + + function initializeLayoutEntry(layoutEntry, parentBlock, areaKey) { + + // $block must have the data property to be a valid BlockObject, if not, its considered as a destroyed blockObject. + if (!layoutEntry.$block || layoutEntry.$block.data === undefined) { + + // each layoutEntry should have a child array, + layoutEntry.areas = layoutEntry.areas || []; + + var block = getBlockObject(layoutEntry); + + // If this entry was not supported by our property-editor it would return 'null'. + if (block !== null) { + layoutEntry.$block = block; + } else { + return null; + } + + // Create areas that is not already created: + block.config.areas?.forEach(areaConfig => { + const areaIndex = layoutEntry.areas.findIndex(x => x.key === areaConfig.key); + if(areaIndex === -1) { + layoutEntry.areas.push({ + $config: areaConfig, + key: areaConfig.key, + items: [] + }) + } else { + // set $config as its not persisted: + layoutEntry.areas[areaIndex].$config = areaConfig; + initializeLayout(layoutEntry.areas[areaIndex].items, block, areaConfig.key); + } + }); + + // Clean up areas that does not exist in config: + let i = layoutEntry.areas.length; + while(i--) { + const layoutEntryArea = layoutEntry.areas[i]; + const areaConfigIndex = block.config.areas.findIndex(x => x.key === layoutEntryArea.key); + if(areaConfigIndex === -1) { + layoutEntry.areas.splice(i, 1); + } + } + + // if no columnSpan, then we set one: + if (!layoutEntry.columnSpan) { + + const contextColumns = getContextColumns(parentBlock, areaKey) + + if (block.config.columnSpanOptions.length > 0) { + // set columnSpan to minimum allowed span for this BlockType: + const minimumColumnSpan = block.config.columnSpanOptions.reduce((prev, option) => Math.min(prev, option.columnSpan), vm.gridColumns); + + // If minimumColumnSpan is larger than contextColumns, then we will make it fit within context anyway: + layoutEntry.columnSpan = Math.min(minimumColumnSpan, contextColumns) + } else { + layoutEntry.columnSpan = contextColumns; + } + } + // if no rowSpan, then we set one: + if (!layoutEntry.rowSpan) { + layoutEntry.rowSpan = 1; + } + + + } else { + updateBlockObject(layoutEntry.$block); + } + + return layoutEntry.$block; + } + + vm.getContextColumns = getContextColumns; + function getContextColumns(parentBlock, areaKey) { + + if(parentBlock != null) { + var area = parentBlock.layout.areas.find(x => x.key === areaKey); + if(!area) { + return null; + } + + return area.$config.columnSpan; + } + + return vm.gridColumns; + } + + vm.getBlockGroupName = getBlockGroupName; + function getBlockGroupName(groupKey) { + return vm.blockGroups.find(x => x.key === groupKey)?.name; + } + + + function updateAllBlockObjects() { + // Update the blockObjects in our layout. + vm.layout.forEach(entry => { + // $block must have the data property to be a valid BlockObject, if not its considered as a destroyed blockObject. + if (entry.$block) { + updateBlockObject(entry.$block); + } + }); + } + + function applyDefaultViewForBlock(block) { + + var defaultViewFolderPath = "views/propertyeditors/blockgrid/blockgridentryeditors/"; + + if (block.config.unsupported === true) { + block.view = defaultViewFolderPath + "unsupportedblock/unsupportedblock.editor.html"; + } else { + block.view = defaultViewFolderPath + "gridblock/gridblock.editor.html"; + } + + } + + /** + * Ensure that the containing content variant language and current property culture is transferred along + * to the scaffolded content object representing this block. + * This is required for validation along with ensuring that the umb-property inheritance is constantly maintained. + * @param {any} content + */ + function ensureCultureData(content) { + + if (!content) return; + + if (vm.umbVariantContent.editor.content.language) { + // set the scaffolded content's language to the language of the current editor + content.language = vm.umbVariantContent.editor.content.language; + } + // currently we only ever deal with invariant content for blocks so there's only one + content.variants[0].tabs.forEach(tab => { + tab.properties.forEach(prop => { + // set the scaffolded property to the culture of the containing property + prop.culture = vm.umbProperty.property.culture; + }); + }); + } + + function getBlockObject(entry) { + var block = modelObject.getBlockObject(entry); + + if (block === null) return null; + + if (!block.config.view) { + applyDefaultViewForBlock(block); + } else { + block.view = block.config.view; + } + + block.stylesheet = block.config.stylesheet; + block.showValidation = true; + + block.hideContentInOverlay = block.config.forceHideContentEditorInOverlay === true; + block.showContent = !block.hideContentInOverlay && block.content?.variants[0].tabs[0]?.properties.length > 0; + block.showSettings = block.config.settingsElementTypeKey != null; + + // If we have content, otherwise it doesn't make sense to copy. + block.showCopy = vm.supportCopy && block.config.contentElementTypeKey != null; + + block.blockUiVisibility = false; + block.showBlockUI = function () { + delete block.__timeout; + shadowRoot.querySelector('*[data-element-udi="'+block.layout.contentUdi+'"] .umb-block-grid__block > .umb-block-grid__block--context').scrollIntoView({block: "nearest", inline: "nearest", behavior: "smooth"}); + block.blockUiVisibility = true; + }; + block.onMouseLeave = function () { + block.__timeout = $timeout(() => {block.blockUiVisibility = false}, 200); + }; + block.onMouseEnter = function () { + if (block.__timeout) { + $timeout.cancel(block.__timeout); + delete block.__timeout; + } + }; + + + // Index is set by umbblockgridblock component and kept up to date by it. + block.index = 0; + block.setParentForm = function (parentForm) { + this._parentForm = parentForm; + }; + + /** decorator methods, to enable switching out methods without loosing references that would have been made in Block Views codes */ + block.activate = function() { + this._activate(); + }; + block.edit = function() { + this._edit(); + }; + block.editSettings = function() { + this._editSettings(); + }; + block.requestDelete = function() { + this._requestDelete(); + }; + block.delete = function() { + this._delete(); + }; + block.copy = function() { + this._copy(); + }; + updateBlockObject(block); + + return block; + } + + /** As the block object now contains references to this instance of a property editor, we need to ensure that the Block Object contains latest references. + * This is a bit hacky but the only way to maintain this reference currently. + * Notice this is most relevant for invariant properties on variant documents, specially for the scenario where the scope of the reference we stored is destroyed, therefor we need to ensure we always have references to a current running property editor*/ + function updateBlockObject(block) { + + ensureCultureData(block.content); + ensureCultureData(block.settings); + + block._activate = activateBlock.bind(null, block); + block._edit = function () { + var blockIndex = vm.layout.indexOf(this.layout); + editBlock(this, false, blockIndex, this._parentForm); + }; + block._editSettings = function () { + var blockIndex = vm.layout.indexOf(this.layout); + editBlock(this, true, blockIndex, this._parentForm); + }; + block._requestDelete = requestDeleteBlock.bind(null, block); + block._delete = deleteBlock.bind(null, block); + block._copy = copyBlock.bind(null, block); + } + + function addNewBlock(parentBlock, areaKey, index, contentElementTypeKey, options) { + + // Create layout entry. (not added to property model jet.) + const layoutEntry = modelObject.create(contentElementTypeKey); + if (layoutEntry === null) { + return false; + } + + // Development note: Notice this is ran before added to the data model. + initializeLayoutEntry(layoutEntry, parentBlock, areaKey); + + // make block model + const blockObject = layoutEntry.$block; + if (blockObject === null) { + return false; + } + + const area = parentBlock?.layout.areas.find(x => x.key === areaKey); + + // fit in row? + if (options.fitInRow === true) { + /* + Idea for finding the proper size for this new block: + Use clientRect to measure previous items, once one is more to the right than the one before, then it must be a new line. + Combine those from the line to inspect if there is left room. Se if the left room fits? + Additionally the sizingOptions of the other can come into play? + */ + + if(blockObject.config.columnSpanOptions.length > 0) { + const minColumnSpan = blockObject.config.columnSpanOptions.reduce((prev, option) => Math.min(prev, option.columnSpan), vm.gridColumns); + layoutEntry.columnSpan = minColumnSpan; + } else { + // because no columnSpanOptions defined, then use contextual layout columns. + layoutEntry.columnSpan = area? area.$config.columnSpan : vm.gridColumns; + } + + } else { + + if(blockObject.config.columnSpanOptions.length > 0) { + // set columnSpan to maximum allowed span for this BlockType: + const maximumColumnSpan = blockObject.config.columnSpanOptions.reduce((prev, option) => Math.max(prev, option.columnSpan), 1); + layoutEntry.columnSpan = maximumColumnSpan; + } else { + // because no columnSpanOptions defined, then use contextual layout columns. + layoutEntry.columnSpan = area? area.$config.columnSpan : vm.gridColumns; + } + + } + + // add layout entry at the decided location in layout. + if(parentBlock != null) { + + if(!area) { + console.error("Could not find area in block creation"); + } + + // limit columnSpan by areaConfig columnSpan: + layoutEntry.columnSpan = Math.min(layoutEntry.columnSpan, area.$config.columnSpan); + + area.items.splice(index, 0, layoutEntry); + } else { + + // limit columnSpan by grid columnSpan: + layoutEntry.columnSpan = Math.min(layoutEntry.columnSpan, vm.gridColumns); + + vm.layout.splice(index, 0, layoutEntry); + } + + // lets move focus to this new block. + vm.setBlockFocus(blockObject); + + return true; + } + + function getLayoutEntryByContentID(layoutList, contentUdi) { + for(const entry of layoutList) { + if(entry.contentUdi === contentUdi) { + return {entry: entry, layoutList: layoutList}; + } + for(const area of entry.areas) { + const result = getLayoutEntryByContentID(area.items, contentUdi); + if(result !== null) { + return result; + } + } + } + return null; + } + + // Used by umbblockgridentries.component to check for drag n' drop allowance: + vm.isElementTypeKeyAllowedAt = isElementTypeKeyAllowedAt; + function isElementTypeKeyAllowedAt(parentBlock, areaKey, contentElementTypeKey) { + return getAllowedTypesOf(parentBlock, areaKey).filter(x => x.blockConfigModel.contentElementTypeKey === contentElementTypeKey).length > 0; + } + + // Used by umbblockgridentries.component to set data for a block when drag n' drop specials(force new line etc.): + vm.getLayoutEntryByIndex = getLayoutEntryByIndex; + function getLayoutEntryByIndex(parentBlock, areaKey, index) { + if(parentBlock) { + const area = parentBlock.layout.areas.find(x => x.key === areaKey); + if(area && area.items.length >= index) { + return area.items[index]; + } + } else { + return vm.layout[index]; + } + return null; + } + + + // Used by umbblockgridentries.component to check how many block types that are available for creation in an area: + vm.getAllowedTypesOf = getAllowedTypesOf; + function getAllowedTypesOf(parentBlock, areaKey) { + + if(areaKey == null || parentBlock == null) { + return vm.availableBlockTypes.filter(x => x.blockConfigModel.allowAtRoot); + } + + const area = parentBlock.layout.areas.find(x => x.key === areaKey); + + if(area) { + if(area.$config.specifiedAllowance.length > 0) { + + const allowedElementTypes = []; + + // Then add specific types (This allows to overwrite the amount for a specific type) + area.$config.specifiedAllowance?.forEach(allowance => { + if(allowance.groupKey) { + vm.availableBlockTypes.forEach(blockType => { + if(blockType.blockConfigModel.groupKey === allowance.groupKey && blockType.blockConfigModel.allowInAreas === true) { + if(allowedElementTypes.indexOf(blockType) === -1) { + allowedElementTypes.push(blockType); + } + } + }); + } else + if(allowance.elementTypeKey) { + const blockType = vm.availableBlockTypes.find(x => x.blockConfigModel.contentElementTypeKey === allowance.elementTypeKey); + if(allowedElementTypes.indexOf(blockType) === -1) { + allowedElementTypes.push(blockType); + } + } + }); + + return allowedElementTypes; + } else { + // as none specifiedAllowance was defined we will allow all area Blocks: + return vm.availableBlockTypes.filter(x => x.blockConfigModel.allowInAreas === true); + } + } + return vm.availableBlockTypes; + } + + function deleteBlock(block) { + + const result = getLayoutEntryByContentID(vm.layout, block.layout.contentUdi); + if (result === null) { + console.error("Could not find layout entry of block with udi: "+block.layout.contentUdi); + return; + } + + setDirty(); + + result.entry.areas.forEach(area => { + area.items.forEach(areaEntry => { + deleteBlock(areaEntry.$block); + }); + }); + + const layoutListIndex = result.layoutList.indexOf(result.entry); + var removed = result.layoutList.splice(layoutListIndex, 1); + removed.forEach(x => { + // remove any server validation errors associated + var guids = [udiService.getKey(x.contentUdi), (x.settingsUdi ? udiService.getKey(x.settingsUdi) : null)]; + guids.forEach(guid => { + if (guid) { + serverValidationManager.removePropertyError(guid, vm.umbProperty.property.culture, vm.umbProperty.property.segment, "", { matchType: "contains" }); + } + }) + }); + + modelObject.removeDataAndDestroyModel(block); + } + + function deleteAllBlocks() { + while(vm.layout.length) { + deleteBlock(vm.layout[0].$block); + }; + } + + function activateBlock(blockObject) { + blockObject.active = true; + } + + function editBlock(blockObject, openSettings, blockIndex, parentForm, options) { + + options = options || vm.options; + + /* + We cannot use the blockIndex as is not possibility in grid, cause of the polymorphism. + But we keep it to stay consistent with Block List Editor. + if (blockIndex === undefined) { + throw "blockIndex was not specified on call to editBlock"; + } + */ + + var wasNotActiveBefore = blockObject.active !== true; + + // don't open the editor overlay if block has hidden its content editor in overlays and we are requesting to open content, not settings. + if (openSettings !== true && blockObject.hideContentInOverlay === true) { + return; + } + + // if requesting to open settings but we don't have settings then return. + if (openSettings === true && !blockObject.config.settingsElementTypeKey) { + return; + } + + activateBlock(blockObject); + + // make a clone to avoid editing model directly. + var blockContentClone = Utilities.copy(blockObject.content); + var blockSettingsClone = null; + + if (blockObject.config.settingsElementTypeKey) { + blockSettingsClone = Utilities.copy(blockObject.settings); + } + + var blockEditorModel = { + $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing + $parentForm: parentForm || vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form) + hideContent: blockObject.hideContentInOverlay, + openSettings: openSettings === true, + createFlow: options.createFlow === true, + liveEditing: liveEditing, + title: blockObject.label, + view: "views/common/infiniteeditors/blockeditor/blockeditor.html", + size: blockObject.config.editorSize || "medium", + hideSubmitButton: vm.readonly, + submit: function(blockEditorModel) { + + if (liveEditing === false) { + // transfer values when submitting in none-liveediting mode. + blockObject.retrieveValuesFrom(blockEditorModel.content, blockEditorModel.settings); + } + + blockObject.active = false; + editorService.close(); + }, + close: function(blockEditorModel) { + if (blockEditorModel.createFlow) { + deleteBlock(blockObject); + } else { + if (liveEditing === true) { + // revert values when closing in liveediting mode. + blockObject.retrieveValuesFrom(blockContentClone, blockSettingsClone); + } + if (wasNotActiveBefore === true) { + blockObject.active = false; + } + } + editorService.close(); + } + }; + + if (liveEditing === true) { + blockEditorModel.content = blockObject.content; + blockEditorModel.settings = blockObject.settings; + } else { + blockEditorModel.content = blockContentClone; + blockEditorModel.settings = blockSettingsClone; + } + + // open property settings editor + editorService.open(blockEditorModel); + } + + vm.requestShowCreate = requestShowCreate; + function requestShowCreate(parentBlock, areaKey, createIndex, mouseEvent, options) { + + if (vm.blockTypePickerIsOpen === true) { + return; + } + + options = options || {}; + + const availableTypes = getAllowedTypesOf(parentBlock, areaKey); + + if (availableTypes.length === 1) { + var wasAdded = false; + var blockType = availableTypes[0]; + + wasAdded = addNewBlock(parentBlock, areaKey, createIndex, blockType.blockConfigModel.contentElementTypeKey, options); + + if(wasAdded && !(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex); + } + } else { + showCreateDialog(parentBlock, areaKey, createIndex, false, options); + } + + } + vm.requestShowClipboard = requestShowClipboard; + function requestShowClipboard(parentBlock, areaKey, createIndex, mouseEvent) { + showCreateDialog(parentBlock, areaKey, createIndex, true); + } + + vm.showCreateDialog = showCreateDialog; + function showCreateDialog(parentBlock, areaKey, createIndex, openClipboard, options) { + + if (vm.blockTypePickerIsOpen === true) { + return; + } + + + options = options || {}; + + const availableTypes = getAllowedTypesOf(parentBlock, areaKey); + + if (availableTypes.length === 0) { + return; + } + + const availableBlockGroups = vm.blockGroups.filter(group => !!availableTypes.find(item => item.blockConfigModel.groupKey === group.key)); + + var amountOfAvailableTypes = availableTypes.length; + var availableContentTypesAliases = modelObject.getAvailableAliasesOfElementTypeKeys(availableTypes.map(x => x.blockConfigModel.contentElementTypeKey)); + var availableClipboardItems = vm.clipboardItems.filter( + (entry) => { + if(entry.aliases) { + return entry.aliases.filter((alias, index) => availableContentTypesAliases.indexOf(alias) === index); + } else { + return availableContentTypesAliases.indexOf(entry.alias) !== -1; + } + } + ); + + var createLabel; + if(parentBlock) { + const area = parentBlock.layout.areas.find(x => x.key === areaKey); + createLabel = area.$config.createLabel; + } else { + createLabel = vm.createLabel; + } + const headline = createLabel || (amountOfAvailableTypes.length === 1 ? localizationService.tokenReplace(vm.labels.blockEditor_addThis, [availableTypes[0].elementTypeModel.name]) : vm.labels.grid_addElement); + + var blockPickerModel = { + $parentScope: $scope, // pass in a $parentScope, this maintains the scope inheritance in infinite editing + $parentForm: vm.propertyForm, // pass in a $parentForm, this maintains the FormController hierarchy with the infinite editing view (if it contains a form) + availableItems: availableTypes, + blockGroups: availableBlockGroups, + title: headline, + openClipboard: openClipboard, + orderBy: "$index", + view: "views/common/infiniteeditors/blockpicker/blockpicker.html", + size: (amountOfAvailableTypes > 8 ? "medium" : "small"), + filter: (amountOfAvailableTypes > 8), + clickPasteItem: function(item, mouseEvent) { + if (Array.isArray(item.pasteData)) { + var indexIncrementor = 0; + item.pasteData.forEach(function (entry) { + if (requestPasteFromClipboard(parentBlock, areaKey, createIndex + indexIncrementor, entry, item.type)) { + indexIncrementor++; + } + }); + } else { + requestPasteFromClipboard(parentBlock, areaKey, createIndex, item.pasteData, item.type); + } + if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + blockPickerModel.close(); + } + }, + submit: function(blockPickerModel, mouseEvent) { + var wasAdded = false; + if (blockPickerModel && blockPickerModel.selectedItem) { + wasAdded = addNewBlock(parentBlock, areaKey, createIndex, blockPickerModel.selectedItem.blockConfigModel.contentElementTypeKey, options); + } + + if(!(mouseEvent.ctrlKey || mouseEvent.metaKey)) { + blockPickerModel.close(); + if (wasAdded) { + userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex); + } + } + }, + close: function() { + // if opened by a inline creator button(index less than length), we want to move the focus away, to hide line-creator. + + // add layout entry at the decided location in layout. + if(parentBlock != null) { + var area = parentBlock.layout.areas.find(x => x.key === areaKey); + if(!area) { + console.error("Could not find area in block creation close flow"); + } + if (createIndex < area.items.length) { + const blockOfInterest = area.items[Math.max(createIndex-1, 0)].$block; + vm.setBlockFocus(blockOfInterest); + } + } else { + if (createIndex < vm.layout.length) { + const blockOfInterest = vm.layout[Math.max(createIndex-1, 0)].$block; + vm.setBlockFocus(blockOfInterest); + } + } + + + editorService.close(); + vm.blockTypePickerIsOpen = false; + } + }; + + blockPickerModel.clickClearClipboard = function ($event) { + clipboardService.clearEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, availableContentTypesAliases); + clipboardService.clearEntriesOfType(clipboardService.TYPES.BLOCK, availableContentTypesAliases); + }; + + blockPickerModel.clipboardItems = availableClipboardItems; + + vm.blockTypePickerIsOpen = true; + // open block picker overlay + editorService.open(blockPickerModel); + + }; + function userFlowWhenBlockWasCreated(parentBlock, areaKey, createIndex) { + var blockObject; + + if (parentBlock) { + var area = parentBlock.layout.areas.find(x => x.key === areaKey); + if (!area) { + console.error("Area could not be found...", parentBlock, areaKey) + } + blockObject = area.items[createIndex].$block; + } else { + if (vm.layout.length <= createIndex) { + console.error("Create index does not fit within available items of root.") + } + blockObject = vm.layout[createIndex].$block; + } + // edit block if not `hideContentInOverlay` and there is content properties. + if(blockObject.hideContentInOverlay !== true && blockObject.content.variants[0].tabs[0]?.properties.length > 0) { + vm.options.createFlow = true; + blockObject.edit(); + vm.options.createFlow = false; + } + } + + function updateClipboard(firstTime) { + + var oldAmount = vm.clipboardItems.length; + + vm.clipboardItems = []; + + var entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.ELEMENT_TYPE, vm.availableContentTypesAliases); + entriesForPaste.forEach(function (entry) { + var pasteEntry = { + type: clipboardService.TYPES.ELEMENT_TYPE, + date: entry.date, + alias: entry.alias, + pasteData: entry.data, + elementTypeModel: { + name: entry.label, + icon: entry.icon + } + } + if(Array.isArray(entry.data) === false) { + var scaffold = modelObject.getScaffoldFromAlias(entry.alias); + if(scaffold) { + pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(scaffold.contentTypeKey); + } + } + vm.clipboardItems.push(pasteEntry); + }); + + var entriesForPaste = clipboardService.retrieveEntriesOfType(clipboardService.TYPES.BLOCK, vm.availableContentTypesAliases); + entriesForPaste.forEach(function (entry) { + var pasteEntry = { + type: clipboardService.TYPES.BLOCK, + date: entry.date, + alias: entry.alias, + aliases: entry.aliases, + pasteData: entry.data, + elementTypeModel: { + name: entry.label, + icon: entry.icon + } + } + if(Array.isArray(entry.data) === false) { + pasteEntry.blockConfigModel = modelObject.getBlockConfiguration(entry.data.data.contentTypeKey); + } + vm.clipboardItems.push(pasteEntry); + }); + + vm.clipboardItems.sort( (a, b) => { + return b.date - a.date + }); + + if(firstTime !== true && vm.clipboardItems.length > oldAmount) { + jumpClipboard(); + } + } + + var jumpClipboardTimeout; + function jumpClipboard() { + + if(jumpClipboardTimeout) { + return; + } + + vm.jumpClipboardButton = true; + jumpClipboardTimeout = $timeout(() => { + vm.jumpClipboardButton = false; + jumpClipboardTimeout = null; + }, 2000); + } + + function requestCopyAllBlocks() { + + var aliases = []; + + var elementTypesToCopy = vm.layout.filter(entry => entry.$block.config.unsupported !== true).map( + (entry) => { + + aliases.push(entry.$block.content.contentTypeAlias); + + const clipboardData = { "layout": entry.$block.layout, "data": entry.$block.data, "settingsData": entry.$block.settingsData }; + // If areas: + if(entry.$block.layout.areas.length > 0) { + clipboardData.nested = gatherNestedBlocks(entry.$block); + } + // No need to clone the data as its begin handled by the clipboardService. + return clipboardData; + } + ); + + // remove duplicate aliases + aliases = aliases.filter((item, index) => aliases.indexOf(item) === index); + + var contentNodeName = "?"; + var contentNodeIcon = null; + if (vm.umbVariantContent) { + contentNodeName = vm.umbVariantContent.editor.content.name; + if (vm.umbVariantContentEditors) { + contentNodeIcon = vm.umbVariantContentEditors.content.icon.split(" ")[0]; + } else if (vm.umbElementEditorContent) { + contentNodeIcon = vm.umbElementEditorContent.model.documentType.icon.split(" ")[0]; + } + } else if (vm.umbElementEditorContent) { + contentNodeName = vm.umbElementEditorContent.model.documentType.name; + contentNodeIcon = vm.umbElementEditorContent.model.documentType.icon.split(" ")[0]; + } + + localizationService.localize("clipboard_labelForArrayOfItemsFrom", [vm.model.label, contentNodeName]).then(function (localizedLabel) { + clipboardService.copyArray(clipboardService.TYPES.BLOCK, aliases, elementTypesToCopy, localizedLabel, contentNodeIcon || "icon-thumbnail-list", vm.model.id); + }); + }; + + function gatherNestedBlocks(block) { + const nested = []; + + block.layout.areas.forEach(area => { + area.items.forEach(item => { + const itemData = {"layout": item.$block.layout, "data": item.$block.data, "settingsData":item.$block.settingsData, "areaKey": area.key}; + if(item.$block.layout.areas?.length > 0) { + itemData.nested = gatherNestedBlocks(item.$block); + } + nested.push(itemData); + }); + }); + + return nested; + } + function copyBlock(block) { + + const clipboardData = {"layout": block.layout, "data": block.data, "settingsData":block.settingsData}; + + // If areas: + if(block.layout.areas.length > 0) { + clipboardData.nested = gatherNestedBlocks(block); + } + + clipboardService.copy(clipboardService.TYPES.BLOCK, block.content.contentTypeAlias, clipboardData, block.label, block.content.icon, block.content.udi); + } + + function pasteClipboardEntry(parentBlock, areaKey, index, pasteEntry, pasteType) { + + if (pasteEntry === undefined) { + return null; + } + + if(!isElementTypeKeyAllowedAt(parentBlock, areaKey, pasteEntry.data.contentTypeKey)) { + console.error("paste clipboard entry inserted an disallowed type.") + return {failed: true}; + } + + var layoutEntry; + if (pasteType === clipboardService.TYPES.ELEMENT_TYPE) { + layoutEntry = modelObject.createFromElementType(pasteEntry); + } else if (pasteType === clipboardService.TYPES.BLOCK) { + layoutEntry = modelObject.createFromBlockData(pasteEntry); + } else { + // Not a supported paste type. + return null; + } + + if (layoutEntry === null) { + // Pasting did not go well. + return null; + } + + if (initializeLayoutEntry(layoutEntry, parentBlock, areaKey) === null) { + return null; + } + + if (layoutEntry.$block === null) { + // Initialization of the Block Object didn't go well, therefor we will fail the paste action. + return null; + } + + var nestedBlockFailed = false; + if(pasteEntry.nested && pasteEntry.nested.length) { + + // Handle nested blocks: + pasteEntry.nested.forEach( nestedEntry => { + if(nestedEntry.areaKey) { + const data = pasteClipboardEntry(layoutEntry.$block, nestedEntry.areaKey, null, nestedEntry, pasteType); + if(data === null || data.failed === true) { + nestedBlockFailed = true; + } + } + }); + + } + + // insert layout entry at the decided location in layout. + if(parentBlock != null) { + var area = parentBlock.layout.areas.find(x => x.key === areaKey); + if (!area) { + console.error("Area could not be found...", parentBlock, areaKey) + } + if(index !== null) { + area.items.splice(index, 0, layoutEntry); + } else { + area.items.push(layoutEntry); + } + } else { + if(index !== null) { + vm.layout.splice(index, 0, layoutEntry); + } else { + vm.layout.push(layoutEntry); + } + } + + return {layoutEntry, failed: nestedBlockFailed}; + } + + function requestPasteFromClipboard(parentBlock, areaKey, index, pasteEntry, pasteType) { + + const data = pasteClipboardEntry(parentBlock, areaKey, index, pasteEntry, pasteType); + if(data) { + if(data.failed === true) { + // one or more of nested block creation failed. + // Ask wether the user likes to continue: + if(data.layoutEntry) { + var blockToRevert = data.layoutEntry.$block; + localizationService.localizeMany(["blockEditor_confirmPasteDisallowedNestedBlockHeadline", "blockEditor_confirmPasteDisallowedNestedBlockMessage", "general_revert", "general_continue"]).then(function (localizations) { + const overlay = { + title: localizations[0], + content: localizationService.tokenReplace(localizations[1], [blockToRevert.label]), + disableBackdropClick: true, + closeButtonLabel: localizations[2], + submitButtonLabel: localizations[3], + close: function () { + // revert: + deleteBlock(blockToRevert); + overlayService.close(); + }, + submit: function () { + // continue: + overlayService.close(); + } + }; + + overlayService.open(overlay); + }); + } else { + console.error("Pasting failed, there was nothing to revert. Should be good to move on with content creation.") + } + } else { + vm.currentBlockInFocus = data.layoutEntry.$block; + return true; + } + } + return false; + } + + function requestDeleteBlock(block) { + if (vm.readonly) return; + + localizationService.localizeMany(["general_delete", "blockEditor_confirmDeleteBlockMessage", "contentTypeEditor_yesDelete"]).then(function (data) { + const overlay = { + title: data[0], + content: localizationService.tokenReplace(data[1], [block.label]), + submitButtonLabel: data[2], + close: function () { + overlayService.close(); + }, + submit: function () { + deleteBlock(block); + overlayService.close(); + } + }; + + overlayService.confirmDelete(overlay); + }); + } + + function requestDeleteAllBlocks() { + localizationService.localizeMany(["content_nestedContentDeleteAllItems", "general_delete"]).then(function (data) { + overlayService.confirmDelete({ + title: data[1], + content: data[0], + close: function () { + overlayService.close(); + }, + submit: function () { + deleteAllBlocks(); + overlayService.close(); + } + }); + }); + } + + function openSettingsForBlock(block, blockIndex, parentForm) { + editBlock(block, true, blockIndex, parentForm); + } + + vm.blockEditorApi = { + activateBlock: activateBlock, + editBlock: editBlock, + copyBlock: copyBlock, + requestDeleteBlock: requestDeleteBlock, + deleteBlock: deleteBlock, + openSettingsForBlock: openSettingsForBlock, + requestShowCreate: requestShowCreate, + requestShowClipboard: requestShowClipboard, + internal: vm, + readonly: vm.readonly + }; + + vm.setDirty = setDirty; + function setDirty() { + if (vm.propertyForm) { + vm.propertyForm.$setDirty(); + } + } + + function onAmountOfBlocksChanged() { + + // enable/disable property actions + if (copyAllBlocksAction) { + copyAllBlocksAction.isDisabled = vm.layout.length === 0; + } + if (deleteAllBlocksAction) { + deleteAllBlocksAction.isDisabled = vm.layout.length === 0; + } + + // validate limits: + if (vm.propertyForm && vm.validationLimit) { + + var isMinRequirementGood = vm.validationLimit.min === null || vm.layout.length >= vm.validationLimit.min; + vm.propertyForm.minCount.$setValidity("minCount", isMinRequirementGood); + + var isMaxRequirementGood = vm.validationLimit.max === null || vm.layout.length <= vm.validationLimit.max; + vm.propertyForm.maxCount.$setValidity("maxCount", isMaxRequirementGood); + } + } + + unsubscribe.push($scope.$watch(() => vm.layout.length, onAmountOfBlocksChanged)); + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js new file mode 100644 index 0000000000..2c4a4bb262 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridblock.component.js @@ -0,0 +1,83 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridBlock + * @description + * The component to render the view for a block in the Block Grid Editor. + * If a stylesheet is used then this uses a ShadowDom to make a scoped element. + * This way the backoffice styling does not collide with the block style. + */ + + angular + .module("umbraco") + .component("umbBlockGridBlock", { + controller: BlockGridBlockController, + controllerAs: "model", + bindings: { + stylesheet: "@", + view: "@", + block: "=", + api: "<", + index: "<", + parentForm: "<" + }, + require: { + valFormManager: "^^valFormManager" + } + } + ); + + function BlockGridBlockController($scope, $compile, $element) { + var model = this; + + model.$onInit = function () { + + // let the Block know about its form + model.block.setParentForm(model.parentForm); + + // let the Block know about the current index + model.block.index = model.index; + + $scope.block = model.block; + $scope.api = model.api; + $scope.index = model.index; + $scope.parentForm = model.parentForm; + $scope.valFormManager = model.valFormManager; + + var shadowRoot = $element[0].attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = + ` + ${ model.stylesheet ? ` + ` + : '' + } +
+ `; + $compile(shadowRoot)($scope); + + }; + + + // We need to watch for changes on primitive types and update the $scope values. + model.$onChanges = function (changes) { + if (changes.index) { + var index = changes.index.currentValue; + $scope.index = index; + + // let the Block know about the current index: + model.block.index = index; + model.block.updateLabel(); + } + }; + + } + + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js new file mode 100644 index 0000000000..c105b98fcb --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentries.component.js @@ -0,0 +1,658 @@ +(function () { + "use strict"; + + + + function getInterpolatedIndexOfPositionInWeightMap(target, weights) { + const map = [0]; + weights.reduce((a, b, i) => { return map[i+1] = a+b; }, 0); + const foundValue = map.reduce((a, b) => { + let aDiff = Math.abs(a - target); + let bDiff = Math.abs(b - target); + + if (aDiff === bDiff) { + return a < b ? a : b; + } else { + return bDiff < aDiff ? b : a; + } + }) + const foundIndex = map.indexOf(foundValue); + const targetDiff = (target-foundValue); + let interpolatedIndex = foundIndex; + if (targetDiff < 0 && foundIndex === 0) { + // Don't adjust. + } else if (targetDiff > 0 && foundIndex === map.length-1) { + // Don't adjust. + } else { + const foundInterpolationWeight = weights[targetDiff >= 0 ? foundIndex : foundIndex-1]; + interpolatedIndex += foundInterpolationWeight === 0 ? interpolatedIndex : (targetDiff/foundInterpolationWeight) + } + return interpolatedIndex; + } + + + function isWithinRect(x, y, rect, modifier) { + return (x > rect.left - modifier && x < rect.right + modifier && y > rect.top - modifier && y < rect.bottom + modifier); + } + + + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridEntries + * @description + * renders all blocks for a given list for the block grid editor + */ + + angular + .module("umbraco") + .component("umbBlockGridEntries", { + templateUrl: 'views/propertyeditors/blockgrid/umb-block-grid-entries.html', + controller: BlockGridEntriesController, + controllerAs: "vm", + bindings: { + blockEditorApi: "<", + entries: "<", + layoutColumns: "<", + areaKey: " area.key === vm.areaKey); + } + + vm.locallyAvailableBlockTypes = vm.blockEditorApi.internal.getAllowedTypesOf(vm.parentBlock, vm.areaKey); + + unsubscribe.push($scope.$watch('vm.entries', onLocalAmountOfBlocksChanged, true)); + }; + + unsubscribe.push($scope.$watch("layoutColumns", (newVal, oldVal) => { + vm.layoutColumnsInt = parseInt(vm.layoutColumns, 10); + })); + + + function onLocalAmountOfBlocksChanged() { + + if (vm.entriesForm && vm.areaConfig) { + + var isMinRequirementGood = vm.entries.length >= vm.areaConfig.minAllowed; + vm.entriesForm.areaMinCount.$setValidity("areaMinCount", isMinRequirementGood); + + var isMaxRequirementGood = vm.areaConfig.maxAllowed == null || vm.entries.length <= vm.areaConfig.maxAllowed; + vm.entriesForm.areaMaxCount.$setValidity("areaMaxCount", isMaxRequirementGood); + + vm.invalidBlockTypes = []; + + vm.areaConfig.specifiedAllowance.forEach(allowance => { + + const minAllowed = allowance.minAllowed || 0; + const maxAllowed = allowance.maxAllowed || 0; + + // For block groups: + if(allowance.groupKey) { + + const groupElementTypeKeys = vm.locallyAvailableBlockTypes.filter(blockType => blockType.blockConfigModel.groupKey === allowance.groupKey && blockType.blockConfigModel.allowInAreas === true).map(x => x.blockConfigModel.contentElementTypeKey); + const groupAmount = vm.entries.filter(entry => groupElementTypeKeys.indexOf(entry.$block.data.contentTypeKey) !== -1).length; + + if(groupAmount < minAllowed || (maxAllowed > 0 && groupAmount > maxAllowed)) { + vm.invalidBlockTypes.push({ + 'groupKey': allowance.groupKey, + 'name': vm.blockEditorApi.internal.getBlockGroupName(allowance.groupKey), + 'amount': groupAmount, + 'minRequirement': minAllowed, + 'maxRequirement': maxAllowed + }); + } + } else + // For specific elementTypes: + if(allowance.elementTypeKey) { + + const amount = vm.entries.filter(entry => entry.$block.data.contentTypeKey === allowance.elementTypeKey).length; + + if(amount < minAllowed || (maxAllowed > 0 && amount > maxAllowed)) { + vm.invalidBlockTypes.push({ + 'key': allowance.elementTypeKey, + 'name': vm.locallyAvailableBlockTypes.find(blockType => blockType.blockConfigModel.contentElementTypeKey === allowance.elementTypeKey).elementTypeModel.name, + 'amount': amount, + 'minRequirement': minAllowed, + 'maxRequirement': maxAllowed + }); + } + } + }); + var isTypeRequirementGood = vm.invalidBlockTypes.length === 0; + vm.entriesForm.areaTypeRequirements.$setValidity("areaTypeRequirements", isTypeRequirementGood); + + + vm.invalidAmount = !isMinRequirementGood || !isMaxRequirementGood || !isTypeRequirementGood; + + $element.toggleClass("--invalid", vm.invalidAmount); + } + } + + vm.acceptBlock = function(contentTypeKey) { + return vm.blockEditorApi.internal.isElementTypeKeyAllowedAt(vm.parentBlock, vm.areaKey, contentTypeKey); + } + + vm.getLayoutEntryByIndex = function(index) { + return vm.blockEditorApi.internal.getLayoutEntryByIndex(vm.parentBlock, vm.areaKey, index); + } + + vm.showNotAllowed = function() { + vm.showNotAllowedUI = true; + $scope.$evalAsync(); + } + vm.hideNotAllowed = function() { + vm.showNotAllowedUI = false; + $scope.$evalAsync(); + } + + var revertIndicateDroppableTimeout; + vm.revertIndicateDroppable = function() { + revertIndicateDroppableTimeout = $timeout(() => { + vm.droppableIndication = false; + }, 2000); + } + vm.indicateDroppable = function() { + if (revertIndicateDroppableTimeout) { + $timeout.cancel(revertIndicateDroppableTimeout); + revertIndicateDroppableTimeout = null; + } + vm.droppableIndication = true; + $scope.$evalAsync(); + } + + function initializeSortable() { + + const gridLayoutContainerEl = $element[0].querySelector('.umb-block-grid__layout-container'); + var _lastIndicationContainerVM = null; + + var targetRect = null; + var relatedEl = null; + var ghostEl = null; + var ghostRect = null; + var dragX = 0; + var dragY = 0; + var dragOffsetX = 0; + + var ghostElIndicateForceLeft = null; + var ghostElIndicateForceRight = null; + + var approvedContainerEl = null; + + // Setup DOM method for communication between sortables: + gridLayoutContainerEl['Sortable:controller'] = () => { + return vm; + }; + + var nextSibling; + + // Borrowed concept from, its not identical as more has been implemented: https://github.com/SortableJS/angular-legacy-sortablejs/blob/master/angular-legacy-sortable.js + function _sync(evt) { + + const oldIndex = evt.oldIndex, + newIndex = evt.newIndex; + + // If not the same gridLayoutContainerEl, then test for transfer option: + if (gridLayoutContainerEl !== evt.from) { + const fromCtrl = evt.from['Sortable:controller'](); + const prevEntries = fromCtrl.entries; + const syncEntry = prevEntries[oldIndex]; + + // Perform the transfer: + + if (Sortable.active && Sortable.active.lastPullMode === 'clone') { + syncEntry = Utilities.copy(syncEntry); + prevEntries.splice(Sortable.utils.index(evt.clone, sortable.options.draggable), 0, prevEntries.splice(oldIndex, 1)[0]); + } + else { + prevEntries.splice(oldIndex, 1); + } + + vm.entries.splice(newIndex, 0, syncEntry); + + const contextColumns = vm.blockEditorApi.internal.getContextColumns(vm.parentBlock, vm.areaKey); + + // if colSpan is lower than contextColumns, and we do have some columnSpanOptions: + if (syncEntry.columnSpan < contextColumns && syncEntry.$block.config.columnSpanOptions.length > 0) { + // then check if the colSpan is a columnSpanOption, if NOT then reset to contextColumns. + const found = syncEntry.$block.config.columnSpanOptions.find(option => option.columnSpan === syncEntry.columnSpan); + if(!found) { + syncEntry.columnSpan = contextColumns; + } + } else { + syncEntry.columnSpan = contextColumns; + } + + if(syncEntry.columnSpan === contextColumns) { + // If we are full width, then reset forceLeft/right. + syncEntry.forceLeft = false; + syncEntry.forceRight = false; + } + + } + else { + vm.entries.splice(newIndex, 0, vm.entries.splice(oldIndex, 1)[0]); + } + } + + function _indication(contextVM, movingEl) { + + if(_lastIndicationContainerVM !== contextVM && _lastIndicationContainerVM !== null) { + _lastIndicationContainerVM.hideNotAllowed(); + _lastIndicationContainerVM.revertIndicateDroppable(); + } + _lastIndicationContainerVM = contextVM; + + if(contextVM.acceptBlock(movingEl.dataset.contentElementTypeKey) === true) { + _lastIndicationContainerVM.hideNotAllowed(); + _lastIndicationContainerVM.indicateDroppable();// This block is accepted to we will indicate a good drop. + return true; + } + + contextVM.showNotAllowed();// This block is not accepted to we will indicate that its not allowed. + + return false; + } + + function _moveGhostElement() { + + rqaId = null; + if(!ghostEl) { + return; + } + if(!approvedContainerEl) { + console.error("Cancel cause had no approvedContainerEl", approvedContainerEl) + return; + } + + ghostRect = ghostEl.getBoundingClientRect(); + + const insideGhost = isWithinRect(dragX, dragY, ghostRect); + if (insideGhost) { + return; + } + + var approvedContainerRect = approvedContainerEl.getBoundingClientRect(); + + const approvedContainerHasItems = approvedContainerEl.querySelector('.umb-block-grid__layout-item:not(.umb-block-grid__layout-item-placeholder)'); + if(!approvedContainerHasItems && isWithinRect(dragX, dragY, approvedContainerRect, 20) || approvedContainerHasItems && isWithinRect(dragX, dragY, approvedContainerRect, -10)) { + // we are good... + } else { + var parentContainer = approvedContainerEl.parentNode.closest('.umb-block-grid__layout-container'); + if(parentContainer) { + + if(parentContainer['Sortable:controller']().sortGroupIdentifier === vm.sortGroupIdentifier) { + approvedContainerEl = parentContainer; + approvedContainerRect = approvedContainerEl.getBoundingClientRect(); + } + } + } + + // gather elements on the same row. + let elementInSameRow = []; + const containerElements = Array.from(approvedContainerEl.children); + for (const el of containerElements) { + const elRect = el.getBoundingClientRect(); + // gather elements on the same row. + if(dragY >= elRect.top && dragY <= elRect.bottom && el !== ghostEl) { + elementInSameRow.push({el: el, rect:elRect}); + } + } + + let lastDistance = 99999; + let foundRelatedEl = null; + let placeAfter = false; + elementInSameRow.forEach( sameRow => { + const centerX = (sameRow.rect.left + (sameRow.rect.width*.5)); + let distance = Math.abs(dragX - centerX); + if(distance < lastDistance) { + foundRelatedEl = sameRow.el; + lastDistance = Math.abs(distance); + placeAfter = dragX > centerX; + } + }); + + if (foundRelatedEl === ghostEl) { + return; + } + + if (foundRelatedEl) { + + + let newIndex = containerElements.indexOf(foundRelatedEl); + + const foundRelatedElRect = foundRelatedEl.getBoundingClientRect(); + + // Ghost is already on same line and we are not hovering the related element? + const ghostCenterY = ghostRect.top + (ghostRect.height*.5); + const isInsideFoundRelated = isWithinRect(dragX, dragY, foundRelatedElRect, 0); + + + if (isInsideFoundRelated && foundRelatedEl.classList.contains('--has-areas')) { + // If mouse is on top of an area, then make that the new approvedContainer? + const blockView = foundRelatedEl.querySelector('.umb-block-grid__block--view'); + const subLayouts = blockView.querySelectorAll('.umb-block-grid__layout-container'); + for (const subLayout of subLayouts) { + const subLayoutRect = subLayout.getBoundingClientRect(); + const hasItems = subLayout.querySelector('.umb-block-grid__layout-item:not(.umb-block-grid__layout-item-placeholder)'); + // gather elements on the same row. + if(!hasItems && isWithinRect(dragX, dragY, subLayoutRect, 20) || hasItems && isWithinRect(dragX, dragY, subLayoutRect, -10)) { + + var subVm = subLayout['Sortable:controller'](); + if(subVm.sortGroupIdentifier === vm.sortGroupIdentifier) { + approvedContainerEl = subLayout; + _moveGhostElement(); + return; + } + } + } + } + + if (ghostCenterY > foundRelatedElRect.top && ghostCenterY < foundRelatedElRect.bottom && !isInsideFoundRelated) { + return; + } + + const containerVM = approvedContainerEl['Sortable:controller'](); + if(_indication(containerVM, ghostEl) === false) { + return; + } + + let verticalDirection = false; + if (ghostEl.dataset.forceLeft) { + placeAfter = true; + } else if (ghostEl.dataset.forceRight) { + placeAfter = true; + } else { + + // if the related element is forceLeft and we are in the left side, we will set vertical direction, to correct placeAfter. + if (foundRelatedEl.dataset.forceLeft && placeAfter === false) { + verticalDirection = true; + } else + // if the related element is forceRight and we are in the right side, we will set vertical direction, to correct placeAfter. + if (foundRelatedEl.dataset.forceRight && placeAfter === true) { + verticalDirection = true; + } else { + + // TODO: move calculations out so they can be persisted a bit longer? + //const approvedContainerRect = approvedContainerEl.getBoundingClientRect(); + const approvedContainerComputedStyles = getComputedStyle(approvedContainerEl); + const gridColumnNumber = parseInt(approvedContainerComputedStyles.getPropertyValue("--umb-block-grid--grid-columns"), 10); + + const relatedColumns = parseInt(foundRelatedEl.dataset.colSpan, 10); + const ghostColumns = parseInt(ghostEl.dataset.colSpan, 10); + + // Get grid template: + const approvedContainerGridColumns = approvedContainerComputedStyles.gridTemplateColumns.trim().split("px").map(x => Number(x)).filter(n => n > 0); + + // ensure all columns are there. + // This will also ensure handling non-css-grid mode, + // use container width divided by amount of columns( or the item width divided by its amount of columnSpan) + let amountOfColumnsInWeightMap = approvedContainerGridColumns.length; + const amountOfUnknownColumns = gridColumnNumber-amountOfColumnsInWeightMap; + if(amountOfUnknownColumns > 0) { + let accumulatedValue = getAccumulatedValueOfIndex(amountOfColumnsInWeightMap, approvedContainerGridColumns) || 0; + const layoutWidth = approvedContainerRect.width; + const missingColumnWidth = (layoutWidth-accumulatedValue)/amountOfUnknownColumns; + while(amountOfColumnsInWeightMap++ < gridColumnNumber) { + approvedContainerGridColumns.push(missingColumnWidth); + } + } + + + const relatedStartX = foundRelatedElRect.left - approvedContainerRect.left; + const relatedStartCol = Math.round(getInterpolatedIndexOfPositionInWeightMap(relatedStartX, approvedContainerGridColumns)); + + if(relatedStartCol + relatedColumns + ghostColumns > gridColumnNumber) { + verticalDirection = true; + } + } + } + if (verticalDirection) { + placeAfter = (dragY > foundRelatedElRect.top + (foundRelatedElRect.height*.5)); + } + + + const nextEl = containerElements[(placeAfter ? newIndex+1 : newIndex)]; + if (nextEl) { + approvedContainerEl.insertBefore(ghostEl, nextEl); + } else { + approvedContainerEl.appendChild(ghostEl); + } + + return; + } + + // If above or below container, we will go first or last. + const containerVM = approvedContainerEl['Sortable:controller'](); + if(_indication(containerVM, ghostEl) === false) { + return; + } + if(dragY < approvedContainerRect.top) { + const firstEl = containerElements[0]; + if (firstEl) { + approvedContainerEl.insertBefore(ghostEl, firstEl); + } else { + approvedContainerEl.appendChild(ghostEl); + } + } else if(dragY > approvedContainerRect.bottom) { + approvedContainerEl.appendChild(ghostEl); + } + } + + var rqaId = null + function _onDragMove(evt) { + + const clientX = (evt.touches ? evt.touches[0] : evt).clientX; + const clientY = (evt.touches ? evt.touches[1] : evt).clientY; + if(vm.movingLayoutEntry && targetRect && ghostRect && clientX !== 0 && clientY !== 0) { + + if(dragX === clientX && dragY === clientY) { + return; + } + dragX = clientX; + dragY = clientY; + + ghostRect = ghostEl.getBoundingClientRect(); + + const insideGhost = isWithinRect(dragX, dragY, ghostRect, 0); + + if (!insideGhost) { + if(rqaId === null) { + rqaId = requestAnimationFrame(_moveGhostElement); + } + } + + + if(vm.movingLayoutEntry.columnSpan !== vm.layoutColumnsInt) { + + const oldForceLeft = vm.movingLayoutEntry.forceLeft; + const oldForceRight = vm.movingLayoutEntry.forceRight; + + var newValue = (dragX < targetRect.left); + if(newValue !== oldForceLeft) { + vm.movingLayoutEntry.forceLeft = newValue; + if(oldForceRight) { + vm.movingLayoutEntry.forceRight = false; + if(ghostElIndicateForceRight) { + ghostEl.removeChild(ghostElIndicateForceRight); + ghostElIndicateForceRight = null; + } + } + vm.blockEditorApi.internal.setDirty(); + vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated + $scope.$evalAsync(); + + // Append element for indication, as angularJS lost connection: + if(newValue === true) { + ghostElIndicateForceLeft = document.createElement("div"); + ghostElIndicateForceLeft.className = "indicateForceLeft"; + ghostEl.appendChild(ghostElIndicateForceLeft); + } else if(ghostElIndicateForceLeft) { + ghostEl.removeChild(ghostElIndicateForceLeft); + ghostElIndicateForceLeft = null; + } + } + + newValue = (dragX > targetRect.right) && (vm.movingLayoutEntry.forceLeft !== true); + if(newValue !== oldForceRight) { + vm.movingLayoutEntry.forceRight = newValue; + vm.blockEditorApi.internal.setDirty(); + vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated + $scope.$evalAsync(); + + // Append element for indication, as angularJS lost connection: + if(newValue === true) { + ghostElIndicateForceRight = document.createElement("div"); + ghostElIndicateForceRight.className = "indicateForceRight"; + ghostEl.appendChild(ghostElIndicateForceRight); + } else if(ghostElIndicateForceRight) { + ghostEl.removeChild(ghostElIndicateForceRight); + ghostElIndicateForceRight = null; + } + } + } + } + } + + vm.sortGroupIdentifier = "BlockGridEditor_"+vm.blockEditorApi.internal.uniqueEditorKey; + + const sortable = Sortable.create(gridLayoutContainerEl, { + group: vm.sortGroupIdentifier, + sort: true, + animation: 0, + cancel: '', + draggable: ".umb-block-grid__layout-item", + ghostClass: "umb-block-grid__layout-item-placeholder", + swapThreshold: .4, + dragoverBubble: true, + emptyInsertThreshold: 40, + + scrollSensitivity: 50, + scrollSpeed: 16, + scroll: true, + forceAutoScrollFallback: true, + + onStart: function (evt) { + nextSibling = evt.from === evt.item.parentNode ? evt.item.nextSibling : evt.clone.nextSibling; + + var contextVM = vm; + if (gridLayoutContainerEl !== evt.to) { + contextVM = evt.to['Sortable:controller'](); + } + + approvedContainerEl = evt.to; + + const oldIndex = evt.oldIndex; + vm.movingLayoutEntry = contextVM.getLayoutEntryByIndex(oldIndex); + if(vm.movingLayoutEntry.forceLeft || vm.movingLayoutEntry.forceRight) { + // if one of these where true before, then we made a change here: + vm.blockEditorApi.internal.setDirty(); + } + vm.movingLayoutEntry.forceLeft = false; + vm.movingLayoutEntry.forceRight = false; + vm.movingLayoutEntry.$block.__scope.$evalAsync();// needed for the block to be updated + + ghostEl = evt.item; + + targetRect = evt.to.getBoundingClientRect(); + ghostRect = ghostEl.getBoundingClientRect(); + + const clientX = (evt.originalEvent.touches ? evt.originalEvent.touches[0] : evt.originalEvent).clientX; + dragOffsetX = clientX - ghostRect.left; + + window.addEventListener('drag', _onDragMove); + window.addEventListener('dragover', _onDragMove); + + document.documentElement.style.setProperty("--umb-block-grid--dragging-mode", 1); + + $scope.$evalAsync(); + }, + // Called by any change to the list (add / update / remove) + onMove: function (evt) { + relatedEl = evt.related; + targetRect = evt.to.getBoundingClientRect(); + ghostRect = evt.draggedRect; + + // Disable SortableJS from handling the drop, instead we will use our own. + return false; + }, + // When an change actually was made, after drop has occurred: + onSort: function (evt) { + vm.blockEditorApi.internal.setDirty(); + }, + + onAdd: function (evt) { + _sync(evt); + $scope.$evalAsync(); + }, + onUpdate: function (evt) { + _sync(evt); + $scope.$evalAsync(); + }, + onEnd: function(evt) { + if(rqaId !== null) { + cancelAnimationFrame(rqaId); + } + window.removeEventListener('drag', _onDragMove); + window.removeEventListener('dragover', _onDragMove); + + if(ghostElIndicateForceLeft) { + ghostEl.removeChild(ghostElIndicateForceLeft); + ghostElIndicateForceLeft = null; + } + if(ghostElIndicateForceRight) { + ghostEl.removeChild(ghostElIndicateForceRight); + ghostElIndicateForceRight = null; + } + + // ensure not-allowed indication is removed. + if(_lastIndicationContainerVM) { + _lastIndicationContainerVM.hideNotAllowed(); + _lastIndicationContainerVM.revertIndicateDroppable(); + _lastIndicationContainerVM = null; + } + + approvedContainerEl = null; + vm.movingLayoutEntry = null; + targetRect = null; + ghostRect = null; + ghostEl = null; + relatedEl = null; + } + }); + + $scope.$on('$destroy', function () { + sortable.destroy(); + for (const subscription of unsubscribe) { + subscription(); + } + }); + + }; + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js new file mode 100644 index 0000000000..22739d748c --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridentry.component.js @@ -0,0 +1,379 @@ +(function () { + "use strict"; + + + /** + * Helper method that takes a weight map and finds the index of the value. + * @param {number} position - the value to find the index of. + * @param {number[]} weights - array of numbers each representing the weight/length. + * @returns {number} - the index of the weight that contains the accumulated value + */ + function getInterpolatedIndexOfPositionInWeightMap(target, weights) { + const map = [0]; + weights.reduce((a, b, i) => { return map[i+1] = a+b; }, 0); + const foundValue = map.reduce((a, b) => { + let aDiff = Math.abs(a - target); + let bDiff = Math.abs(b - target); + + if (aDiff === bDiff) { + return a < b ? a : b; + } else { + return bDiff < aDiff ? b : a; + } + }) + const foundIndex = map.indexOf(foundValue); + const targetDiff = (target-foundValue); + let interpolatedIndex = foundIndex; + if (targetDiff < 0 && foundIndex === 0) { + // Don't adjust. + } else if (targetDiff > 0 && foundIndex === map.length-1) { + // Don't adjust. + } else { + const foundInterpolationWeight = weights[targetDiff >= 0 ? foundIndex : foundIndex-1]; + interpolatedIndex += foundInterpolationWeight === 0 ? interpolatedIndex : (targetDiff/foundInterpolationWeight) + } + return interpolatedIndex; + } + + function getAccumulatedValueOfIndex(index, weights) { + let i = 0, len = Math.min(index, weights.length), calc = 0; + while(i { + if (a.columnSpan > max) { + return b; + } + let aDiff = Math.abs(a.columnSpan - target); + let bDiff = Math.abs(b.columnSpan - target); + + if (aDiff === bDiff) { + return a.columnSpan < b.columnSpan ? a : b; + } else { + return bDiff < aDiff ? b : a; + } + }); + if(result) { + return result; + } + return max; + } + + + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridEntry + * @description + * renders each row for the block grid editor + */ + angular + .module("umbraco") + .component("umbBlockGridEntry", { + templateUrl: 'views/propertyeditors/blockgrid/umb-block-grid-entry.html', + controller: BlockGridEntryController, + controllerAs: "vm", + bindings: { + blockEditorApi: "<", + layoutColumns: "<", + layoutEntry: "<", + index: "<", + parentBlock: "<", + areaKey: "<", + propertyEditorForm: " { + vm.childDepth = parseInt(vm.depth) + 1; + })); + /** + * We want to only show the validation errors on the specific Block, not the parent blocks. + * So we need to avoid having a Block as the parent to the Block Form. + * Therefor we skip any parent blocks forms, and sets the parent form to the property editor. + */ + vm.$postLink = function() { + // If parent form is not the property editor form, then its another Block Forms and we will change it. + if(vm.blockForm.$$parentForm !== vm.propertyEditorForm) { + // Remove from parent block: + vm.blockForm.$$parentForm.$removeControl(vm.blockForm); + // Connect with property editor form: + vm.propertyEditorForm.$addControl(vm.blockForm); + } + } + vm.mouseOverArea = function(area) { + if(area.items.length > 0) { + vm.isHoveringArea = true; + } + } + vm.mouseLeaveArea = function() { + vm.isHoveringArea = false; + } + vm.toggleForceLeft = function() { + vm.layoutEntry.forceLeft = !vm.layoutEntry.forceLeft; + if(vm.layoutEntry.forceLeft) { + vm.layoutEntry.forceRight = false; + } + vm.blockEditorApi.internal.setDirty(); + } + vm.toggleForceRight = function() { + vm.layoutEntry.forceRight = !vm.layoutEntry.forceRight; + if(vm.layoutEntry.forceRight) { + vm.layoutEntry.forceLeft = false; + } + vm.blockEditorApi.internal.setDirty(); + } + + // Block sizing functionality: + let layoutContainer = null; + let gridColumns = null; + let gridRows = null; + let scaleBoxBackdropEl = null; + + + function getNewSpans(startX, startY, endX, endY) { + + const blockStartCol = Math.round(getInterpolatedIndexOfPositionInWeightMap(startX, gridColumns)); + const blockStartRow = Math.round(getInterpolatedIndexOfPositionInWeightMap(startY, gridRows)); + const blockEndCol = getInterpolatedIndexOfPositionInWeightMap(endX, gridColumns); + const blockEndRow = getInterpolatedIndexOfPositionInWeightMap(endY, gridRows); + + let newColumnSpan = Math.max(blockEndCol-blockStartCol, 1); + + // Find nearest allowed Column: + newColumnSpan = closestColumnSpanOption(newColumnSpan , vm.layoutEntry.$block.config.columnSpanOptions, gridColumns.length - blockStartCol).columnSpan; + + let newRowSpan = Math.round(Math.max(blockEndRow-blockStartRow, vm.layoutEntry.$block.config.rowMinSpan || 1)); + if(vm.layoutEntry.$block.config.rowMaxSpan != null) { + newRowSpan = Math.min(newRowSpan, vm.layoutEntry.$block.config.rowMaxSpan); + } + + return {'columnSpan': newColumnSpan, 'rowSpan': newRowSpan, 'startCol': blockStartCol, 'startRow': blockStartRow}; + } + + function updateGridLayoutData(layoutContainerRect, layoutItemRect) { + + const computedStyles = window.getComputedStyle(layoutContainer); + + gridColumns = computedStyles.gridTemplateColumns.trim().split("px").map(x => Number(x)); + gridRows = computedStyles.gridTemplateRows.trim().split("px").map(x => Number(x)); + + // remove empties: + gridColumns = gridColumns.filter(n => n > 0); + gridRows = gridRows.filter(n => n > 0); + + // ensure all columns are there. + // This will also ensure handling non-css-grid mode, + // use container width divided by amount of columns( or the item width divided by its amount of columnSpan) + let amountOfColumnsInWeightMap = gridColumns.length; + let gridColumnNumber = parseInt(computedStyles.getPropertyValue('--umb-block-grid--grid-columns')); + const amountOfUnknownColumns = gridColumnNumber-amountOfColumnsInWeightMap; + if(amountOfUnknownColumns > 0) { + let accumulatedValue = getAccumulatedValueOfIndex(amountOfColumnsInWeightMap, gridColumns) || 0; + const layoutWidth = layoutContainerRect.width; + const missingColumnWidth = (layoutWidth-accumulatedValue)/amountOfUnknownColumns; + while(amountOfColumnsInWeightMap++ < gridColumnNumber) { + gridColumns.push(missingColumnWidth); + } + } + + + // Handle non css grid mode for Rows: + // use item height divided by rowSpan to identify row heights. + if(gridRows.length === 0) { + // Push its own height twice, to give something to scale with. + gridRows.push(layoutItemRect.top - layoutContainerRect.top); + + let i = 0; + const itemSingleRowHeight = layoutItemRect.height; + while(i++ < vm.layoutEntry.rowSpan) { + gridRows.push(itemSingleRowHeight); + } + } + + // add a few extra rows, so there is something to extend too. + // Add extra options for the ability to extend beyond current content: + gridRows.push(50); + gridRows.push(50); + gridRows.push(50); + } + + vm.scaleHandlerMouseDown = function($event) { + $event.originalEvent.preventDefault(); + vm.isScaleMode = true; + + window.addEventListener('mousemove', vm.onMouseMove); + window.addEventListener('mouseup', vm.onMouseUp); + window.addEventListener('mouseleave', vm.onMouseUp); + + + layoutContainer = $element[0].closest('.umb-block-grid__layout-container'); + if(!layoutContainer) { + console.error($element[0], 'could not find parent layout-container'); + } + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + updateGridLayoutData(layoutContainerRect, layoutItemRect); + + + scaleBoxBackdropEl = document.createElement('div'); + scaleBoxBackdropEl.className = 'umb-block-grid__scalebox-backdrop'; + layoutContainer.appendChild(scaleBoxBackdropEl); + + } + vm.onMouseMove = function(e) { + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + updateGridLayoutData(layoutContainerRect, layoutItemRect); + + + const startX = layoutItemRect.left - layoutContainerRect.left; + const startY = layoutItemRect.top - layoutContainerRect.top; + const endX = e.clientX - layoutContainerRect.left; + const endY = e.clientY - layoutContainerRect.top; + + const newSpans = getNewSpans(startX, startY, endX, endY); + + // update as we go: + vm.layoutEntry.columnSpan = newSpans.columnSpan; + vm.layoutEntry.rowSpan = newSpans.rowSpan; + + $scope.$evalAsync(); + } + + vm.onMouseUp = function(e) { + + vm.isScaleMode = false; + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + + const startX = layoutItemRect.left - layoutContainerRect.left; + const startY = layoutItemRect.top - layoutContainerRect.top; + const endX = e.clientX - layoutContainerRect.left; + const endY = e.clientY - layoutContainerRect.top; + + const newSpans = getNewSpans(startX, startY, endX, endY); + + // Remove listeners: + window.removeEventListener('mousemove', vm.onMouseMove); + window.removeEventListener('mouseup', vm.onMouseUp); + window.removeEventListener('mouseleave', vm.onMouseUp); + + layoutContainer.removeChild(scaleBoxBackdropEl); + + // Clean up variables: + layoutContainer = null; + gridColumns = null; + gridRows = null; + scaleBoxBackdropEl = null; + + // Update block size: + vm.layoutEntry.columnSpan = newSpans.columnSpan; + vm.layoutEntry.rowSpan = newSpans.rowSpan; + vm.blockEditorApi.internal.setDirty(); + $scope.$evalAsync(); + } + + + + vm.scaleHandlerKeyUp = function($event) { + + let addCol = 0; + let addRow = 0; + + switch ($event.originalEvent.key) { + case 'ArrowUp': + addRow = -1; + break; + case 'ArrowDown': + addRow = 1; + break; + case 'ArrowLeft': + addCol = -1; + break; + case 'ArrowRight': + addCol = 1; + break; + } + + const newColumnSpan = Math.max(vm.layoutEntry.columnSpan + addCol, 1); + + vm.layoutEntry.columnSpan = closestColumnSpanOption(newColumnSpan, vm.layoutEntry.$block.config.columnSpanOptions, gridColumns.length).columnSpan; + let newRowSpan = Math.max(vm.layoutEntry.rowSpan + addRow, vm.layoutEntry.$block.config.rowMinSpan || 1); + if(vm.layoutEntry.$block.config.rowMaxSpan != null) { + newRowSpan = Math.min(newRowSpan, vm.layoutEntry.$block.config.rowMaxSpan); + } + vm.layoutEntry.rowSpan = newRowSpan; + + vm.blockEditorApi.internal.setDirty(); + $event.originalEvent.stopPropagation(); + } + + + vm.clickInlineCreateAfter = function($event) { + if(vm.hideInlineCreateAfter === false) { + vm.blockEditorApi.requestShowCreate(vm.parentBlock, vm.areaKey, vm.index+1, $event, {'fitInRow': true}); + } + } + vm.mouseOverInlineCreateAfter = function() { + + layoutContainer = $element[0].closest('.umb-block-grid__layout-container'); + if(!layoutContainer) { + return; + } + + const layoutContainerRect = layoutContainer.getBoundingClientRect(); + const layoutItemRect = $element[0].getBoundingClientRect(); + + if(layoutItemRect.right > layoutContainerRect.right - 5) { + vm.hideInlineCreateAfter = true; + return; + } + + vm.hideInlineCreateAfter = false; + vm.blockEditorApi.internal.showAreaHighlight(vm.parentBlock, vm.areaKey); + + } + + $scope.$on("$destroy", function () { + for (const subscription of unsubscribe) { + subscription(); + } + }); + + } + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js new file mode 100644 index 0000000000..130150a3ae --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbblockgridroot.component.js @@ -0,0 +1,64 @@ +(function () { + "use strict"; + + /** + * @ngdoc directive + * @name umbraco.directives.directive:umbBlockGridRoot + * @description + * The component to render the view for a block grid in the Property Editor. + * Creates a ShadowDom for the layout. + */ + + angular + .module("umbraco") + .component("umbBlockGridRoot", { + controller: umbBlockGridRootController, + controllerAs: "vm", + bindings: { + gridColumns: "@", + createLabel: "@", + stylesheet: "@", + blockEditorApi: "<", + propertyEditorForm: " + {{vm.stylesheet ? "@import '"+vm.stylesheet+"';" : ""}} + @import 'assets/css/blockgridui.css'; + :host { + --umb-block-grid--grid-columns: ${vm.gridColumns}; + } + +
+ + +
+ `; + $compile(shadowRoot)($scope); + + }; + } + + +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css new file mode 100644 index 0000000000..94974a9111 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout-flexbox.css @@ -0,0 +1,36 @@ +/** Example of how a grid layout stylehseet could be done with Flex box: */ + + +.umb-block-grid__layout-container { + position: relative; + display: flex; + flex-wrap: wrap; +} +.umb-block-grid__layout-item { + --umb-block-grid__layout-item-calc: calc(var(--umb-block-grid--item-column-span) / var(--umb-block-grid--grid-columns)); + width: calc(var(--umb-block-grid__layout-item-calc) * 100%); +} +.umb-block-grid__layout-item[data-force-left] { + align-self: flex-start; +} +.umb-block-grid__layout-item[data-force-left]::before { + content: ''; + flex-basis: 100%; + height: 0; +} +.umb-block-grid__layout-item[data-force-right] { + margin-left: auto; + align-self: flex-end; +} + + +.umb-block-grid__area-container, .umb-block-grid__block--view::part(area-container) { + position: relative; + display: flex; + flex-wrap: wrap; + width: 100%; +} +.umb-block-grid__area { + --umb-block-grid__area-calc: calc(var(--umb-block-grid--area-column-span) / var(--umb-block-grid--area-grid-columns, 1)); + width: calc(var(--umb-block-grid__area-calc) * 100%); +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css new file mode 100644 index 0000000000..91cb751f58 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blockgrid/umbraco-blockgridlayout.css @@ -0,0 +1,46 @@ +.umb-block-grid__layout-container { + position: relative; + display: grid; + grid-template-columns: repeat(var(--umb-block-grid--grid-columns, 1), minmax(0, 1fr)); + grid-gap: 0px; + grid-auto-flow: row; + grid-auto-rows: minmax(50px, min-content); +} +.umb-block-grid__layout-item { + position: relative; + /* For small devices we scale columnSpan by three, to make everything bigger than 1/3 take full width: */ + grid-column-end: span min(calc(var(--umb-block-grid--item-column-span, 1) * 3), var(--umb-block-grid--grid-columns)); + grid-row: span var(--umb-block-grid--item-row-span, 1); +} +.umb-block-grid__layout-item[data-force-left] { + grid-column-start: 1; +} +.umb-block-grid__layout-item[data-force-right] { + grid-column-start: calc(1 + var(--umb-block-grid--grid-columns) - var(--umb-block-grid--item-column-span)); +} + + +.umb-block-grid__area-container, .umb-block-grid__block--view::part(area-container) { + position: relative; + display: grid; + grid-template-columns: repeat(var(--umb-block-grid--area-grid-columns, var(--umb-block-grid--grid-columns, 1)), minmax(0, 1fr)); + grid-gap: 0px; + grid-auto-flow: row; + grid-auto-rows: minmax(50px, min-content); + width: 100%; +} +.umb-block-grid__area { + position: relative; + /* For small devices we scale columnSpan by three, to make everything bigger than 1/3 take full width: */ + grid-column-end: span min(calc(var(--umb-block-grid--item-column-span, 1) * 3), var(--umb-block-grid--grid-columns)); + grid-row: span var(--umb-block-grid--area-row-span, 1); +} + +@media (min-width:1024px) { + .umb-block-grid__layout-item { + grid-column-end: span var(--umb-block-grid--item-column-span, 1); + } + .umb-block-grid__area { + grid-column-end: span var(--umb-block-grid--area-column-span, 1); + } +} \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html index 67e557bff4..8f6042613d 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/blocklist.html @@ -1,4 +1,4 @@ - + \ No newline at end of file diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less index 8d063c77fb..d2d875aa94 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/prevalue/blocklist.blockconfiguration.overlay.less @@ -16,6 +16,9 @@ opacity: 0; transition: opacity 120ms; } + .controls:hover &, + .controls:focus &, + .controls:focus-within &, .control-group:hover, .control-group:focus, .control-group:focus-within { diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js index c71773a04b..ca9efe0382 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/blocklist/umbBlockListPropertyEditor.component.js @@ -586,7 +586,7 @@ var blockObject = vm.layout[createIndex].$block; if (inlineEditing === true) { blockObject.activate(); - } else if (inlineEditing === false && blockObject.hideContentInOverlay !== true) { + } else if (inlineEditing === false && blockObject.hideContentInOverlay !== true && blockObject.content.variants[0].tabs[0]?.properties.length > 0) { vm.options.createFlow = true; blockObject.edit(); vm.options.createFlow = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js index bcfbda4759..25ca237bb9 100644 --- a/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js +++ b/src/Umbraco.Web.UI.Client/src/views/propertyeditors/mediapicker3/umbMediaPicker3PropertyEditor.component.js @@ -463,7 +463,7 @@ } } - unsubscribe.push($scope.$watch(() => vm.model.value.length, onAmountOfMediaChanged)); + unsubscribe.push($scope.$watch(() => vm.model.value?.length || 0, onAmountOfMediaChanged)); $scope.$on("$destroy", function () { for (const subscription of unsubscribe) { diff --git a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js index 6619754315..89101d2f1d 100644 --- a/src/Umbraco.Web.UI.Client/test/config/karma.conf.js +++ b/src/Umbraco.Web.UI.Client/test/config/karma.conf.js @@ -29,6 +29,7 @@ module.exports = function (config) { 'lib/umbraco/Extensions.js', 'node_modules/lazyload-js/LazyLoad.min.js', 'node_modules/angular-dynamic-locale/dist/tmhDynamicLocale.min.js', + 'node_modules/sortablejs/Sortable.min.js', //app bootstrap and loader 'test/config/app.unit.js', diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index b5767d5f81..f8650658b5 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -9,6 +9,19 @@ + + + + + + + + + + + + + @@ -66,5 +79,9 @@ + + + + diff --git a/src/Umbraco.Web.UI/package-lock.json b/src/Umbraco.Web.UI/package-lock.json new file mode 100644 index 0000000000..daa5a8007f --- /dev/null +++ b/src/Umbraco.Web.UI/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "Umbraco.Web.UI", + "lockfileVersion": 2, + "requires": true, + "packages": {} +} diff --git a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs index 13a606b28b..2e44bae31f 100644 --- a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs +++ b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs @@ -391,6 +391,28 @@ public static class HtmlHelperRenderExtensions string action, string controllerName, object? additionalRouteVals, + IDictionary htmlAttributes) => html.BeginUmbracoForm(action, controllerName, additionalRouteVals, htmlAttributes, FormMethod.Post); + + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller + /// + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + string controllerName, + object? additionalRouteVals, + IDictionary htmlAttributes, + FormMethod method) => html.BeginUmbracoForm(action, controllerName, additionalRouteVals, null, htmlAttributes, method); + + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller + /// + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + string controllerName, + object? additionalRouteVals, + bool? antiforgery, IDictionary htmlAttributes, FormMethod method) { @@ -418,44 +440,7 @@ public static class HtmlHelperRenderExtensions nameof(controllerName)); } - return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, htmlAttributes, method); - } - - /// - /// Helper method to create a new form to execute in the Umbraco request pipeline against a locally declared controller - /// - public static MvcForm BeginUmbracoForm( - this IHtmlHelper html, - string action, - string controllerName, - object? additionalRouteVals, - IDictionary htmlAttributes) - { - if (action == null) - { - throw new ArgumentNullException(nameof(action)); - } - - if (string.IsNullOrWhiteSpace(action)) - { - throw new ArgumentException( - "Value can't be empty or consist only of white-space characters.", - nameof(action)); - } - - if (controllerName == null) - { - throw new ArgumentNullException(nameof(controllerName)); - } - - if (string.IsNullOrWhiteSpace(controllerName)) - { - throw new ArgumentException( - "Value can't be empty or consist only of white-space characters.", - nameof(controllerName)); - } - - return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, htmlAttributes); + return html.BeginUmbracoForm(action, controllerName, string.Empty, additionalRouteVals, antiforgery, htmlAttributes, method); } /// @@ -477,6 +462,13 @@ public static class HtmlHelperRenderExtensions public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, FormMethod method) where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), method); + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin + /// + /// The type + public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, FormMethod method, bool? antiforgery) + where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), null, antiforgery, new Dictionary(), method); + /// /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin /// @@ -524,6 +516,21 @@ public static class HtmlHelperRenderExtensions public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, object additionalRouteVals) where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), additionalRouteVals); + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin + /// + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + Type surfaceType, + object additionalRouteVals, + object htmlAttributes) => + html.BeginUmbracoForm( + action, + surfaceType, + additionalRouteVals, + HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)); + /// /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin /// @@ -549,12 +556,16 @@ public static class HtmlHelperRenderExtensions string action, Type surfaceType, object additionalRouteVals, - object htmlAttributes) => + object htmlAttributes, + FormMethod method, + bool? antiforgery) => html.BeginUmbracoForm( action, surfaceType, additionalRouteVals, - HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes)); + antiforgery, + HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes), + method); /// /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin @@ -569,6 +580,20 @@ public static class HtmlHelperRenderExtensions where T : SurfaceController => html.BeginUmbracoForm(action, typeof(T), additionalRouteVals, htmlAttributes, method); + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin + /// + /// The type + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + object additionalRouteVals, + object htmlAttributes, + FormMethod method, + bool? antiforgery) + where T : SurfaceController => + html.BeginUmbracoForm(action, typeof(T), additionalRouteVals, htmlAttributes, method, antiforgery); + /// /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin /// @@ -588,6 +613,18 @@ public static class HtmlHelperRenderExtensions Type surfaceType, object? additionalRouteVals, IDictionary htmlAttributes, + FormMethod method) => html.BeginUmbracoForm(action, surfaceType, additionalRouteVals, null, htmlAttributes, method); + + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin + /// + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + Type surfaceType, + object? additionalRouteVals, + bool? antiforgery, + IDictionary htmlAttributes, FormMethod method) { if (action == null) @@ -630,6 +667,7 @@ public static class HtmlHelperRenderExtensions metaData.ControllerName, area!, additionalRouteVals, + antiforgery, htmlAttributes, method); } @@ -673,7 +711,7 @@ public static class HtmlHelperRenderExtensions /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin /// public static MvcForm BeginUmbracoForm(this IHtmlHelper html, string action, string controllerName, string area, FormMethod method) - => html.BeginUmbracoForm(action, controllerName, area, null, new Dictionary(), method); + => html.BeginUmbracoForm(action, controllerName, area, additionalRouteVals: null, new Dictionary(), method); /// /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin @@ -692,6 +730,20 @@ public static class HtmlHelperRenderExtensions object? additionalRouteVals, IDictionary htmlAttributes, FormMethod method) + => html.BeginUmbracoForm(action, controllerName, area, additionalRouteVals, null, htmlAttributes, method); + + /// + /// Helper method to create a new form to execute in the Umbraco request pipeline to a surface controller plugin + /// + public static MvcForm BeginUmbracoForm( + this IHtmlHelper html, + string action, + string? controllerName, + string area, + object? additionalRouteVals, + bool? antiforgery, + IDictionary htmlAttributes, + FormMethod method) { if (action == null) { @@ -718,7 +770,7 @@ public static class HtmlHelperRenderExtensions IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService(html); IUmbracoContext umbracoContext = umbracoContextAccessor.GetRequiredUmbracoContext(); var formAction = umbracoContext.OriginalRequestUrl.PathAndQuery; - return html.RenderForm(formAction, method, htmlAttributes, controllerName, action, area, additionalRouteVals); + return html.RenderForm(formAction, method, htmlAttributes, controllerName, action, area, antiforgery, additionalRouteVals); } /// @@ -753,6 +805,7 @@ public static class HtmlHelperRenderExtensions string surfaceController, string surfaceAction, string area, + bool? antiforgery = null, object? additionalRouteVals = null) { // ensure that the multipart/form-data is added to the HTML attributes @@ -781,7 +834,7 @@ public static class HtmlHelperRenderExtensions HtmlEncoder htmlEncoder = GetRequiredService(htmlHelper); // new UmbracoForm: - var theForm = new UmbracoForm(htmlHelper.ViewContext, htmlEncoder, surfaceController, surfaceAction, area, additionalRouteVals); + var theForm = new UmbracoForm(htmlHelper.ViewContext, htmlEncoder, surfaceController, surfaceAction, area, antiforgery, additionalRouteVals); if (traditionalJavascriptEnabled) { @@ -798,6 +851,7 @@ public static class HtmlHelperRenderExtensions { private readonly string _surfaceControllerInput; private readonly ViewContext _viewContext; + private readonly bool? _antiforgery; /// /// Initializes a new instance of the class. @@ -808,10 +862,12 @@ public static class HtmlHelperRenderExtensions string controllerName, string controllerAction, string area, + bool? antiforgery = null, object? additionalRouteVals = null) : base(viewContext, htmlEncoder) { _viewContext = viewContext; + _antiforgery = antiforgery; _surfaceControllerInput = GetSurfaceControllerHiddenInput( GetRequiredService(viewContext), controllerName, @@ -822,10 +878,13 @@ public static class HtmlHelperRenderExtensions protected override void GenerateEndForm() { - // Always output an anti-forgery token - IAntiforgery antiforgery = _viewContext.HttpContext.RequestServices.GetRequiredService(); - IHtmlContent antiforgeryHtml = antiforgery.GetHtml(_viewContext.HttpContext); - _viewContext.Writer.Write(antiforgeryHtml.ToHtmlString()); + // Always output an anti-forgery token unless explicitly requested to omit. + if (!_antiforgery.HasValue || _antiforgery.Value) + { + IAntiforgery antiforgery = _viewContext.HttpContext.RequestServices.GetRequiredService(); + IHtmlContent antiforgeryHtml = antiforgery.GetHtml(_viewContext.HttpContext); + _viewContext.Writer.Write(antiforgeryHtml.ToHtmlString()); + } // write out the hidden surface form routes _viewContext.Writer.Write(_surfaceControllerInput); diff --git a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj index e98070719d..fb9b051667 100644 --- a/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj +++ b/src/Umbraco.Web.Website/Umbraco.Web.Website.csproj @@ -3,6 +3,7 @@ Umbraco.Cms.Web.Website Umbraco CMS - Web - Website Contains the website assembly needed to run the frontend of Umbraco CMS. + Library Umbraco.Cms.Web.Website diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 62031665f7..9768d3ee8e 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -3,6 +3,7 @@ + annotations diff --git a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker index e3f723c137..97777a0865 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker +++ b/tests/Umbraco.Tests.AcceptanceTest/misc/umbraco-linux.docker @@ -6,6 +6,8 @@ FROM mcr.microsoft.com/dotnet/nightly/sdk:7.0 AS build COPY nuget.config . +COPY nuget.config . + WORKDIR /nupkg COPY nupkg . diff --git a/tests/Umbraco.Tests.AcceptanceTest/package-lock.json b/tests/Umbraco.Tests.AcceptanceTest/package-lock.json index 72f6c5904e..70229528bb 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": "^1.0.0", - "@umbraco/playwright-testhelpers": "^1.0.1", + "@umbraco/playwright-testhelpers": "^1.0.2", "camelize": "^1.0.0", "dotenv": "^16.0.2", "faker": "^4.1.0", @@ -101,11 +101,11 @@ } }, "node_modules/@umbraco/playwright-testhelpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.1.tgz", - "integrity": "sha512-o3UnVpIlwd9KMKp5Hnv31cUBCkzzIagFY2quQsMFeVfaKXr7Ku1+3egArB9S3bwQhz3aan0jzlmwIp9D9r8vxg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.2.tgz", + "integrity": "sha512-j1y6YRq2Rg5AXyYk/304P2rTrDCLU7Sz67/MMfkPBHSvadjdof7EW8649Aio29xGAg1YAR4y+Zeyw6XnM35ZkA==", "dependencies": { - "@umbraco/playwright-models": "^5.0.0", + "@umbraco/json-models-builders": "^1.0.0", "camelize": "^1.0.0", "faker": "^4.1.0", "form-data": "^4.0.0", @@ -113,15 +113,6 @@ "xhr2": "^0.2.1" } }, - "node_modules/@umbraco/playwright-testhelpers/node_modules/@umbraco/playwright-models": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-models/-/playwright-models-5.0.0.tgz", - "integrity": "sha512-HOf81JzlGysH9MoZTOH77jjHBEjveTMcxQRpyIfXfQmjdOar6nrEv5MPBMXwgiizLwnkhQBFkRuzKA/YASQnAg==", - "dependencies": { - "camelize": "^1.0.0", - "faker": "^4.1.0" - } - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -915,27 +906,16 @@ } }, "@umbraco/playwright-testhelpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.1.tgz", - "integrity": "sha512-o3UnVpIlwd9KMKp5Hnv31cUBCkzzIagFY2quQsMFeVfaKXr7Ku1+3egArB9S3bwQhz3aan0jzlmwIp9D9r8vxg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@umbraco/playwright-testhelpers/-/playwright-testhelpers-1.0.2.tgz", + "integrity": "sha512-j1y6YRq2Rg5AXyYk/304P2rTrDCLU7Sz67/MMfkPBHSvadjdof7EW8649Aio29xGAg1YAR4y+Zeyw6XnM35ZkA==", "requires": { - "@umbraco/playwright-models": "^5.0.0", + "@umbraco/json-models-builders": "^1.0.0", "camelize": "^1.0.0", "faker": "^4.1.0", "form-data": "^4.0.0", "node-fetch": "^2.6.7", "xhr2": "^0.2.1" - }, - "dependencies": { - "@umbraco/playwright-models": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@umbraco/playwright-models/-/playwright-models-5.0.0.tgz", - "integrity": "sha512-HOf81JzlGysH9MoZTOH77jjHBEjveTMcxQRpyIfXfQmjdOar6nrEv5MPBMXwgiizLwnkhQBFkRuzKA/YASQnAg==", - "requires": { - "camelize": "^1.0.0", - "faker": "^4.1.0" - } - } } }, "aggregate-error": { diff --git a/tests/Umbraco.Tests.AcceptanceTest/package.json b/tests/Umbraco.Tests.AcceptanceTest/package.json index b48fafb781..8996b7dbaa 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/package.json +++ b/tests/Umbraco.Tests.AcceptanceTest/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@umbraco/json-models-builders": "^1.0.0", - "@umbraco/playwright-testhelpers": "^1.0.1", + "@umbraco/playwright-testhelpers": "^1.0.2", "camelize": "^1.0.0", "faker": "^4.1.0", "form-data": "^4.0.0", diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts new file mode 100644 index 0000000000..1da0c016f0 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/DataTypes/dataTypes.spec.ts @@ -0,0 +1,185 @@ +import {AliasHelper, ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {expect} from "@playwright/test"; +import { + ApprovedColorPickerDataTypeBuilder, + DocumentTypeBuilder, + TextBoxDataTypeBuilder +} from "@umbraco/json-models-builders"; + +test.describe('DataTypes', () => { + + test.beforeEach(async ({page, umbracoApi}) => { + await umbracoApi.login(); + }); + + test('Tests Approved Colors', async ({page, umbracoApi, umbracoUi}) => { + const name = 'Approved Colour Test'; + const alias = AliasHelper.toAlias(name); + + await umbracoApi.documentTypes.ensureNameNotExists(name); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.dataTypes.ensureNameNotExists(name); + await umbracoApi.templates.ensureNameNotExists(name); + + const pickerDataType = new ApprovedColorPickerDataTypeBuilder() + .withName(name) + .withPrevalues(['000000', 'FF0000']) + .build() + await umbracoApi.content.createDocTypeWithContent(name, alias, pickerDataType); + + // This is an ugly wait, but we have to wait for cache to rebuild + await page.waitForTimeout(5000); + + // Editing template with some content + await umbracoApi.templates.edit(name, + '@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage' + + '\n@{' + + '\n Layout = null;' + + '\n}' + + '\n

Lorem ipsum dolor sit amet

'); + + // Enter content + await umbracoUi.refreshContentTree(); + await umbracoUi.clickElement(umbracoUi.getTreeItem("content", [name])); + + // Pick a colour + await page.locator('.btn-000000').click(); + + // Save + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await umbracoUi.isSuccessNotificationVisible(); + + // Assert + const expected = `

Lorem ipsum dolor sit amet

`; + await expect(umbracoApi.content.verifyRenderedContent('/', expected, true)).toBeTruthy(); + await expect(await page.locator('.umb-button__overlay')).not.toBeVisible(); + + // Pick another colour to verify both work + await page.locator('.btn-FF0000').click(); + + // Save + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await umbracoUi.isSuccessNotificationVisible(); + await expect(await page.locator('.umb-button__overlay')).not.toBeVisible(); + + // Assert + const expected2 = '

Lorem ipsum dolor sit amet

'; + await expect(await umbracoApi.content.verifyRenderedContent('/', expected2, true)).toBeTruthy(); + + // Clean + await umbracoApi.documentTypes.ensureNameNotExists(name); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.dataTypes.ensureNameNotExists(name); + await umbracoApi.templates.ensureNameNotExists(name); + }); + + test('Tests Textbox Maxlength', async ({page, umbracoApi, umbracoUi}) => { + const name = 'Textbox Maxlength Test'; + const alias = AliasHelper.toAlias(name); + + await umbracoApi.documentTypes.ensureNameNotExists(name); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.dataTypes.ensureNameNotExists(name); + await umbracoApi.templates.ensureNameNotExists(name); + + const textBoxDataType = new TextBoxDataTypeBuilder() + .withName(name) + .withMaxChars(10) + .build() + await umbracoApi.content.createDocTypeWithContent(name, alias, textBoxDataType); + + // Needs to wait for content to be created. + await page.waitForTimeout(1000); + await umbracoUi.refreshContentTree(); + + // Enter content + await umbracoUi.clickElement(umbracoUi.getTreeItem('content', [name])); + await page.locator('input[name="textbox"]').type('12345678'); + await expect(await page.locator('localize[key="textbox_characters_left"]')).not.toBeVisible(); + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await umbracoUi.isSuccessNotificationVisible(); + + // Add char and assert helptext appears - no publish to save time & has been asserted above & below + await page.locator('input[name="textbox"]').type('9'); + await expect(page.locator('localize[key="textbox_characters_left"]', {hasText: "characters left"}).first()).toBeVisible(); + await expect(await umbracoUi.getErrorNotification()).not.toBeVisible(); + + // Add char and assert errortext appears and can't save + await page.locator('input[name="textbox"]').type('10'); // 1 char over max + await expect(page.locator('localize[key="textbox_characters_exceed"]', {hasText: 'too many'}).first()).toBeVisible(); + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await expect(await page.locator('.property-error')).toBeVisible(); + + // Clean + await umbracoApi.documentTypes.ensureNameNotExists(name); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.dataTypes.ensureNameNotExists(name); + await umbracoApi.templates.ensureNameNotExists(name); + }); + + test('Test Url Picker', async ({page, umbracoApi, umbracoUi}) => { + + const urlPickerDocTypeName = 'Url Picker Test'; + const pickerDocTypeAlias = AliasHelper.toAlias(urlPickerDocTypeName); + + await umbracoApi.documentTypes.ensureNameNotExists(urlPickerDocTypeName); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.templates.ensureNameNotExists(urlPickerDocTypeName); + + const pickerDocType = new DocumentTypeBuilder() + .withName(urlPickerDocTypeName) + .withAlias(pickerDocTypeAlias) + .withAllowAsRoot(true) + .withDefaultTemplate(pickerDocTypeAlias) + .addGroup() + .withName('ContentPickerGroup') + .addUrlPickerProperty() + .withAlias('picker') + .done() + .done() + .build(); + await umbracoApi.documentTypes.save(pickerDocType); + + await umbracoApi.templates.edit(urlPickerDocTypeName, '@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage' + + '\n@{' + + '\n Layout = null;' + + '\n}' + + '\n@foreach(var link in @Model.Picker)' + + '\n{' + + '\n @link.Name' + + '\n}'); + + // Create content with url picker + await page.locator('.umb-tree-root').click({button: "right"}); + await page.locator('[data-element="action-create"]').click(); + await page.locator('[data-element="action-create-' + pickerDocTypeAlias + '"] > .umb-action-link').click(); + + // Fill out content + await umbracoUi.setEditorHeaderName('UrlPickerContent'); + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await umbracoUi.isSuccessNotificationVisible(); + await page.locator('.umb-node-preview-add').click(); + + // Should really try and find a better way to do this, but umbracoTreeItem tries to click the content pane in the background + await page.locator('#treePicker >> [data-element="tree-item-UrlPickerContent"]').click(); + await page.locator('.umb-editor-footer-content__right-side > [button-style="success"] > .umb-button > .btn > .umb-button__content').click(); + await expect(await page.locator('.umb-node-preview__name').first()).toBeVisible(); + + // Save and publish + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.saveAndPublish)); + await umbracoUi.isSuccessNotificationVisible(); + + // Assert + await expect(await umbracoUi.getErrorNotification()).not.toBeVisible(); + + // Testing if the edits match the expected results + const expected = 'UrlPickerContent'; + await expect(await umbracoApi.content.verifyRenderedContent('/', expected, true)).toBeTruthy(); + + // Clean + await umbracoApi.documentTypes.ensureNameNotExists(urlPickerDocTypeName); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.templates.ensureNameNotExists(urlPickerDocTypeName); + }); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Packages/packages.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Packages/packages.spec.ts index 9677418ae2..b0bb06e2a8 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Packages/packages.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Packages/packages.spec.ts @@ -7,7 +7,6 @@ test.describe('Packages', () => { const packageName = "TestPackage"; const rootDocTypeName = "Test document type"; const nodeName = "1) Home"; - test.beforeEach(async ({page, umbracoApi}) => { // TODO: REMOVE THIS WHEN SQLITE IS FIXED // Wait so we don't bombard the API @@ -46,9 +45,60 @@ test.describe('Packages', () => { .build(); const generatedContent = await umbracoApi.content.save(rootContentNode); await CreatePackage(umbracoApi, generatedContent.Id); - } + test('Creates a simple package', async ({page, umbracoApi, umbracoUi}) => { + await umbracoApi.packages.ensureNameNotExists(packageName); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.documentTypes.ensureNameNotExists(rootDocTypeName); + + const rootDocType = new DocumentTypeBuilder() + .withName(rootDocTypeName) + .withAllowAsRoot(true) + .build(); + + const generatedRootDocType = await umbracoApi.documentTypes.save(rootDocType); + const rootDocTypeAlias = generatedRootDocType["alias"]; + + const rootContentNode = new ContentBuilder() + .withContentTypeAlias(rootDocTypeAlias) + .withAction("saveNew") + .addVariant() + .withName(nodeName) + .withSave(true) + .done() + .build(); + await umbracoApi.content.save(rootContentNode); + + // We have to wait for navigation to the packages section, if not it can cause the test to fail + await Promise.all([ + page.waitForNavigation(), + umbracoUi.goToSection(ConstantHelper.sections.packages) + ]); + await page.locator('[data-element="sub-view-umbCreatedPackages"]').click(); + await page.locator("button", {hasText: "Create package"}).click(); + + // Fill out package creation form + // Waits until the element package Content is visible + await page.locator('[key="packager_packageContent"]').isVisible(); + await page.locator("#headerName").type(packageName); + await page.locator('.controls > .umb-node-preview-add').click(); + await page.locator('.umb-tree-item__label').first().click(); + await page.locator("button", {hasText: "Create"}).click(); + + // Navigate pack to packages and Assert the file is created + // Waits until the button download is visible + await page.locator('[label-key="general_download"]').isVisible(); + await umbracoUi.goToSection(ConstantHelper.sections.packages); + await page.locator('[data-element="sub-view-umbCreatedPackages"]').click(); + await expect(await page.locator("body", {hasText: packageName})).toBeVisible(); + + // Cleanup + await umbracoApi.packages.ensureNameNotExists(packageName); + await umbracoApi.content.deleteAllContent(); + await umbracoApi.documentTypes.ensureNameNotExists(rootDocTypeName); + }); + test('Deletes a package', async ({page, umbracoApi, umbracoUi}) => { await umbracoApi.content.deleteAllContent(); await umbracoApi.documentTypes.ensureNameNotExists(rootDocTypeName); diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/macro.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/macro.spec.ts new file mode 100644 index 0000000000..d127ae5186 --- /dev/null +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Settings/macro.spec.ts @@ -0,0 +1,45 @@ +import {ConstantHelper, test} from '@umbraco/playwright-testhelpers'; +import {PartialViewMacroBuilder} from "@umbraco/json-models-builders"; + +test.describe('Macros', () => { + + test.beforeEach(async ({ page, umbracoApi }) => { + await umbracoApi.login(); + }); + + test('Create macro', async ({ page, umbracoApi, umbracoUi }) => { + const name = "Test macro"; + const partialViewName = "Test partialView"; + + await umbracoApi.macros.ensureNameNotExists(name); + await umbracoApi.partialViews.ensureMacroFileNameNotExists(partialViewName); + + const partialViewMacro = new PartialViewMacroBuilder() + .withName(partialViewName) + .withContent("@inherits Umbraco.Web.Macros.PartialViewMacroPage") + .build(); + await umbracoApi.partialViews.save(partialViewMacro); + + await umbracoUi.goToSection(ConstantHelper.sections.settings); + await umbracoUi.clickElement(umbracoUi.getTreeItem("settings", ["Macros"]), {button: "right"}); + + // Creates macro + await umbracoUi.clickElement(umbracoUi.getContextMenuAction(ConstantHelper.actions.create)); + let form = await page.locator('form[name="createMacroForm"]'); + await form.locator('input[name="itemKey"]').type(name); + await form.locator(".btn-primary").click(); + + // Adds partial view to macro + await page.locator('[label="Macro partial view"]').click(); + await page.locator('[data-element="tree-item-' + partialViewName + '.cshtml"]').click(); + + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.save)); + + // Assert + await umbracoUi.isSuccessNotificationVisible(); + + // Clean up + await umbracoApi.macros.ensureNameNotExists(name); + await umbracoApi.partialViews.ensureMacroFileNameNotExists(partialViewName); + }); +}); \ No newline at end of file diff --git a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Tabs/tabs.spec.ts b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Tabs/tabs.spec.ts index cff2a2ba7b..f009af5ce1 100644 --- a/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Tabs/tabs.spec.ts +++ b/tests/Umbraco.Tests.AcceptanceTest/tests/DefaultConfig/Tabs/tabs.spec.ts @@ -451,6 +451,60 @@ test.describe('Tabs', () => { await expect(await page.locator('[title="aTab 2"]').first()).toBeVisible(); }); + test('Drags and drops a property in a tab', async ({umbracoUi, umbracoApi, page}) => { + await umbracoApi.documentTypes.ensureNameNotExists(tabsDocTypeName); + const tabsDocType = new DocumentTypeBuilder() + .withName(tabsDocTypeName) + .withAlias(tabsDocTypeAlias) + .withAllowAsRoot(true) + .withDefaultTemplate(tabsDocTypeAlias) + .addTab() + .withName('Tab 1') + .addGroup() + .withName('Tab group') + .addUrlPickerProperty() + .withAlias("urlPicker") + .withLabel('UrlPickerOne') + .done() + .done() + .done() + .addTab() + .withName('Tab 2') + .addGroup() + .withName('Tab group tab 2') + .addUrlPickerProperty() + .withAlias('urlPickerTabTwo') + .withLabel('UrlPickerTabTwo') + .done() + .addUrlPickerProperty() + .withAlias('urlPickerTwo') + .withLabel('UrlPickerTwo') + .done() + .done() + .done() + .build(); + await umbracoApi.documentTypes.save(tabsDocType); + await openDocTypeFolder(umbracoUi, page); + await page.locator('[alias="reorder"]').click(); + await page.locator('.umb-group-builder__tab').last().click(); + + // Drag and drop property from tab 2 into tab 1 + await page.locator('.umb-group-builder__property-meta > .flex > .icon >> nth=1').last().hover(); + await page.mouse.down(); + await page.locator('.umb-group-builder__tab >> nth=1').hover({force:true}); + await page.waitForTimeout(500); + await page.locator('[data-element="group-Tab group"]').hover({force:true}); + await page.mouse.up(); + + // Stop reordering and save + await page.locator('[alias="reorder"]').click(); + await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.save)); + + // Assert + await umbracoUi.isSuccessNotificationVisible(); + await expect(await page.locator('[title="urlPickerTabTwo"]')).toBeVisible(); + }); + test('Drags and drops a group and converts to tab', async ({umbracoUi, umbracoApi, page}) => { await umbracoApi.documentTypes.ensureNameNotExists(tabsDocTypeName); const tabsDocType = new DocumentTypeBuilder() @@ -496,7 +550,7 @@ test.describe('Tabs', () => { await page.waitForTimeout(2000); await page.mouse.up(); await umbracoUi.clickElement(umbracoUi.getButtonByLabelKey(ConstantHelper.buttons.save)); - //Assert + // Assert await umbracoUi.isSuccessNotificationVisible(); await expect(await page.locator('[title="tabGroup"]').first()).toBeVisible(); }); diff --git a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj index b46ba68501..bb4866d0e1 100644 --- a/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj +++ b/tests/Umbraco.Tests.Benchmarks/Umbraco.Tests.Benchmarks.csproj @@ -10,6 +10,9 @@ + + + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 35d4549393..b25cb02a01 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -17,6 +17,17 @@ + + + + + + + + + + + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs index 045e3002e7..af6dbb1f1c 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Composing/TypeLoaderTests.cs @@ -174,7 +174,7 @@ public class TypeLoaderTests public void GetDataEditors() { var types = _typeLoader.GetDataEditors(); - Assert.AreEqual(41, types.Count()); + Assert.AreEqual(42, types.Count()); } /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs index c92b98562c..86a97815b3 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/BlockEditorComponentTests.cs @@ -30,35 +30,30 @@ public class BlockEditorComponentTests private const string SubContentGuid2 = "a062c06d6b0b44ac892b35d90309c7f8"; private const string SubSettingsGuid1 = "4d998d980ffa4eee8afdc23c4abd6d29"; - private static readonly ILogger s_logger = - Mock.Of>(); - [Test] public void Cannot_Have_Null_Udi() { - var component = new BlockEditorPropertyHandler(s_logger); + var component = new BlockListPropertyNotificationHandler(Mock.Of>()); var json = GetBlockListJson(null, string.Empty); - Assert.Throws(() => component.ReplaceBlockListUdis(json)); + Assert.Throws(() => component.ReplaceBlockEditorUdis(json)); } [Test] public void No_Nesting() { - var guids = Enumerable.Range(0, 3).Select(x => Guid.NewGuid()).ToList(); - var guidCounter = 0; - - Guid GuidFactory() + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) { - return guids[guidCounter++]; + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; } var json = GetBlockListJson(null); - var expected = ReplaceGuids(json, guids, ContentGuid1, ContentGuid2, SettingsGuid1); - - var component = new BlockEditorPropertyHandler(s_logger); - var result = component.ReplaceBlockListUdis(json, GuidFactory); + var component = new BlockListPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); + var expected = ReplaceGuids(json, guidMap); var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); Console.WriteLine(expectedJson); @@ -69,13 +64,11 @@ public class BlockEditorComponentTests [Test] public void One_Level_Nesting_Escaped() { - var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); - - var guidCounter = 0; - - Guid GuidFactory() + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) { - return guids[guidCounter++]; + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; } var innerJson = GetBlockListJson(null, SubContentGuid1, SubContentGuid2, SubSettingsGuid1); @@ -87,19 +80,11 @@ public class BlockEditorComponentTests // get the json with the subFeatures as escaped var json = GetBlockListJson(innerJsonEscaped); - var component = new BlockEditorPropertyHandler(s_logger); - var result = component.ReplaceBlockListUdis(json, GuidFactory); + var component = new BlockListPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); - // the expected result is that the subFeatures data is no longer escaped - var expected = ReplaceGuids( - GetBlockListJson(innerJson), - guids, - ContentGuid1, - ContentGuid2, - SettingsGuid1, - SubContentGuid1, - SubContentGuid2, - SubSettingsGuid1); + // the expected result is that the subFeatures data remains escaped + var expected = ReplaceGuids(GetBlockListJson(innerJsonEscaped), guidMap); var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); @@ -111,12 +96,11 @@ public class BlockEditorComponentTests [Test] public void One_Level_Nesting_Unescaped() { - var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); - var guidCounter = 0; - - Guid GuidFactory() + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) { - return guids[guidCounter++]; + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; } // nested blocks without property value escaping used in the conversion @@ -125,19 +109,10 @@ public class BlockEditorComponentTests // get the json with the subFeatures as unescaped var json = GetBlockListJson(innerJson); - var expected = ReplaceGuids( - GetBlockListJson(innerJson), - guids, - ContentGuid1, - ContentGuid2, - SettingsGuid1, - SubContentGuid1, - SubContentGuid2, - SubSettingsGuid1); - - var component = new BlockEditorPropertyHandler(s_logger); - var result = component.ReplaceBlockListUdis(json, GuidFactory); + var component = new BlockListPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); + var expected = ReplaceGuids(GetBlockListJson(innerJson), guidMap); var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); Console.WriteLine(expectedJson); @@ -148,12 +123,11 @@ public class BlockEditorComponentTests [Test] public void Nested_In_Complex_Editor_Escaped() { - var guids = Enumerable.Range(0, 6).Select(x => Guid.NewGuid()).ToList(); - var guidCounter = 0; - - Guid GuidFactory() + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) { - return guids[guidCounter++]; + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; } var innerJson = GetBlockListJson(null, SubContentGuid1, SubContentGuid2, SubSettingsGuid1); @@ -167,19 +141,12 @@ public class BlockEditorComponentTests var json = GetBlockListJson(complexEditorJsonEscaped); - var component = new BlockEditorPropertyHandler(s_logger); - var result = component.ReplaceBlockListUdis(json, GuidFactory); + var component = new BlockListPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); - // the expected result is that the subFeatures data is no longer escaped - var expected = ReplaceGuids( - GetBlockListJson(GetGridJson(innerJson)), - guids, - ContentGuid1, - ContentGuid2, - SettingsGuid1, - SubContentGuid1, - SubContentGuid2, - SubSettingsGuid1); + // the expected result is that the subFeatures remains escaped + Assert.True(guidMap.Any()); + var expected = ReplaceGuids(GetBlockListJson(GetGridJson(innerJsonEscaped)), guidMap); var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); @@ -188,6 +155,103 @@ public class BlockEditorComponentTests Assert.AreEqual(expectedJson, resultJson); } + [Test] + public void BlockGrid_With_Nested_BlockList_Escaped() + { + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) + { + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; + } + + var innerJson = GetBlockListJson(null, SubContentGuid1, SubContentGuid2, SubSettingsGuid1); + + // we need to ensure the escaped json is consistent with how it will be re-escaped after parsing + // and this is how to do that, the result will also include quotes around it. + var innerJsonEscaped = JsonConvert.ToString(innerJson); + + var json = GetBlockGridJson(innerJsonEscaped); + + var component = new BlockGridPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); + + // the expected result is that the subFeatures remains escaped + Assert.True(guidMap.Any()); + var expected = ReplaceGuids(GetBlockGridJson(innerJsonEscaped), guidMap); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + [Test] + public void BlockGrid_With_Nested_BlockList_Unescaped() + { + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) + { + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; + } + + var innerJson = GetBlockListJson(null, SubContentGuid1, SubContentGuid2, SubSettingsGuid1); + + var json = GetBlockGridJson(innerJson); + + var component = new BlockGridPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); + + // the expected result is that the subFeatures remains unescaped + Assert.True(guidMap.Any()); + var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + } + + [Test] + public void BlockGrid_With_Nested_Udi_Based_Editor() + { + var guidMap = new Dictionary(); + Guid GuidFactory(Guid oldKey) + { + guidMap[oldKey] = Guid.NewGuid(); + return guidMap[oldKey]; + } + + var innerJson = @"{ + ""udi"": ""umb://element/eb459ab17259495b90a3d2f6bb299826"", + ""title"": ""Some title"", + ""nested"": { + ""udi"": ""umb://element/7f33e17a00b742cebd1eb7f2af4c56b5"" + } + }"; + + var json = GetBlockGridJson(innerJson); + + var component = new BlockGridPropertyNotificationHandler(Mock.Of>()); + var result = component.ReplaceBlockEditorUdis(json, GuidFactory); + + // the expected result is that the subFeatures remains unaltered - the UDIs within should still exist + Assert.True(guidMap.Any()); + var expected = ReplaceGuids(GetBlockGridJson(innerJson), guidMap); + + var expectedJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(expected, _serializerSettings), _serializerSettings); + var resultJson = JsonConvert.SerializeObject(JsonConvert.DeserializeObject(result, _serializerSettings), _serializerSettings); + Console.WriteLine(expectedJson); + Console.WriteLine(resultJson); + Assert.AreEqual(expectedJson, resultJson); + + Assert.True(result.Contains("umb://element/eb459ab17259495b90a3d2f6bb299826")); + Assert.True(result.Contains("umb://element/7f33e17a00b742cebd1eb7f2af4c56b5")); + } + private string GetBlockListJson( string subFeatures, string contentGuid1 = ContentGuid1, @@ -291,12 +355,119 @@ public class BlockEditorComponentTests }] }"; - private string ReplaceGuids(string json, List newGuids, params string[] oldGuids) + private string GetBlockGridJson(string subFeatures) => + @"{ + ""layout"": { + ""Umbraco.BlockGrid"": [{ + ""contentUdi"": ""umb://element/d05861169d124582a7c2826e52a51b47"", + ""areas"": [{ + ""key"": ""b17663f0-c1f4-4bee-97cd-290fbc7b9a2c"", + ""items"": [] + }, { + ""key"": ""2bdcdadd-f609-4acc-b840-01970b9ced1d"", + ""items"": [] + } + ], + ""columnSpan"": 12, + ""rowSpan"": 1, + ""settingsUdi"": ""umb://element/262d5efd2eeb43ed95e95c094c45ce1c"" + }, { + ""contentUdi"": ""umb://element/5abad9f1b4e24d7aa269fbd1b50033ac"", + ""areas"": [{ + ""key"": ""b17663f0-c1f4-4bee-97cd-290fbc7b9a2c"", + ""items"": [{ + ""contentUdi"": ""umb://element/5fc866c590be4d01a28a979472a1ffee"", + ""areas"": [], + ""columnSpan"": 3, + ""rowSpan"": 1 + } + ] + }, { + ""key"": ""2bdcdadd-f609-4acc-b840-01970b9ced1d"", + ""items"": [{ + ""contentUdi"": ""umb://element/264536b65b0f4641aa43d4bfb515831d"", + ""areas"": [], + ""columnSpan"": 3, + ""rowSpan"": 1, + ""settingsUdi"": ""umb://element/20d735c7c57b40229ed845375cf22d1f"" + } + ] + } + ], + ""columnSpan"": 6, + ""rowSpan"": 1, + ""settingsUdi"": ""umb://element/4d121eaba49c4e09a7460069d1bee600"" + }, { + ""contentUdi"": ""umb://element/76e24aeb6eeb4370892ca521932a96df"", + ""areas"": [], + ""columnSpan"": 6, + ""rowSpan"": 1 + }, { + ""contentUdi"": ""umb://element/90549d94555647fdbe4d111c7178ada4"", + ""areas"": [{ + ""key"": ""b17663f0-c1f4-4bee-97cd-290fbc7b9a2c"", + ""items"": [] + }, { + ""key"": ""2bdcdadd-f609-4acc-b840-01970b9ced1d"", + ""items"": [] + } + ], + ""columnSpan"": 12, + ""rowSpan"": 3, + ""settingsUdi"": ""umb://element/3dfabc96584c4c35ac2e6bf06ffa20de"" + } + ] + }, + ""contentData"": [{ + ""contentTypeKey"": ""36ccf44a-aac8-40a6-8685-73ab03bc9709"", + ""udi"": ""umb://element/d05861169d124582a7c2826e52a51b47"", + ""title"": ""Element one - 12 cols"" + }, { + ""contentTypeKey"": ""36ccf44a-aac8-40a6-8685-73ab03bc9709"", + ""udi"": ""umb://element/5abad9f1b4e24d7aa269fbd1b50033ac"", + ""title"": ""Element one - 6 cols, left side"" + }, { + ""contentTypeKey"": ""5cc488aa-ba24-41f2-a01e-8f2d1982f865"", + ""udi"": ""umb://element/76e24aeb6eeb4370892ca521932a96df"", + ""text"": ""Element two - 6 cols, right side"" + }, { + ""contentTypeKey"": ""36ccf44a-aac8-40a6-8685-73ab03bc9709"", + ""udi"": ""umb://element/90549d94555647fdbe4d111c7178ada4"", + ""title"": ""One more element one - 12 cols"", + ""subFeatures"": " + subFeatures.OrIfNullOrWhiteSpace(@"""""") + @" + }, { + ""contentTypeKey"": ""5cc488aa-ba24-41f2-a01e-8f2d1982f865"", + ""udi"": ""umb://element/5fc866c590be4d01a28a979472a1ffee"", + ""text"": ""Nested element two - left side"" + }, { + ""contentTypeKey"": ""36ccf44a-aac8-40a6-8685-73ab03bc9709"", + ""udi"": ""umb://element/264536b65b0f4641aa43d4bfb515831d"", + ""title"": ""Nested element one - right side"" + } + ], + ""settingsData"": [{ + ""contentTypeKey"": ""ef150524-7145-469e-8d99-166aad69a7ad"", + ""udi"": ""umb://element/262d5efd2eeb43ed95e95c094c45ce1c"", + ""enabled"": 1 + }, { + ""contentTypeKey"": ""ef150524-7145-469e-8d99-166aad69a7ad"", + ""udi"": ""umb://element/4d121eaba49c4e09a7460069d1bee600"" + }, { + ""contentTypeKey"": ""ef150524-7145-469e-8d99-166aad69a7ad"", + ""udi"": ""umb://element/20d735c7c57b40229ed845375cf22d1f"" + }, { + ""contentTypeKey"": ""ef150524-7145-469e-8d99-166aad69a7ad"", + ""udi"": ""umb://element/3dfabc96584c4c35ac2e6bf06ffa20de"", + ""enabled"": 1 + } + ] +}"; + + private string ReplaceGuids(string json, Dictionary guidMap) { - for (var i = 0; i < oldGuids.Length; i++) + foreach ((Guid oldKey, Guid newKey) in guidMap) { - var old = oldGuids[i]; - json = json.Replace(old, newGuids[i].ToString("N")); + json = json.Replace(oldKey.ToString("N"), newKey.ToString("N")); } return json; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs index 46f79ebebc..947c3beecd 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs @@ -34,14 +34,14 @@ public class DataValueEditorReuseTests _dataValueEditorFactoryMock .Setup(m => - m.Create(It.IsAny())) - .Returns(() => new BlockEditorPropertyEditor.BlockEditorPropertyValueEditor( + m.Create(It.IsAny())) + .Returns(() => new BlockListPropertyEditorBase.BlockListEditorPropertyValueEditor( new DataEditorAttribute("a", "b", "c"), _propertyEditorCollection, Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of>(), + Mock.Of>(), Mock.Of(), Mock.Of(), Mock.Of(), @@ -63,7 +63,7 @@ public class DataValueEditorReuseTests Assert.NotNull(dataValueEditor2); Assert.AreSame(dataValueEditor1, dataValueEditor2); _dataValueEditorFactoryMock.Verify( - m => m.Create(It.IsAny()), + m => m.Create(It.IsAny()), Times.Once); } @@ -84,7 +84,7 @@ public class DataValueEditorReuseTests Assert.AreEqual("config", ((DataValueEditor)dataValueEditor2).Configuration); Assert.AreNotSame(dataValueEditor1, dataValueEditor2); _dataValueEditorFactoryMock.Verify( - m => m.Create(It.IsAny()), + m => m.Create(It.IsAny()), Times.Exactly(2)); } @@ -104,7 +104,7 @@ public class DataValueEditorReuseTests Assert.NotNull(dataValueEditor2); Assert.AreNotSame(dataValueEditor1, dataValueEditor2); _dataValueEditorFactoryMock.Verify( - m => m.Create(It.IsAny()), + m => m.Create(It.IsAny()), Times.Exactly(2)); } @@ -126,7 +126,7 @@ public class DataValueEditorReuseTests Assert.AreEqual("config", ((DataValueEditor)dataValueEditor2).Configuration); Assert.AreNotSame(dataValueEditor1, dataValueEditor2); _dataValueEditorFactoryMock.Verify( - m => m.Create(It.IsAny()), + m => m.Create(It.IsAny()), Times.Exactly(2)); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 813f431ad5..0de43671ad 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -1,5 +1,6 @@ + Exe true Umbraco.Cms.Tests.UnitTests false @@ -12,6 +13,10 @@ + + + + diff --git a/umbraco.sln b/umbraco.sln index 70f299236a..ba85d0965b 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -124,6 +124,20 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Umbraco.Cms.Targets", "src\Umbraco.Cms.Targets\Umbraco.Cms.Targets.csproj", "{B51C10FC-FD20-451E-90DD-A117133403DF}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E5D4B5F9-6CCE-46CE-8985-9A350445F92B}" + ProjectSection(SolutionItems) = preProject + .artifactignore = .artifactignore + .editorconfig = .editorconfig + .gitattributes = .gitattributes + .gitignore = .gitignore + .globalconfig = .globalconfig + Directory.Build.props = Directory.Build.props + icon.png = icon.png + LICENSE.md = LICENSE.md + umbraco.sln.DotSettings = umbraco.sln.DotSettings + version.json = version.json + EndProjectSection +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{20CE9C97-9314-4A19-BCF1-D12CF49B7205}" ProjectSection(SolutionItems) = preProject build\azure-pipelines.yml = build\azure-pipelines.yml